-
Notifications
You must be signed in to change notification settings - Fork 1.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(insertTab): Render inserted nav html only once #4179
base: main
Are you sure you want to change the base?
Conversation
4810e9f
to
4040039
Compare
// nav controls so we can rewrite some attributes and choose where to | ||
// insert the new controls. | ||
const $fragLi = $("<div>"); | ||
await renderContentAsync($fragLi, message.liTag, "afterBegin"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note B in the mega-comment below says "renderContent must be called on an element that's attached to the document". Is that going to be a problem here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually it seems like renderContentAsync()
is essentially the same but renderHtml()
has changed. (Leaving crumbs for myself)
https://github.com/rstudio/shiny/blob/91dbb0e77bfe137335e628ba314c6c9c399e2b71/srcjs/output_binding_html.js
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I found a very helpful comment from you @jcheng5 in that PR thread that I'll move up to a top-level comment here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The mega comment also states
// D) renderContent has a bug where only position "replace" (the default)
// uses the jQuery functions, so other positions like "beforeend" will
// prevent child script tags from running.
but one core difference is that renderHtml()
does now use jQuery for all insertion positions. The change was made in #3630.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, I found it! Output elements need to be attached to the DOM, otherwise they aren't bound
Lines 317 to 320 in d764ea9
// In some uncommon cases, elements that are later in the | |
// matches array can be removed from the document by earlier | |
// iterations. See https://github.com/rstudio/shiny/issues/1399 | |
if (!$.contains(document.documentElement, el)) continue; |
So we just need to call bindAll()
again after inserting the nav controls, in case they contain outputs. af6ecdb
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After further reflection, I'm fine with this the way you've written it. I was going to suggest doing all the tag manipulation with a parsed-but-not-rendered-or-inserted $liTag
, then calling renderContentAsync without doing the appending the $liTag
as a separate step. And if it were me I'd still maybe try to do it, but it's a pretty marginal improvement in code clarity and no difference in behavior AFAICT. So I'll leave it up to you.
In #1794 (comment) @jcheng5 wrote:
I don't think these test cases became codified directly in a test app in shinycoreci, but I've recreated all of them in the following app (that can serve as the basis for an automated test). All cases work as expected with the current PR. Test dynamic tabslibrary(shiny)
library(bslib)
ui <- page_navbar(
title = "Reprex for #4179",
id = "main",
lang = "en",
navbar_options = navbar_options(collapsible = FALSE)
)
# https://github.com/rstudio/shiny/pull/1794#issuecomment-318722200
# We need these test cases for anywhere we insert dynamic UI:
# 1. `<script>` blocks should run
# 2. `<script>` blocks should only run once
# 3. `head()`/`singleton()` should be respected
# 4. HTML widgets should work
# a. Even when the dependencies are not part of the initial page load
# 5. Shiny inputs/outputs should work
# 6. Subapps should work (include a `shinyApp` object right in the UI)
action_link <- shiny::actionLink("refresh", "Refresh")
script_hello_world <- local({
i <- 0
function() {
i <<- i + 1
shiny::HTML(
sprintf('<p id="hello-%s"></p>', i),
"<script>(function() {",
sprintf("const text = 'Hello from %s.';", i),
sprintf("const p = document.getElementById('hello-%s');", i),
"p.textContent = text;",
"alert(text);",
"})()</script>"
)
}
})
script_singleton <- shiny::singleton(script_hello_world())
server <- function(input, output, session) {
nav_insert(
id = "main",
select = TRUE,
nav_panel(
"One",
p("Alert 'Hello 1' should run once."),
# 1. script blocks should run
script_singleton,
# 3. head() should be respected
tags$head(tags$meta(content = "shiny-test-head"))
),
)
nav_insert(
id = "main",
select = TRUE,
nav_panel(
"Two",
p(
"Alert 'Hello 2' and 'Hello 3' should appear once each. No 'Hello 1' should happen."
),
# 3. singleton blocks should be respected
script_singleton,
# 2. script blocks should only run once
script_hello_world(),
script_hello_world()
),
)
# 4. htmlwidgets work even if not part of initial page load
nav_insert(
id = "main",
select = TRUE,
nav_panel(
"Map",
leaflet::addTiles(leaflet::leaflet())
),
)
# 5. Input/outputs should work (in content)
nav_insert(
id = "main",
select = TRUE,
nav_panel(
"Inputs/outputs",
layout_columns(
actionButton("btn", "Click me"),
sliderInput("slider", "Slide me", min = 0, max = 10, value = 2),
),
verbatimTextOutput("debug")
)
)
output$debug <- renderPrint({
list(
btn = input$btn,
slider = input$slider,
nav_link = input$nav_link
)
})
# 5. Inputs/outputs work (in navbar)
nav_insert(
id = "main",
nav_item(
actionLink("nav_link", "Click me too", class = "nav-link")
)
)
nav_insert(
id = "main",
nav_item(textOutput("nav_output"))
)
output$nav_output <- renderText({
sprintf("Clicked %d times", input$nav_link)
})
# 6. Shiny subapps
nav_insert(
id = "main",
select = TRUE,
nav_panel(
"Shiny app",
p("There should be another shiny app in here."),
shinyApp(
ui = page_fluid(
theme = bs_theme(preset = "darkly"),
titlePanel("Hello from in here!"),
p("This is a sub-app. Notice we're re-using the btn id."),
actionButton("btn", "Click me"),
verbatimTextOutput("debug")
),
server = function(input, output, session) {
output$debug <- renderPrint(list(btn = input$btn))
}
)
)
)
}
shinyApp(ui, server) |
Output bindings require outputs to be attached to the DOM.
This PR refactors the
shiny-insert-tab
message handler.This message handler recieves two critical components:
liTag
: the<li>
item to be added to the nav controlsdivTag
: the tab content associated with those nav controls.These parts are not very well named:
nav_panel()
(or one tab), then there will be one<li>
tag and one<div>
tag.nav_menu()
, which counts as one<li>
tag but includes many<div>
tags.There are many places that the
<li>
tag could end up, either as a direct child of the parent nav list, or somewhere inside a nav menu of the selected navset. To know where to add the<li>
tag, we need to render it so we can inspect it, and then we probably also need to update attributes inside that tag to coordinate everything appropriately.There is only one place the
<div>
tag(s) end up, which is appended to the tab content container. Position is not important because visibility of this item is controlled by the nav item. If we're adding anav_menu()
, shiny/bslib will create IDs pairing the nav controls and the nav panel contents. But if we're adding a singlenav_panel()
, Shiny currently uses the existing navset markup to find an incremental ID for the new content<div>
, and we need to modify its ID.For these reasons, we previously would render the HTML once via jQuery, modify the markup as needed, and then stringify the markup and run it through
renderContentAsync()
.But there's no guarantee that the stringified markup will match the initial markup. If any elements modify their own attributes when added to the page (say a web component doing some initialization), the second insertion might not match the first.
This PR refactors the
shiny-insert-tab
message handler to userenderContentAsync()
to handle rendering ofliTag
anddivTag
and to perform the rendering exactly once:liTag
into a virtual document element viarenderContentAsync()
, modify the markup and then move the updated nodes into place.renderContentAsync()
once on thedivTag
html to add it to the tab content area. After it's added, we update the placeholder ID with the correct ID, if needed.Replaces #4176
Example app
Example app
This isn't the absolutely most comprehensive test, but it covers some of the different content types that could be included in the nav items and content.