Skip to content
Open
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
103 changes: 103 additions & 0 deletions .ai/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,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
72 changes: 72 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 @@ -678,6 +687,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 714 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-43GPR20ESPB3hfYqe&open=AZ-43GPR20ESPB3hfYqe&pullRequest=3931

- 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 733 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-43GPR20ESPB3hfYqf&open=AZ-43GPR20ESPB3hfYqf&pullRequest=3931
python -m pip install "setuptools<80.0.0"

Check warning on line 734 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-43GPR20ESPB3hfYqg&open=AZ-43GPR20ESPB3hfYqg&pullRequest=3931
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

Check failure on line 738 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-43GPR20ESPB3hfYqh&open=AZ-43GPR20ESPB3hfYqh&pullRequest=3931
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).

### Added
- [#3930](https://github.com/plotly/dash/pull/3930) 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, replayable publish/subscribe (`publish`/`subscribe`) for sharing state between callbacks or across worker processes without an external service. Subscriptions resume from a caller's last-seen sequence out of 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. Three backends ship, selected by passing a `BaseSharedStorage` instance to `shared_storage=`: the default `LocalSharedStorage` (in-memory, elects one owner process per machine over an `AF_UNIX`/loopback socket — correct within a single container, including across multiple gunicorn workers); `DiskcacheSharedStorage` (on a `diskcache.Cache`, shared by every process on one host — parity for single-machine deployments, not for multi-pod ones with ephemeral disks); and `RedisSharedStorage` (on Redis, using Redis Streams for the ordered pub/sub — the backend for horizontally-scaled deployments behind a load balancer, e.g. apps scaled across pods).
- [#3931](https://github.com/plotly/dash/pull/3931) 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
Loading
Loading