Stop the pivot snapping when its settings sidebar is opened

Two causes, both mine, both triggered by adjusting the layout.

A zero size was being treated as the viewer going away, and opening
Perspective's settings sidebar collapses the datagrid for a frame. The
return then re-applied the stored row depth over whatever had been expanded
by hand. Only lost intersection counts as a departure now; a zero size is a
transient of layout, not an absence.

And perspective-config-update fires for anything in the config, the settings
flag included -- not only for split_by. While collapsed the live split_by is
a truncation of the full hierarchy, so adopting it recorded the truncation
as the hierarchy and put the deeper levels out of reach. A genuine
rearrangement is not a prefix of what we already hold, which is the
difference the handler now tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-17 02:58:10 -04:00
parent 7cfd0068e7
commit 8aa0ae2ece

View File

@ -346,14 +346,14 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
let settle = null
const onObserved = (ev) => {
const d = ev.detail || {}
// Losing intersection, or collapsing to zero size, is the element going
// away: record it, because that is when the view gets discarded.
const leaving = d.entries?.some(e =>
e.isIntersecting === false || (e.width === 0 && e.height === 0))
// Only intersection counts as going away. A zero size is a transient of
// layout -- opening Perspective's own settings sidebar collapses the
// datagrid for a frame -- and treating it as a departure meant the return
// re-applied depth, so adjusting the layout snapped the tree back.
const leaving = d.entries?.some(e => e.isIntersecting === false)
if (leaving) { wentAwayRef.current = true; return dbg(`observer ${d.kind} -> viewer went away`) }
// A resize with a non-zero size is an ordinary reflow. It is not a
// restoration, so it must not re-apply anything.
// Everything else is a reflow. Not a restoration, so it restores nothing.
if (!wentAwayRef.current) return
clearTimeout(settle)
@ -875,7 +875,17 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// it redefines the hierarchy. Ours is a collapse, and must not overwrite it.
if (!collapsingRef.current) {
const live = await viewer.save()
adoptSplit(live.split_by || [], (live.split_by || []).length)
const next = live.split_by || []
const full = splitFullRef.current || []
// This event fires for anything in the config -- opening the settings
// sidebar included -- not only for split_by. While collapsed, the live
// split_by is a prefix of the full hierarchy, and adopting it would
// record the truncation as the hierarchy and make the deeper levels
// unreachable. A genuine rearrangement is not a prefix of what we hold.
const isCollapsedPrefix =
next.length < full.length && next.every((c, i) => c === full[i])
if (!isCollapsedPrefix) adoptSplit(next, next.length)
else dbg(`config-update: split_by is our collapse (${next.length}/${full.length}), keeping the full hierarchy`)
}
const cfg = await captureConfig()
if (cfg) await persistLayout(vid, cfg)