Follow-up to #87. Builds on the automatic dependency discovery shipped there (synchronously-mounted top-level + @module.server renderers) and the @render.ui dynamic-UI path (delivered natively by Shiny).
Goal
Make the server-registers-a-renderer-after-page-load + React-supplied placeholder pattern work automatically, with no user ceremony. Example:
@reactive.effect
@reactive.event(input.add)
async def _():
@render_plotly
def scatter():
return px.scatter(...)
get_current_session().output(scatter) # registered after the page is built
…with the React client mounting <ShinyOutput id="scatter" .../>. Today the chart never renders: the renderer's HTMLDependency (the binding JS) was never delivered, so the element can't bind.
What we already ruled in / out (investigation summary)
All verified with Playwright spikes against shinywidgets render_plotly:
- Value delivery is NOT the problem. A dynamically-registered output sends its value fine; Shiny's
bindOutput even replays a stored $values[id] when the element later binds. A dynamically-registered render.text (binding JS in core shiny.js) renders correctly.
- The problem is the missing dependency.
dep_scripts=0 → bound=0 → no render. Proof: a ui.hold()-ed warm-up render_plotly (so Layer A injects the binding JS at startup) makes the dynamically registered output render (bound=1, plotly=1).
- Naive late-push does NOT work for widgets. Pushing the dep after registration +
renderDependenciesAsync + bindAll fails with Error: No model found for id …. shinywidgets sends the widget model via a comm_open custom message alongside the value; because the dep (which registers the comm handler) loads asynchronously after comm_open arrives, the message is dropped and the model is never created. This is a fundamental ordering problem, not a timing tweak (retrying bindAll for 2.4s did not help — the model is gone).
- A re-trigger handshake DOES work (
bound=1, plotly=1): push deps → client loads them → client signals server → the output recomputes → shinywidgets re-sends comm_open with its handler now present.
- But auto-triggering that recompute is the open problem. The handshake only worked when the renderer depended on a reactive that the deps-ready signal bumped (one line of user cooperation). Two no-cooperation triggers failed:
- re-registering the renderer (
session.output(renderer) again) → bound=0;
- manually invalidating the output effect's context (
effect._ctx.invalidate()) → bound=0 (invalidation without Shiny's flush-scheduling pipeline doesn't drive the recompute).
What "automatic" needs (remaining work)
A way to force a registered output to recompute through Shiny's normal reactive pipeline — without the user's renderer explicitly depending on a deps-ready signal. Candidate directions to explore:
- A supported force-invalidate-and-flush primitive (invalidate the output context and request a session flush), rather than the private
effect._ctx poke that didn't drive the recompute.
- A
suspend_when_hidden-based dance: register the dynamic output suspended/hidden, push deps, then reveal it so Shiny's resume-on-show recomputes it after deps load.
- A shinyreact-owned reactive that dynamic outputs implicitly depend on (would likely require wrapping the renderer at registration).
Interim opt-in fallback (proven, ~1 line): have the dynamic renderer depend on a shinyreact-provided "deps-ready" reactive so the existing reactive path recomputes it.
Related
Follow-up to #87. Builds on the automatic dependency discovery shipped there (synchronously-mounted top-level +
@module.serverrenderers) and the@render.uidynamic-UI path (delivered natively by Shiny).Goal
Make the server-registers-a-renderer-after-page-load + React-supplied placeholder pattern work automatically, with no user ceremony. Example:
…with the React client mounting
<ShinyOutput id="scatter" .../>. Today the chart never renders: the renderer'sHTMLDependency(the binding JS) was never delivered, so the element can't bind.What we already ruled in / out (investigation summary)
All verified with Playwright spikes against
shinywidgetsrender_plotly:bindOutputeven replays a stored$values[id]when the element later binds. A dynamically-registeredrender.text(binding JS in coreshiny.js) renders correctly.dep_scripts=0 → bound=0 → no render. Proof: aui.hold()-ed warm-uprender_plotly(so Layer A injects the binding JS at startup) makes the dynamically registered output render (bound=1, plotly=1).renderDependenciesAsync+bindAllfails withError: No model found for id …. shinywidgets sends the widget model via acomm_opencustom message alongside the value; because the dep (which registers the comm handler) loads asynchronously aftercomm_openarrives, the message is dropped and the model is never created. This is a fundamental ordering problem, not a timing tweak (retryingbindAllfor 2.4s did not help — the model is gone).bound=1, plotly=1): push deps → client loads them → client signals server → the output recomputes → shinywidgets re-sendscomm_openwith its handler now present.session.output(renderer)again) →bound=0;effect._ctx.invalidate()) →bound=0(invalidation without Shiny's flush-scheduling pipeline doesn't drive the recompute).What "automatic" needs (remaining work)
A way to force a registered output to recompute through Shiny's normal reactive pipeline — without the user's renderer explicitly depending on a deps-ready signal. Candidate directions to explore:
effect._ctxpoke that didn't drive the recompute.suspend_when_hidden-based dance: register the dynamic output suspended/hidden, push deps, then reveal it so Shiny's resume-on-show recomputes it after deps load.Interim opt-in fallback (proven, ~1 line): have the dynamic renderer depend on a shinyreact-provided "deps-ready" reactive so the existing reactive path recomputes it.
Related
docs/superpowers/specs/2026-06-11-dynamic-renderer-dep-delivery-design.md.