From 7cfd0068e77ce632649d89f9d0d018a1197a4310 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Thu, 17 Sep 2026 02:55:39 -0400 Subject: [PATCH] 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) --- ui/src/views/Forecast.jsx | 45 +++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index d4c38ec..2a7d9f9 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -226,6 +226,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const viewSeqRef = useRef(0) const viewIdsRef = useRef(new WeakMap()) 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) { let on = false @@ -301,6 +304,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio 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 (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 // 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 @@ -312,7 +321,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio try { const d = expandDepthRef.current 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) { dbg('re-apply THREW', err) } finally { @@ -334,26 +346,41 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio let settle = null const onObserved = (ev) => { const d = ev.detail || {} - // A resize to zero, or losing intersection, is the element going away -- - // nothing to re-apply to yet. Only act on it coming back. - const returning = d.entries?.some(e => - e.isIntersecting === true || (e.width > 0 && e.height > 0)) - if (!returning) return + // 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)) + 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) 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}` }) }, 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) - document.addEventListener('visibilitychange', reapply) + document.addEventListener('visibilitychange', onVisibility) window.addEventListener('focus', reapply) window.addEventListener('pageshow', reapply) return () => { clearTimeout(settle) window.removeEventListener(PF_OBSERVER_EVENT, onObserved) - document.removeEventListener('visibilitychange', reapply) + document.removeEventListener('visibilitychange', onVisibility) window.removeEventListener('focus', reapply) window.removeEventListener('pageshow', reapply) }