-
Couldn't load subscription status.
- Fork 1.9k
Description
Background
We often need to run a small amount of initialization code for custom components, e.g. in bslib for sidebars, cards and accordions. In a static context, we could, in theory, just use $(document).ready() and initialize everything once on page load.
In Shiny, though, we need to support dynamically inserted UI, where the elements appear on the page either via uiOutput() or insertUI() after the initial startup. While our immediate use case is initialization, we could also imagine having a similar need to run clean up code when UI elements are removed with removeUI() or replaced via a new uiOutput() update.
Both uiOutput() and insertUI() (in R) result in client-side calls to renderContentAsync(). While this function kicks off a lot of related work to handle html dependencies and other nuances, the key step of adding the new HTML to the DOM is performed by renderHTML(). That insertion step is sandwiched between output unbinding (if replacing) and input/output binding post-insertion.
Other use cases
-
In a recent issue, a user described a situation where they know that the UI insertion step will take some time to complete and they'd like to be informed when that work is complete.
Proposal
My proposal is to trigger events for two key steps in the renderHtml() process:
-
When an element is going to be removed, we emit
shiny:render.willremoveon the children of the element whose inner HTML will change. -
After the content is inserted, we emit
shiny:render.insertedon the content that was added. -
As an optional third event, we could emit
shiny:render.completefromrenderContentAsync()after the input/output binding occurs.
Adding the first two events requires a small change in renderHtml(). Currently, it receives html as a string that's processed by processHtml() where it's returned as a string of HTML having caught singletons and items destined for <head>.
The change would be to update processHtml() to instead use jQuery to convert the html string to HTMLElements using html: $(val), knowing that we'll use jQuery for insertion in renderHtml().
// instead of this
const processed = processHtml(html);
// we need to do this
const processed = processHtml(html);
processed.html = $(processed.html);This way, processed.html will contain references to the elements themselves. Later when we call, e.g.
$(el).before(processed.html);jQuery will here returns a reference to el and we'd have to go find the added elements ourselves. When processed.html refers to the DOM elements, after insertion we can call
processed.html.trigger('shiny:render.inserted')and anyone who needs to know about the inserted UI can listen for the shiny:render.inserted event.
$(document).on('shiny:render.inserted', function(ev) {
const inserted = ev.target
// process inserted elements
})The event-based method naturally solves the problem of wanting to limit the initialization to specific components
$(document).on('shiny:render.inserted', '.my-component', function(ev) {
// only process newly-inserted UI with .my-component class
})Update: this is actually less useful than I first thought. Because .my-component can be anywhere within the inserted UI, you can't expect a filter like this to work. Instead you'd have to $(ev.target).find('.my-component'). I still think it's reasonable to include the shiny:render.inserted event, but in practice shiny:render.complete would probably be most used.
The final shiny:render.complete event could be helpful. E.g. in the case of the linked issue above where the user would like to know when a renderUI() step is fully complete, this event could be helpful. To emit from the inserted elements we'd have to pass the DOM references back up to renderContentAsync(), but that seems reasonably straight-forward.
Alternative
I gave some thought to another process where the JavaScript author would need to register callbacks to be executed at various steps in the html-insertion lifecycle. An advantage of this approach is that you could do more work on the HTML prior to insertion, rather than just after insertion.
In the end, I think it would be a more complicated system to set up and the advantage would be small. It would also be a Shiny-focused solution. In the above approach, component authors can write one "on load" function that can be used to in both DOMContentLoaded and shiny:render.inserted events.
Questions
Most of my questions are around naming. Here are some other things I've thought of:
-
Use
shiny:renderhtmlas the prefix. This is more verbose but also more specific, whereshiny:rendermight be confused with many other actions in Shiny.shiny:renderhtml.willremoveshiny:renderhtml.insertedshiny:renderhtml.complete
-
Use names that might be associated with other common frameworks, e.g.
shiny:render.willUnmount(but I think this sounds more like unbind + remove than just remove)shiny:render.mountedshiny:render.complete
-
Camel case (
shiny:renderHtml,shiny:render.willRemove) or all lowercase (shiny:renderhtml,shiny:render.willremove)? -
Event name pattern: I like
{namespace}:{event}.{type}but all of our other events areshiny:{event}. E.g. we don't differentiate between input/outputboundevents, but it's something we could consider doing withshiny:bound.inputorshiny:bound.output.