Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 76 additions & 1 deletion components/dash-core-components/src/fragments/Graph.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,33 @@
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)/,

Check warning on line 164 in components/dash-core-components/src/fragments/Graph.react.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this alternation with a character class.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaGJNN3zj5fRon0WeWYO&open=AaGJNN3zj5fRon0WeWYO&pullRequest=4102
/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)$/,

Check warning on line 173 in components/dash-core-components/src/fragments/Graph.react.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this alternation with a character class.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaGJNN3zj5fRon0WeWYP&open=AaGJNN3zj5fRon0WeWYP&pullRequest=4102
/^(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.
*
Expand All @@ -183,6 +210,7 @@
this.amendTraces = this.amendTraces.bind(this);

this.state = {override: {}, originals: {}};
this._guiEdits = {};
}

plot(props) {
Expand All @@ -197,9 +225,12 @@
// 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,
};
Expand Down Expand Up @@ -365,6 +396,36 @@
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:
Expand Down Expand Up @@ -466,7 +527,7 @@
gd.on('plotly_deselect', () => {
setProps({selectedData: null});
});
gd.on('plotly_relayout', eventData => {

Check failure on line 530 in components/dash-core-components/src/fragments/Graph.react.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaGJNN3zj5fRon0WeWYQ&open=AaGJNN3zj5fRon0WeWYQ&pullRequest=4102
const relayout = filterEventData(gd, eventData, 'relayout');
if (!isNil(relayout) && !equals(relayout, relayoutData)) {
setProps({relayoutData: relayout});
Expand All @@ -491,6 +552,20 @@
!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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() == []
Loading