From 5b03787a8c5127d449f4c8264aa8c587cc3b9239 Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 15 May 2026 11:13:31 -0400 Subject: [PATCH 1/3] fix graph patch & duplicate clicks --- .../src/fragments/Graph.react.js | 6 + .../integration/graph/test_graph_basics.py | 135 ++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/components/dash-core-components/src/fragments/Graph.react.js b/components/dash-core-components/src/fragments/Graph.react.js index fdff615190..dab04e1c65 100644 --- a/components/dash-core-components/src/fragments/Graph.react.js +++ b/components/dash-core-components/src/fragments/Graph.react.js @@ -317,6 +317,8 @@ class PlotlyGraph extends Component { if (!layout) { return layout; } + // Clone layout to avoid mutating the original (important for Patch) + layout = {...layout}; const override = this.getLayoutOverride(responsive); const {override: prev_override, originals: prev_originals} = this.state; // Store the original data that we're about to override @@ -414,6 +416,8 @@ class PlotlyGraph extends Component { gd.on('plotly_click', eventData => { const clickData = filterEventData(gd, eventData, 'click'); if (!isNil(clickData)) { + // Add timestamp to ensure each click is unique (for DashWrapper deduplication) + clickData.timestamp = Date.now(); setProps({clickData}); } }); @@ -422,6 +426,8 @@ class PlotlyGraph extends Component { ['event', 'fullAnnotation'], eventData ); + // Add timestamp to ensure each click is unique (for DashWrapper deduplication) + clickAnnotationData.timestamp = Date.now(); setProps({clickAnnotationData}); }); gd.on('plotly_hover', eventData => { diff --git a/components/dash-core-components/tests/integration/graph/test_graph_basics.py b/components/dash-core-components/tests/integration/graph/test_graph_basics.py index 5ad78e658a..94a871e922 100644 --- a/components/dash-core-components/tests/integration/graph/test_graph_basics.py +++ b/components/dash-core-components/tests/integration/graph/test_graph_basics.py @@ -412,3 +412,138 @@ def toggle_figure(n_clicks): ) assert dash_dcc.get_logs() == [] + + +def test_grbs009_graph_click_same_point_twice(dash_dcc): + """Clicking the same point twice should trigger callback both times.""" + app = Dash(__name__) + + app.layout = html.Div( + [ + dcc.Graph( + id="graph", + figure=go.Figure( + data=[go.Scatter(x=[1, 2, 3], y=[1, 2, 3], mode="markers")], + ), + style={"width": 600, "height": 400}, + ), + html.Div(id="output", children="[]"), + ] + ) + + @app.callback( + Output("output", "children"), + Input("graph", "clickData"), + prevent_initial_call=True, + ) + def on_click(click_data): + # Return the timestamp to verify each click has a unique one + return json.dumps(click_data.get("timestamp", "missing")) + + dash_dcc.start_server(app) + dash_dcc.wait_for_element("#graph .main-svg") + + # Click on the graph area - uses the drag overlay which receives clicks + graph = dash_dcc.find_element("#graph .nsewdrag") + graph.click() + + # Wait for callback to fire and verify timestamp exists + dash_dcc.wait_for_contains_text("#output", "") + first_output = dash_dcc.find_element("#output").text + assert first_output != "[]", "First click should trigger callback" + assert first_output != '"missing"', "clickData should contain timestamp" + + # Click again - should trigger callback with different timestamp + graph.click() + sleep(0.5) + second_output = dash_dcc.find_element("#output").text + assert ( + second_output != first_output + ), "Second click should trigger callback with new timestamp" + + assert dash_dcc.get_logs() == [] + + +def test_grbs010_graph_patch_deeply_nested_figure(dash_dcc): + """Patching deeply nested figure properties should work correctly.""" + from dash import Patch + + app = Dash(__name__) + + app.layout = html.Div( + [ + dcc.Graph( + id="graph", + figure={ + "data": [ + { + "x": [1, 2, 3], + "y": [1, 2, 3], + "marker": {"color": "red", "size": 10}, + "type": "scatter", + "mode": "markers", + } + ], + "layout": {"title": {"text": "Initial Title"}}, + }, + style={"width": 600, "height": 400}, + ), + html.Button("Patch Color", id="patch-color-btn"), + html.Button("Patch Title", id="patch-title-btn"), + html.Div(id="output"), + ] + ) + + @app.callback( + Output("graph", "figure"), + Input("patch-color-btn", "n_clicks"), + prevent_initial_call=True, + ) + def patch_color(n): + p = Patch() + p.data[0].marker.color = "blue" if n % 2 else "green" + return p + + @app.callback( + Output("graph", "figure", allow_duplicate=True), + Input("patch-title-btn", "n_clicks"), + prevent_initial_call=True, + ) + def patch_title(n): + p = Patch() + p.layout.title.text = f"Updated Title {n}" + return p + + @app.callback( + Output("output", "children"), + Input("graph", "figure"), + ) + def show_figure_state(figure): + color = figure.get("data", [{}])[0].get("marker", {}).get("color", "unknown") + title = figure.get("layout", {}).get("title", {}).get("text", "unknown") + return f"color={color}, title={title}" + + dash_dcc.start_server(app) + dash_dcc.wait_for_element("#graph .main-svg") + + # Initial state + dash_dcc.wait_for_text_to_equal("#output", "color=red, title=Initial Title") + + # Patch the color + dash_dcc.find_element("#patch-color-btn").click() + dash_dcc.wait_for_text_to_equal("#output", "color=blue, title=Initial Title") + + # Patch the title + dash_dcc.find_element("#patch-title-btn").click() + dash_dcc.wait_for_text_to_equal("#output", "color=blue, title=Updated Title 1") + + # Patch color again - should toggle + dash_dcc.find_element("#patch-color-btn").click() + dash_dcc.wait_for_text_to_equal("#output", "color=green, title=Updated Title 1") + + # Multiple rapid patches + dash_dcc.find_element("#patch-title-btn").click() + dash_dcc.find_element("#patch-title-btn").click() + dash_dcc.wait_for_text_to_equal("#output", "color=green, title=Updated Title 3") + + assert dash_dcc.get_logs() == [] From f51dc6306f9b0d8efd06eaa1b9d2178c0a59af5c Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 15 May 2026 11:31:39 -0400 Subject: [PATCH 2/3] set arb008 flaky --- tests/integration/callbacks/test_arbitrary_callbacks.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/callbacks/test_arbitrary_callbacks.py b/tests/integration/callbacks/test_arbitrary_callbacks.py index d6038fd7e7..1845d16053 100644 --- a/tests/integration/callbacks/test_arbitrary_callbacks.py +++ b/tests/integration/callbacks/test_arbitrary_callbacks.py @@ -1,6 +1,8 @@ import time from multiprocessing import Value +from flaky import flaky + from dash import ( Dash, Input, @@ -234,6 +236,7 @@ def test_arb007_clientside_no_output(dash_duo): dash_duo.wait_for_text_to_equal("#output", "start2") +@flaky(max_runs=3) def test_arb008_set_props_chain_cb(dash_duo): app = Dash(suppress_callback_exceptions=True) From fe8f781ecb69ef0da4a42992a356f57feaa3aef3 Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 15 May 2026 12:12:53 -0400 Subject: [PATCH 3/3] sync relayout shapes --- .../src/fragments/Graph.react.js | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/components/dash-core-components/src/fragments/Graph.react.js b/components/dash-core-components/src/fragments/Graph.react.js index dab04e1c65..fbe477fd37 100644 --- a/components/dash-core-components/src/fragments/Graph.react.js +++ b/components/dash-core-components/src/fragments/Graph.react.js @@ -450,6 +450,29 @@ class PlotlyGraph extends Component { if (!isNil(relayout) && !equals(relayout, relayoutData)) { setProps({relayoutData: relayout}); } + // Sync shapes from gd.layout to figure when shapes are modified by user + // This is needed because getLayout() clones layout to prevent mutation issues + if (eventData && gd.layout) { + const hasShapeChanges = Object.keys(eventData).some( + key => key === 'shapes' || key.startsWith('shapes[') + ); + if (hasShapeChanges) { + const {figure} = this.props; + const currentShapes = figure?.layout?.shapes; + const newShapes = gd.layout.shapes; + if (!equals(currentShapes, newShapes)) { + setProps({ + figure: { + ...figure, + layout: { + ...figure?.layout, + shapes: newShapes, + }, + }, + }); + } + } + } }); gd.on('plotly_restyle', eventData => { const restyle = filterEventData(gd, eventData, 'restyle');