diff --git a/CHANGELOG.md b/CHANGELOG.md index d9b25edf2f..8404a7f124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/dash/_callback_context.py b/dash/_callback_context.py index 4f296bde66..0717e65fe5 100644 --- a/dash/_callback_context.py +++ b/dash/_callback_context.py @@ -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[ @@ -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 diff --git a/dash/dash-renderer/src/observers/websocketObserver.ts b/dash/dash-renderer/src/observers/websocketObserver.ts index 26201eab91..c32079dab2 100644 --- a/dash/dash-renderer/src/observers/websocketObserver.ts +++ b/dash/dash-renderer/src/observers/websocketObserver.ts @@ -9,6 +9,7 @@ import {path} from 'ramda'; import {IStoreState} from '../store'; import {updateProps, notifyObservers} from '../actions'; +import {parsePatchProps} from '../actions/patch'; import {getPath} from '../actions/paths'; import { getWorkerClient, @@ -73,7 +74,7 @@ export async function initializeWebSocket( // 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); @@ -85,17 +86,26 @@ export async function initializeWebSocket( return; } + // Get old props for Patch processing + const oldProps = (path([...componentPath, 'props'], state.layout) || + {}) as Record; + + // 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 diff --git a/tests/websocket/test_ws_patch.py b/tests/websocket/test_ws_patch.py new file mode 100644 index 0000000000..f278b5f26e --- /dev/null +++ b/tests/websocket/test_ws_patch.py @@ -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() == [] diff --git a/tests/websocket/test_ws_props.py b/tests/websocket/test_ws_props.py index 6b940792b3..51a9d1eec6 100644 --- a/tests/websocket/test_ws_props.py +++ b/tests/websocket/test_ws_props.py @@ -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 @@ -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() == []