From db67f6eede6641fa2ec77e42f98814cb0a35205e Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Wed, 16 Sep 2026 22:19:25 -0400 Subject: [PATCH] DEBUG: instrument view rebuilds and depth re-apply on refocus Temporary. Row grouping re-expands whenever the browser regains focus, for both the toolbar EXPAND buttons and the per-row +/-. set_depth() and per-node expansion both live on the view rather than in ViewConfig, so a rebuilt view loses them -- but nothing so far distinguishes "the view was rebuilt" from "the re-apply lost its race with the redraw". Tags each view object with an id via a WeakMap and logs it at every point that could rebuild one: focus/visibilitychange/pageshow, config-update, applyDepth, the theme effect, and initViewer. A changed id across a refocus means the view was discarded; an unchanged id means the depth re-apply itself is at fault. Each bail-out in the refocus handler now says which branch it took, which also shows whether expandDepthRef was ever set. Prefix [pf-depth]. Revert this commit once the cause is known. Co-Authored-By: Claude Opus 5 (1M context) --- ui/src/views/Forecast.jsx | 68 +++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 2580b43..1929dee 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -202,19 +202,69 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // Intersection/ResizeObserver triggers when you switch back to the tab — and the // fresh view has no depth set, so the whole tree appears expanded. Re-apply the // depth we last set once the redraw has settled. + // ---------------------------------------------------------------- DEBUG + // Temporary instrumentation for the "row groups re-expand on refocus" bug. + // set_depth() and per-node expand/collapse live on the *view*, not in + // ViewConfig, so a rebuilt view loses both. These lines exist to show whether + // that is what happens. Remove once the cause is known. + const viewSeqRef = useRef(0) + const viewIdsRef = useRef(new WeakMap()) + const lastViewIdRef = useRef(null) + + function dbg(msg, extra) { + const t = new Date().toISOString().slice(11, 23) + if (extra !== undefined) console.log(`[pf-depth ${t}] ${msg}`, extra) + else console.log(`[pf-depth ${t}] ${msg}`) + } + + function viewId(v) { + if (!v) return 'none' + if (!viewIdsRef.current.has(v)) viewIdsRef.current.set(v, ++viewSeqRef.current) + return '#' + viewIdsRef.current.get(v) + } + + // Read the viewer's current view and report whether it is the same object we + // saw last time. A changed id means the view was discarded and rebuilt. + async function probeView(where) { + const viewer = viewerRef.current + if (!viewer) return dbg(`probe(${where}): no viewer`) + try { + const v = await viewer.getView() + const id = viewId(v) + const changed = lastViewIdRef.current !== null && lastViewIdRef.current !== id + dbg(`probe(${where}): view ${id}${changed ? ` <-- REBUILT (was ${lastViewIdRef.current})` : ''}`) + lastViewIdRef.current = id + } catch (err) { dbg(`probe(${where}) threw`, err) } + } + // -------------------------------------------------------------- END DEBUG + useEffect(() => { let queued = false - const reapply = () => { - if (document.visibilityState !== 'visible') return - if (expandDepthRef.current == null || !viewerRef.current) return - if (queued) return + const reapply = async (ev) => { + dbg(`event ${ev?.type || '?'}`, { + visibility: document.visibilityState, + expandDepthRef: expandDepthRef.current, + hasViewer: !!viewerRef.current, + }) + // Probe the view identity before touching anything: if the id has changed + // since the last log line, the viewer rebuilt its view and that is where + // the depth went, rather than the re-apply below losing a race. + await probeView('on ' + (ev?.type || '?')) + + if (document.visibilityState !== 'visible') return dbg('bail: not visible') + 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') queued = true // let the viewer finish its own redraw first, or it will draw over us requestAnimationFrame(() => setTimeout(async () => { queued = false const d = expandDepthRef.current - if (d == null) return - try { await applyDepth(d) } catch {} + if (d == null) return dbg('re-apply aborted: depth became null') + try { + await applyDepth(d) + dbg(`re-apply done depth=${d}`) + } catch (err) { dbg('re-apply THREW', err) } }, 60)) } document.addEventListener('visibilitychange', reapply) @@ -370,6 +420,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio useEffect(() => { if (viewerRef.current) { + dbg(`theme effect -> ${dark ? 'Pro Dark' : 'Pro Light'} (a theme change rebuilds the view)`) viewerRef.current.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light') } }, [dark, versionId]) @@ -575,6 +626,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio async function initViewer(vid, sid) { const viewer = viewerRef.current if (!viewer) return + dbg(`initViewer(version=${vid}, source=${sid}) -- FULL RELOAD, depth reset to null`) const myId = ++initIdRef.current setLoading(true) setLargeDataset(false) @@ -677,6 +729,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // auto-persist viewer state (formatting, columns, etc.) to the last-used cache if (viewer._pspUpdate) viewer.removeEventListener('perspective-config-update', viewer._pspUpdate) viewer._pspUpdate = async () => { + dbg(`perspective-config-update (collapsing=${collapsingRef.current})`) + await probeView('config-update') try { // A split_by change that is not ours is the user rearranging the pivot, and // it redefines the hierarchy. Ours is a collapse, and must not overwrite it. @@ -787,6 +841,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const viewer = viewerRef.current if (!viewer) return const view = await viewer.getView() + dbg(`applyDepth(${d}) on view ${viewId(view)}`) + lastViewIdRef.current = viewId(view) await view.set_depth(d) const plugin = await viewer.getPlugin() await plugin.draw(view)