Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e71af1d
Add streaming callbacks: @callback(..., stream=True)
T4rk1n Jul 9, 2026
65c72b9
Fix CI: guard WS stream protocol tests on httpx
T4rk1n Jul 9, 2026
b2c340c
Merge branch 'dev' into feat/stream-callbacks
T4rk1n Jul 27, 2026
e444e72
Keep streamed callback responses alive through proxy idle timeouts
T4rk1n Jul 27, 2026
3fd3ed7
Infer streaming callbacks from the function, drop stream=True
T4rk1n Jul 28, 2026
84551e5
Forbid sync-generator streaming callbacks
T4rk1n Jul 30, 2026
82daa62
Exempt clientside and streaming callbacks from the request limiter
T4rk1n Jul 30, 2026
a16bc7f
Isolate streaming callback tests into tests/streaming with its own CI…
T4rk1n Jul 30, 2026
a0e3ad9
Add backend-agnostic shared storage (state manager + pub/sub)
T4rk1n Jul 30, 2026
f8850ec
Multiplex streaming over shared storage (server side, Flask)
T4rk1n Jul 30, 2026
34180bc
Multiplex streaming over shared storage on Quart and FastAPI
T4rk1n Jul 30, 2026
d903d96
Add the client multiplexed streaming transport
T4rk1n Jul 30, 2026
d8b9c69
Wire streaming callbacks onto the multiplexed transport
T4rk1n Jul 30, 2026
c9baf68
Fix multiplexed streaming bugs found in browser validation
T4rk1n Jul 30, 2026
d2b4398
Document multiplexed streaming in the CHANGELOG
T4rk1n Jul 30, 2026
2fc54fd
CHANGELOG: fill in PR number for shared storage entry
T4rk1n Jul 30, 2026
b4a72dc
End async subscriptions cleanly when the loop is shutting down
T4rk1n Jul 30, 2026
7e925b9
Reduce streaming frames to plain JSON before shared storage
T4rk1n Jul 30, 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
103 changes: 103 additions & 0 deletions .ai/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,109 @@ async def validate_message(websocket, message):
- `@plotly/dash-websocket-worker/src/worker.ts` - SharedWorker entry point
- `dash/backends/_fastapi.py` - Server-side WebSocket handler

## Streaming Callbacks

A callback defined as a generator function (or async generator function)
streams: its yields are pushed to the browser as they are produced — for LLM
token streaming, progress feeds, and long computations. There is no opt-in
keyword; `dash._callback.register_callback` infers it from the decorated
function (`inspect.isgeneratorfunction` / `isasyncgenfunction`) and registers
the streaming wrapper instead of the regular one.

```python
import asyncio
from dash import callback, Output, Input, Patch

@callback(
Output('log', 'children'),
Input('btn', 'n_clicks'),
prevent_initial_call=True,
)
async def run(n):
yield 'Starting...' # replaces children immediately
async for token in llm():
p = Patch()
p += token
yield p # appends to children (incremental)
yield 'Done' # last yield = final value
```

### Semantics

- Each yield has the same shape as a regular return value (one value per
`Output`) and **replaces** the outputs. Yield `dash.Patch` objects for
incremental updates.
- `no_update` works per-output within a yield; a yield where nothing updates
produces no frame. Raising `PreventUpdate` mid-stream ends the stream
cleanly.
- `set_props()` between yields is folded into the next frame's `sideUpdate`
(HTTP) or streams immediately (WebSocket transport).
- Intermediate frames are applied through the same renderer path as
`set_props`, so dependent callbacks fire per frame and loading states stay
on until the stream completes (`Updating...` title for the whole stream).
- `on_error` applies per-stream: its return value becomes a final frame.
Without it, an exception mid-stream sends an error frame shown in devtools;
frames already applied stay applied.
- Works with sync generators (Flask/Quart/FastAPI) and async generators.
Sync generators warn at registration: they occupy a server worker (or WS
executor thread) for the whole stream — prefer `async def`.
- Incompatible with `background=True`, `mcp_enabled` and `api_endpoint`
(validated at registration, when the function is inspected). Clientside
callbacks cannot stream at all.
- `callback_map[callback_id]['stream']` records the inferred flag server-side;
it is not part of the callback spec sent to the client, which detects a
stream from the response instead (NDJSON content type / `stream` frames).

### Transport & frame protocol

Transport follows the callback's normal transport selection: if the callback
runs over the WebSocket callback transport (`websocket=True` or
`websocket_callbacks=True`), frames ride the open connection as
`callback_response` messages with `stream: true`; the terminal message is
`{status: 'ok', stream: true, done: true}`. Otherwise the HTTP POST response
streams NDJSON (`application/x-ndjson`), one frame per line:

```
{"multi": true, "response": {"<id>": {"<prop>": <value>}}, "sideUpdate": {...}?}
{"done": true} <- terminal frame
{"done": true, "error": {"message": "..."}} <- error terminal frame
```

The renderer applies each frame on arrival (via the `sideUpdate` path, so
`Patch` applies exactly once) and resolves the callback's execution promise
with an empty result on the terminal frame.

### Caveats

- Streaming is inferred from the decorated function, so another decorator
between `@callback` and the generator hides it: if that decorator returns a
plain function, Dash registers a regular callback and the returned generator
object fails to serialize (`InvalidCallbackReturnValue: type generator`).
- Long streams should check `ctx.websocket.is_shutdown` (WS transport) in
loops; on HTTP, client disconnect raises `GeneratorExit` into the user
generator at its current `yield`.
- Proxies and compression middleware (nginx buffering, flask-compress/gzip,
Jupyter proxies) can buffer NDJSON and defeat streaming. Dash sets
`X-Accel-Buffering: no`, but middleware configuration may still be needed.
- Streamed frames bypass persistence (`prunePersistence`/`applyPersistence`).
- Wrapping a streamed output in `dcc.Loading` hides it for the entire stream
(loading stays on by design).
- Flask + async generator requires `dash[async]`; frames are bridged from a
private event-loop thread.
- `flask.request` inside a streamed callback body only works on the pure-WSGI
Flask path (no `dash[async]`/`use_async`); under async dispatch the request
context cannot be carried into the stream. Use Dash's `ctx` (cookies,
headers, args are captured at dispatch) instead.

### Key Files

- `dash/_callback.py` - `add_context_stream`/`async_add_context_stream` wrappers, frame builders
- `dash/_streaming.py` - `StreamedCallbackResponse` marker, context-safe iteration, NDJSON helpers
- `dash/backends/_flask.py`, `_quart.py`, `_fastapi.py` - streaming dispatch branches
- `dash/backends/ws.py` - `make_stream_frame_emitter`, `consume_stream_frames`/`aconsume_stream_frames`
- `dash/dash-renderer/src/actions/callbacks.ts` - `applyStreamFrame`, NDJSON reader, WS frame handling
- `dash/dash-renderer/src/utils/workerClient.ts` - stream-aware `callback_response` handling

## Security

### XSS Protection
Expand Down
75 changes: 75 additions & 0 deletions .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
dcc_paths_changed: ${{ steps.filter.outputs.dcc_related_paths }}
html_paths_changed: ${{ steps.filter.outputs.html_related_paths }}
websocket_changed: ${{ steps.filter.outputs.websocket_paths }}
streaming_changed: ${{ steps.filter.outputs.streaming_paths }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand Down Expand Up @@ -61,6 +62,14 @@
- 'dash/dash-renderer/src/**'
- 'tests/websocket/**'
- 'requirements/**'
streaming_paths:
- 'dash/_callback.py'
- 'dash/_streaming.py'
- 'dash/_callback_context.py'
- 'dash/backends/**'
- 'dash/dash-renderer/src/**'
- 'tests/streaming/**'
- 'requirements/**'

lint-unit:
name: Lint & Unit Tests (Python ${{ matrix.python-version }})
Expand Down Expand Up @@ -129,6 +138,9 @@
- name: Run unit tests
run: npm run citest.unit

- name: Run shared-storage tests
run: pytest tests/shared_storage -v

build:
name: Build Dash Package
runs-on: ubuntu-latest
Expand Down Expand Up @@ -607,6 +619,69 @@
touch __init__.py
pytest --headless --nopercyfinalize tests/websocket -v -s

streaming-tests:
name: Streaming Callback Tests (Python ${{ matrix.python-version }})
needs: [build, changes_filter]
if: |
(github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) ||
needs.changes_filter.outputs.streaming_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.12"]

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'

- name: Install Node.js dependencies
run: npm ci

Check warning on line 646 in .github/workflows/testing.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Omitting "--ignore-scripts" allows lifecycle scripts to run during package installation.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AZ-zUYCJ7AnJIq3aeF_s&open=AZ-zUYCJ7AnJIq3aeF_s&pullRequest=3888

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: requirements/*.txt

- name: Download built Dash packages
uses: actions/download-artifact@v4
with:
name: dash-packages
path: packages/

- name: Install Dash packages
# Streaming callbacks are async generators; the async extra pulls in
# flask[async], which they require on the Flask backend.
run: |
python -m pip install --upgrade pip wheel

Check warning on line 665 in .github/workflows/testing.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AZ-zUYCJ7AnJIq3aeF_t&open=AZ-zUYCJ7AnJIq3aeF_t&pullRequest=3888
python -m pip install "setuptools<80.0.0"

Check warning on line 666 in .github/workflows/testing.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AZ-zUYCJ7AnJIq3aeF_u&open=AZ-zUYCJ7AnJIq3aeF_u&pullRequest=3888
find packages -name dash-*.whl -print -exec sh -c 'pip install "{}[async,ci,testing,dev,fastapi,quart]"' \;

- name: Setup Chrome and ChromeDriver
uses: browser-actions/setup-chrome@v1
with:
chrome-version: stable

- name: Build/Setup test components
run: npm run setup-tests.py

- name: Run streaming tests
run: |
mkdir streamtests
cp -r tests streamtests/tests
cd streamtests
touch __init__.py
pytest --headless --nopercyfinalize tests/streaming -v -s

test-main:
name: Main Dash Tests (Python ${{ matrix.python-version }}, React ${{ matrix.react-version }}, Group ${{ matrix.test-group }})
needs: build
Expand Down Expand Up @@ -667,7 +742,7 @@

- name: Setup Chrome and ChromeDriver
id: setup-chrome
uses: browser-actions/setup-chrome@v1

Check failure on line 745 in .github/workflows/testing.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use full commit SHA hash for this dependency.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AZ-zUYCJ7AnJIq3aeF_v&open=AZ-zUYCJ7AnJIq3aeF_v&pullRequest=3888
with:
chrome-version: stable

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]

### Added
- [#3888](https://github.com/plotly/dash/pull/3888/) Shared storage: a backend-agnostic state manager available on every app via `dash.ctx.shared_storage` (and `app.shared_storage`). It offers a cross-process key/value store (`get`/`set`/`delete`) and ordered publish/subscribe (`publish`/`subscribe`) for sharing state between callbacks or across worker processes without an external service. The default `LocalSharedStorage` backend elects a single owner process per machine (binding an `AF_UNIX` socket on POSIX, TCP loopback on Windows) and serves the rest; a single-process deployment is its own owner and pays no socket overhead. Subscriptions are ordered and replayable — a consumer that reconnects resumes from its last-seen message from a bounded buffer, and a buffer overrun surfaces as an explicit gap rather than a silent loss. Values must be JSON-compatible (like `dcc.Store`). Started lazily on first use, so it costs nothing until touched; pass `shared_storage=None` to `Dash(...)` to disable, or a `BaseSharedStorage` subclass/instance to swap the backend. The wire codec is msgspec (never pickle).
- [#3888](https://github.com/plotly/dash/pull/3888/) Streaming callbacks: a callback defined as a generator (or async generator) function streams — its yields are pushed to the browser as they are produced, no keyword needed. Each yielded value has the same shape as a regular return value and replaces the outputs; yielding `dash.Patch` objects gives incremental updates (e.g. LLM token streaming). Streams ride the WebSocket callback transport when active, otherwise the HTTP response streams NDJSON frames. Works on Flask, Quart, and FastAPI; the callback must be an `async def` generator — synchronous generators are rejected at registration since they occupy a server worker for the whole stream. When the app has a shared-storage backend (the default), all of a page's HTTP streams are multiplexed over a single downlink connection instead of one per callback — so they no longer count against the browser's ~6-connections-per-host limit — and the connection resumes from its last sequence on a reconnect without dropping frames. HTTP streams emit a blank keepalive line every `stream_keepalive_interval` milliseconds (default 15000) that the callback spends between yields, so proxy idle timeouts (nginx's `proxy_read_timeout` defaults to 60s) don't close a stream while the callback is still working; set `stream_keepalive_interval=None` on the app to disable.
- [#3646](https://github.com/plotly/dash/pull/3646) Experimental support for React 19. The default is still React 18.3.1; to use React 19 set the environment variable `REACT_VERSION=19.2.4` before running your app, or call `dash._dash_renderer._set_react_version("19.2.4")` inside the app. React 19 has no official UMD builds, so Dash serves the [`umd-react`](https://www.npmjs.com/package/umd-react) package, together with a compatibility shim loaded after react-dom and before any component package. The shim keeps component libraries built against React <=18 (e.g. dash-bootstrap-components, dash-mantine-components) working under React 19: it stubs the removed `ReactCurrentOwner` internals, redirects the legacy element `$$typeof` symbol so pre-bundled React 18 jsx-runtimes produce elements React 19 accepts (error #525), and exposes a global `react/jsx-runtime` (`window.ReactJSXRuntime`) that Dash's own component bundles externalize to. Component library authors adopting this convention should copy the defensive `jsxRuntimeExternal` webpack external from `components/dash-core-components/webpack.config.js` rather than a bare `'ReactJSXRuntime'` string: it falls back to a `React.createElement`-based runtime when the global is missing, so the same build also works on Dash versions older than this release.

### Removed
Expand Down
13 changes: 13 additions & 0 deletions dash/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@
from ._patch import Patch # noqa: F401,E402
from ._jupyter import jupyter_dash # noqa: F401,E402

from ._shared_storage import ( # noqa: F401,E402
BaseSharedStorage,
LocalSharedStorage,
SharedStorageError,
SharedStorageGap,
Subscription,
)

from ._hooks import hooks # noqa: F401,E402

ctx = callback_context
Expand Down Expand Up @@ -94,4 +102,9 @@ def _jupyter_nbextension_paths():
"ctx",
"hooks",
"stringify_id",
"BaseSharedStorage",
"LocalSharedStorage",
"SharedStorageError",
"SharedStorageGap",
"Subscription",
]
Loading
Loading