Restore row depth only after the viewer has actually gone away

Driving the re-apply from the observer callbacks caught the refocus case but
fired on far more than that: ResizeObserver reports every reflow, so
dragging the panel, a scrollbar appearing, or anything that changed the
layout re-applied the stored depth and discarded whatever had just been
expanded by hand. From the outside that is the pivot snapping to a different
layout while clicking around.

Going away is what discards the view; a reflow is not. So the callbacks now
record the departure -- lost intersection, collapsed to zero size, or a
hidden document -- and only a return after one of those restores anything.
The flag clears once the depth is back, so one departure causes one restore.

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

View File

@ -226,6 +226,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const viewSeqRef = useRef(0) const viewSeqRef = useRef(0)
const viewIdsRef = useRef(new WeakMap()) const viewIdsRef = useRef(new WeakMap())
const lastViewIdRef = useRef(null) const lastViewIdRef = useRef(null)
// set when the viewer stops being visible; only then is there anything to
// restore, and only then may a callback re-apply depth
const wentAwayRef = useRef(false)
function dbg(msg, extra) { function dbg(msg, extra) {
let on = false let on = false
@ -301,6 +304,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
if (!viewerRef.current) return dbg('bail: no viewer') if (!viewerRef.current) return dbg('bail: no viewer')
if (expandDepthRef.current == null) return dbg('bail: no depth recorded (toolbar EXPAND never used, or per-node +/- only)') if (expandDepthRef.current == null) return dbg('bail: no depth recorded (toolbar EXPAND never used, or per-node +/- only)')
if (queued) return dbg('bail: already queued') if (queued) return dbg('bail: already queued')
// Only after the viewer has actually been away. Re-applying on every
// callback meant a panel drag, an appearing scrollbar or any reflow reset
// the tree to the stored depth, discarding whatever had just been expanded
// by hand -- the pivot "snapping to a different layout" while clicking
// around. Going away is what discards the view; a reflow does not.
if (!wentAwayRef.current) return dbg('bail: viewer never went away, nothing to restore')
queued = true queued = true
// Let the viewer finish its own redraw first, then wait for it to actually // Let the viewer finish its own redraw first, then wait for it to actually
// have a table again. A fixed delay loses this race most of the time -- the // have a table again. A fixed delay loses this race most of the time -- the
@ -312,7 +321,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
try { try {
const d = expandDepthRef.current const d = expandDepthRef.current
if (d == null) return dbg('re-apply aborted: depth became null') if (d == null) return dbg('re-apply aborted: depth became null')
if (await applyDepthWhenReady(d)) dbg(`re-apply done depth=${d}`) if (await applyDepthWhenReady(d)) {
wentAwayRef.current = false
dbg(`re-apply done depth=${d}`)
}
} catch (err) { } catch (err) {
dbg('re-apply THREW', err) dbg('re-apply THREW', err)
} finally { } finally {
@ -334,26 +346,41 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
let settle = null let settle = null
const onObserved = (ev) => { const onObserved = (ev) => {
const d = ev.detail || {} const d = ev.detail || {}
// A resize to zero, or losing intersection, is the element going away -- // Losing intersection, or collapsing to zero size, is the element going
// nothing to re-apply to yet. Only act on it coming back. // away: record it, because that is when the view gets discarded.
const returning = d.entries?.some(e => const leaving = d.entries?.some(e =>
e.isIntersecting === true || (e.width > 0 && e.height > 0)) e.isIntersecting === false || (e.width === 0 && e.height === 0))
if (!returning) return 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.
if (!wentAwayRef.current) return
clearTimeout(settle) clearTimeout(settle)
settle = setTimeout(() => { settle = setTimeout(() => {
dbg(`observer ${d.kind} settled -> viewer visible again`) dbg(`observer ${d.kind} settled -> viewer back after going away`)
reapply({ type: `observer:${d.kind}` }) reapply({ type: `observer:${d.kind}` })
}, 150) }, 150)
} }
// A hidden tab is the other way the viewer goes away, and the one the
// observers may not report on every browser.
const onVisibility = (ev) => {
if (document.visibilityState !== 'visible') {
wentAwayRef.current = true
return dbg('document hidden -> viewer went away')
}
reapply(ev)
}
window.addEventListener(PF_OBSERVER_EVENT, onObserved) window.addEventListener(PF_OBSERVER_EVENT, onObserved)
document.addEventListener('visibilitychange', reapply) document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('focus', reapply) window.addEventListener('focus', reapply)
window.addEventListener('pageshow', reapply) window.addEventListener('pageshow', reapply)
return () => { return () => {
clearTimeout(settle) clearTimeout(settle)
window.removeEventListener(PF_OBSERVER_EVENT, onObserved) window.removeEventListener(PF_OBSERVER_EVENT, onObserved)
document.removeEventListener('visibilitychange', reapply) document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('focus', reapply) window.removeEventListener('focus', reapply)
window.removeEventListener('pageshow', reapply) window.removeEventListener('pageshow', reapply)
} }