Summary
Since #4388 (shiny 1.14.0), a dynamically-rendered output (e.g. uiOutput()) that lives inside a display:contents wrapper is never reported as visible when it becomes visible, so the server keeps it suspended and it renders nothing (it stays stuck recalculating). This surfaces most visibly with bslib popover()/tooltip() content — see downstream report rstudio/bslib#1326.
Reproducible example
library(shiny)
library(bslib)
ui <- page_fillable(
class = "p-5",
popover(
icon("maximize"),
uiOutput("ui") # no `title =` on the popover
)
)
server <- function(input, output) {
output$ui <- renderUI(span("Hello from the popover!"))
}
shinyApp(ui, server)
Open the popover: the body is empty. Add a title to the popover (or give the output an intrinsic width, e.g. uiOutput("ui", style = "min-width: 1px")) and it renders. Works in shiny 1.13.0; broken in 1.14.0. git bisect points at #4388.
Root cause
PR #4388 changed ensureObservers() so that, for a .shiny-html-output, the IntersectionObserver observes a box-model ancestor (via resolveObservableTarget()) instead of the output itself — because an output with children gets display:contents from the :has(> *) CSS rule, and a boxless element has a permanent intersection ratio of 0.
But resolveObservableTarget() only walks past ancestors that are themselves .shiny-html-output:
|
function resolveObservableTarget(el: Element): Element { |
|
if (!el.classList.contains("shiny-html-output")) { |
|
return el; |
|
} |
|
let candidate = el.parentElement; |
|
|
|
while (candidate) { |
|
if (!candidate.classList.contains("shiny-html-output")) { |
|
return candidate; |
|
} |
|
if (candidate === document.documentElement) { |
|
break; |
|
} |
|
candidate = candidate.parentElement; |
|
} |
|
return el; |
|
} |
When the output's parent is some other display:contents element — e.g. bslib wraps popover/tooltip content in <div style="display:contents"> — the walk stops on that wrapper and observes it. That wrapper is also boxless, so the IntersectionObserver still never fires, and visibility changes are never detected. (isVisible() itself is correct; the problem is purely that nothing triggers the refresh.)
Why it looks "title-dependent" downstream
The output's ResizeObserver (still attached to the output element) is the only remaining trigger. It only fires if the output's box size changes from the initial 0×0 it had while hidden. A bslib popover with a title gets intrinsic width from its header, so the empty output gets a non-zero width and the RO fires (masking the bug); without a title the popover collapses to ~0 width, the output stays 0×0, the RO never fires, and nothing renders. So the title axis is a side effect — the real defect is the disabled IntersectionObserver.
Proposed fix
Generalize resolveObservableTarget() to keep walking past any boxless display:contents element, not just .shiny-html-output, so the IO lands on an ancestor that actually has a box (for the popover case, .popover-body). The function's own comment already anticipates this ("if other containers adopt display:contents, generalize here").
Open question for implementation: detect via computed display === "contents" (catches the inline-style case like bslib's wrapper at setup time; note the deferred :has() case is why the original class-name check exists), and/or broaden the structural walk.
Summary
Since #4388 (shiny 1.14.0), a dynamically-rendered output (e.g.
uiOutput()) that lives inside adisplay:contentswrapper is never reported as visible when it becomes visible, so the server keeps it suspended and it renders nothing (it stays stuckrecalculating). This surfaces most visibly with bslibpopover()/tooltip()content — see downstream report rstudio/bslib#1326.Reproducible example
Open the popover: the body is empty. Add a
titleto the popover (or give the output an intrinsic width, e.g.uiOutput("ui", style = "min-width: 1px")) and it renders. Works in shiny 1.13.0; broken in 1.14.0.git bisectpoints at #4388.Root cause
PR #4388 changed
ensureObservers()so that, for a.shiny-html-output, theIntersectionObserverobserves a box-model ancestor (viaresolveObservableTarget()) instead of the output itself — because an output with children getsdisplay:contentsfrom the:has(> *)CSS rule, and a boxless element has a permanent intersection ratio of 0.But
resolveObservableTarget()only walks past ancestors that are themselves.shiny-html-output:shiny/srcts/src/shiny/index.ts
Lines 468 to 484 in 107747a
When the output's parent is some other
display:contentselement — e.g. bslib wraps popover/tooltip content in<div style="display:contents">— the walk stops on that wrapper and observes it. That wrapper is also boxless, so theIntersectionObserverstill never fires, and visibility changes are never detected. (isVisible()itself is correct; the problem is purely that nothing triggers the refresh.)Why it looks "title-dependent" downstream
The output's
ResizeObserver(still attached to the output element) is the only remaining trigger. It only fires if the output's box size changes from the initial0×0it had while hidden. A bslib popover with a title gets intrinsic width from its header, so the empty output gets a non-zero width and the RO fires (masking the bug); without a title the popover collapses to ~0 width, the output stays0×0, the RO never fires, and nothing renders. So the title axis is a side effect — the real defect is the disabledIntersectionObserver.Proposed fix
Generalize
resolveObservableTarget()to keep walking past any boxlessdisplay:contentselement, not just.shiny-html-output, so the IO lands on an ancestor that actually has a box (for the popover case,.popover-body). The function's own comment already anticipates this ("if other containers adopt display:contents, generalize here").Open question for implementation: detect via computed
display === "contents"(catches the inline-style case like bslib's wrapper at setup time; note the deferred:has()case is why the original class-name check exists), and/or broaden the structural walk.