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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- [#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.
Expand Down
11 changes: 5 additions & 6 deletions dash/dash-renderer/src/AppProvider.react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
53 changes: 33 additions & 20 deletions dash/dash-renderer/src/observers/websocketObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,12 @@ export async function initializeWebSocket(
store: Store<IStoreState>,
config: DashConfig
): Promise<void> {
// 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
);
Expand Down Expand Up @@ -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)
Expand All @@ -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);
}
}

/**
Expand Down
136 changes: 136 additions & 0 deletions tests/websocket/test_ws_lazy_connect.py
Original file line number Diff line number Diff line change
@@ -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() == []
Loading