Skip to content

Fix: Allow users to opt out of shiny's download autoenable#4371

Open
elnelson575 wants to merge 12 commits intomainfrom
fix/autoenable-behavior
Open

Fix: Allow users to opt out of shiny's download autoenable#4371
elnelson575 wants to merge 12 commits intomainfrom
fix/autoenable-behavior

Conversation

@elnelson575
Copy link
Copy Markdown
Collaborator

@elnelson575 elnelson575 commented Apr 9, 2026

These changes provide a generic, framework-agnostic opt-out for the auto-enable behavior introduced in PR #4041 based on the discussion in #4119.
I believe this may also fix: daattali/shinyjs#277

(Will also be used by rstudio/bslib#1298)

The changes made are:

  • Adding a param to downloadButton & downloadLink: autoEnable which defaults to TRUE. This represents whether Shiny is responsible for tracking download readiness and enabling/disabling the button appropriately, or if the user is claiming responsibility for that, either through shinyjs or their own custom js functions.

  • Add logic to downloadLink.ts to skip enabling the download button/link on completion if the button/link has data-ignore-update (our indication that the user has set autoEnable=FALSE) or if it has shinyjs-disabled (shinyjs's indication that the element has been wrapped in disable() in the UI.

There is no behavioral change unless the user either:

  1. opts into managing their own state using autoEnable = FALSE
    and/or
  2. uses shinyjs, in which case we will now detect the download buttons/links that they have given the shinyjs-disabled class, both on initiation, and through server functions.

Download Button:

Screen.Recording.2026-04-09.at.9.07.12.PM.mov

Download Link:

Screen.Recording.2026-04-09.at.10.47.51.PM.mov
Sample Demo App (Button):
library(shinyjs)

# This app tests all permutations of autoEnable x shinyjs disable method:

code_block <- function(...) {
  tags$pre(style = "background:#f5f5f5; padding:10px; margin:0;", tags$code(...))
}

case_row <- function(heading, description = NULL, controls, code) {
  tagList(
    h4(heading),
    if (!is.null(description)) p(description),
    fluidRow(
      column(4, controls),
      column(8, code_block(code))
    )
  )
}

ui <- fluidPage(
  useShinyjs(),

  h2("downloadButton: autoEnable x shinyjs permutations"),

  # --- autoEnable = TRUE (default) -------------------------------------------

  hr(),
  h3("autoEnable = TRUE (default)"),

  case_row(
    "Case 1: Default. No shinyjs — should AUTO-ENABLE once server is ready",
    NULL,
    downloadButton("dl_1", "Download"),
    'downloadButton("dl_1", "Download")'
  ),

  br(),
  case_row(
    "Case 2: autoEnable=True, shinyjs::disabled() in UI (static) — stays disabled until toggled",
    controls = tagList(
      actionButton("toggle_2", "Toggle case 2"),
      br(), br(),
      disabled(downloadButton("dl_2", "Download"))
    ),
    code = 'disabled(
  downloadButton("dl_2", "Download")
)

# server:
observeEvent(input$toggle_2, {
  if (input$toggle_2 %% 2 == 1) enable("dl_2") else disable("dl_2")
})'
  ),

  br(),
  case_row(
    "Case 3: autoEnable=True, shinyjs::disable() from server (runtime) — should enable, then disable on toggle",
    "When disabled, renderValue should not override it (that is, disabled state should be preserved until the toggle is clicked again).",
    tagList(
      actionButton("toggle_3", "Toggle case 3"),
      br(), br(),
      downloadButton("dl_3", "Download")
    ),
    'downloadButton("dl_3", "Download")

# server:
observeEvent(input$toggle_3, {
  if (input$toggle_3 %% 2 == 1) disable("dl_3") else enable("dl_3")
})'
  ),

  # --- autoEnable = FALSE -----------------------------------------------------

  hr(),
  h3("autoEnable = FALSE (explicit opt-out)"),

  case_row(
    "Case 4: autoEnable = FALSE, no shinyjs — should STAY DISABLED",
    NULL,
    downloadButton("dl_4", "Download", autoEnable = FALSE),
    'downloadButton("dl_4", "Download", autoEnable = FALSE)'
  ),

  br(),
  case_row(
    "Case 5: autoEnable = FALSE, shinyjs::disabled() in UI (static) — should STAY DISABLED until toggled",
    "Toggle should enable on first click, then disable on the next, and so on.",
    tagList(
      actionButton("toggle_5", "Toggle case 5"),
      br(), br(),
      disabled(downloadButton("dl_5", "Download", autoEnable = FALSE))
    ),
    'disabled(
  downloadButton("dl_5", "Download", autoEnable = FALSE)
)

# server:
observeEvent(input$toggle_5, {
  if (input$toggle_5 %% 2 == 1) enable("dl_5") else disable("dl_5")
})'
  ),

  br(),
  case_row(
    "Case 6: autoEnable = FALSE, shinyjs::disable() from server (runtime) — should STAY DISABLED until the toggle is clicked",
    NULL,
    tagList(
      actionButton("toggle_6", "Toggle case 6"),
      br(), br(),
      downloadButton("dl_6", "Download", autoEnable = FALSE)
    ),
    'downloadButton("dl_6", "Download", autoEnable = FALSE)

# server:
observeEvent(input$toggle_6, {
    if (input$toggle_6 %% 2 == 1) enable("dl_6") else disable("dl_6")
})'
  )
)

server <- function(input, output, session) {
  make_handler <- function(label) {
    downloadHandler(
      filename = paste0(label, ".txt"),
      content = function(file) writeLines(paste("File from", label), file)
    )
  }

  output$dl_1 <- make_handler("case1")
  output$dl_2 <- make_handler("case2")
  output$dl_3 <- make_handler("case3")
  output$dl_4 <- make_handler("case4")
  output$dl_5 <- make_handler("case5")
  output$dl_6 <- make_handler("case6")

  observeEvent(input$toggle_2, {
    if (input$toggle_2 %% 2 == 1) enable("dl_2") else disable("dl_2")
  })

  observeEvent(input$toggle_3, {
    if (input$toggle_3 %% 2 == 1) disable("dl_3") else enable("dl_3")
  })

  observeEvent(input$toggle_5, {
    if (input$toggle_5 %% 2 == 1) enable("dl_5") else disable("dl_5")
  })

  observeEvent(input$toggle_6, {
    if (input$toggle_6 %% 2 == 1) enable("dl_6") else disable("dl_6")
  })
}

shinyApp(ui, server)


elnelson575 and others added 3 commits April 3, 2026 09:39
…load button auto-enable (#4119)

Frameworks like shinyjs, py-shiny, and custom JS can add
`data-shiny-disable-auto-enable` to a download button/link to prevent
Shiny from automatically removing the `disabled` class and
`aria-disabled` attribute on render. This provides a generic,
framework-agnostic opt-out for the auto-enable behavior introduced
in PR #4041.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@elnelson575 elnelson575 linked an issue Apr 9, 2026 that may be closed by this pull request
elnelson575 and others added 9 commits April 9, 2026 14:39
…ttr, block clicks on disabled download buttons

- Rename `autoUpdate` param to `autoEnable` in `downloadButton()`/`downloadLink()`
- Invert HTML attribute: `autoEnable=FALSE` now sets `data-ignore-update` (absent by default)
- Block click/auxclick events via preventDefault() when button has `disabled` class
- Update NEWS.md and add testthat tests for new attribute behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ry this binding

- Add `a.shiny-download-link.disabled { pointer-events: none }` to shiny.scss
  so disabled download links (not just btn-styled ones) block pointer events
- Fix click handler to use `this` instead of `e.currentTarget` — with jQuery
  delegated events, `e.currentTarget` is the document, not the matched element

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@elnelson575 elnelson575 marked this pull request as ready for review April 10, 2026 03:40
@elnelson575 elnelson575 requested a review from cpsievert April 10, 2026 14:35
Comment thread R/bootstrap.R
downloadLink <- function(outputId, label="Download", class=NULL, ...) {
downloadLink <- function(outputId, label="Download", class=NULL, ..., autoEnable = TRUE) {
tags$a(id=outputId,
class='shiny-download-link disabled',
Copy link
Copy Markdown
Collaborator

@cpsievert cpsievert Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a sound argument for defaulting to a disabled state when managing the state yourself?

I haven't thought about this deeply, but just generally speaking, it feels safer to default to enable state (since, if the developer doesn't enable properly, the end user is hosed)? It's also the behavior that download button had prior to the auto-enable change.

By the way, if we did go in a direction where we defaulted to enable state when you're managing it yourself, maybe the argument should be called something like enableWhenReady?

Also, to help me think through this, it would help to more context on where/when we need this for bslib. I feel like I still don't quite understand the motivation for managing download disabled state by hand.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defaulting to disabled: I have mixed feelings on this. I think most cases where someone wants to manually manage it's because they have a clear idea of what special conditions they want to use to enable/disable the button. The clearest example seems to me a case where the developer wants a user to do something not related to download readiness as a prerequisite to downloading the file (ex. confirm some modal, check the user's permissions). In some cases, maybe this could be solved through a better chain of reactive dependencies, but given the amount of traffic on this issue, it seems like folks aren't finding that solution intuitive.
On the other hand, defaulting to enabled and making users update to disabled as a quick call on server load doesn't seem awful. So if we think that will be more intuitive based on precedent, I don't have a significant problem with it.

Param naming: If we do default to enabled, I like enableWhenReady. Naming this param to something that doesn't have the potential to be confusing has been vexing me, but that one is pretty straight forward.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the bslib side, we currently allow enable/disable for toolbar_input_buttons, so it felt more complete to have it also for download buttons. I feel like also given that toolbar buttons are going into more genAI cases, there might get to be complicated cases where there is something in the download, but it is outdated and/or will be updated on the next turn or some such? I haven't thought in great depth about the specific use cases with genAI, but it seems like we might have some more complex reactive chains where people might want to just tie enabling the download to something else.

In terms of solving a particular part of the implementation of that in bslib, we can add the enable/disable without making changes in main shiny. The disabled-on-start scenario can work with a workaround, using a session$onFlushed call (see below). It's not a particularly intuitive work around as I have it here, but we might be able to make it moreso.
I'm not sure that we could fix the Case 2 version (if you put the button in a renderUI, the button enables on rerender)

Here's a quick example:

toolbardownloadex.mov
App code
library(shiny)
library(bslib)

ui <- page_fluid(
  theme = bs_theme(preset = "shiny"),
  h2("toolbar_download_button() auto-enable behavior demo"),
  p(
    "Reload the page to reset all buttons.",
    "Inspect each card to see whether the disabled state is preserved or overridden."
  ),
  layout_columns(
    col_widths = c(6, 6),

    # ---- BROKEN 1: disabled = TRUE at render ----
    card(
      card_header(
        "Case 1 (works, workaround): disabled = TRUE + immediate update()",
        toolbar(
          align = "right",
          toolbar_download_button(
            "dl_broken_initial",
            label = "Download",
            disabled = TRUE
          )
        )
      ),
      card_body(
        p(strong("Expected:"), " button stays disabled."),
        p(
          strong("Actual:"), " Works.", tags$code("session$onFlushed()"),
          " fires after the first flush has delivered the output value.",
          " The disable message therefore arrives at the client after",
          " renderValue() has already run, so it wins."
        ),
        p(
          class = "text-muted",
          "A direct call in the server body would not work — sendCustomMessage",
          " sends immediately over the WebSocket, before the flush, so",
          " renderValue() would fire afterwards and re-enable the button."
        ),
        actionButton("enable_initial", "Enable", class = "btn-sm btn-primary mt-1"),
        tags$pre(class = "bg-light p-2 mt-3", tags$code(
'# UI
toolbar_download_button("dl_broken_initial", label = "Download", disabled = TRUE)

# Server
output$dl_broken_initial <- downloadHandler(
  filename = function() "iris.csv",
  content = function(file) write.csv(iris, file, row.names = FALSE)
)
# sendCustomMessage sends immediately, so a direct call would arrive
# at the client BEFORE the output value — renderValue() would then
# re-enable the button. session$onFlushed() sends AFTER the flush,
# so the disable message always arrives after renderValue() has fired.
session$onFlushed(function() {
  update_toolbar_download_button("dl_broken_initial", disabled = TRUE)
}, once = TRUE)'
        ))
      )
    ),

    # ---- BROKEN 2: button inside renderUI ----
    card(
      card_header(
        "Case 2 (broken): button inside renderUI",
        uiOutput("dl_renderui_toolbar")
      ),
      card_body(
        p(strong("Steps to reproduce:")),
        tags$ol(
          tags$li("Click 'Disable' — button greys out."),
          tags$li(
            "Change the dataset select — this re-renders the",
            tags$code("renderUI"), "containing the button."
          ),
          tags$li(
            "On re-bind, Shiny finds a cached URL in", tags$code("$values"),
            " and calls renderValue() immediately, re-enabling the button."
          )
        ),
        selectInput(
          "dataset_choice",
          "Dataset",
          choices = c("iris", "mtcars", "airquality")
        ),
        actionButton("disable_renderui", "Disable", class = "btn-sm btn-secondary mt-1"),
        tags$pre(class = "bg-light p-2 mt-3", tags$code(
'# UI
card_header(
  "...",
  uiOutput("dl_renderui_toolbar")  # button lives inside renderUI
)

# Server
output$dl_renderui_toolbar <- renderUI({
  # input$dataset_choice used in label — natural reactive dep
  toolbar(align = "right",
    toolbar_download_button(
      "dl_renderui",
      label = paste("Download", input$dataset_choice)
    )
  )
})

output$dl_renderui <- downloadHandler(
  filename = function() paste0(input$dataset_choice, ".csv"),
  content = function(file) write.csv(..., file, row.names = FALSE)
)

observeEvent(input$disable_renderui, {
  update_toolbar_download_button("dl_renderui", disabled = TRUE)
})'
        ))
      )
    ),

    # ---- WORKS: disabled = FALSE ----
    card(
      card_header(
        "Case 3 (works): disabled = FALSE (default)",
        toolbar(
          align = "right",
          toolbar_download_button(
            "dl_works_default",
            label = "Download"
          )
        )
      ),
      card_body(
        p(strong("Expected:"), " button is enabled and downloads iris.csv."),
        p(
          strong("Actual:"), " renderValue() fires at startup, sets the href,",
          " and enables the button (already enabled). Works correctly."
        ),
        tags$pre(class = "bg-light p-2 mt-3", tags$code(
'# UI
toolbar_download_button("dl_works_default", label = "Download")

# Server
output$dl_works_default <- downloadHandler(
  filename = function() "iris.csv",
  content = function(file) write.csv(iris, file, row.names = FALSE)
)'
        ))
      )
    ),

    # ---- WORKS: update() outside renderUI ----
    card(
      card_header(
        "Case 4 (works): update() outside renderUI",
        toolbar(
          align = "right",
          toolbar_download_button(
            "dl_works_update",
            label = "Download"
          )
        )
      ),
      card_body(
        p(strong("Expected:"), " disable/enable buttons control the state."),
        p(
          strong("Actual:"), " renderValue() already fired at startup.",
          " Subsequent update() calls are not overridden because the button",
          " element is never recreated. Works correctly."
        ),
        actionButton("disable_btn", "Disable", class = "btn-sm btn-secondary mt-1"),
        actionButton("enable_btn", "Enable", class = "btn-sm btn-primary mt-1"),
        tags$pre(class = "bg-light p-2 mt-3", tags$code(
'# UI
toolbar_download_button("dl_works_update", label = "Download")

# Server
output$dl_works_update <- downloadHandler(
  filename = function() "iris.csv",
  content = function(file) write.csv(iris, file, row.names = FALSE)
)

observeEvent(input$disable_btn, {
  update_toolbar_download_button("dl_works_update", disabled = TRUE)
})
observeEvent(input$enable_btn, {
  update_toolbar_download_button("dl_works_update", disabled = FALSE)
})'
        ))
      )
    )
  )
)

server <- function(input, output, session) {

  # WORKAROUND for disabled = TRUE at render:
  # sendCustomMessage sends immediately over the WebSocket, so calling
  # update_toolbar_download_button() directly in the server body would send
  # the disable message BEFORE the first flush delivers the output value —
  # meaning renderValue() fires afterwards and re-enables the button anyway.
  #
  # session$onFlushed() runs after the flush has completed and the output
  # values have been sent. The disable message therefore arrives at the client
  # AFTER renderValue() has already fired, so it wins.
  output$dl_broken_initial <- downloadHandler(
    filename = function() "iris.csv",
    content = function(file) write.csv(iris, file, row.names = FALSE)
  )
  session$onFlushed(function() {
    update_toolbar_download_button("dl_broken_initial", disabled = TRUE)
  }, once = TRUE)

  observeEvent(input$enable_initial, {
    update_toolbar_download_button("dl_broken_initial", disabled = FALSE)
  })

  # BROKEN 2: button inside renderUI
  # The toolbar is re-rendered whenever input$dataset_choice changes.
  # On each re-render, the element is replaced and re-bound; Shiny finds the
  # cached URL in $values and immediately calls renderValue(), removing disabled.
  output$dl_renderui_toolbar <- renderUI({
    # Reading input$dataset_choice here makes it a reactive dep so the toolbar
    # re-renders when the dataset changes. We also use it in the button label
    # so the dep is natural rather than a bare side-effect read.
    toolbar(
      align = "right",
      toolbar_download_button(
        "dl_renderui",
        label = paste("Download", input$dataset_choice)
      )
    )
  })

  output$dl_renderui <- downloadHandler(
    filename = function() paste0(input$dataset_choice, ".csv"),
    content = function(file) {
      data <- switch(
        input$dataset_choice,
        iris = iris,
        mtcars = mtcars,
        airquality = airquality
      )
      req(!is.null(data))
      write.csv(data, file, row.names = FALSE)
    }
  )

  observeEvent(input$disable_renderui, {
    update_toolbar_download_button("dl_renderui", disabled = TRUE)
  })

  # WORKS: disabled = FALSE (default)
  output$dl_works_default <- downloadHandler(
    filename = function() "iris.csv",
    content = function(file) write.csv(iris, file, row.names = FALSE)
  )

  # WORKS: update() when button is not inside renderUI
  output$dl_works_update <- downloadHandler(
    filename = function() "iris.csv",
    content = function(file) write.csv(iris, file, row.names = FALSE)
  )

  observeEvent(input$disable_btn, {
    update_toolbar_download_button("dl_works_update", disabled = TRUE)
  })

  observeEvent(input$enable_btn, {
    update_toolbar_download_button("dl_works_update", disabled = FALSE)
  })
}

shinyApp(ui, server)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: shinyjs::disabled() doesn't fully prevent downloads from shiny::downloadButton() Allow creating disabled downloadButton()/downloadLink()

2 participants