From e4849eb61127465f767dd27bb504224c1d4c6646 Mon Sep 17 00:00:00 2001 From: CNFeffery Date: Sun, 26 Apr 2026 10:39:21 +0800 Subject: [PATCH 1/7] Fix websocket callback set_props() with Patch object problems --- dash/_callback_context.py | 11 +++++++---- .../src/observers/websocketObserver.ts | 14 +++++++++++--- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/dash/_callback_context.py b/dash/_callback_context.py index 4f296bde66..47fcc4cc64 100644 --- a/dash/_callback_context.py +++ b/dash/_callback_context.py @@ -9,12 +9,13 @@ 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[ - typing.Dict[str, typing.Any] -] = contextvars.ContextVar("callback_context") +context_value: contextvars.ContextVar[typing.Dict[str, typing.Any]] = ( + contextvars.ContextVar("callback_context") +) context_value.set({}) @@ -370,6 +371,8 @@ def set_props(component_id: typing.Union[str, dict], props: dict): async def _send_props(): for prop_name, value in props.items(): + if isinstance(value, Patch): + 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..cec3cbdc85 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,24 @@ 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 From 70cbf48383c4a157b1087fe6dde18f9756860f4f Mon Sep 17 00:00:00 2001 From: CNFeffery Date: Sun, 26 Apr 2026 10:39:40 +0800 Subject: [PATCH 2/7] Add websocket callback set_props patch tests --- tests/websocket/test_ws_patch.py | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/websocket/test_ws_patch.py diff --git a/tests/websocket/test_ws_patch.py b/tests/websocket/test_ws_patch.py new file mode 100644 index 0000000000..83d3aef0a0 --- /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() == [] \ No newline at end of file From 68a696a84bafa9c3c94feaef38af3fd69361f997 Mon Sep 17 00:00:00 2001 From: CNFeffery Date: Sun, 26 Apr 2026 10:47:27 +0800 Subject: [PATCH 3/7] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 From 65f3dc2ea70a40d5c0206668a6e37d9fb38a0be6 Mon Sep 17 00:00:00 2001 From: CNFeffery Date: Sun, 26 Apr 2026 10:49:31 +0800 Subject: [PATCH 4/7] Format code --- dash/_callback_context.py | 6 +++--- dash/dash-renderer/src/observers/websocketObserver.ts | 4 +++- tests/websocket/test_ws_patch.py | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/dash/_callback_context.py b/dash/_callback_context.py index 47fcc4cc64..871afb90da 100644 --- a/dash/_callback_context.py +++ b/dash/_callback_context.py @@ -13,9 +13,9 @@ from ._utils import AttributeDict, stringify_id, to_json -context_value: contextvars.ContextVar[typing.Dict[str, typing.Any]] = ( - contextvars.ContextVar("callback_context") -) +context_value: contextvars.ContextVar[ + typing.Dict[str, typing.Any] +] = contextvars.ContextVar("callback_context") context_value.set({}) diff --git a/dash/dash-renderer/src/observers/websocketObserver.ts b/dash/dash-renderer/src/observers/websocketObserver.ts index cec3cbdc85..c32079dab2 100644 --- a/dash/dash-renderer/src/observers/websocketObserver.ts +++ b/dash/dash-renderer/src/observers/websocketObserver.ts @@ -103,7 +103,9 @@ export async function initializeWebSocket( ); // Notify observers - store.dispatch(notifyObservers({id: parsedId, props: processedProps}) 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 index 83d3aef0a0..f278b5f26e 100644 --- a/tests/websocket/test_ws_patch.py +++ b/tests/websocket/test_ws_patch.py @@ -39,4 +39,4 @@ def patch_append(n): dash_duo.wait_for_text_to_equal("#output", "initial + click 1", timeout=10) - assert dash_duo.get_logs() == [] \ No newline at end of file + assert dash_duo.get_logs() == [] From 64603357471f878df25230b7dee806fcd6b9e93b Mon Sep 17 00:00:00 2001 From: CNFeffery Date: Sun, 26 Apr 2026 22:22:33 +0800 Subject: [PATCH 5/7] Fix websocket callback update component prop via set_props() --- dash/_callback_context.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dash/_callback_context.py b/dash/_callback_context.py index 871afb90da..0717e65fe5 100644 --- a/dash/_callback_context.py +++ b/dash/_callback_context.py @@ -371,7 +371,8 @@ def set_props(component_id: typing.Union[str, dict], props: dict): async def _send_props(): for prop_name, value in props.items(): - if isinstance(value, Patch): + # 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) From 4b19d9abbfdb5c1e61f460d28ba6628a63eaaded Mon Sep 17 00:00:00 2001 From: CNFeffery Date: Sun, 26 Apr 2026 22:23:26 +0800 Subject: [PATCH 6/7] Add websocket callback update component prop tests --- tests/websocket/test_ws_props.py | 149 ++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 2 deletions(-) diff --git a/tests/websocket/test_ws_props.py b/tests/websocket/test_ws_props.py index 6b940792b3..898f1604d7 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, Patch from dash.exceptions import PreventUpdate @@ -265,3 +265,148 @@ 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_with_patch_objects(dash_duo): + """Test set_props with Patch objects - verifies bug fix for component property updates.""" + app = Dash(__name__, backend="fastapi", websocket_callbacks=True) + + app.layout = html.Div( + [ + html.Button("Patch Update", id="btn"), + html.Div("initial text", id="output"), + html.Div(id="result"), + ] + ) + + @app.callback( + Output("result", "children"), Input("btn", "n_clicks"), websocket=True + ) + async def patch_update(n): + if not n: + raise PreventUpdate + + p = Patch() + p += f" - updated {n}" + + set_props("output", {"children": p}) + return f"Completed {n}" + + dash_duo.start_server(app) + + dash_duo.find_element("#btn").click() + + dash_duo.wait_for_text_to_equal("#output", "initial text - updated 1", timeout=10) + dash_duo.wait_for_text_to_equal("#result", "Completed 1") + + assert dash_duo.get_logs() == [] + + +def test_ws046_set_props_multiple_props_with_patch(dash_duo): + """Test set_props with multiple props including Patch objects.""" + app = Dash(__name__, backend="fastapi", websocket_callbacks=True) + + app.layout = html.Div( + [ + html.Button("Multi Patch", id="btn"), + html.Div("start", id="output1"), + html.Div("count: 0", id="output2"), + html.Div(id="result"), + ] + ) + + @app.callback( + Output("result", "children"), Input("btn", "n_clicks"), websocket=True + ) + async def multi_patch_update(n): + if not n: + raise PreventUpdate + + p = Patch() + p += f" + added {n}" + + set_props("output1", {"children": p, "style": {"color": "blue"}}) + set_props("output2", {"children": f"count: {n}"}) + return f"Multi update {n}" + + dash_duo.start_server(app) + + dash_duo.find_element("#btn").click() + + dash_duo.wait_for_text_to_equal("#output1", "start + added 1", timeout=10) + dash_duo.wait_for_text_to_equal("#output2", "count: 1") + dash_duo.wait_for_text_to_equal("#result", "Multi update 1") + + assert dash_duo.get_logs() == [] + + +def test_ws047_set_props_patch_in_sync_callback(dash_duo): + """Test set_props with Patch in synchronous callback.""" + app = Dash(__name__, backend="fastapi", websocket_callbacks=True) + + app.layout = html.Div( + [ + html.Button("Sync Patch", id="btn"), + html.Div("original", id="target"), + html.Div(id="result"), + ] + ) + + @app.callback( + Output("result", "children"), Input("btn", "n_clicks"), websocket=True + ) + def sync_patch_update(n): + if not n: + raise PreventUpdate + + p = Patch() + p += f" sync {n}" + + set_props("target", {"children": p}) + return f"Sync done {n}" + + dash_duo.start_server(app) + + dash_duo.find_element("#btn").click() + + dash_duo.wait_for_text_to_equal("#target", "original sync 1", timeout=10) + dash_duo.wait_for_text_to_equal("#result", "Sync done 1") + + assert dash_duo.get_logs() == [] + + +def test_ws048_set_props_patch_with_dict_id(dash_duo): + """Test set_props with Patch and dict component ID (pattern matching).""" + app = Dash(__name__, backend="fastapi", websocket_callbacks=True) + + app.layout = html.Div( + [ + html.Button("Dict ID Patch", id="btn"), + html.Div("base", id={"type": "item", "index": 0}), + html.Div(id="result"), + ] + ) + + @app.callback( + Output("result", "children"), Input("btn", "n_clicks"), websocket=True + ) + async def dict_id_patch(n): + if not n: + raise PreventUpdate + + p = Patch() + p += f" patched {n}" + + set_props({"type": "item", "index": 0}, {"children": p}) + return f"Patched item {n}" + + dash_duo.start_server(app) + + dash_duo.find_element("#btn").click() + + dash_duo.wait_for_text_to_equal( + '[id=\'{"index":0,"type":"item"}\']', "base patched 1", timeout=10 + ) + dash_duo.wait_for_text_to_equal("#result", "Patched item 1") + + assert dash_duo.get_logs() == [] From 411b467a8c9bf0f67e8744435e830e6bf8e51c8b Mon Sep 17 00:00:00 2001 From: CNFeffery Date: Sun, 26 Apr 2026 22:50:31 +0800 Subject: [PATCH 7/7] Add websocket callback update component prop tests --- tests/websocket/test_ws_props.py | 156 ++++++++++++++----------------- 1 file changed, 71 insertions(+), 85 deletions(-) diff --git a/tests/websocket/test_ws_props.py b/tests/websocket/test_ws_props.py index 898f1604d7..51a9d1eec6 100644 --- a/tests/websocket/test_ws_props.py +++ b/tests/websocket/test_ws_props.py @@ -9,7 +9,7 @@ """ import asyncio -from dash import Dash, html, Input, Output, set_props, Patch +from dash import Dash, html, Input, Output, set_props from dash.exceptions import PreventUpdate @@ -267,146 +267,132 @@ async def update_with_dict_id(n): assert dash_duo.get_logs() == [] -def test_ws045_set_props_with_patch_objects(dash_duo): - """Test set_props with Patch objects - verifies bug fix for component property updates.""" +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("Patch Update", id="btn"), - html.Div("initial text", id="output"), + html.Button("Update Children", id="btn"), + html.Div(id="container"), html.Div(id="result"), ] ) - @app.callback( - Output("result", "children"), Input("btn", "n_clicks"), websocket=True - ) - async def patch_update(n): + @app.callback(Output("result", "children"), Input("btn", "n_clicks")) + async def update_children(n): if not n: raise PreventUpdate - p = Patch() - p += f" - updated {n}" - - set_props("output", {"children": p}) - return f"Completed {n}" + 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("#output", "initial text - updated 1", timeout=10) - dash_duo.wait_for_text_to_equal("#result", "Completed 1") + 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_multiple_props_with_patch(dash_duo): - """Test set_props with multiple props including Patch objects.""" +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("Multi Patch", id="btn"), - html.Div("start", id="output1"), - html.Div("count: 0", id="output2"), + html.Button("Update Nested", id="btn"), + html.Div(id="wrapper"), html.Div(id="result"), ] ) - @app.callback( - Output("result", "children"), Input("btn", "n_clicks"), websocket=True - ) - async def multi_patch_update(n): + @app.callback(Output("result", "children"), Input("btn", "n_clicks")) + async def update_nested(n): if not n: raise PreventUpdate - p = Patch() - p += f" + added {n}" - - set_props("output1", {"children": p, "style": {"color": "blue"}}) - set_props("output2", {"children": f"count: {n}"}) - return f"Multi update {n}" + 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("#output1", "start + added 1", timeout=10) - dash_duo.wait_for_text_to_equal("#output2", "count: 1") - dash_duo.wait_for_text_to_equal("#result", "Multi update 1") - - assert dash_duo.get_logs() == [] - - -def test_ws047_set_props_patch_in_sync_callback(dash_duo): - """Test set_props with Patch in synchronous callback.""" - app = Dash(__name__, backend="fastapi", websocket_callbacks=True) - - app.layout = html.Div( - [ - html.Button("Sync Patch", id="btn"), - html.Div("original", id="target"), - html.Div(id="result"), - ] - ) - - @app.callback( - Output("result", "children"), Input("btn", "n_clicks"), websocket=True + dash_duo.wait_for_text_to_equal( + "#wrapper ul li:first-child", "Item 1.1", timeout=10 ) - def sync_patch_update(n): - if not n: - raise PreventUpdate - - p = Patch() - p += f" sync {n}" - - set_props("target", {"children": p}) - return f"Sync done {n}" - - dash_duo.start_server(app) - - dash_duo.find_element("#btn").click() - - dash_duo.wait_for_text_to_equal("#target", "original sync 1", timeout=10) - dash_duo.wait_for_text_to_equal("#result", "Sync done 1") + 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_ws048_set_props_patch_with_dict_id(dash_duo): - """Test set_props with Patch and dict component ID (pattern matching).""" +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("Dict ID Patch", id="btn"), - html.Div("base", id={"type": "item", "index": 0}), + html.Button("Update List", id="btn"), + html.Div(id="list-container"), html.Div(id="result"), ] ) - @app.callback( - Output("result", "children"), Input("btn", "n_clicks"), websocket=True - ) - async def dict_id_patch(n): + @app.callback(Output("result", "children"), Input("btn", "n_clicks")) + async def update_list(n): if not n: raise PreventUpdate - p = Patch() - p += f" patched {n}" - - set_props({"type": "item", "index": 0}, {"children": p}) - return f"Patched item {n}" + 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( - '[id=\'{"index":0,"type":"item"}\']', "base patched 1", timeout=10 - ) - dash_duo.wait_for_text_to_equal("#result", "Patched item 1") + 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() == []