Fix: Allow users to opt out of shiny's download autoenable#4371
Fix: Allow users to opt out of shiny's download autoenable#4371elnelson575 wants to merge 12 commits intomainfrom
Conversation
…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>
…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>
| 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', |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
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:
autoEnablewhich defaults toTRUE. 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 throughshinyjsor their own custom js functions.Add logic to
downloadLink.tsto skip enabling the download button/link on completion if the button/link hasdata-ignore-update(our indication that the user has setautoEnable=FALSE) or if it hasshinyjs-disabled(shinyjs's indication that the element has been wrapped indisable()in the UI.There is no behavioral change unless the user either:
autoEnable = FALSEand/or
shinyjs, in which case we will now detect the download buttons/links that they have given theshinyjs-disabledclass, 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):