Skip to content

[BUG]: Plotly.react ignores a new axis range on a scaleanchor + constrain:'domain' axis (stale _inputRange) #8110

Open

Description

@invarianz

Description

Plotly.react doesn't apply a new axis range on a constrained axis group
(yaxis.scaleanchor: 'x' and xaxis.constrain: 'domain'). It draws the
axis's previous range, widened to keep the scale ratio. Plotly.newPlot
with the same layout draws the new range and shrinks the x domain. A
dotted Plotly.relayout of the same ranges ('xaxis.range',
'yaxis.range') does the same. The nested form
Plotly.relayout(gd, {xaxis: {...}}) behaves like react.

This looks like the problem suspected in #2540 ("I think Plotly.react is
broken with constrained axes"). That issue was closed during a cleanup with a
request to open a new one with a short reproducible example, so here it is.

Cause, from reading the source (main at the time of writing):

  • _relayout nulls the axis's cached _inputRange when a dotted
    *.range / *.autorange key changes (src/plot_api/plot_api.js, the
    AX_RANGE_RE / AX_AUTORANGE_RE branches) and forces a constraint
    recalc for the group.
  • react → diffLayout records the axis in rangesAltered but never
    clears _inputRange. Only _inputDomain is reset there.
  • Plots.supplyDefaults → relinkPrivateKeys carries the old full
    layout's _inputRange into the new one.
  • constraints.enforce, in its constrain: 'domain' branch, computes
    rangeShrunk against that stale _inputRange. When it is < 1, it
    restores ax.range = ax._inputRange.slice(), so the new range is
    replaced by the old one.

The nested relayout({xaxis: {...}}) form doesn't match AX_RANGE_RE, so
it skips the reset as well.

Expected: a range passed to Plotly.react (or through a nested
relayout) is handled like one set through the dotted relayout. Clear
_inputRange for the axes in rangesAltered, and mark the rest of their
constraint group shrinkable, so the constraint is re-derived from the new
range.

Screenshots/Video

Image

Results from the repro below, in Chromium 140 (QtWebEngine 6.11), plotly.js
4.1.2 un-minified.
All plots start at x [0, 100], y [0, 100] in a 400×400 plot, then x is
set to [20, 80]:

Path drawn x range x domain xaxis._inputRange after
Plotly.newPlot (expected) [20, 80] [0.2, 0.8] [20, 80]
Plotly.relayout({'xaxis.range': [20, 80], 'yaxis.range': [0, 100]}) [20, 80] [0.2, 0.8] [20, 80]
Plotly.react(gd, data, newLayout) [0, 100] [0, 1] [0, 100] (stale)

Steps to reproduce

Open this page; the <pre> shows the drawn x range, x domain and
_inputRange for each path:

<!doctype html>
<meta charset="utf-8">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/plotly.js"></script>
<div id="a" style="width:400px;height:400px"></div>
<div id="b" style="width:400px;height:400px"></div>
<div id="c" style="width:400px;height:400px"></div>
<pre id="out"></pre>
<script>
(async () => {
  const data = () => [{ type: 'scatter', mode: 'markers',
    x: [0, 20, 50, 80, 100], y: [0, 20, 50, 80, 100] }]
  const layout = (xr) => ({
    width: 400, height: 400, margin: { l: 0, r: 0, t: 0, b: 0 },
    xaxis: { range: xr, constrain: 'domain' },
    yaxis: { range: [0, 100], scaleanchor: 'x', scaleratio: 1 },
  })
  const state = (gd) => {
    const x = gd._fullLayout.xaxis
    return { range: x.range, domain: x.domain, _inputRange: x._inputRange }
  }
  const [a, b, c] = ['a', 'b', 'c'].map((id) => document.getElementById(id))

  await Plotly.newPlot(a, data(), layout([0, 100]))
  await Plotly.react(a, data(), layout([20, 80]))      // a fresh layout object

  await Plotly.newPlot(b, data(), layout([0, 100]))
  await Plotly.relayout(b, { 'xaxis.range': [20, 80], 'yaxis.range': [0, 100] })

  await Plotly.newPlot(c, data(), layout([20, 80]))    // expected result

  document.getElementById('out').textContent = JSON.stringify({
    expected_newPlot: state(c),
    react: state(a),
    dotted_relayout: state(b),
  }, null, 1)
})()
</script>
  1. Open the page.
  2. Plot c (newPlot) and plot b (dotted relayout) show x
    [20, 80] in a narrowed domain.
  3. Plot a (react) still shows x [0, 100] across the full width,
    with _inputRange stuck at [0, 100].

Notes

  • Same behaviour in 3.7.0. It isn't new in 4.x.
  • It also breaks zooming through react on such a plot (an app that pans
    or zooms by passing new ranges to react): the range snaps back to the
    one the last newPlot or dotted relayout set.
  • With constrain: 'range' (the default) the problem doesn't arise; that
    branch never reads _inputRange.
  • Related: with dotted keys no longer in the Partial<Layout> type of
    4.x, react is the typed way to change a range, which makes this path
    more common.

Activity

  1. camdecoster commented on Oct 13, 2026

    @camdecoster
    Contributor

    Thanks for the report! I'll add this to our backlog.

  2. Roshan1299 commented on Oct 20, 2026

    @Roshan1299

    Hi @camdecoster, I'd like to take this one if nobody is on it yet.

    I checked @invarianz's reading against main and it holds: _relayout clears _inputRange for a dotted range key, but the layout diff react uses only resets _inputDomain, so constraints.enforce puts the old range back from the stale _inputRange in the constrain: 'domain' branch.

    My plan: clear _inputRange for the axes the diff marks in rangesAltered and let the rest of their constraint group shrink, the way _relayout does, so react and the nested relayout({xaxis: {...}}) form behave like the dotted relayout. I'd add a jasmine test with the issue's three paths (newPlot, dotted relayout, react) and a draftlog entry. Does that sound right?

  3. camdecoster commented on Oct 20, 2026

    @camdecoster
    Contributor

    Could you please explain how fixing this would help you in your daily usage?

  4. Roshan1299 commented on Oct 20, 2026

    @Roshan1299

    I don't use it day to day, to be honest. I was looking for a bug I could fix and this one had a clear cause.

    It would help anyone who updates a plot with Plotly.react instead of newPlot, which is what react-plotly.js and Dash's dcc.Graph do. In an app with an equal-aspect plot, setting a new range from new props or a callback keeps showing the old range.

    If you'd rather leave it in the backlog, no problem.

  5. camdecoster commented on Oct 21, 2026

    @camdecoster
    Contributor

    The approach you describe is a bit broad. What do you think of mirroring the way that _inputDomain is handled?

  6. Roshan1299 commented on Oct 21, 2026

    @Roshan1299

    Makes sense. I'll do the same thing for _inputRange in that loop in diffLayout: keep it if the new range is the same as before, otherwise clear it so the constraint code recomputes it from the new range. For the example in the issue that gives the same result as newPlot. I'll add a jasmine test for it too.

    One thing: the nested Plotly.relayout(gd, {xaxis: {...}}) case doesn't go through diffLayout, so this won't fix that one. Should I include it here or leave it for later?

  7. camdecoster commented on Oct 21, 2026

    @camdecoster
    Contributor

    Go ahead and fix that here as well.

  8. Roshan1299 commented on Oct 23, 2026

    @Roshan1299

    Opened #8144 with the _inputRange handling in diffLayout mirroring _inputDomain, plus the nested relayout({xaxis: {...}}) case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions