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
- [#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
Expand Down
49 changes: 30 additions & 19 deletions components/dash-core-components/src/fragments/Graph.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() == []
Loading