Skip to content

[BUG] Manual-zoom resampling snaps back on dash β‰₯ 4.2.0 when a data Patch must clip an x-referenced layout shape (dash PR #3785)`Β #368

Description

@AlexisJanin

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):

  1. a FigureResampler whose zoom callback returns a data-only Patch
    (construct_update_data_patch);
  2. 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);
  3. 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;
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions