From c5f21166b5ec76dabf8ce89302141efd7da4e49f Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Sat, 12 Sep 2026 21:58:05 -0400 Subject: [PATCH] docs(skills): two silent failure modes of the hosted-widget recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `shiny-outputs.md`'s hosted-input recipe (#294) leads readers straight into two Shiny behaviors it does not mention, both of which fail with no error, no warning, and nothing in the console: - an `update_slider()` / `updateSliderInput()` sent before the holder has rendered targets an id the client has not bound, and is dropped. The section previously said updates "keep working against the id" with no qualifier, which is exactly the sentence that walks you in. - a holder that is not visible on first paint is suspended, so its render function never runs and no widget appears at all. Both are plain Shiny — a `output_ui()` / `uiOutput()` app with no React anywhere loses the same update and suspends the same output — but the holder recipe makes them easy to hit, so the skill now documents them. Found via samuelbharti/shinyreact-apps' upstream gap report, which verified both in R over ~20 real ports. Pinned here for Python with two new e2e tests, each with its own control so neither can pass vacuously: the update test bumps the same slider after it exists (9 -> 30), and the suspension test mounts two identical holders in one hidden panel differing only by `suspend_when_hidden=False`. Flipping that one line fails the test. R has no e2e suite yet (#194), so the R halves are the upstream report's verification plus the shared Shiny mechanism, not a test here. --- .../references/shiny-outputs.md | 43 +++++++++++++- FEATURES.md | 11 ++++ .../references/shiny-outputs.md | 43 +++++++++++++- .../apps/hosted_input_timing/app.py | 57 +++++++++++++++++++ .../apps/hosted_input_timing/www/app.js | 26 +++++++++ .../apps/hosted_input_timing/www/index.html | 19 +++++++ .../playwright/test_hosted_input_timing.py | 42 ++++++++++++++ .../references/shiny-outputs.md | 43 +++++++++++++- 8 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 pkg-py/tests/playwright/apps/hosted_input_timing/app.py create mode 100644 pkg-py/tests/playwright/apps/hosted_input_timing/www/app.js create mode 100644 pkg-py/tests/playwright/apps/hosted_input_timing/www/index.html create mode 100644 pkg-py/tests/playwright/test_hosted_input_timing.py diff --git a/.claude/skills/shinyreact-build-app/references/shiny-outputs.md b/.claude/skills/shinyreact-build-app/references/shiny-outputs.md index 348cd846..4e144cb9 100644 --- a/.claude/skills/shinyreact-build-app/references/shiny-outputs.md +++ b/.claude/skills/shinyreact-build-app/references/shiny-outputs.md @@ -66,9 +66,50 @@ def widgets(): Shiny's html-output binding calls `renderContent()`, which loads the dependencies and then runs `initializeInputs()` *and* `bindAll()`. So `input.bins()` / `input$bins` arrives exactly as in a classic app, and -`update_slider()` / `updateSliderInput()` keeps working against the id. +`update_slider()` / `updateSliderInput()` keeps working against the id — once +the holder has rendered; see below. This is a deliberate exception, not the default — React-owned state through `useShinyInput` / `useSetShinyInput` is still how you build inputs. Use the holder when pixel-identical widgets matter more than owning the state. +## Two things that break only once a widget is hosted + +Both are Shiny's own behavior, not shinyreact's — a plain `output_ui()` / +`uiOutput()` app with no React anywhere hits them identically. The holder +recipe above just makes them easy to walk into. + +**An update sent before the holder has rendered goes nowhere.** The widget +does not exist, server or client, until its `@render.ui` / `renderUI()` has +actually run. An update reaching Shiny before that targets an id Shiny does +not know about yet and is dropped: no error, no warning, the widget simply +starts at its own initial value. + +```python +@reactive.effect # [r] observe({ +def _(): # [r] updateSliderInput(session, "bins", value = 30) + ui.update_slider("bins", value=30) # [r] }) +``` + +A hosted widget makes the race easy to hit, since the render now waits on a +React commit as well. Prefer putting the value in the widget's own call +(`ui.input_slider("bins", "Bins", 1, 50, value=30)`); reach for `update_*` +only for a value you do not know until after the widget already exists. + +**A holder that starts hidden never renders at all.** Shiny suspends a +`@render.ui` / `renderUI()` output while nothing on screen is asking for it, +and a `ShinyOutput` inside a closed accordion, a non-default tab, or any +`display: none` container counts as not asked for — even though the element +itself has mounted. The render function never runs, the widget stays blank +indefinitely, and nothing in the console says why. + +```python +# [py] Express +session.output(suspend_when_hidden=False)(widgets) + +# [py] Core: @output(suspend_when_hidden=False) above @render.ui +# [r] outputOptions(output, "widgets", suspendWhenHidden = FALSE) +``` + +Set it on every hosted widget that can start outside the visible panel. + diff --git a/FEATURES.md b/FEATURES.md index a1fed8be..e0893395 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1097,6 +1097,17 @@ the shinyreact bundle dependency and the `#shinyreact-config` tag — except - dragging the hosted slider to its max pushes `50` to the server `(e2e)` - `ui.update_slider()` / `ui.update_selectize()` still target the hosted widgets by id, and the new values flow back through `input` `(e2e)` + - but only once the holder has rendered: an update sent before that + targets an id the client has not bound and is dropped silently — the + widget arrives at its own initial value `9`, not the updated `30` + `(e2e)` + - Shiny's own behavior, not shinyreact's — same drop in a plain + `output_ui()` app; the skill documents it in `shiny-outputs.md` + - a holder inside a `display: none` container is suspended and its render + function never runs, so no widget appears at all; + `[py]` `suspend_when_hidden=False` / `[r]` `suspendWhenHidden = FALSE` + is the opt-out `(e2e)` + - also Shiny's own behavior; documented in `shiny-outputs.md` - several holders side by side under one parent share one `bindAll` pass (see `ShinyOutput`), so the browser logs no "Duplicate output IDs" warning for them (#298) `(e2e)` diff --git a/pkg-py/src/shinyreact/.agents/skills/shinyreact-build-app/references/shiny-outputs.md b/pkg-py/src/shinyreact/.agents/skills/shinyreact-build-app/references/shiny-outputs.md index 348cd846..4e144cb9 100644 --- a/pkg-py/src/shinyreact/.agents/skills/shinyreact-build-app/references/shiny-outputs.md +++ b/pkg-py/src/shinyreact/.agents/skills/shinyreact-build-app/references/shiny-outputs.md @@ -66,9 +66,50 @@ def widgets(): Shiny's html-output binding calls `renderContent()`, which loads the dependencies and then runs `initializeInputs()` *and* `bindAll()`. So `input.bins()` / `input$bins` arrives exactly as in a classic app, and -`update_slider()` / `updateSliderInput()` keeps working against the id. +`update_slider()` / `updateSliderInput()` keeps working against the id — once +the holder has rendered; see below. This is a deliberate exception, not the default — React-owned state through `useShinyInput` / `useSetShinyInput` is still how you build inputs. Use the holder when pixel-identical widgets matter more than owning the state. +## Two things that break only once a widget is hosted + +Both are Shiny's own behavior, not shinyreact's — a plain `output_ui()` / +`uiOutput()` app with no React anywhere hits them identically. The holder +recipe above just makes them easy to walk into. + +**An update sent before the holder has rendered goes nowhere.** The widget +does not exist, server or client, until its `@render.ui` / `renderUI()` has +actually run. An update reaching Shiny before that targets an id Shiny does +not know about yet and is dropped: no error, no warning, the widget simply +starts at its own initial value. + +```python +@reactive.effect # [r] observe({ +def _(): # [r] updateSliderInput(session, "bins", value = 30) + ui.update_slider("bins", value=30) # [r] }) +``` + +A hosted widget makes the race easy to hit, since the render now waits on a +React commit as well. Prefer putting the value in the widget's own call +(`ui.input_slider("bins", "Bins", 1, 50, value=30)`); reach for `update_*` +only for a value you do not know until after the widget already exists. + +**A holder that starts hidden never renders at all.** Shiny suspends a +`@render.ui` / `renderUI()` output while nothing on screen is asking for it, +and a `ShinyOutput` inside a closed accordion, a non-default tab, or any +`display: none` container counts as not asked for — even though the element +itself has mounted. The render function never runs, the widget stays blank +indefinitely, and nothing in the console says why. + +```python +# [py] Express +session.output(suspend_when_hidden=False)(widgets) + +# [py] Core: @output(suspend_when_hidden=False) above @render.ui +# [r] outputOptions(output, "widgets", suspendWhenHidden = FALSE) +``` + +Set it on every hosted widget that can start outside the visible panel. + diff --git a/pkg-py/tests/playwright/apps/hosted_input_timing/app.py b/pkg-py/tests/playwright/apps/hosted_input_timing/app.py new file mode 100644 index 00000000..2aff1a60 --- /dev/null +++ b/pkg-py/tests/playwright/apps/hosted_input_timing/app.py @@ -0,0 +1,57 @@ +"""Two ways a *hosted* Shiny input widget quietly fails to appear or update. + +Both are Shiny's own reactive-graph behavior, not shinyreact's, but the +`@render.ui` holder recipe walks you straight into them: + +1. An `update_slider()` sent before the holder has rendered targets an id the + client does not know yet, and is dropped — silently. +2. A holder that is not visible on first paint is *suspended*, so its render + function never runs and the widget never appears at all. +""" + +from shiny import reactive +from shiny.express import input, render, session, ui # noqa: F401 # marks Express +from shinyreact import set_react_page + +set_react_page() + + +# --- 1. An update that arrives before the widget exists is dropped ---------- +# +# The holder stays empty until the test clicks "Render it", so `_at_startup` +# below is guaranteed to have run first. +@render.ui +def late_widget(): + if not input.show(): + return None + return ui.input_slider("late_bins", "Late", min=1, max=50, value=9) + + +@reactive.effect +def _at_startup(): + # First flush, long before `late_bins` exists on the client. Goes nowhere. + ui.update_slider("late_bins", value=30) + + +@reactive.effect +@reactive.event(input.bump, ignore_init=True) +def _after_it_exists(): + # The control: the very same call, once the widget is really there. + ui.update_slider("late_bins", value=30) + + +# --- 2. A holder that starts hidden never computes -------------------------- +# +# Both of these are mounted inside a `display: none` panel. Only the second +# one opts out of suspension, so only the second one ever renders. +@render.ui +def suspended_widget(): + return ui.input_slider("suspended_bins", "Suspended", min=1, max=50, value=9) + + +@render.ui +def unsuspended_widget(): + return ui.input_slider("unsuspended_bins", "Unsuspended", min=1, max=50, value=9) + + +session.output(suspend_when_hidden=False)(unsuspended_widget) diff --git a/pkg-py/tests/playwright/apps/hosted_input_timing/www/app.js b/pkg-py/tests/playwright/apps/hosted_input_timing/www/app.js new file mode 100644 index 00000000..34a530e6 --- /dev/null +++ b/pkg-py/tests/playwright/apps/hosted_input_timing/www/app.js @@ -0,0 +1,26 @@ +const { React, ReactDOM, useSetShinyInput, ShinyOutput } = window.shinyreact; +const h = React.createElement; + +const eventInput = { debounceMs: 0, priority: "event" }; + +function App() { + const show = useSetShinyInput("show", 0, eventInput); + const bump = useSetShinyInput("bump", 0, eventInput); + + return h( + "div", + { "data-test": "container" }, + h("button", { id: "show", onClick: () => show((n) => (n ?? 0) + 1) }, "Render it"), + h("button", { id: "bump", onClick: () => bump((n) => (n ?? 0) + 1) }, "Bump it"), + h(ShinyOutput, { id: "late_widget", className: "shiny-html-output" }), + // Never visible on first paint, so Shiny reports both outputs hidden. + h( + "div", + { id: "hidden-panel" }, + h(ShinyOutput, { id: "suspended_widget", className: "shiny-html-output" }), + h(ShinyOutput, { id: "unsuspended_widget", className: "shiny-html-output" }), + ), + ); +} + +ReactDOM.createRoot(document.getElementById("root")).render(h(App)); diff --git a/pkg-py/tests/playwright/apps/hosted_input_timing/www/index.html b/pkg-py/tests/playwright/apps/hosted_input_timing/www/index.html new file mode 100644 index 00000000..8ad57b25 --- /dev/null +++ b/pkg-py/tests/playwright/apps/hosted_input_timing/www/index.html @@ -0,0 +1,19 @@ + +

+ Click Render it: the slider appears at 9, not + 30 — the startup update_slider() was dropped + because the widget did not exist yet. Click Bump it and the same call + now works, giving 30. +

+

+ A hidden panel below holds two more sliders. Only + #unsuspended_bins exists in the DOM; + #suspended_bins never rendered, because Shiny suspends an output + nothing on screen is asking for. +

+
+ diff --git a/pkg-py/tests/playwright/test_hosted_input_timing.py b/pkg-py/tests/playwright/test_hosted_input_timing.py new file mode 100644 index 00000000..4da61a65 --- /dev/null +++ b/pkg-py/tests/playwright/test_hosted_input_timing.py @@ -0,0 +1,42 @@ +"""Two silent failure modes of the hosted-widget recipe in `shiny-outputs.md`. + +Neither is a shinyreact bug — both are Shiny's own behavior — but the +`@render.ui` holder recipe leads straight into them, so the skill documents +them and these tests pin the claims for Python. (R was verified separately; +R has no e2e suite yet, see issue #194.) +""" + +from playwright.sync_api import Page, expect +from shiny.pytest import create_app_fixture +from shiny.run import ShinyAppProc + +timing_app = create_app_fixture("apps/hosted_input_timing/app.py") + + +def test_update_before_the_holder_renders_is_dropped( + page: Page, timing_app: ShinyAppProc +) -> None: + page.goto(timing_app.url) + + # The app fired `update_slider("late_bins", value=30)` in its first flush, + # before this holder had rendered. The widget arrives at its own initial + # value; the update was dropped with no error and no warning. + page.locator("#show").click() + expect(page.locator("#late_widget .irs-single")).to_have_text("9") + + # Control, so the assertion above cannot pass vacuously: the identical + # call, once the widget really exists, does land. + page.locator("#bump").click() + expect(page.locator("#late_widget .irs-single")).to_have_text("30") + + +def test_hosted_widget_hidden_on_first_paint_never_renders( + page: Page, timing_app: ShinyAppProc +) -> None: + page.goto(timing_app.url) + + # Both holders sit in a `display: none` panel. `suspend_when_hidden=False` + # is the only difference between them, and it is the difference between a + # widget and an empty div. + expect(page.locator("#unsuspended_bins")).to_be_attached() + expect(page.locator("#suspended_bins")).to_have_count(0) diff --git a/pkg-r/inst/skills/shinyreact-build-app/references/shiny-outputs.md b/pkg-r/inst/skills/shinyreact-build-app/references/shiny-outputs.md index 348cd846..4e144cb9 100644 --- a/pkg-r/inst/skills/shinyreact-build-app/references/shiny-outputs.md +++ b/pkg-r/inst/skills/shinyreact-build-app/references/shiny-outputs.md @@ -66,9 +66,50 @@ def widgets(): Shiny's html-output binding calls `renderContent()`, which loads the dependencies and then runs `initializeInputs()` *and* `bindAll()`. So `input.bins()` / `input$bins` arrives exactly as in a classic app, and -`update_slider()` / `updateSliderInput()` keeps working against the id. +`update_slider()` / `updateSliderInput()` keeps working against the id — once +the holder has rendered; see below. This is a deliberate exception, not the default — React-owned state through `useShinyInput` / `useSetShinyInput` is still how you build inputs. Use the holder when pixel-identical widgets matter more than owning the state. +## Two things that break only once a widget is hosted + +Both are Shiny's own behavior, not shinyreact's — a plain `output_ui()` / +`uiOutput()` app with no React anywhere hits them identically. The holder +recipe above just makes them easy to walk into. + +**An update sent before the holder has rendered goes nowhere.** The widget +does not exist, server or client, until its `@render.ui` / `renderUI()` has +actually run. An update reaching Shiny before that targets an id Shiny does +not know about yet and is dropped: no error, no warning, the widget simply +starts at its own initial value. + +```python +@reactive.effect # [r] observe({ +def _(): # [r] updateSliderInput(session, "bins", value = 30) + ui.update_slider("bins", value=30) # [r] }) +``` + +A hosted widget makes the race easy to hit, since the render now waits on a +React commit as well. Prefer putting the value in the widget's own call +(`ui.input_slider("bins", "Bins", 1, 50, value=30)`); reach for `update_*` +only for a value you do not know until after the widget already exists. + +**A holder that starts hidden never renders at all.** Shiny suspends a +`@render.ui` / `renderUI()` output while nothing on screen is asking for it, +and a `ShinyOutput` inside a closed accordion, a non-default tab, or any +`display: none` container counts as not asked for — even though the element +itself has mounted. The render function never runs, the widget stays blank +indefinitely, and nothing in the console says why. + +```python +# [py] Express +session.output(suspend_when_hidden=False)(widgets) + +# [py] Core: @output(suspend_when_hidden=False) above @render.ui +# [r] outputOptions(output, "widgets", suspendWhenHidden = FALSE) +``` + +Set it on every hosted widget that can start outside the visible panel. +