From f45f6f6f7c073f6b34c935a9ad3f054163205b68 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 4 Aug 2026 10:00:26 -0400 Subject: [PATCH 1/2] Open FastAPI/Quart websocket only when a websocket callback needs it The FastAPI and Quart backends always advertise websocket infrastructure (url/worker_url) in the page config, and the renderer keyed the connect decision on the mere presence of that infra, so every app on these backends opened a socket on page load even with no websocket callbacks. Register the websocket message handlers whenever the infra is present, but only open the socket eagerly when websocket_callbacks=True (config.enabled). Per-callback websocket=True opens it lazily on first dispatch via the existing ensureConnected path; an app with no websocket callbacks never connects. Also guard the visibility reconnect so it never opens a first connection. Add regression tests using a websocket_connect hook as a server-side probe: never-connect (HTTP only), lazy-connect (per-callback), eager-connect (global). --- CHANGELOG.md | 1 + dash/dash-renderer/src/AppProvider.react.tsx | 11 +- .../src/observers/websocketObserver.ts | 53 ++++--- tests/websocket/test_ws_lazy_connect.py | 136 ++++++++++++++++++ 4 files changed, 175 insertions(+), 26 deletions(-) create mode 100644 tests/websocket/test_ws_lazy_connect.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e0eed61f06..722a3992ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3646](https://github.com/plotly/dash/pull/3646) Remove React 16 support (`16.14.0` is no longer an accepted value for `REACT_VERSION` / `_set_react_version`). ### Fixed +- Fix the FastAPI and Quart backends opening a WebSocket connection on every page load, even for apps with no WebSocket callbacks. The renderer keyed the connection on the mere presence of WebSocket infrastructure (always advertised by these backends) rather than on whether it was needed. The socket now opens eagerly only when `websocket_callbacks=True`; with just per-callback `websocket=True` it opens lazily on the first such callback dispatch, and an app with no WebSocket callbacks never opens one. - [#3916](https://github.com/plotly/dash/pull/3916) Fixed a regression where dragging multiple files into `dcc.Upload` would upload only the first file when `multiple=True` - [#3922](https://github.com/plotly/dash/pull/3922) Fix `dcc.Input(type="number")` stepper behavior when only `min` is set. - [#3925](https://github.com/plotly/dash/pull/3925) Use the proxied url as the Jupyter server url so `DASH_PROXY` is honored by the external url and inline iframe in notebooks. diff --git a/dash/dash-renderer/src/AppProvider.react.tsx b/dash/dash-renderer/src/AppProvider.react.tsx index f9d8b06f1a..82ad96049e 100644 --- a/dash/dash-renderer/src/AppProvider.react.tsx +++ b/dash/dash-renderer/src/AppProvider.react.tsx @@ -22,14 +22,13 @@ const AppProvider = ({ }: any) => { const [{store}] = useState(() => new Store()); - // Initialize WebSocket connection if enabled or if websocket config is available - // (for per-callback websocket=True) + // Register the WebSocket observer whenever the backend exposes websocket + // infrastructure. initializeWebSocket only opens the socket eagerly when + // websocket callbacks are enabled globally; per-callback websocket=True + // opens it lazily on first dispatch. useEffect(() => { const config = getConfigFromDOM(); - if ( - config.websocket?.enabled || - (config.websocket?.url && config.websocket?.worker_url) - ) { + if (config.websocket?.url && config.websocket?.worker_url) { // Add fetch config for consistency const fullConfig = { ...config, diff --git a/dash/dash-renderer/src/observers/websocketObserver.ts b/dash/dash-renderer/src/observers/websocketObserver.ts index 24dc5a39d4..5384de32d4 100644 --- a/dash/dash-renderer/src/observers/websocketObserver.ts +++ b/dash/dash-renderer/src/observers/websocketObserver.ts @@ -54,9 +54,12 @@ export async function initializeWebSocket( store: Store, config: DashConfig ): Promise { - // Initialize WebSocket if: - // 1. Global websocket is enabled, OR - // 2. WebSocket config is available (for per-callback websocket=True) + // Register the observer whenever the backend exposes websocket + // infrastructure. The handlers below are set up in both cases, but the + // socket is only opened eagerly when websocket callbacks are enabled + // globally (see the end of this function). When only per-callback + // websocket=True is used, the connection is opened lazily on first dispatch + // (handleWebsocketCallback -> workerClient.ensureConnected). const wsAvailable = !!( config.websocket?.url && config.websocket?.worker_url ); @@ -230,28 +233,16 @@ export async function initializeWebSocket( console.error(`[Dash] WebSocket error: ${message}`, code); }; - // Connect to the worker - const wsUrl = buildWebSocketUrl(config); - - try { - // config.websocket is guaranteed to exist due to wsAvailable check above - await workerClient.connect( - config.websocket!.worker_url, - wsUrl, - config.websocket!.inactivity_timeout - ); - } catch (error) { - console.error('[Dash] Failed to connect to WebSocket worker:', error); - } - - // Handle tab visibility changes + // Handle tab visibility changes. Only reconnect a socket that was + // previously established (wasDisconnected); never open the first connection + // here, so apps without an active websocket callback stay socket-free. document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { if (workerClient.connected) { // Tab visible and connected - reset inactivity timer workerClient.notifyTabVisible(); - } else { - // Tab visible but disconnected - reconnect + } else if (wasDisconnected) { + // Tab visible but dropped - reconnect console.log('[Dash] Tab visible, reconnecting WebSocket...'); workerClient .ensureConnected(config) @@ -261,6 +252,28 @@ export async function initializeWebSocket( } } }); + + // Only open the socket eagerly when websocket callbacks are enabled + // globally. With only per-callback websocket=True, the handlers above stay + // registered but no socket is opened until a websocket callback actually + // runs and calls ensureConnected. + if (!config.websocket?.enabled) { + return; + } + + // Connect to the worker + const wsUrl = buildWebSocketUrl(config); + + try { + // config.websocket is guaranteed to exist due to wsAvailable check above + await workerClient.connect( + config.websocket!.worker_url, + wsUrl, + config.websocket!.inactivity_timeout + ); + } catch (error) { + console.error('[Dash] Failed to connect to WebSocket worker:', error); + } } /** diff --git a/tests/websocket/test_ws_lazy_connect.py b/tests/websocket/test_ws_lazy_connect.py new file mode 100644 index 0000000000..4efc698ed1 --- /dev/null +++ b/tests/websocket/test_ws_lazy_connect.py @@ -0,0 +1,136 @@ +""" +Regression tests for lazy WebSocket connection on the FastAPI/Quart backends. + +The FastAPI/Quart backends always advertise WebSocket infrastructure in the +page config (they have ``websocket_capability = True``). A regression once made +the renderer open a socket on every page load just because that infra was +present, even for apps with no WebSocket callbacks at all. + +These tests pin down the intended behavior. The ``websocket_connect`` hook only +fires when a socket is actually accepted, so a connection counter driven by that +hook is a precise, server-side observable for "did a socket open": + + - no WebSocket callbacks -> socket never opens + - only per-callback websocket=True -> socket opens lazily, on first dispatch + - global websocket_callbacks=True -> socket opens eagerly, on page load +""" + +import time + +from dash import Dash, html, Input, Output, hooks +from dash.testing.wait import until + + +def _count_connections(): + """Register a websocket_connect hook and return its connection counter.""" + counter = {"value": 0} + + @hooks.websocket_connect() + def _on_connect(websocket): # pylint: disable=unused-argument + counter["value"] += 1 + return True + + return counter + + +def test_ws030_no_ws_callbacks_never_connects(dash_duo, ws_hook_cleanup): + """An app with only HTTP callbacks must never open a WebSocket. + + This is the regression: the socket used to open on load for any + FastAPI app, regardless of whether a WebSocket callback existed. + """ + connections = _count_connections() + + app = Dash(__name__, backend="fastapi") + app.layout = html.Div( + [ + html.Button("Click", id="btn", n_clicks=0), + html.Div(id="output"), + ] + ) + + @app.callback(Output("output", "children"), Input("btn", "n_clicks")) + def on_click(n_clicks): + return f"Clicked {n_clicks or 0}" + + dash_duo.start_server(app) + + # Drive an HTTP round-trip so we know the app is fully booted. + dash_duo.wait_for_text_to_equal("#output", "Clicked 0") + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#output", "Clicked 1") + + # Give any (erroneous) eager connection time to land, then assert none did. + time.sleep(1.5) + assert connections["value"] == 0, "no socket should open without a ws callback" + assert dash_duo.get_logs() == [] + + +def test_ws031_per_callback_connects_lazily(dash_duo, ws_hook_cleanup): + """A per-callback websocket=True must open the socket only on dispatch. + + With prevent_initial_call=True the callback does not run on load, so no + socket should exist until the button is clicked. + """ + connections = _count_connections() + + app = Dash(__name__, backend="fastapi") + app.layout = html.Div( + [ + html.Button("Click", id="btn", n_clicks=0), + html.Div("initial", id="output"), + ] + ) + + @app.callback( + Output("output", "children"), + Input("btn", "n_clicks"), + websocket=True, + prevent_initial_call=True, + ) + def on_click(n_clicks): + return f"Clicked {n_clicks}" + + dash_duo.start_server(app) + + # Page is up but the ws callback hasn't run yet -> no socket. + dash_duo.wait_for_text_to_equal("#output", "initial") + time.sleep(1.5) + assert connections["value"] == 0, "socket must not open before first dispatch" + + # First dispatch of the ws callback opens the socket lazily. + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#output", "Clicked 1") + until( + lambda: connections["value"] >= 1, + timeout=5, + msg="socket should open on first websocket callback dispatch", + ) + assert dash_duo.get_logs() == [] + + +def test_ws032_global_ws_connects_eagerly(dash_duo, ws_hook_cleanup): + """Global websocket_callbacks=True keeps opening the socket on load.""" + connections = _count_connections() + + app = Dash(__name__, backend="fastapi", websocket_callbacks=True) + app.layout = html.Div( + [ + html.Button("Click", id="btn", n_clicks=0), + html.Div(id="output"), + ] + ) + + @app.callback(Output("output", "children"), Input("btn", "n_clicks")) + def on_click(n_clicks): + return f"Clicked {n_clicks or 0}" + + dash_duo.start_server(app) + + # No interaction required: the socket opens eagerly at page load. + until( + lambda: connections["value"] >= 1, + timeout=5, + msg="global websocket_callbacks should open the socket on load", + ) + assert dash_duo.get_logs() == [] From c956234d397ba32f7de66933d201c24e68381e3f Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 4 Aug 2026 10:15:43 -0400 Subject: [PATCH 2/2] Backfill PR and issue links in changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 722a3992ff..7ec0a56a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3646](https://github.com/plotly/dash/pull/3646) Remove React 16 support (`16.14.0` is no longer an accepted value for `REACT_VERSION` / `_set_react_version`). ### Fixed -- Fix the FastAPI and Quart backends opening a WebSocket connection on every page load, even for apps with no WebSocket callbacks. The renderer keyed the connection on the mere presence of WebSocket infrastructure (always advertised by these backends) rather than on whether it was needed. The socket now opens eagerly only when `websocket_callbacks=True`; with just per-callback `websocket=True` it opens lazily on the first such callback dispatch, and an app with no WebSocket callbacks never opens one. +- [#3941](https://github.com/plotly/dash/pull/3941) Fix the FastAPI and Quart backends opening a WebSocket connection on every page load, even for apps with no WebSocket callbacks. The renderer keyed the connection on the mere presence of WebSocket infrastructure (always advertised by these backends) rather than on whether it was needed. The socket now opens eagerly only when `websocket_callbacks=True`; with just per-callback `websocket=True` it opens lazily on the first such callback dispatch, and an app with no WebSocket callbacks never opens one. Fixes [#3939](https://github.com/plotly/dash/issues/3939). - [#3916](https://github.com/plotly/dash/pull/3916) Fixed a regression where dragging multiple files into `dcc.Upload` would upload only the first file when `multiple=True` - [#3922](https://github.com/plotly/dash/pull/3922) Fix `dcc.Input(type="number")` stepper behavior when only `min` is set. - [#3925](https://github.com/plotly/dash/pull/3925) Use the proxied url as the Jupyter server url so `DASH_PROXY` is honored by the external url and inline iframe in notebooks.