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) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-16 22:19:25 -04:00
parent 1807014323
commit db67f6eede

View File

@ -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)