Skip to content

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

Merged
cpsievert merged 72 commits into
mainfrom
fix/autoenable-behavior
May 11, 2026
Merged

Fix: Allow users to opt out of shiny's download autoenable#4371
cpsievert merged 72 commits into
mainfrom
fix/autoenable-behavior

Conversation

@elnelson575

@elnelson575 elnelson575 commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Motivation

In v1.9.0 (#4041), Shiny began automatically enabling downloadButton()/downloadLink() once the server's downloadHandler is ready. This is a good default — it prevents users from clicking a download button before the server can handle it.

However, this auto-enable creates a problem for apps that want to keep a download button disabled until some condition is met (e.g., the user selects a dataset first). Before #4041, apps could render the button as disabled and use shinyjs::enable()/shinyjs::disable() (or custom JS) to manage its state. Now, the auto-enable unconditionally overrides that disabled state as soon as the downloadHandler initializes.

Closes #4119. Also likely fixes daattali/shinyjs#277.

Solution

Add an enabled parameter to downloadButton() and downloadLink() with three possible values:

  • "auto" (default): Current behavior — starts disabled, auto-enables when the downloadHandler is ready. No breaking change.
  • TRUE: Starts enabled immediately, without waiting for the server. Useful when you know the handler will be ready and don't want the brief disabled flash.
  • FALSE: Starts disabled and Shiny will never auto-enable it. You manage the enabled/disabled state yourself (e.g., via shinyjs).

Additionally, when enabled = "auto", Shiny now detects if shinyjs has independently disabled the element (via the shinyjs-disabled class) and skips the auto-enable in that case. This means shinyjs::disabled() in the UI and shinyjs::disable() from the server both work correctly without any explicit opt-out.

Implementation details

  • A data-shiny-disable-auto-enable attribute is added to the element when enabled is TRUE or FALSE (i.e., any non-"auto" value). The client-side renderValue checks for this attribute (and for shinyjs-disabled) before enabling.
  • Clicks on disabled download links are now properly blocked via CSS pointer-events: none (previously, a disabled-looking anchor link could still be clicked).

Also used by rstudio/bslib#1298.

App example

Screen.Recording.2026-04-23.at.6.33.35.PM.mov
Sample demo app
library(shinyjs)

# This app tests all permutations of enabled x shinyjs disable method,
# for both downloadButton and downloadLink.

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: enabled x shinyjs permutations"),

  hr(),
  h3('enabled = "auto" (default)'),

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

  br(),
  case_row(
    "Case 2: shinyjs::disabled() in UI — stays disabled until toggled",
    controls = tagList(
      actionButton("toggle_btn_2", "Toggle"),
      br(), br(),
      disabled(downloadButton("btn_2", "Download"))
    ),
    code = 'disabled(
  downloadButton("btn_2", "Download")
)

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

  br(),
  case_row(
    "Case 3: shinyjs::disable() from server — auto-enables, then disable/enable on toggle",
    "When disabled, renderValue should not override it.",
    tagList(
      actionButton("toggle_btn_3", "Toggle"),
      br(), br(),
      downloadButton("btn_3", "Download")
    ),
    'downloadButton("btn_3", "Download")

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

  hr(),
  h3("enabled = TRUE (explicit pre-enable)"),

  case_row(
    "Case 4: No shinyjs — starts enabled immediately (before server), stays enabled",
    NULL,
    downloadButton("btn_4", "Download", enabled = TRUE),
    'downloadButton("btn_4", "Download", enabled = TRUE)'
  ),

  br(),
  case_row(
    "Case 5: shinyjs::disabled() in UI — starts disabled, toggle enables/disables",
    controls = tagList(
      actionButton("toggle_btn_5", "Toggle"),
      br(), br(),
      disabled(downloadButton("btn_5", "Download", enabled = TRUE))
    ),
    code = 'disabled(
  downloadButton("btn_5", "Download", enabled = TRUE)
)

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

  br(),
  case_row(
    "Case 6: shinyjs::disable() from server — starts enabled, disable/enable on toggle",
    "Disabled state should be preserved after renderValue fires.",
    tagList(
      actionButton("toggle_btn_6", "Toggle"),
      br(), br(),
      downloadButton("btn_6", "Download", enabled = TRUE)
    ),
    'downloadButton("btn_6", "Download", enabled = TRUE)

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

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

  case_row(
    "Case 7: No shinyjs — should STAY DISABLED",
    NULL,
    downloadButton("btn_7", "Download", enabled = FALSE),
    'downloadButton("btn_7", "Download", enabled = FALSE)'
  ),

  br(),
  case_row(
    "Case 8: shinyjs::disabled() in UI — stays disabled until toggled",
    "Toggle should enable on first click, then disable on the next.",
    tagList(
      actionButton("toggle_btn_8", "Toggle"),
      br(), br(),
      disabled(downloadButton("btn_8", "Download", enabled = FALSE))
    ),
    'disabled(
  downloadButton("btn_8", "Download", enabled = FALSE)
)

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

  br(),
  case_row(
    "Case 9: shinyjs::disable() from server — stays disabled until toggled",
    NULL,
    tagList(
      actionButton("toggle_btn_9", "Toggle"),
      br(), br(),
      downloadButton("btn_9", "Download", enabled = FALSE)
    ),
    'downloadButton("btn_9", "Download", enabled = FALSE)

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

  h2("downloadLink: enabled x shinyjs permutations"),

  hr(),
  h3('enabled = "auto" (default)'),

  case_row(
    "Case 10: No shinyjs — should AUTO-ENABLE once server is ready",
    NULL,
    downloadLink("lnk_1", "Download"),
    'downloadLink("lnk_1", "Download")'
  ),

  br(),
  case_row(
    "Case 11: shinyjs::disabled() in UI — stays disabled until toggled",
    controls = tagList(
      actionButton("toggle_lnk_2", "Toggle"),
      br(), br(),
      disabled(downloadLink("lnk_2", "Download"))
    ),
    code = 'disabled(
  downloadLink("lnk_2", "Download")
)

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

  br(),
  case_row(
    "Case 12: shinyjs::disable() from server — auto-enables, then disable/enable on toggle",
    "When disabled, renderValue should not override it.",
    tagList(
      actionButton("toggle_lnk_3", "Toggle"),
      br(), br(),
      downloadLink("lnk_3", "Download")
    ),
    'downloadLink("lnk_3", "Download")

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

  hr(),
  h3("enabled = TRUE (explicit pre-enable)"),

  case_row(
    "Case 13: No shinyjs — starts enabled immediately (before server), stays enabled",
    NULL,
    downloadLink("lnk_4", "Download", enabled = TRUE),
    'downloadLink("lnk_4", "Download", enabled = TRUE)'
  ),

  br(),
  case_row(
    "Case 14: shinyjs::disabled() in UI — starts disabled, toggle enables/disables",
    controls = tagList(
      actionButton("toggle_lnk_5", "Toggle"),
      br(), br(),
      disabled(downloadLink("lnk_5", "Download", enabled = TRUE))
    ),
    code = 'disabled(
  downloadLink("lnk_5", "Download", enabled = TRUE)
)

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

  br(),
  case_row(
    "Case 15: shinyjs::disable() from server — starts enabled, disable/enable on toggle",
    "Disabled state should be preserved after renderValue fires.",
    tagList(
      actionButton("toggle_lnk_6", "Toggle"),
      br(), br(),
      downloadLink("lnk_6", "Download", enabled = TRUE)
    ),
    'downloadLink("lnk_6", "Download", enabled = TRUE)

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

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

  case_row(
    "Case 16: No shinyjs — should STAY DISABLED",
    NULL,
    downloadLink("lnk_7", "Download", enabled = FALSE),
    'downloadLink("lnk_7", "Download", enabled = FALSE)'
  ),

  br(),
  case_row(
    "Case 17: shinyjs::disabled() in UI — stays disabled until toggled",
    "Toggle should enable on first click, then disable on the next.",
    tagList(
      actionButton("toggle_lnk_8", "Toggle"),
      br(), br(),
      disabled(downloadLink("lnk_8", "Download", enabled = FALSE))
    ),
    'disabled(
  downloadLink("lnk_8", "Download", enabled = FALSE)
)

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

  br(),
  case_row(
    "Case 18: shinyjs::disable() from server — stays disabled until toggled",
    NULL,
    tagList(
      actionButton("toggle_lnk_9", "Toggle"),
      br(), br(),
      downloadLink("lnk_9", "Download", enabled = FALSE)
    ),
    'downloadLink("lnk_9", "Download", enabled = FALSE)

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

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

  for (i in 1:9) output[[paste0("btn_", i)]] <- make_handler(paste0("btn_", i))
  for (i in 1:9) output[[paste0("lnk_", i)]] <- make_handler(paste0("lnk_", i))

  observeEvent(input$toggle_btn_2, {
    if (input$toggle_btn_2 %% 2 == 1) enable("btn_2") else disable("btn_2")
  })
  observeEvent(input$toggle_btn_3, {
    if (input$toggle_btn_3 %% 2 == 1) disable("btn_3") else enable("btn_3")
  })
  observeEvent(input$toggle_btn_5, {
    if (input$toggle_btn_5 %% 2 == 1) enable("btn_5") else disable("btn_5")
  })
  observeEvent(input$toggle_btn_6, {
    if (input$toggle_btn_6 %% 2 == 1) disable("btn_6") else enable("btn_6")
  })
  observeEvent(input$toggle_btn_8, {
    if (input$toggle_btn_8 %% 2 == 1) enable("btn_8") else disable("btn_8")
  })
  observeEvent(input$toggle_btn_9, {
    if (input$toggle_btn_9 %% 2 == 1) enable("btn_9") else disable("btn_9")
  })
  observeEvent(input$toggle_lnk_2, {
    if (input$toggle_lnk_2 %% 2 == 1) enable("lnk_2") else disable("lnk_2")
  })
  observeEvent(input$toggle_lnk_3, {
    if (input$toggle_lnk_3 %% 2 == 1) disable("lnk_3") else enable("lnk_3")
  })
  observeEvent(input$toggle_lnk_5, {
    if (input$toggle_lnk_5 %% 2 == 1) enable("lnk_5") else disable("lnk_5")
  })
  observeEvent(input$toggle_lnk_6, {
    if (input$toggle_lnk_6 %% 2 == 1) disable("lnk_6") else enable("lnk_6")
  })
  observeEvent(input$toggle_lnk_8, {
    if (input$toggle_lnk_8 %% 2 == 1) enable("lnk_8") else disable("lnk_8")
  })
  observeEvent(input$toggle_lnk_9, {
    if (input$toggle_lnk_9 %% 2 == 1) enable("lnk_9") else disable("lnk_9")
  })
}

shinyApp(ui, server)

@elnelson575 elnelson575 linked an issue Apr 9, 2026 that may be closed by this pull request
@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 Outdated
@elnelson575 elnelson575 force-pushed the fix/autoenable-behavior branch from f348912 to 35f71c4 Compare April 23, 2026 23:21
@elnelson575 elnelson575 requested a review from cpsievert April 23, 2026 23:27
Comment thread srcts/src/bindings/output/downloadlink.ts
Comment thread srcts/src/bindings/output/downloadlink.ts
Comment thread srcts/src/bindings/output/downloadlink.ts Outdated
Comment thread DESCRIPTION Outdated
Comment thread R/bootstrap.R Outdated
Comment thread R/bootstrap.R Outdated
Comment thread tests/testthat/test-downloadButton-shinytest2.R Outdated
Comment thread DESCRIPTION Outdated
Comment thread DESCRIPTION Outdated
Comment thread tests/testthat/test-downloadButton-shinytest2.R Outdated
@elnelson575 elnelson575 force-pushed the fix/autoenable-behavior branch from 2e1c842 to c16218b Compare April 24, 2026 16:11
Comment thread R/bootstrap.R Outdated
Comment thread srcts/src/bindings/output/downloadlink.ts Outdated
Comment thread R/bootstrap.R Outdated
Comment thread DESCRIPTION Outdated
Comment thread tests/testthat/test-downloadButton-shinytest2.R Outdated
Comment thread R/bootstrap.R Outdated
Comment thread srcts/src/bindings/output/downloadlink.ts Outdated
Comment thread srcts/src/bindings/output/downloadlink.ts Outdated
Comment thread tests/testthat/apps/download-button/app.R Outdated
Comment thread tests/testthat/apps/download-button/app.R Outdated
Comment thread R/bootstrap.R Outdated
Comment thread R/bootstrap.R Outdated
Comment thread tests/testthat/apps/download-button/app.R Outdated
- Prevent early clicks on enabled=TRUE buttons before renderValue sets
  the URL (href="" would otherwise navigate to the current page)
- Add skip_if_not_installed("shinyjs") to skip_if_not_shinytest2() so
  tests skip cleanly in envs where shinyjs is not available

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@elnelson575 elnelson575 force-pushed the fix/autoenable-behavior branch from afbab40 to d3696b2 Compare May 7, 2026 19:57
@cpsievert

Copy link
Copy Markdown
Collaborator

@copilot resolve the merge conflicts in this pull request

Co-authored-by: cpsievert <1365941+cpsievert@users.noreply.github.com>
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
2 out of 3 committers have signed the CLA.

✅ elnelson575
✅ cpsievert
❌ Copilot
You have signed the CLA already but the status is still pending? Let us recheck it.

Copilot AI commented May 8, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Resolved and pushed in 4c0548e. I merged origin/main into this branch and resolved the conflict in DESCRIPTION by keeping both Config/Needs/check: shinyjs, shinytest2 and Config/roxygen2/version: 8.0.0. Screenshot: N/A (no UI changes in this conflict-only update).

@cpsievert

Copy link
Copy Markdown
Collaborator

Thanks @elnelson575. Please also make a py-shiny PR with the equivalent changes

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 15 changed files in this pull request and generated 2 comments.

Comment thread DESCRIPTION Outdated
Comment thread tests/testthat/test-zzz-st2-download.R
E Nelson and others added 3 commits May 8, 2026 13:19
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Move shinytest2 to Suggests; access shinyjs indirectly via
getFromNamespace() to avoid the static pattern scanner.
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()

6 participants