Skip to content
Open
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- [#3723](https://github.com/plotly/dash/pull/3723) Fix misaligned `dcc.Slider` marks when some labels are empty strings
- [#3740](https://github.com/plotly/dash/pull/3740) Fix cannot tab into dropdowns in Safari
- [#2462](https://github.com/plotly/dash/issues/2462) Allow `MATCH` in `Input`/`State` when the callback's `Output` has no wildcards (fixed-id Output, no Output, or `ALL`-only wildcard Output). `ALLSMALLER` still requires a corresponding `MATCH` in an Output.
- [#3759](https://github.com/plotly/dash/pull/3759) Fix the issue where `Patch` objects cannot be updated via `set_props()` in `websocket` callback. Fix [#3742](https://github.com/plotly/dash/issues/3742)
- [#3759](https://github.com/plotly/dash/pull/3759) Fix the error when using `set_props()` to update component-type properties in the `websocket` callback.

## [4.2.0rc1] - 2026-04-13

Expand Down
4 changes: 2 additions & 2 deletions dash/backends/_fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ def __init__(
super().__init__(pending_get_props, renderer_id)
self._websocket = websocket

async def _send_json(self, data: dict) -> None:
await self._websocket.send_json(data)
async def _send(self, data: str) -> None:
await self._websocket.send({"type": "websocket.send", "text": data})

async def _close_websocket(self, code: int, reason: str) -> None:
await self._websocket.close(code=code, reason=reason)
Expand Down
4 changes: 2 additions & 2 deletions dash/backends/_quart.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ def __init__(
super().__init__(pending_get_props, renderer_id)
self._websocket = ws

async def _send_json(self, data: dict) -> None:
await self._websocket.send_json(data)
async def _send(self, data: str) -> None:
await self._websocket.send({"type": "websocket.send", "text": data})

async def _close_websocket(self, code: int, reason: str) -> None:
await self._websocket.close(code=code, reason=reason)
Expand Down
36 changes: 26 additions & 10 deletions dash/backends/base_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""
from abc import ABC, abstractmethod
import asyncio
import json
import uuid
from typing import Any, Dict, Type, TypeVar, Generic, Protocol, TYPE_CHECKING

Expand Down Expand Up @@ -392,7 +393,7 @@ class DashWebsocketCallback(ABC):
Provides methods for real-time bidirectional communication between
the server and renderer during callback execution.

Subclasses must implement _send_json and _close_websocket for their
Subclasses must implement _send and _close_websocket for their
specific WebSocket implementation.
"""

Expand All @@ -411,13 +412,29 @@ def __init__(
self._renderer_id = renderer_id

@abstractmethod
async def _send_json(self, data: dict) -> None:
"""Send JSON data over the WebSocket. Must be implemented by subclasses."""
async def _send(self, data: str) -> None:
"""Send string data over the WebSocket. Must be implemented by subclasses."""

@abstractmethod
async def _close_websocket(self, code: int, reason: str) -> None:
"""Close the WebSocket connection. Must be implemented by subclasses."""

async def _send_plotly_json(self, value: Any) -> None:
"""Serialize and send value to client using plotly JSON serialization.

Uses to_json for full compatibility with all supported prop types,
then sends the string directly to avoid double serialization.
"""
# pylint: disable=import-outside-toplevel
from dash._utils import to_json

serialized = to_json(value)
await self._send(serialized)

async def _send_json(self, data: dict) -> None:
"""Send JSON dict over the WebSocket."""
await self._send(json.dumps(data))

async def set_prop(self, component_id: str, prop_name: str, value: Any) -> None:
"""Send immediate prop update to the client via WebSocket.

Expand All @@ -426,13 +443,12 @@ async def set_prop(self, component_id: str, prop_name: str, value: Any) -> None:
prop_name: The property name to update
value: The new value to set
"""
await self._send_json(
{
"type": "set_props",
"rendererId": self._renderer_id,
"payload": {"componentId": component_id, "props": {prop_name: value}},
}
)
payload = {
"type": "set_props",
"rendererId": self._renderer_id,
"payload": {"componentId": component_id, "props": {prop_name: value}},
}
await self._send_plotly_json(payload)

async def get_prop(self, component_id: str, prop_name: str) -> Any:
"""Request current prop value from the client.
Expand Down
16 changes: 13 additions & 3 deletions dash/dash-renderer/src/observers/websocketObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import {IStoreState} from '../store';
import {updateProps, notifyObservers} from '../actions';
import {parsePatchProps} from '../actions/patch';
import {getPath} from '../actions/paths';
import {
getWorkerClient,
Expand Down Expand Up @@ -73,7 +74,7 @@

// Handle SET_PROPS messages
workerClient.onSetProps = (payload: SetPropsPayload) => {
const {componentId, props} = payload;
const {componentId, props: rawProps} = payload;
const parsedId = parseComponentId(componentId);
const state = store.getState();
const componentPath = getPath(state.paths, parsedId);
Expand All @@ -85,17 +86,26 @@
return;
}

// Get old props for Patch processing
const oldProps = (path([...componentPath, 'props'], state.layout) ||
{}) as Record<string, unknown>;

// Process props to handle Patch objects
const processedProps = parsePatchProps(rawProps, oldProps);

// Update the component props
store.dispatch(
updateProps({
props,
props: processedProps,
itempath: componentPath,
renderType: 'websocket'
}) as any
);

// Notify observers
store.dispatch(notifyObservers({id: parsedId, props}) as any);
store.dispatch(
notifyObservers({id: parsedId, props: processedProps}) as any
);
};

// Handle GET_PROPS_REQUEST messages
Expand Down Expand Up @@ -150,9 +160,9 @@
try {
// config.websocket is guaranteed to exist due to wsAvailable check above
await workerClient.connect(
config.websocket!.worker_url,

Check warning on line 163 in dash/dash-renderer/src/observers/websocketObserver.ts

View workflow job for this annotation

GitHub Actions / Lint & Unit Tests (Python 3.8)

Forbidden non-null assertion

Check warning on line 163 in dash/dash-renderer/src/observers/websocketObserver.ts

View workflow job for this annotation

GitHub Actions / Lint & Unit Tests (Python 3.12)

Forbidden non-null assertion
wsUrl,
config.websocket!.inactivity_timeout

Check warning on line 165 in dash/dash-renderer/src/observers/websocketObserver.ts

View workflow job for this annotation

GitHub Actions / Lint & Unit Tests (Python 3.8)

Forbidden non-null assertion

Check warning on line 165 in dash/dash-renderer/src/observers/websocketObserver.ts

View workflow job for this annotation

GitHub Actions / Lint & Unit Tests (Python 3.12)

Forbidden non-null assertion
);
} catch (error) {
console.error('[Dash] Failed to connect to WebSocket worker:', error);
Expand Down
42 changes: 42 additions & 0 deletions tests/websocket/test_ws_patch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""
WebSocket set_props with Patch object test.

Verifies that set_props works with Patch objects in websocket callbacks.
"""

from dash import Dash, html, Input, Output, set_props, Patch
from dash.exceptions import PreventUpdate


def test_ws037_set_props_with_patch(dash_duo):
"""Test set_props with Patch object in websocket callback."""
app = Dash(__name__, backend="fastapi", websocket_callbacks=True)

app.layout = html.Div(
[
html.Button("Patch", id="btn"),
html.Div("initial", id="output"),
html.Div(id="result"),
]
)

@app.callback(
Output("result", "children"), Input("btn", "n_clicks"), websocket=True
)
def patch_append(n):
if not n:
raise PreventUpdate

p = Patch()
p += f" + click {n}"

set_props("output", {"children": p})
return f"Appended {n}"

dash_duo.start_server(app)

dash_duo.find_element("#btn").click()

dash_duo.wait_for_text_to_equal("#output", "initial + click 1", timeout=10)

assert dash_duo.get_logs() == []
135 changes: 133 additions & 2 deletions tests/websocket/test_ws_props.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
- set_props streaming during long-running callback
- get_prop reads current component value
- async set_prop method
- set_props with Patch objects (bug fix for component property updates)
"""

import asyncio
from dash import Dash, html, Input, Output
from dash._callback_context import set_props
from dash import Dash, html, Input, Output, set_props
from dash.exceptions import PreventUpdate


Expand Down Expand Up @@ -265,3 +265,134 @@ async def update_with_dict_id(n):
dash_duo.wait_for_text_to_equal("#result", "Done 1")

assert dash_duo.get_logs() == []


def test_ws045_set_props_component_prop_children(dash_duo):
"""Test set_props updating component props like Div's children with component."""
app = Dash(__name__, backend="fastapi", websocket_callbacks=True)

app.layout = html.Div(
[
html.Button("Update Children", id="btn"),
html.Div(id="container"),
html.Div(id="result"),
]
)

@app.callback(Output("result", "children"), Input("btn", "n_clicks"))
async def update_children(n):
if not n:
raise PreventUpdate

set_props(
"container",
{
"children": html.Div(
[
html.Span(f"Updated {n}"),
html.B(" - Bold Text"),
]
)
},
)
return f"Children updated {n}"

dash_duo.start_server(app)

dash_duo.find_element("#btn").click()

dash_duo.wait_for_text_to_equal("#container span", "Updated 1", timeout=10)
dash_duo.wait_for_text_to_equal("#container b", "- Bold Text")
dash_duo.wait_for_text_to_equal("#result", "Children updated 1")

assert dash_duo.get_logs() == []


def test_ws046_set_props_nested_component_children(dash_duo):
"""Test set_props with nested component in children prop."""
app = Dash(__name__, backend="fastapi", websocket_callbacks=True)

app.layout = html.Div(
[
html.Button("Update Nested", id="btn"),
html.Div(id="wrapper"),
html.Div(id="result"),
]
)

@app.callback(Output("result", "children"), Input("btn", "n_clicks"))
async def update_nested(n):
if not n:
raise PreventUpdate

set_props(
"wrapper",
{
"children": html.Div(
[
html.Ul(
[
html.Li(f"Item {n}.1"),
html.Li(f"Item {n}.2"),
]
)
]
)
},
)
return f"Nested updated {n}"

dash_duo.start_server(app)

dash_duo.find_element("#btn").click()

dash_duo.wait_for_text_to_equal(
"#wrapper ul li:first-child", "Item 1.1", timeout=10
)
dash_duo.wait_for_text_to_equal("#wrapper ul li:last-child", "Item 1.2")
dash_duo.wait_for_text_to_equal("#result", "Nested updated 1")

assert dash_duo.get_logs() == []


def test_ws047_set_props_children_with_list(dash_duo):
"""Test set_props with list of components wrapped in a single component."""
app = Dash(__name__, backend="fastapi", websocket_callbacks=True)

app.layout = html.Div(
[
html.Button("Update List", id="btn"),
html.Div(id="list-container"),
html.Div(id="result"),
]
)

@app.callback(Output("result", "children"), Input("btn", "n_clicks"))
async def update_list(n):
if not n:
raise PreventUpdate

set_props(
"list-container",
{
"children": html.Div(
[
html.Div(f"Item 1 - {n}"),
html.Div(f"Item 2 - {n}"),
html.Div(f"Item 3 - {n}"),
]
)
},
)
return f"List updated {n}"

dash_duo.start_server(app)

dash_duo.find_element("#btn").click()

dash_duo.wait_for_text_to_equal("#result", "List updated 1", timeout=10)
assert "Item 1 - 1" in dash_duo.find_element("#list-container").text
assert "Item 2 - 1" in dash_duo.find_element("#list-container").text
assert "Item 3 - 1" in dash_duo.find_element("#list-container").text

assert dash_duo.get_logs() == []
Loading