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 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() == []