rstudio / shiny

Easy interactive web applications with R
https://shiny.posit.co/
Other
5.37k stars 1.86k forks source link

Bindings can't be registered asynchronously #3635

Open cpsievert opened 2 years ago

cpsievert commented 2 years ago

Consider a basic input binding:

# app.R
library(shiny)

incrementButton <- function(inputId, value = 0) {
  tagList(
    tags$head(tags$script(src = "increment.js")),
    tags$button(id = inputId,
                class = "increment btn btn-default",
                type = "button",
                as.character(value))
  )
}

ui <- fluidPage(
  incrementButton("foo"),
  textOutput("foo_value")
)

server <- function(input, output, session) {
  output$foo_value <- renderText({
    paste("Clicked this many times:", input$foo)
  })
}

shinyApp(ui, server)
// www/increment.js

$(document).on("click", "button.increment", function(evt) {

  // evt.target is the button that was clicked
  var el = $(evt.target);

  // Set the button's text to its current value plus 1
  el.text(parseInt(el.text()) + 1);

  // Raise an event to signal that the value changed
  el.trigger("change");
});

var incrementBinding = new Shiny.InputBinding();
$.extend(incrementBinding, {
  find: function(scope) {
    return $(scope).find(".increment");
  },
  getValue: function(el) {
    return parseInt($(el).text());
  },
  setValue: function(el, value) {
    $(el).text(value);
  },
  subscribe: function(el, callback) {
    $(el).on("change.incrementBinding", function(e) {
      callback();
    });
  },
  unsubscribe: function(el) {
    $(el).off(".incrementBinding");
  }
});

Now, if we register synchronously all is fine, but not if we register in a setTimeout(), the binding doesn't bind to the DOM:

setTimeout(() => { Shiny.inputBindings.register(incrementBinding); }, 100);

That use case may seem contrived, but becomes a real problem if you're trying to load JS dependencies asynchronously (e.g., via requirejs) to define/register the binding:

incrementButton <- function(inputId, value = 0) {
  tagList(
    tags$head(
      tags$script(src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js"
      tags$script(src = "increment.js")
    ),
    tags$button(id = inputId,
                class = "increment btn btn-default",
                type = "button",
                as.character(value))
  )
}
require.config({paths: {"lodash": "https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.min"}});
require(["lodash"], function(_) {
  const finalBinding = _.last([incrementBinding]);
  Shiny.inputBindings.register(finalBinding);
})
cpsievert commented 1 year ago

For posterity, with Shiny>1.7.4, one can workaround this issue by adding this right after registering the binding (as was done in https://github.com/rstudio/py-shinywidgets/pull/91)

Shiny.shinyapp.taskQueue.enqueue(() => Shiny.bindAll(document.body));
wch commented 1 year ago

Update: see version in https://github.com/rstudio/py-shinywidgets/pull/93

I think it can actually be simplified to:

Shiny.shinyapp?.taskQueue?.enqueue(() => Shiny.bindAll(document.body));