From 1325faa754d107cc6100b58d2b28e9b247fece52 Mon Sep 17 00:00:00 2001 From: mao <118530756+maoxor@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:46:59 +0100 Subject: [PATCH 1/2] Keep dcc.Graph edits across figure updates when uirevision is unchanged Since #3882 the plotly_relayout handler copies user edits (zoom, pan, ...) into the figure prop. The graph then re-plots with them as figure input, so plotly.js takes them as the new starting point and drops them from its uirevision bookkeeping. The next figure that leaves them out resets the view, even with the same uirevision. Remember the value each synced layout key had before the edit, and while the figure still holds the synced value under the same uirevision, give that value to plotly.js instead, so plotly.js keeps the edit itself. Only do this for edits that plotly.js keeps under uirevision, so other edits are not undone. Fixes #4093 --- .../src/fragments/Graph.react.js | 77 +++++++- .../integration/graph/test_graph_basics.py | 170 ++++++++++++++++++ 2 files changed, 246 insertions(+), 1 deletion(-) diff --git a/components/dash-core-components/src/fragments/Graph.react.js b/components/dash-core-components/src/fragments/Graph.react.js index c122dde0c9..e007e24b1f 100644 --- a/components/dash-core-components/src/fragments/Graph.react.js +++ b/components/dash-core-components/src/fragments/Graph.react.js @@ -157,6 +157,33 @@ const filterEventData = (gd, eventData, event) => { return filteredEventData; }; +// Layout edits that plotly.js keeps across figure updates while uirevision is +// unchanged (layoutUIControlPatterns in plotly.js src/plot_api/plot_api.js) +const UIREVISION_LAYOUT_EDITS = [ + /^hiddenlabels/, + /^(x|y)axis\d*\.((auto)?range|title\.text)/, + /axis\d*\.showspikes$/, + /(hover|drag)mode$/, + /^scene\d*\.camera/, + /^geo\d*\.(projection|center|fitbounds)/, + /^ternary\d*\.[abc]axis\.(min|title\.text)$/, + /^polar\d*\.radialaxis\.((auto)?range|angle|title\.text)/, + /^polar\d*\.angularaxis\.rotation/, + /^map\d*\.(center|zoom|bearing|pitch)/, + /^legend\.(x|y)$/, + /^(shapes|annotations)/, + /^selections/, + /^title\.text$/, +]; + +// Keys like 'map._derived' carry computed state, not user edits +const isKeptByUirevision = eventKey => + eventKey.includes('._') || + UIREVISION_LAYOUT_EDITS.some(pattern => pattern.test(eventKey)); + +const getUirevision = (layout, key) => + layout?.[key]?.uirevision ?? layout?.uirevision; + /** * Graph can be used to render any plotly.js-powered data visualization. * @@ -183,6 +210,7 @@ class PlotlyGraph extends Component { this.amendTraces = this.amendTraces.bind(this); this.state = {override: {}, originals: {}}; + this._guiEdits = {}; } plot(props) { @@ -197,9 +225,12 @@ class PlotlyGraph extends Component { // add typesetMath | not exposed to the dash API configClone.typesetMath = mathjax; + const layout = this.getLayout(figure?.layout, responsive); + this.restoreGuiEditOrigins(layout); + const figureClone = { data: figure?.data, - layout: this.getLayout(figure?.layout, responsive), + layout, frames: figure?.frames, config: configClone, }; @@ -365,6 +396,36 @@ class PlotlyGraph extends Component { return layout; } + /* + * The plotly_relayout handler copies user edits (pan, zoom, ...) into the + * figure prop. If they reach Plotly.react as figure input, plotly.js takes + * them as the new starting point and drops them from its uirevision + * bookkeeping, so the next figure without them resets the view. While the + * figure still holds the synced value and the same uirevision, give + * plotly.js the value from before the edit instead, and plotly.js keeps + * the edit itself. + */ + restoreGuiEditOrigins(layout) { + for (const key of Object.keys(this._guiEdits)) { + const {original, synced, uirevision, keptByPlotly} = + this._guiEdits[key]; + if ( + keptByPlotly && + uirevision && + getUirevision(layout, key) === uirevision && + equals(layout[key], synced) + ) { + if (original === undefined) { + delete layout[key]; + } else { + layout[key] = clone(original); + } + } else { + delete this._guiEdits[key]; + } + } + } + getConfigOverride(responsive) { switch (responsive) { case false: @@ -491,6 +552,20 @@ class PlotlyGraph extends Component { !equals(figure?.layout?.[key], gd.layout[key]) ) { updates[key] = clone(gd.layout[key]); + if (!(key in this._guiEdits)) { + this._guiEdits[key] = { + original: figure?.layout?.[key], + uirevision: getUirevision(figure?.layout, key), + keptByPlotly: true, + }; + } + this._guiEdits[key].synced = updates[key]; + } + if ( + key in this._guiEdits && + !isKeptByUirevision(eventKey) + ) { + this._guiEdits[key].keptByPlotly = false; } } if (Object.keys(updates).length) { 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 f268c1edd5..2a2c9ca35f 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 @@ -709,3 +709,173 @@ def rendered_range(): assert rendered_range() == [3, 5] assert dash_dcc.get_logs() == [] + + +def test_grbs013_graph_uirevision_keeps_zoom(dash_dcc): + """A zoom must survive new figures and Patches while uirevision is unchanged. + + Regression test for https://github.com/plotly/dash/issues/4093 - syncing + the zoom into the figure prop must not make plotly.js forget it. + """ + from dash import Patch + from selenium.webdriver.common.action_chains import ActionChains + + def make_figure(showlegend): + return { + "data": [{"x": list(range(10)), "y": [x**2 for x in range(10)]}], + "layout": {"showlegend": showlegend, "uirevision": True}, + } + + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button("figure", id="figure-btn", n_clicks=0), + html.Button("patch", id="patch-btn", n_clicks=0), + dcc.Graph( + id="graph", + figure=make_figure(True), + style={"width": 600, "height": 400}, + ), + html.Div(id="range-output"), + ] + ) + + @app.callback( + Output("graph", "figure", allow_duplicate=True), + Input("figure-btn", "n_clicks"), + prevent_initial_call=True, + ) + def new_figure(n_clicks): + return make_figure(n_clicks % 2 == 0) + + @app.callback( + Output("graph", "figure"), + Input("patch-btn", "n_clicks"), + prevent_initial_call=True, + ) + def patch_figure(n_clicks): + p = Patch() + p.data[0].y = [(n_clicks + 1) * x**2 for x in range(10)] + return p + + @app.callback(Output("range-output", "children"), Input("graph", "figure")) + def show_range(figure): + return json.dumps(figure["layout"].get("xaxis", {}).get("range")) + + dash_dcc.start_server(app) + dash_dcc.wait_for_element("#graph .main-svg") + dash_dcc.wait_for_text_to_equal("#range-output", "null") + + def rendered_range(): + return dash_dcc.driver.execute_script( + "return document.querySelector('#graph .js-plotly-plot')" + "._fullLayout.xaxis.range.slice()" + ) + + initial = rendered_range() + drag = dash_dcc.find_element("#graph .nsewdrag") + width = drag.size["width"] + ActionChains(dash_dcc.driver).move_to_element_with_offset( + drag, -width // 4, 0 + ).click_and_hold().move_by_offset(width // 4, 5).release().perform() + wait.until(lambda: rendered_range() != initial, timeout=3) + zoomed = rendered_range() + # The zoom is synced into the figure prop + wait.until(lambda: dash_dcc.find_element("#range-output").text != "null", timeout=3) + + dash_dcc.find_element("#figure-btn").click() + dash_dcc.wait_for_text_to_equal("#range-output", "null") + sleep(0.5) + assert rendered_range() == zoomed + + dash_dcc.find_element("#patch-btn").click() + wait.until( + lambda: dash_dcc.driver.execute_script( + "return document.querySelector('#graph .js-plotly-plot').data[0].y[9]" + ) + == 162, + timeout=3, + ) + dash_dcc.find_element("#figure-btn").click() + wait.until( + lambda: dash_dcc.driver.execute_script( + "return document.querySelector('#graph .js-plotly-plot').data[0].y[9]" + ) + == 81, + timeout=3, + ) + sleep(0.5) + assert rendered_range() == zoomed + + assert dash_dcc.get_logs() == [] + + +def test_grbs014_graph_uirevision_keeps_untracked_edit(dash_dcc): + """An edit that uirevision does not track must not be undone by the sync. + + plotly.js only keeps some layout edits under uirevision (axis ranges, + shapes, ...). The fix for #4093 must leave the others, like an edited + legend title, as the user left them. + """ + from dash import Patch + + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button("patch", id="patch-btn", n_clicks=0), + dcc.Graph( + id="graph", + figure={ + "data": [{"y": [1, 3, 2]}, {"y": [2, 1, 3]}], + "layout": { + "uirevision": True, + "showlegend": True, + "legend": {"title": {"text": "original"}}, + }, + }, + config={"editable": True}, + ), + ] + ) + + @app.callback( + Output("graph", "figure"), + Input("patch-btn", "n_clicks"), + prevent_initial_call=True, + ) + def patch_figure(n_clicks): + p = Patch() + p.data[0].y = [n_clicks, 3, 2] + return p + + dash_dcc.start_server(app) + dash_dcc.wait_for_element("#graph .main-svg") + + def legend_title(): + return dash_dcc.driver.execute_script( + "return document.querySelector('#graph .js-plotly-plot')" + "._fullLayout.legend.title.text" + ) + + # Same as plotly.js's own guiEdit wrapper for an edit made in the graph + dash_dcc.driver.execute_script( + "var gd = document.querySelector('#graph .js-plotly-plot');" + "gd._fullLayout._guiEditing = true;" + "Plotly.relayout(gd, {'legend.title.text': 'edited'});" + "gd._fullLayout._guiEditing = false;" + ) + wait.until(lambda: legend_title() == "edited", timeout=3) + sleep(0.5) + assert legend_title() == "edited" + + dash_dcc.find_element("#patch-btn").click() + wait.until( + lambda: dash_dcc.driver.execute_script( + "return document.querySelector('#graph .js-plotly-plot').data[0].y[0]" + ) + == 1, + timeout=3, + ) + assert legend_title() == "edited" + + assert dash_dcc.get_logs() == [] From 5fd7027b6a5f1aa26dc5ff578c17654cca52e925 Mon Sep 17 00:00:00 2001 From: mao <118530756+maoxor@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:47:33 +0100 Subject: [PATCH 2/2] Add CHANGELOG entry for #4102 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d752b883a1..c5acb68b9e 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 +- [#4102](https://github.com/plotly/dash/pull/4102) Fix `dcc.Graph` zoom, pan and other edits being reset by a new figure or a `Patch` while `uirevision` is unchanged, a regression from #3882. Fixes [#4093](https://github.com/plotly/dash/issues/4093). + ## [4.5.0] - 2026-10-29 ### Added