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 @@ -17,6 +17,7 @@ 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 callbacks. Fix [#3742](https://github.com/plotly/dash/issues/3742)

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

Expand Down
6 changes: 5 additions & 1 deletion dash/_callback_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@

from . import exceptions
from ._get_app import get_app
from ._utils import AttributeDict, stringify_id
from ._patch import Patch
from ._utils import AttributeDict, stringify_id, to_json


context_value: contextvars.ContextVar[
Expand Down Expand Up @@ -370,6 +371,9 @@ def set_props(component_id: typing.Union[str, dict], props: dict):

async def _send_props():
for prop_name, value in props.items():
# Convert Patch and Dash Components to JSON-compatible format
if isinstance(value, Patch) or hasattr(value, "to_plotly_json"):
value = json.loads(to_json(value))
await ws.set_prop(_id, prop_name, value)

# If we're in an async context, schedule the coroutine
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.12)

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.8)

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.12)

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.8)

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