Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
102 commits
Select commit Hold shift + click to select a range
a10db83
test: Add concurrency tests for cross-session and busy-session behavior
schloerke Sep 25, 2026
3c889f5
feat: Run reactive effects concurrently, R Shiny-style
schloerke Sep 25, 2026
332edff
test(chat): Don't assert message order in chat stream test
schloerke Sep 25, 2026
98b8a8c
test: Add two-tab e2e test for cross-session concurrency
schloerke Sep 25, 2026
feeeee2
docs: Move flush reentrancy note to a code comment; drop R references
schloerke Sep 25, 2026
3b9bd39
test(chat): Restore message order check in chat stream test
schloerke Sep 25, 2026
00f74e0
fix(reactive): Share in-flight async calc runs and serialize effect runs
schloerke Sep 30, 2026
156c5bf
fix(reactive): Make reactive.flush() wait reliably without hanging
schloerke Sep 30, 2026
7869400
fix(session): Flush only sessions that request it, each on its own
schloerke Sep 30, 2026
dd72820
docs: Update extended-task skill and otel example for concurrent effects
schloerke Sep 30, 2026
d7b444a
test: Add regression tests from the PR review's repros
schloerke Sep 30, 2026
2f33e40
Merge remote-tracking branch 'origin/main' into schloerke/async-pr-21…
schloerke Sep 30, 2026
7b16aa0
docs: Add changelog entry for concurrent reactive effects
schloerke Sep 30, 2026
dc42c0c
fix(reactive): Don't wait in reactive.flush() under a running flush o…
schloerke Sep 30, 2026
5bd513c
fix(reactive): Cache only an async calc's latest, still-valid run
schloerke Sep 30, 2026
0bf0ebe
test: Cover flushing through child tasks and stale async calc runs
schloerke Sep 30, 2026
f283caf
refactor: Drop two checks no behavior depends on
schloerke Sep 30, 2026
fd6b68e
test: Give every concurrency fix a test that fails without it
schloerke Sep 30, 2026
33d1711
docs: Say when reactive.flush() returns without waiting
schloerke Sep 30, 2026
a00ce1a
feat(session): Send each session's outputs in a task of its own
schloerke Sep 30, 2026
f910a1f
feat(reactive): Accept flush requests from other threads
schloerke Sep 30, 2026
fc959b3
docs: Describe per-session output and thread-safe flush requests
schloerke Sep 30, 2026
8e4587e
test: Cover per-session output tasks and flush requests from threads
schloerke Sep 30, 2026
3430e1c
test: Catch Exception, not BaseException, in closed-loop test (flake8…
schloerke Sep 30, 2026
b02a18d
test: Cover reactive values set from a download handler (#1785)
schloerke Oct 1, 2026
f50c245
test: Cover values set and input handled while a download streams
jat255 Oct 2, 2026
0b89ab7
test: Fail any unit test that runs longer than 2 minutes
schloerke Oct 2, 2026
d0865c0
fix(reactive): Discard flush state left by an event loop that has sto…
schloerke Oct 2, 2026
4c7eabc
test: Make the flushed-callback busy test deterministic
schloerke Oct 2, 2026
d481337
fix(reactive): Keep tasks from a stopped event loop alive
schloerke Oct 2, 2026
a9fe446
Merge remote-tracking branch 'origin/main' into schloerke/async-pr-21…
schloerke Oct 2, 2026
7d27a41
test: Wait for the flush task in the dead-event-loop test
jat255 Oct 3, 2026
caa7ce4
feat(reactive): Cancel an effect's in-progress run when it is destroyed
schloerke Oct 1, 2026
d856b00
docs: Cite #2515 in the changelog
schloerke Oct 1, 2026
435e1ae
test: Cancel a recursive Fibonacci effect and calc mid-run
schloerke Oct 1, 2026
5d118d5
fix(reactive): Log a destroyed effect's errors instead of closing the…
schloerke Oct 1, 2026
8320cfd
test: Drop the Fibonacci cancellation test; smaller tests cover it
schloerke Oct 1, 2026
11743a3
feat(session): Add `session.run_once_when_idle()`
schloerke Oct 1, 2026
c6cf363
docs: Cite #2517 in the changelog
schloerke Oct 1, 2026
fd31149
feat(reactive): Deprecate `reactive.lock()`, which now does nothing
jat255 Oct 2, 2026
aa2f70c
fix(reactive): Keep `reactive.lock()` typed as an `asyncio.Lock`
jat255 Oct 2, 2026
56b49e6
docs: Cite #2520 in the changelog
jat255 Oct 2, 2026
74ca130
feat(otel): Give each session's cycle its own `reactive_update` span
schloerke Oct 2, 2026
24377ec
docs: Cite #2522 in the changelog
schloerke Oct 2, 2026
a45110c
fix(bookmark): Run async on_restore callbacks before the session's ef…
jat255 Oct 4, 2026
4be9d36
refactor(reactive): Name rounds, the reactive effect queue, and outpu…
jat255 Oct 8, 2026
4a70f30
Merge remote-tracking branch 'origin/main' into schloerke/async-pr-21…
schloerke Oct 8, 2026
8721519
Merge branch 'schloerke/async-pr-2182-reimpl' into jat255/2508-flush-…
schloerke Oct 8, 2026
ea811a0
Merge branch 'jat255/2508-flush-vocabulary' into schloerke/async-sess…
schloerke Oct 8, 2026
e3b1079
refactor(reactive): Use the round and output-flush names from #2534
schloerke Oct 8, 2026
ed61789
Merge branch 'schloerke/async-session-end-cancel' into schloerke/asyn…
schloerke Oct 8, 2026
6bc506e
Merge branch 'schloerke/async-run-once-when-idle' into jat255/698-loc…
schloerke Oct 8, 2026
1f5e6b5
Merge branch 'jat255/698-lock-flush-replacement' into schloerke/async…
schloerke Oct 8, 2026
adde427
Merge branch 'schloerke/async-otel-session-span' into jat255/2508-boo…
schloerke Oct 8, 2026
38d74df
refactor(reactive): Name the round methods by what they start
jat255 Oct 8, 2026
959a2ae
Merge branch 'schloerke/async-pr-2182-reimpl' into schloerke/async-se…
schloerke Oct 8, 2026
bd59e88
Merge branch 'schloerke/async-session-end-cancel' into schloerke/asyn…
schloerke Oct 8, 2026
5439bbb
Merge branch 'schloerke/async-run-once-when-idle' into jat255/698-loc…
schloerke Oct 8, 2026
1981b03
Merge branch 'jat255/698-lock-flush-replacement' into schloerke/async…
schloerke Oct 8, 2026
b170a5b
Merge branch 'schloerke/async-otel-session-span' into jat255/2508-boo…
schloerke Oct 8, 2026
de97fba
Merge remote-tracking branch 'origin/main' into schloerke/async-pr-21…
schloerke Oct 13, 2026
fa65fc1
Merge branch 'schloerke/async-pr-2182-reimpl' into schloerke/async-se…
schloerke Oct 13, 2026
c6bdb78
Merge branch 'schloerke/async-session-end-cancel' into schloerke/asyn…
schloerke Oct 13, 2026
e21b6c2
Merge branch 'schloerke/async-run-once-when-idle' into jat255/698-loc…
schloerke Oct 13, 2026
25e34ce
Merge branch 'jat255/698-lock-flush-replacement' into schloerke/async…
schloerke Oct 13, 2026
167c3f6
Merge branch 'schloerke/async-otel-session-span' into jat255/2508-boo…
schloerke Oct 13, 2026
9ff1dbd
docs(changelog): Drop Shiny for R reference from concurrency note
schloerke Oct 13, 2026
71c0acf
Merge branch 'schloerke/async-pr-2182-reimpl' into schloerke/async-se…
schloerke Oct 13, 2026
4896955
Merge branch 'schloerke/async-session-end-cancel' into schloerke/asyn…
schloerke Oct 13, 2026
0c5d92e
docs(changelog): Trim effect-destroy cancellation note
schloerke Oct 13, 2026
91b6044
Merge branch 'schloerke/async-run-once-when-idle' into jat255/698-loc…
schloerke Oct 13, 2026
ae41186
Merge branch 'jat255/698-lock-flush-replacement' into schloerke/async…
schloerke Oct 13, 2026
8a83556
Merge branch 'schloerke/async-otel-session-span' into jat255/2508-boo…
schloerke Oct 13, 2026
9b6b8e0
Merge branch 'schloerke/async-session-end-cancel' into schloerke/asyn…
schloerke Oct 13, 2026
192ce05
test(extended_task): Prove queued results need the next round, not th…
schloerke Oct 13, 2026
9be69e9
Merge branch 'schloerke/async-pr-2182-reimpl' into schloerke/async-se…
schloerke Oct 13, 2026
e14011e
Merge branch 'schloerke/async-run-once-when-idle' into jat255/698-loc…
schloerke Oct 13, 2026
83fe2a5
Merge branch 'jat255/698-lock-flush-replacement' into schloerke/async…
schloerke Oct 13, 2026
206b62a
Merge branch 'schloerke/async-otel-session-span' into jat255/2508-boo…
schloerke Oct 13, 2026
0cfbe93
Merge branch 'schloerke/async-session-end-cancel' into schloerke/asyn…
schloerke Oct 13, 2026
9c570e2
test(extended_task): Prove queued invocations don't wait for unrelate…
schloerke Oct 13, 2026
c6c9aec
Merge branch 'schloerke/async-pr-2182-reimpl' into schloerke/async-se…
schloerke Oct 13, 2026
437c126
Merge branch 'schloerke/async-run-once-when-idle' into jat255/698-loc…
schloerke Oct 13, 2026
6add354
Merge branch 'jat255/698-lock-flush-replacement' into schloerke/async…
schloerke Oct 13, 2026
8b93400
Merge branch 'schloerke/async-otel-session-span' into jat255/2508-boo…
schloerke Oct 13, 2026
3a01e78
fix(session): Print the traceback of a failing `run_once_when_idle()`…
schloerke Oct 13, 2026
00f2636
feat(reactive): Warn once for the deprecated `reactive.lock()`
schloerke Oct 13, 2026
157b37c
fix(otel): Parent each `reactive_update` span explicitly
schloerke Oct 13, 2026
4030ca2
fix(reactive): Keep output flushes and calc caches correct in three e…
schloerke Oct 13, 2026
6295bcb
Merge branch 'schloerke/async-pr-2182-reimpl' into schloerke/async-se…
schloerke Oct 13, 2026
2a82092
Merge branch 'schloerke/async-session-end-cancel' into schloerke/asyn…
schloerke Oct 13, 2026
6638381
Merge branch 'schloerke/async-run-once-when-idle' into jat255/698-loc…
schloerke Oct 13, 2026
75422f0
Merge branch 'jat255/698-lock-flush-replacement' into schloerke/async…
schloerke Oct 13, 2026
c9ce0ac
Merge branch 'schloerke/async-otel-session-span' into jat255/2508-boo…
schloerke Oct 13, 2026
1c0f302
fix(session): Send what `on_flushed` callbacks queue, and supervise h…
schloerke Oct 13, 2026
caf6786
docs: Describe rounds, output flushes, and `reactive.flush()` accurately
schloerke Oct 13, 2026
f314624
Merge branch 'schloerke/async-pr-2182-reimpl' into schloerke/async-se…
schloerke Oct 13, 2026
bffdc88
Merge branch 'schloerke/async-session-end-cancel' into schloerke/asyn…
schloerke Oct 13, 2026
1c94738
Merge branch 'schloerke/async-run-once-when-idle' into jat255/698-loc…
schloerke Oct 13, 2026
40946d4
Merge branch 'jat255/698-lock-flush-replacement' into schloerke/async…
schloerke Oct 13, 2026
b587420
Merge branch 'schloerke/async-otel-session-span' into jat255/2508-boo…
schloerke Oct 13, 2026
013c474
fix(session): Narrow a handler task's exception to `Exception` before…
schloerke Oct 13, 2026
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
11 changes: 7 additions & 4 deletions .claude/references/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ The reactive system is based on a **push-pull** model with three core abstractio
sources (Value) it reads from.
- **Dependents**: Each reactive source maintains a list of downstream consumers
that depend on it. When a source invalidates, it notifies all dependents.
- **ReactiveEnvironment**: Global singleton managing the reactive graph,
execution queue, and flush cycles.
- **ReactiveEnvironment**: Global singleton managing the reactive graph, the
reactive effect queue, and rounds. Its class docstring in
`shiny/reactive/_core.py` defines the terms used for the reactive system:
reactive effect queue, round, idle, cycle, and output flush.

Key implementation details:

Expand All @@ -25,8 +27,9 @@ Key implementation details:
- `Effect_()` is a side-effect that re-executes when dependencies change
- `event()` decorator suppresses reactive dependencies for specific reads
- The reactive graph is built automatically through the Context's dependency tracking
- Execution uses a priority queue to ensure correct invalidation ordering
- In tests, `reactive.flush()` forces a synchronous flush of the reactive graph
- Each round takes effects from the reactive effect queue in priority order
- In tests, `await reactive.flush()` runs rounds until the reactive environment is
idle

## Session Hierarchy

Expand Down
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Breaking changes

* OpenTelemetry `reactive_update` spans now follow each session's cycle: one span per session, from when the session turns busy until all of its effects (including async ones) have finished, carrying the session's `session.id`. The session's calc, effect, and output spans are its children. Before, there was one span per reactive flush, which can serve several sessions, so with concurrent effects it couldn't carry a single `session.id` and ended before async effects finished. Effects without a session no longer have a `reactive_update` parent. (#2522)

* Reactive effects now run concurrently. Shiny now starts each invalidated effect without waiting for an `async` effect's awaited part, so a slow `async` effect, calc, or render function no longer delays other sessions. Within a session, input changes and `reactive.invalidate_later()` still wait until all of that session's effects have finished, and outputs are sent together once they have. What app authors may notice:

* `async` effects interleave at each `await` instead of running one after another. `priority` orders when effects start, not when they finish. Runs of the same effect still don't overlap: a re-run waits for the previous run.

* `reactive.lock()` no longer pauses reactive processing: Shiny itself no longer takes it (see Deprecations). To change reactive state from another `asyncio` task, set the value directly; its dependents re-run automatically, so there's no need to `await reactive.flush()`.

* `await reactive.flush()` returns right away, without waiting for dependents, when called from within an effect, a `reactive.on_flushed()` or `session.on_flush()`/`on_flushed()` callback, or a task one of these started while it is still running, since waiting there could deadlock. A task that outlives the effect that started it, such as an extended task's body, still waits. The wait covers the whole process, including other sessions' effects and output.

* `reactive.on_flushed()` callbacks run after each round of effects has *started*, without waiting for `async` effects, and can run several times per `reactive.flush()`. Before, they ran once all effects had finished. An error in one of these callbacks is now printed, and the other callbacks still run, instead of closing a session.

* Message handlers (`session.set_message_handler()`) run in their own task, so a slow handler no longer holds up other messages from the client.

* Each session sends its outputs in a task of its own, once all of its effects have finished. A slow client, or a slow `session.on_flush()` callback, delays only its own session's output, not other sessions' output or other reactive work.

* Setting a `reactive.value` now re-runs its dependents automatically, without a call to `reactive.flush()`. The reactive graph itself still isn't thread-safe, so from another thread use `loop.call_soon_threadsafe(value.set, new_value)`.

* File uploads and `session.dynamic_route()` requests no longer mark the session busy, matching Shiny for R.

* Destroying an effect (`effect.destroy()`) cancels its in-progress run, raising `asyncio.CancelledError` at its current `await`. So ending a session, or `session.destroy(id)`, cancels its in-progress `async` effects and render functions instead of letting them run on after the session or module is gone.

(#2508, #2515)

### New features

* Added `session.run_once_when_idle(fn)`, which runs a function once, at the start of the session's next cycle: once all of the session's effects have finished and its outputs have been sent. Input changes from the client and `reactive.invalidate_later()` already wait this way, so effects that are still running keep seeing the same values; use it to change reactive state the same way from a background task or a custom timer. `reactive.invalidate_later()` now uses it instead of a private method. (#2517)

* `ui.sidebar()` gains a `role` parameter (`"form"`, `"search"`, `"complementary"`, or `"region"`) for opt-in ARIA landmark markup (rstudio/bslib#1359). `"complementary"` renders an `<aside>`, other roles render a `<div>` with the corresponding `role` attribute, and landmark roles require an accessible name (from `title`, `aria_label`, or `aria_labelledby`). Note that the default (`role=None`) now renders a neutral `<div>` instead of an `<aside>`. In addition, `ui.page_sidebar()` now places the whole sidebar layout inside the page's `<main>` landmark. (#2526)

### Improvements
Expand All @@ -19,8 +47,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* In Express, `@output_args()` now accepts every argument of the output's UI function, with matching types. `@render.ui` now takes `inline`, `container`, `fill`, `fillable`, and HTML attributes, instead of raising a `TypeError`. `@render.text` now takes `container`. `@render.plot` and `@render.image` now declare `inline`, `click`, `dblclick`, `hover`, `brush`, and `fill` explicitly (and `width` and `height` for `@render.image`), instead of accepting any keyword. `@render.download_button()` and `@render.download_link()` now take `label`. (#2539)

### Deprecations

* `reactive.lock()` is deprecated and now emits a `ShinyDeprecationWarning` (once per process). It does nothing else: it returns an `asyncio.Lock` that never blocks, so code that holds it no longer keeps out other code that holds it. Code that relies on it for mutual exclusion should create its own `asyncio.Lock`. Remove `async with reactive.lock():` and the `await reactive.flush()` that usually follows it, and set the reactive value directly. To apply a change only once a session's running effects have finished, use `session.run_once_when_idle()`. (#2520)

### Bug fixes

* UI updates and custom messages sent outside an output or effect, such as `ui.update_text()` in a `session.on_flushed()` callback or in an `asyncio` task, or `ui.insert_ui()` from a message handler, are now sent to the client right away. Before, they waited until the next input change. (#1889, #21)

* Setting a reactive value from a download handler (`@render.download_button`) now updates the outputs and effects that read it right away, including while a streamed download is still sending. Before, they updated only after the next message from the client. (#1785)

* `Jsonifiable` now uses `Sequence` and `Mapping` for its container arms instead of `List` and `Dict`, which are invariant. Values like `dict[str, int]` and `list[str]` now type-check as `Jsonifiable`, including as the return type of a function passed to a `Renderer[Jsonifiable]`. As a side effect, type checkers also accept other sequences and mappings, such as `range`, `deque`, and `bytes`, even though only `list`, `tuple`, and `dict` serialize. Nothing changes at runtime. (Thanks, @wolfgang-aura!) (#2511)

* `Progress.set(0)` now places the bar at `0` relative to `min` and `max`, instead of sending `0` unnormalized. With a negative `min`, such as `Progress(min=-10, max=10)`, a value of `0` showed an empty bar rather than a half-full one. (#2518)
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ CI then rejects.
- **Missing HTML deps**: If styles don't work, ensure `components_dependencies()` is included
- **Express mode confusion**: Remember Express uses AST transformation; debugging requires understanding the transformed code
- **Session context errors**: Always access `input`/`output` within reactive context
- **Reactive graph debugging**: Use `reactive.flush()` to force synchronous execution in tests
- **Reactive graph debugging**: `await reactive.flush()` runs rounds until the reactive environment is idle (tests)
- **Playwright timing**: Use `.expect_*()` methods which auto-wait; avoid manual `sleep()`
- **Asset updates**: After running `make upgrade-html-deps`, verify theme preset files were updated
- **Out-of-sync Playwright controllers**: When adding or altering UI components, always create or update their companion Playwright controller in `shiny/playwright/controller/` with matching locators, action methods, and expectation methods, and export them from `shiny/playwright/controller/__init__.py`
Expand Down
3 changes: 2 additions & 1 deletion docs/_quartodoc-core.yml
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,6 @@ quartodoc:
- reactive.flush
- reactive.poll
- reactive.file_reader
- reactive.lock
- req
- title: Create and run applications
desc: ""
Expand Down Expand Up @@ -296,6 +295,7 @@ quartodoc:
- session.Session.on_flush
- session.Session.on_flushed
- session.Session.on_ended
- session.Session.run_once_when_idle
- session.Session.dynamic_route
- session.Session.allow_reconnect
- session.Session.close
Expand Down Expand Up @@ -414,6 +414,7 @@ quartodoc:
- render.download
- render.transformer.output_transformer
- render.transformer.resolve_value_fn
- reactive.lock
# `ui.update_navs()` is deprecated (superseded by `ui.update_navset()`) but
# still exists. It is deliberately left undocumented; uncomment to publish it.
# - ui.update_navs
Expand Down
2 changes: 1 addition & 1 deletion docs/_quartodoc-express.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,6 @@ quartodoc:
- reactive.flush
- reactive.poll
- reactive.file_reader
- reactive.lock
- req
- title: Reusable Express code
desc: Create reusable Express code.
Expand Down Expand Up @@ -255,3 +254,4 @@ quartodoc:
desc: ""
contents:
- express.render.download
- reactive.lock
11 changes: 11 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ asyncio_default_fixture_loop_scope=function
testpaths=tests/pytest/
; Note: Browsers are set within `./Makefile`
addopts = --strict-markers --durations=6 --durations-min=5.0 --numprocesses auto
; No test may take more than 2 minutes, so a hang fails instead of running until
; CI cancels the job. The `thread` method kills the hung xdist worker (which
; xdist reports as a failure of that test); its own stack dump is lost in the
; worker's IPC pipe, so faulthandler dumps every thread's stack to stderr first.
; (Same approach as the Playwright targets in the Makefile.)
timeout = 120
timeout_method = thread
faulthandler_timeout = 110
; Tests must not emit stray warnings; assert expected ones with `pytest.warns()`.
; Third-party deprecations we can't act on go in the ignore list below.
filterwarnings =
Expand All @@ -24,4 +32,7 @@ filterwarnings =
; test at fault -- i.e. they make the suite flaky instead of strict.
ignore::ResourceWarning
ignore::pytest.PytestUnraisableExceptionWarning
; When a timeout kills an xdist worker, syrupy warns that the killed worker's
; tests are missing; as an error, that hides the summary naming the hung test.
ignore:.*selected test\(s\) missing from collected items:UserWarning
; verbosity_test_cases=2
4 changes: 2 additions & 2 deletions shiny/.agents/skills/shiny-for-python/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ reference file before writing code** for that area.

| Topic | Use when | Reference |
|---|---|---|
| Reactivity | A value should recompute or an output update as inputs change; choosing between calc / effect / value; `req`, `isolate`, timers, polling | `references/reactivity.md` |
| Reactivity | A value should recompute or an output update as inputs change; choosing between calc / effect / value; `req`, `isolate`, timers, polling, setting values from background tasks | `references/reactivity.md` |
| Express mode | Writing or converting an Express app (`from shiny.express import ...`); context-manager layout; `page_opts`, `@expressify` | `references/express.md` |
| Modules (Core) | A reusable, repeatable UI+server component in a Core app; avoiding input/output id collisions across copies | `references/modules-core.md` |
| Modules (Express) | The same reusable-component need in an Express app, via the single `@module` decorator | `references/modules-express.md` |
Expand Down Expand Up @@ -57,7 +57,7 @@ reference file before writing code** for that area.
| Topic | Use when | Reference |
|---|---|---|
| Feedback | Toasts/notifications, modal dialogs, progress bars, busy indicators | `references/feedback.md` |
| Extended tasks | Running slow work off the reactive flush without freezing the app; task buttons | `references/extended-tasks.md` |
| Extended tasks | Running slow work outside the session's reactive cycle so the session stays responsive; task buttons | `references/extended-tasks.md` |
| Bookmarking | Saving/restoring app state — shareable URLs, refresh persistence, server-side state | `references/bookmarking.md` |

## AI & streaming
Expand Down
18 changes: 12 additions & 6 deletions shiny/.agents/skills/shiny-for-python/references/extended-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
## Overview

A slow computation inside a `@reactive.calc`, `@reactive.effect`, or `@render.*`
blocks the reactive flush - even an `async def` one - so the whole session (and,
for shared resources, other sessions) freezes until it returns. Do NOT put a
slow API call, model inference, or long loop directly in a calc/effect.
keeps its session busy until it returns: that session applies no input changes
and sends no outputs in the meantime. A sync one also blocks every session in the
process, since it holds the event loop. (Other sessions keep running while an
`async def` one awaits.) Do NOT put a slow API call, model inference, or long loop
directly in a calc/effect.

`@reactive.extended_task` runs an **async** function in a background asyncio
task, off the reactive flush. Reactivity keeps processing while it runs; the
result flows back into the reactive graph when it finishes.
task, outside the session's reactive cycle. The session keeps processing inputs
and outputs while it runs; the result flows back into the reactive graph when it
finishes.

Because it runs outside the reactive flush, the task **cannot read reactive
Because it runs outside the reactive graph, the task **cannot read reactive
sources** (`input.x()`, a `reactive.value`, a calc). Read those values *before*
invoking and pass them in as arguments; reading one inside raises an error.

Expand Down Expand Up @@ -121,6 +124,9 @@ current one finishes.
- Long synchronous work (a blocking library call) inside the async task still
blocks the event loop - offload it with `asyncio.to_thread(...)` or an async
client.
- Calling `value.set(...)` from that worker thread -> the reactive graph isn't
thread-safe and it can raise. Return the result to the task instead, or hand the
call to the event loop: `loop.call_soon_threadsafe(value.set, new_value)`.
- Calling `task.result()` outside a reactive context -> no dependency is tracked
and it errors; read it inside a `@render.*`, calc, or effect.
- Expecting a fresh `.invoke()` to interrupt the running task -> it queues
Expand Down
13 changes: 10 additions & 3 deletions shiny/.agents/skills/shiny-for-python/references/otel.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,18 @@ At the `all` level the span hierarchy looks like:

```text
session_start
└─ reactive_update
├─ reactive.calc filtered_data
└─ reactive_update # the session's first cycle
└─ output result
└─ reactive.calc filtered_data
reactive_update # each later cycle: a trace of its own
└─ output result
└─ reactive.calc filtered_data
```

A `reactive_update` span covers one session cycle: from when the session turns
busy until all of its effects, including async ones, have finished. It carries
`session.id`. Effects without a session have no `reactive_update` parent.

## Collection levels: `SHINY_OTEL_COLLECT`

Controls how much Shiny telemetry is emitted (default `all`):
Expand All @@ -52,7 +59,7 @@ Controls how much Shiny telemetry is emitted (default `all`):
|---|---|---|
| `none` | nothing from Shiny | disable Shiny spans, keep your own |
| `session` | session lifecycle only | low-overhead production |
| `reactive_update` | + one span per flush cycle | balanced production |
| `reactive_update` | + one span per session cycle | balanced production |
| `reactivity` | + per calc/effect/output spans, value-update logs | development, debugging |
| `all` | everything (currently = `reactivity`) | maximum detail |

Expand Down
48 changes: 47 additions & 1 deletion shiny/.agents/skills/shiny-for-python/references/reactivity.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,47 @@ def data2():
Declare `poll`/`file_reader` at module top level to share one cache across
sessions.

To change reactive state from a background `asyncio` task, call `.set()`
directly. Setting a value schedules a flush, so there's no lock to take and no
`reactive.flush()` to await. (From another thread, use
`loop.call_soon_threadsafe(v.set, x)`.) For a producer shared by all sessions
(e.g. one polling an API), define it at module top level and start it from the
first session, once the event loop is running. Keep a reference to its task,
since the event loop holds tasks only weakly:

```python
import asyncio
import contextvars
import logging

from shiny import reactive

latest = reactive.value(None)
_tasks: set[asyncio.Task[None]] = set()

async def _produce():
while True:
try:
latest.set(await fetch()) # your async data source
except Exception:
logging.exception("fetch failed; retrying") # keep the producer alive
await asyncio.sleep(10)

def server(input, output, session):
if not _tasks:
# A fresh context keeps this session's context out of the shared task.
_tasks.add(contextvars.Context().run(asyncio.create_task, _produce()))
```

A session's effects that are paused at an `await` see a direct `.set()` right
away. To apply the change the way input changes and `invalidate_later` do, hand
it to the session. It runs once, after the session's running effects finish, so
they keep seeing stable values:

```python
session.run_once_when_idle(lambda: latest.set(new_value))
```

(For long-running async work that must not block the session, see
`reactive.extended_task` - out of scope here.)

Expand All @@ -158,8 +199,9 @@ sessions.
| Wait for / validate a value | `req(x)`, `req(x, cancel_output=True)` |
| Read without depending | `with reactive.isolate():` |
| Timer | `reactive.invalidate_later(secs)` |
| Set a value from a background task | `v.set(x)`; between cycles: `session.run_once_when_idle(fn)` |
| Poll a data source / file | `@reactive.poll(...)` / `@reactive.file_reader(...)` |
| Force sync execution in tests | `reactive.flush()` |
| Run reactive work until idle (tests) | `await reactive.flush()` |

## Common mistakes

Expand All @@ -176,3 +218,7 @@ sessions.
`isolate()` -> self-invalidating loop; wrap the read in `reactive.isolate()`.
- `while`/`sleep` loop to watch a DB or file -> use `reactive.poll` or
`reactive.file_reader`.
- `async with reactive.lock():` around a `.set()`, followed by
`await reactive.flush()` -> `reactive.lock()` is deprecated and does nothing,
and the flush adds nothing but a wait on every session's effects. Call
`.set()` alone.
Loading