Describe the bug ποΈ
With dash >= 4.2.0 (incl. 4.3.0), a FigureResampler in a Dash app no longer resamples on manual zoom: the axes snap straight back to the original range immediately after dragging a zoom box. The same app works on dash == 4.1.0.
I bisected it to a narrow, specific trigger:
The snap-back happens only when the new zoom range must clip an
x-axis-referenced layout.shape (a shape whose x-extent is not fully
contained in the zoomed window). If the zoom fully contains every shape, the
zoom holds and resampling works β even on dash 4.2+.
This points at dash PR #3785 ("Fix patch with dcc.Graph figure", first released in 4.2.0), which deep-clones and re-syncs layout (including shape coordinates) whenever a Patch is applied to a dcc.Graph figure. plotly-resampler's zoom path returns a data-only Patch (construct_update_data_patch β assigns only data[i].x / y / name); the re-sync it now triggers rebuilds layout from the (zoom-less) figure prop and discards the user's live drag-zoom.
Reproducing the bug π
Minimal trigger (bisected β all four required; dropping any one makes dash 4.2
keep the zoom):
- a
FigureResampler whose zoom callback returns a data-only Patch
(construct_update_data_patch);
- the
dcc.Graph is injected by a callback, not present in app.layout at
startup (a statically-declared Graph keeps its zoom on dash 4.2);
- one x-axis-referenced layout shape that spans an x-range (a
rect with
xref="x", x0 != x1). A zero-width vertical line (x0 == x1) is not
enough;
dash >= 4.2.0.
Things that turned out not to matter (removed, bug persists): make_subplots
/ multiple axes, matched x-axes (matches=), annotations, hovermode,
pattern-matching callback ids, allow_duplicate, and any server-side resampler
cache. A single trace on a single axis is enough.
It fires only when the zoom clips the shape β rect spans x β [0.60Β·n, 0.70Β·n]:
Zoom box vs. rect [0.60, 0.70] |
Result |
| entirely left of rect (e.g. 0β30%) |
snaps back |
| entirely right of rect (e.g. 80β100%) |
snaps back |
| straddles rect's left edge (50β65%) |
snaps back |
| straddles rect's right edge (65β80%) |
snaps back |
| strictly inside rect (62β68%) |
snaps back |
| fully contains rect (e.g. 50β80%) |
holds β no clip, no bug |
Steps:
python3 -m venv .venv && .venv/bin/pip install "dash==4.2.0" "plotly==5.24.1" \
"plotly-resampler==0.11.0" numpy pandas pytz
.venv/bin/python mwe.py # http://127.0.0.1:8050
Click "Load plot", then drag a zoom box on the left half of the plot (so
the salmon rectangle stays off-screen β first row of the table). On
dash==4.2.0 the axes snap back; pin dash==4.1.0 instead and the zoom holds
and resamples. (Same plotly / plotly-resampler in both β dash is the only
variable.)
mwe.py (~80 lines, self-contained)
import numpy as np
import plotly.graph_objects as go
from dash import Dash, Input, Output, callback, dcc, html, no_update
from dash.exceptions import PreventUpdate
from plotly_resampler import FigureResampler
USE_UIREVISION_WORKAROUND = False # set True to test the layout.uirevision fix
n = 1_500_000
x = np.arange(n)
y = np.sin(x / 300) + np.random.default_rng(0).standard_normal(n) / 5
def build_figure() -> FigureResampler:
base = go.Figure()
base.add_trace(go.Scattergl(name="signal", x=x, y=y))
# REQUIRED: one x-referenced shape spanning an x-range. Removing it (or making
# it a zero-width line) makes dash 4.2 KEEP the zoom -- this x-extent is what
# PR #3785 re-syncs against the axis range and discards the live zoom from.
base.add_shape(type="rect", x0=n * 0.6, x1=n * 0.7, xref="x",
y0=0, y1=1, yref="y domain",
fillcolor="LightSalmon", opacity=0.3, line_width=0)
fig = FigureResampler(base, default_n_shown_samples=2_000)
if USE_UIREVISION_WORKAROUND:
fig.update_layout(uirevision="constant-token")
return fig
fig = build_figure()
# suppress_callback_exceptions: the graph id only exists after the load callback fires.
app = Dash(__name__, suppress_callback_exceptions=True)
app.layout = html.Div(
[
html.H3("plotly-resampler manual-zoom repro"),
html.P("Click 'Load plot', then drag a zoom box on the LEFT half (so the "
"salmon rectangle stays off-screen). On dash 4.1.0 the zoom holds "
"and resamples; on dash 4.2.0 it snaps back."),
html.Button("Load plot", id="load-btn"),
html.Div(id="container"), # empty at startup -- Graph injected below
]
)
@callback(
Output("container", "children"),
Input("load-btn", "n_clicks"),
prevent_initial_call=True,
)
def load_plot(_n_clicks):
return dcc.Graph(id="graph", figure=fig, config={"displayModeBar": True},
style={"height": "800px"})
@callback(
Output("graph", "figure"),
Input("graph", "relayoutData"),
prevent_initial_call=True,
)
def resample_on_zoom(relayout):
if not relayout:
raise PreventUpdate
patch = fig.construct_update_data_patch(relayout)
if patch is no_update:
raise PreventUpdate
return patch
if __name__ == "__main__":
app.run(debug=True, port=8050)
Expected behavior π§
Dragging a zoom box should keep the zoomed view and resample the trace to higher resolution within that window β exactly as it does on dash == 4.1.0 β instead of the axes reverting to the full range. The presence of an x-referenced layout.shape should not cause the live zoom to be discarded when a data-only Patch is applied.
Screenshots πΈ
Environment information: (please complete the following information)
- OS: macOS (also reproducible on Linux)
- Python environment:
- Python version: 3.12
- plotly-resampler environment: Dash web app (Safari / Firefox)
- plotly-resampler version: 0.11.0
- dash version: broken on 4.2.0 and 4.3.0, works on 4.1.0 (only variable changed)
- plotly version: 5.24.1 (held constant across broken/working β also seen on 6.x)
Additional context
Root cause (bisected). dash PR #3785 β "Fix patch with dcc.Graph figure", first released in dash 4.2.0 (plotly/dash#3785) β makes dcc.Graph deep-clone and re-sync layout whenever a Patch is applied to its figure (reviewer notes: "Clone layout to avoid mutating the original (important for Patch)" and "Sync shapes from gd.layout to figure ... because getLayout() clones layout"). The Python-side Patch is byte-identical across dash 4.1 / 4.2 / 4.3 (same Assign ops on data[0].x/y/name), so the regression is entirely in dash's client-side patch application, not in plotly-resampler's output. The clipping observation above pinpoints the shape-coordinate sync path as where the live zoom is lost.
Workaround. Setting a constant per-figure layout.uirevision makes plotly.js preserve the drag-zoom across the data Patch, restoring manual-zoom resampling on dash 4.2+ (USE_UIREVISION_WORKAROUND = True in mwe.py). However it then runs into existing issue #252 ("Figures updated with layout.uirevision do not resample"): a programmatic figure update while zoomed preserves the view but fires no relayout, so the visible window isn't re-resampled.
Open questions
(1) Should plotly-resampler set uirevision
internally on dash >= 4.2.0 and address the #252 interaction so a programmatic
update while zoomed re-triggers resampling?
(2) Or is this squarely a dash
regression to escalate against PR #3785, with plotly-resampler documenting a
dash < 4.2 cap meanwhile?
Related (checked β not duplicates).
Links.
Happy to help further β I have a ready-to-run repro repo with two pinned venvs
(dash==4.1.0 vs 4.2.0, everything else identical) and can record a short
screencast or test patches against my setup. Just ping me here.
Describe the bug ποΈ
With
dash >= 4.2.0(incl. 4.3.0), aFigureResamplerin a Dash app no longer resamples on manual zoom: the axes snap straight back to the original range immediately after dragging a zoom box. The same app works ondash == 4.1.0.I bisected it to a narrow, specific trigger:
This points at dash PR #3785 ("Fix patch with
dcc.Graphfigure", first released in 4.2.0), which deep-clones and re-syncslayout(including shape coordinates) whenever aPatchis applied to adcc.Graphfigure. plotly-resampler's zoom path returns a data-onlyPatch(construct_update_data_patchβ assigns onlydata[i].x / y / name); the re-sync it now triggers rebuildslayoutfrom the (zoom-less) figure prop and discards the user's live drag-zoom.Reproducing the bug π
Minimal trigger (bisected β all four required; dropping any one makes dash 4.2
keep the zoom):
FigureResamplerwhose zoom callback returns a data-onlyPatch(
construct_update_data_patch);dcc.Graphis injected by a callback, not present inapp.layoutatstartup (a statically-declared Graph keeps its zoom on dash 4.2);
rectwithxref="x",x0 != x1). A zero-width vertical line (x0 == x1) is notenough;
dash >= 4.2.0.Things that turned out not to matter (removed, bug persists):
make_subplots/ multiple axes, matched x-axes (
matches=), annotations,hovermode,pattern-matching callback ids,
allow_duplicate, and any server-side resamplercache. A single trace on a single axis is enough.
It fires only when the zoom clips the shape β rect spans
x β [0.60Β·n, 0.70Β·n]:[0.60, 0.70]Steps:
Click "Load plot", then drag a zoom box on the left half of the plot (so
the salmon rectangle stays off-screen β first row of the table). On
dash==4.2.0the axes snap back; pindash==4.1.0instead and the zoom holdsand resamples. (Same
plotly/plotly-resamplerin both β dash is the onlyvariable.)
mwe.py (~80 lines, self-contained)
Expected behavior π§
Dragging a zoom box should keep the zoomed view and resample the trace to higher resolution within that window β exactly as it does on
dash == 4.1.0β instead of the axes reverting to the full range. The presence of an x-referencedlayout.shapeshould not cause the live zoom to be discarded when a data-onlyPatchis applied.Screenshots πΈ
Environment information: (please complete the following information)
Additional context
Root cause (bisected). dash PR #3785 β "Fix patch with
dcc.Graphfigure", first released in dash 4.2.0 (plotly/dash#3785) β makesdcc.Graphdeep-clone and re-synclayoutwhenever aPatchis applied to itsfigure(reviewer notes: "Clone layout to avoid mutating the original (important for Patch)" and "Sync shapes from gd.layout to figure ... because getLayout() clones layout"). The Python-sidePatchis byte-identical across dash 4.1 / 4.2 / 4.3 (sameAssignops ondata[0].x/y/name), so the regression is entirely in dash's client-side patch application, not in plotly-resampler's output. The clipping observation above pinpoints the shape-coordinate sync path as where the live zoom is lost.Workaround. Setting a constant per-figure
layout.uirevisionmakes plotly.js preserve the drag-zoom across the dataPatch, restoring manual-zoom resampling on dash 4.2+ (USE_UIREVISION_WORKAROUND = Trueinmwe.py). However it then runs into existing issue #252 ("Figures updated withlayout.uirevisiondo not resample"): a programmatic figure update while zoomed preserves the view but fires norelayout, so the visible window isn't re-resampled.Open questions
(1) Should plotly-resampler set
uirevisioninternally on
dash >= 4.2.0and address the #252 interaction so a programmaticupdate while zoomed re-triggers resampling?
(2) Or is this squarely a dash
regression to escalate against PR #3785, with plotly-resampler documenting a
dash < 4.2cap meanwhile?Related (checked β not duplicates).
uirevisionβ resample (the workaround's side effect, not this regression)._grid_ref/xaxis mapping).Links.
Happy to help further β I have a ready-to-run repro repo with two pinned venvs
(
dash==4.1.0vs4.2.0, everything else identical) and can record a shortscreencast or test patches against my setup. Just ping me here.