Repository navigation
[BUG]: Plotly.react ignores a new axis range on a scaleanchor + constrain:'domain' axis (stale _inputRange) #8110
Description
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):
_relayoutnulls the axis's cached_inputRangewhen a dotted
*.range/*.autorangekey changes (src/plot_api/plot_api.js, the
AX_RANGE_RE/AX_AUTORANGE_REbranches) and forces a constraint
recalc for the group.react→diffLayoutrecords the axis inrangesAlteredbut never
clears_inputRange. Only_inputDomainis reset there.Plots.supplyDefaults→relinkPrivateKeyscarries the old full
layout's_inputRangeinto the new one.constraints.enforce, in itsconstrain: 'domain'branch, computes
rangeShrunkagainst that stale_inputRange. When it is< 1, it
restoresax.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
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>- Open the page.
- Plot c (
newPlot) and plot b (dottedrelayout) show x
[20, 80]in a narrowed domain. - Plot a (
react) still shows x[0, 100]across the full width,
with_inputRangestuck at[0, 100].
Notes
- Same behaviour in 3.7.0. It isn't new in 4.x.
- It also breaks zooming through
reacton such a plot (an app that pans
or zooms by passing new ranges toreact): the range snaps back to the
one the lastnewPlotor dottedrelayoutset. - 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,reactis the typed way to change a range, which makes this path
more common.
Activity
camdecoster commentedon Oct 13, 2026
Thanks for the report! I'll add this to our backlog.
Roshan1299 commentedon Oct 20, 2026
Hi @camdecoster, I'd like to take this one if nobody is on it yet.
I checked @invarianz's reading against
mainand it holds:_relayoutclears_inputRangefor a dotted range key, but the layout diffreactuses only resets_inputDomain, soconstraints.enforceputs the old range back from the stale_inputRangein theconstrain: 'domain'branch.My plan: clear
_inputRangefor the axes the diff marks inrangesAlteredand let the rest of their constraint group shrink, the way_relayoutdoes, soreactand the nestedrelayout({xaxis: {...}})form behave like the dottedrelayout. I'd add a jasmine test with the issue's three paths (newPlot, dotted relayout, react) and a draftlog entry. Does that sound right?camdecoster commentedon Oct 20, 2026
Could you please explain how fixing this would help you in your daily usage?
Roshan1299 commentedon Oct 20, 2026
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.reactinstead ofnewPlot, which is what react-plotly.js and Dash'sdcc.Graphdo. 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.
camdecoster commentedon Oct 21, 2026
The approach you describe is a bit broad. What do you think of mirroring the way that
_inputDomainis handled?Roshan1299 commentedon Oct 21, 2026
Makes sense. I'll do the same thing for
_inputRangein that loop indiffLayout: 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 asnewPlot. I'll add a jasmine test for it too.One thing: the nested
Plotly.relayout(gd, {xaxis: {...}})case doesn't go throughdiffLayout, so this won't fix that one. Should I include it here or leave it for later?camdecoster commentedon Oct 21, 2026
Go ahead and fix that here as well.
Roshan1299 commentedon Oct 23, 2026
Opened #8144 with the
_inputRangehandling indiffLayoutmirroring_inputDomain, plus the nestedrelayout({xaxis: {...}})case.