Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion .claude/skills/shinyreact-build-app/references/shiny-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

11 changes: 11 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

57 changes: 57 additions & 0 deletions pkg-py/tests/playwright/apps/hosted_input_timing/app.py
Original file line number Diff line number Diff line change
@@ -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)
26 changes: 26 additions & 0 deletions pkg-py/tests/playwright/apps/hosted_input_timing/www/app.js
Original file line number Diff line number Diff line change
@@ -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));
19 changes: 19 additions & 0 deletions pkg-py/tests/playwright/apps/hosted_input_timing/www/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<style>
#hidden-panel {
display: none;
}
</style>
<p>
Click <b>Render it</b>: the slider appears at <code>9</code>, not
<code>30</code> — the startup <code>update_slider()</code> was dropped
because the widget did not exist yet. Click <b>Bump it</b> and the same call
now works, giving <code>30</code>.
</p>
<p>
A hidden panel below holds two more sliders. Only
<code>#unsuspended_bins</code> exists in the DOM;
<code>#suspended_bins</code> never rendered, because Shiny suspends an output
nothing on screen is asking for.
</p>
<div id="root"></div>
<script src="app.js" defer></script>
42 changes: 42 additions & 0 deletions pkg-py/tests/playwright/test_hosted_input_timing.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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.