renderUI fails to update when htmlwidgets has JS error - Is that a bug?

Hey everyone,

I've been debugging something that's driving me crazy, and I think I've found either a bug or at least some unexpected behavior in Shiny/htmlwidgets. Would love to hear your thoughts!

The Issue:

I have a uiOutput that conditionally shows one of two things based on which button you click:

  • A DT::datatable with data, OR
  • An error message (just a div with some text)

The datatable uses htmlwidgets::onRender to run some JavaScript. Here's the weird part: if that JavaScript has an error in it, Shiny won't update the UI at all - even when the app logic says it should be showing the error message instead of the table!

What I Expected:
When I click the error button, I should see the error message div (no table).

What Actually Happens:
The R console correctly logs that it's rendering the error message, but the browser still shows the old table. The JavaScript error (in code that shouldn't even be running!) breaks the entire UI update.

Here's a minimal example that shows the problem:

library(shiny)
library(DT)

ui <- fluidPage(
  "Click buttons in this order: ",
  tags$ul(
    tags$li("Table"),
    tags$li("Error (works)"),
    tags$li("Table"),
    tags$li("Error with JS bug (wrongly shows a table instead of an error view)"),
    tags$li("Error (works)"),
    tags$li("Error with JS bug (now it works)"),
  ),
  actionButton("show_table", "Show Table"),
  actionButton("show_error", "Show Error Message (works)"),
  actionButton("show_error_js", "Show Error Message (with JS bug)"),
  hr(),
  h3("UI-Output 'Content'"),
  uiOutput("content")
)

server <- function(input, output, session) {
  view <- reactiveVal("table")

  observeEvent(input$show_table, {
    view("table")
  })
  observeEvent(input$show_error, {
    view("error")
  })
  observeEvent(input$show_error_js, {
    view("error_js")
  })

  output$content <- renderUI({
    cat("Rendering:", view(), "\n")

    if (view() == "table") {
      DT::DTOutput("my_table")
    } else {
      div(
        h2("ERROR MESSAGE", style = "color: red;"),
        p("This is what you should see when clicking error buttons"),
        p(paste("Current view:", view()))
      )
    }
  })

  output$my_table <- DT::renderDT({
    DT::datatable(data.frame(view = view(), someData = rnorm(5))) |> htmlwidgets::onRender(htmlwidgets::JS(
      switch(view(),
        "table" = "function(el, x) { console.log('Table rendered OK'); }",
        "error" = "function(el, x) { console.log('Error view - no JS bug'); }",
        "error_js" = "function(el, x) { thisWillCauseAnError; }" # JS error
      )
    ))
  })
}

shinyApp(ui, server)

In this picture you can see the log of the R session claiming the error view was rendered despite displaying the table:

My Question:

Is this expected behavior? It seems problematic that:

  • An output that's not included in the rendered UI still executes
  • A JS error in that output silently breaks the UI update
  • There's no error message - just stale UI

Should I file this as an issue on GitHub, or am I missing something about how this is supposed to work?

Thanks!

PS: I used AI to improve my writing style, I hope you do not mind. Nevermind, all questions are thought out by me and I am curious to learn more about whether you have had problems like that before and how to prevent things like that.

The issue here is, that your nested render* calls (renderUI and renderDT) take a reactive dependency on the the same reactiveVal (view()) - so you can't tell which is rendered first. If renderDT runs first you'll get a table.

You can avoid this via isolate:

library(shiny)
library(DT)

ui <- fluidPage(
  "Click buttons in this order: ",
  tags$ul(
    tags$li("Table"),
    tags$li("Error (works)"),
    tags$li("Table"),
    tags$li("Error with JS bug (wrongly shows a table instead of an error view)"),
    tags$li("Error (works)"),
    tags$li("Error with JS bug (now it works)"),
  ),
  actionButton("show_table", "Show Table"),
  actionButton("show_error", "Show Error Message (works)"),
  actionButton("show_error_js", "Show Error Message (with JS bug)"),
  hr(),
  h3("UI-Output 'Content'"),
  uiOutput("content")
)

server <- function(input, output, session) {
  view <- reactiveVal("table")
  
  observeEvent(input$show_table, {
    view("table")
  })
  observeEvent(input$show_error, {
    view("error")
  })
  observeEvent(input$show_error_js, {
    view("error_js")
  })
  
  output$content <- renderUI({
    cat("Rendering:", view(), "\n")
    
    if (view() == "table") {
      DT::DTOutput("my_table")
    } else {
      div(
        h2("ERROR MESSAGE", style = "color: red;"),
        p("This is what you should see when clicking error buttons"),
        p(paste("Current view:", view()))
      )
    }
  })
  
  output$my_table <- DT::renderDT({
    DT::datatable(data.frame(view = isolate(view()), someData = rnorm(5))) |> htmlwidgets::onRender(htmlwidgets::JS(
      switch(isolate(view()),
             "table" = "function(el, x) { console.log('Table rendered OK'); }",
             "error" = "function(el, x) { console.log('Error view - no JS bug'); }",
             "error_js" = "function(el, x) { thisWillCauseAnError; }" # JS error
      )
    ))
  })
}

shinyApp(ui, server)

Furthermore, please check Mastering Shiny - Dynamic UI.

The approach with isolate does not work:

library(shiny)
library(DT)

ui <- fluidPage(
  "Click buttons in this order: ",
  tags$ul(
    tags$li("Table"),
    tags$li("Table2"),
  ),
  actionButton("show_table", "Show Table"),
  actionButton("show_table2", "Show Table 2"),
  actionButton("show_error", "Show Error Message (works)"),
  actionButton("show_error_js", "Show Error Message (with JS bug)"),
  hr(),
  h3("UI-Output 'Content'"),
  uiOutput("content")
)

server <- function(input, output, session) {
  view <- reactiveVal("table")
  
  observeEvent(input$show_table, {
    view("table")
  })
  observeEvent(input$show_table2, {
    view("table2")
  })
  observeEvent(input$show_error, {
    view("error")
  })
  observeEvent(input$show_error_js, {
    view("error_js")
  })
  
  output$content <- renderUI({
    cat("Rendering:", view(), "\n")
    
    if (view() %in% c("table", "table2")) {
      DT::DTOutput("my_table")
    } else {
      div(
        h2("ERROR MESSAGE", style = "color: red;"),
        p("This is what you should see when clicking error buttons"),
        p(paste("Current view:", view()))
      )
    }
  })
  
  output$my_table <- DT::renderDT({
    DT::datatable(data.frame(view = isolate(view()), someData = rnorm(5))) |> htmlwidgets::onRender(htmlwidgets::JS(
      switch(isolate(view()),
             "table" = "function(el, x) { console.log('Table rendered OK'); }",
             "table2" = "function(el, x) { console.log('Table2 rendered OK'); }",
             "error" = "function(el, x) { console.log('Error view - no JS bug'); }",
             "error_js" = "function(el, x) { thisWillCauseAnError; }" # JS error
      )
    ))
  })
}

shinyApp(ui, server)

Here I added a second table, but since the view is isolated in renderDT, the column "view" is rendered incorrectly in table2 (the table stays the same).

I currently can't test your modified example (mobile). However, I'd suggest using different reactives for the data you'd like to display and the error messages to avoid the common dependency.

1 Like

This looks like a winner to me. @Noskario, does the following work for you?

library(shiny)
library(DT)

ui <- fluidPage(
  "Click buttons in this order: ",
  tags$ul(
    tags$li("Table"),
    tags$li("Table2"),
  ),
  actionButton("show_table", "Show Table"),
  actionButton("show_table2", "Show Table 2"),
  actionButton("show_error", "Show Error Message (works)"),
  actionButton("show_error_js", "Show Error Message (with JS bug)"),
  hr(),
  h3("UI-Output 'Content'"),
  uiOutput("content")
)

server <- function(input, output, session) {
  view <- reactiveVal("table")
  view2 <- reactiveVal(NULL)
  
  observeEvent(input$show_table, {
    view("table")
  })
  observeEvent(input$show_table2, {
    view("table2")
  })
  observeEvent(input$show_error, {
    view("error")
  })
  observeEvent(input$show_error_js, {
    view("error_js")
  })
  
  output$content <- renderUI({
    cat("Rendering:", view(), "\n")
    
    if (view() %in% c("table", "table2")) {
      view2(view())
      DT::DTOutput("my_table")
    } else {
      div(
        h2("ERROR MESSAGE", style = "color: red;"),
        p("This is what you should see when clicking error buttons"),
        p(paste("Current view:", view()))
      )
    }
  })
  
  output$my_table <- DT::renderDT({
    DT::datatable(data.frame(view = view2(), someData = rnorm(5))) |> htmlwidgets::onRender(htmlwidgets::JS(
      switch(view2(),
             "table" = "function(el, x) { console.log('Table rendered OK'); }",
             "table2" = "function(el, x) { console.log('Table2 rendered OK'); }",
             "error" = "function(el, x) { console.log('Error view - no JS bug'); }",
             "error_js" = "function(el, x) { thisWillCauseAnError; }" # JS error
      )
    ))
  })
}

shinyApp(ui, server)

Note that view() triggers rendering of the output, view2() is used in creating the table, and view2 is updated only when view selects one of the two tables. This (hopefully) makes the order in which changes occur deterministic.

@prubin thanks for sharing an example with the approach I've mentioned above!

Here is another option to consider, which is avoiding renderUI, based on a hidden tabsetPanel:

library(shiny)
library(DT)

ui <- fluidPage(
  actionButton("show_table1", "Show Table 1"),
  actionButton("show_table2", "Show Table 2"),
  actionButton(
    "show_error",
    "Show Error Message (works)",
    onclick = "console.log('Error view - no JS bug');"
  ),
  actionButton(
    "show_error_js",
    "Show Error Message (with JS bug)",
    onclick = "function(el, x) { thisWillCauseAnError; }"
  ),
  hr(),
  tabsetPanel(
    id = "view",
    type = "hidden",
    tabPanelBody("table1", DT::DTOutput("my_table1")),
    tabPanelBody("table2", DT::DTOutput("my_table2")),
    tabPanelBody(
      "error",
      div(
        h2("ERROR MESSAGE", style = "color: red;"),
        p("This is what you should see when clicking error buttons"),
        p("Current view: error")
      )
    ),
    tabPanelBody(
      "error_js",
      div(
        h2("ERROR MESSAGE", style = "color: red;"),
        p("This is what you should see when clicking error buttons"),
        p("Current view: error_js")
      )
    )
  )
)

server <- function(input, output, session) {
  observeEvent(input$show_table1, {
    updateTabsetPanel(inputId = "view", selected = "table1")
  })
  observeEvent(input$show_table2, {
    updateTabsetPanel(inputId = "view", selected = "table2")
  })
  observeEvent(input$show_error, {
    updateTabsetPanel(inputId = "view", selected = "error")
  })
  observeEvent(input$show_error_js, {
    updateTabsetPanel(inputId = "view", selected = "error_js")
  })
  my_data <- reactive({data.frame(view = input$view, someData = rnorm(5))})
  output$my_table1 <- DT::renderDT({
    req(input$view == "table1", cancelOutput = TRUE)
      DT::datatable(my_data()) |>
        htmlwidgets::onRender(htmlwidgets::JS(
          "function(el, x) { console.log('Table1 rendered OK'); }"
        ))
  })
  output$my_table2 <- DT::renderDT({
    req(input$view == "table2", cancelOutput = TRUE)
      DT::datatable(my_data()) |>
        htmlwidgets::onRender(htmlwidgets::JS(
          "function(el, x) { console.log('Table2 rendered OK'); }"
        ))
  })
}

shinyApp(ui, server)

Thank you for your suggestions. I think for my usecase the solution by prubin looks promising, it still allows for a dynamic rendering of arbitrary content of a number of options not known when the ui is created.

I will still file a bug report on shiny though, I am still convinced the behaviour of my app should not happen.

This topic was automatically closed 7 days after the last reply. New replies are no longer allowed.

If you have a query related to it or one of the replies, start a new topic and refer back with a link.