From c07a3195b9a90c849d41961e060d8ef5761a073d Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 7 Jul 2026 10:31:19 -0400 Subject: [PATCH] Sync all relayout changes back to the figure prop, not only shapes Since #3785 getLayout() clones the layout, so plotly.js writes user interactions (pan/zoom ranges, edited annotations, ...) only into its own copy (gd.layout) and the figure prop goes stale. A subsequent Patch update is applied to the stale figure and reverts the user's view. Generalize the shapes-only sync in the plotly_relayout handler to every layout key touched by the relayout event, excluding autosize/width/height which come from the resize machinery rather than user interactions. Fixes #3810 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 ++ .../src/fragments/Graph.react.js | 49 ++++++---- .../integration/graph/test_graph_basics.py | 90 +++++++++++++++++++ 3 files changed, 125 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e7b816d7..52e244746d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## [UNRELEASED] + +## Fixed +- [#3882](https://github.com/plotly/dash/pull/3882) Fix `dcc.Graph` user interactions (pan, zoom, edited shapes & annotations, ...) being reverted by a subsequent `Patch` update, by syncing all relayout changes back to the `figure` prop instead of only shapes. Fixes [#3810](https://github.com/plotly/dash/issues/3810). + ## [4.4.0] - 2026-07-03 ### Added diff --git a/components/dash-core-components/src/fragments/Graph.react.js b/components/dash-core-components/src/fragments/Graph.react.js index a9c75c4df0..8fae4f7189 100644 --- a/components/dash-core-components/src/fragments/Graph.react.js +++ b/components/dash-core-components/src/fragments/Graph.react.js @@ -473,28 +473,39 @@ 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 + // Sync user-driven layout changes (pan/zoom ranges, edited + // shapes, annotations, ...) from gd.layout back to the figure + // prop. This is needed because getLayout() clones layout to + // prevent mutation issues, so plotly.js only updates its own + // copy and the figure prop (used as the base for Patch + // updates) would otherwise go stale. 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, - }, - }, - }); + const {figure = {}} = this.props; + const updates = {}; + for (const eventKey of Object.keys(eventData)) { + // 'xaxis.range[0]' -> 'xaxis', 'shapes[1].x0' -> 'shapes' + const key = eventKey.split('.')[0].split('[')[0]; + if ( + // autosize/width/height relayouts come from the + // resize machinery, not from user interactions. + !includes(key, ['autosize', 'width', 'height']) && + !(key in updates) && + !equals(figure?.layout?.[key], gd.layout[key]) + ) { + updates[key] = clone(gd.layout[key]); } } + if (Object.keys(updates).length) { + setProps({ + figure: { + ...figure, + layout: { + ...figure?.layout, + ...updates, + }, + }, + }); + } } }); gd.on('plotly_restyle', 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 8ed8f3b328..6b43b90bc3 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 @@ -596,3 +596,93 @@ def on_event(click_data, hover_data): assert out["click_y"] is not None assert out["hover_x"] is not None assert out["hover_y"] is not None + + +def test_grbs012_graph_patch_keeps_relayout_ranges(dash_dcc): + """Panning/zooming then applying a Patch should keep the new axis range. + + Regression test for https://github.com/plotly/dash/issues/3810 - since + getLayout() clones the layout, user relayout changes must be synced back + to the figure prop or a subsequent Patch reverts them. + """ + from dash import Patch + from dash.exceptions import PreventUpdate + + app = Dash(__name__) + + app.layout = html.Div( + [ + dcc.Graph( + id="graph", + figure={ + "data": [ + { + "x": list(range(10)), + "y": list(range(10)), + "type": "scatter", + "mode": "lines+markers", + } + ], + "layout": {"xaxis": {"range": [2, 8], "autorange": False}}, + }, + style={"width": 600, "height": 400}, + ), + html.Div(id="range-output"), + ] + ) + + @app.callback( + Output("graph", "figure"), + Input("graph", "relayoutData"), + prevent_initial_call=True, + ) + def on_relayout(relayout_data): + if not relayout_data or "xaxis.range[0]" not in relayout_data: + raise PreventUpdate + # Data-only patch: the axis range must survive from the figure prop + p = Patch() + p.data[0].y = [i * 2 for i in range(10)] + return p + + @app.callback( + Output("range-output", "children"), + Input("graph", "figure"), + ) + def show_range(figure): + xrange = figure.get("layout", {}).get("xaxis", {}).get("range", "none") + return json.dumps(xrange) + + dash_dcc.start_server(app) + dash_dcc.wait_for_element("#graph .main-svg") + dash_dcc.wait_for_text_to_equal("#range-output", "[2, 8]") + + # Simulate a user pan - Plotly.relayout fires the same plotly_relayout + # event as a drag interaction + dash_dcc.driver.execute_script( + "Plotly.relayout(" + "document.querySelector('#graph .js-plotly-plot')," + "{'xaxis.range[0]': 3, 'xaxis.range[1]': 5})" + ) + + # The relayout triggers the data-only Patch; the panned range must + # survive in both the figure prop and the rendered graph + dash_dcc.wait_for_text_to_equal("#range-output", "[3, 5]") + + def rendered_range(): + return dash_dcc.driver.execute_script( + "return document.querySelector('#graph .js-plotly-plot')" + ".layout.xaxis.range" + ) + + wait.until(lambda: rendered_range() == [3, 5], timeout=3) + # Verify the patch was applied on top of the panned figure + wait.until( + lambda: dash_dcc.driver.execute_script( + "return document.querySelector('#graph .js-plotly-plot').data[0].y[9]" + ) + == 18, + timeout=3, + ) + assert rendered_range() == [3, 5] + + assert dash_dcc.get_logs() == []