From 0520e5e54253de240e74f700966f87db86ece821 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Wed, 16 Sep 2026 22:26:46 -0400 Subject: [PATCH] Wait for the viewer's table before re-applying row depth on refocus The instrumentation showed the re-apply mostly throwing "No table set": the viewer is detached from its table for a while after a refocus, the fixed 60ms delay fired inside that window, set_depth threw, and the tree rendered fully expanded. The handful of times the delay happened to be long enough, the log reads "re-apply done depth=0" and the collapse survived. So poll for the table instead of guessing, up to 5s. queued now clears in a finally after the work rather than at the top of the callback, so the focus and visibilitychange that both fire for one window switch no longer each run a re-apply -- that was the doubled applyDepth in the log. Does not address per-node +/- collapse, which is still not recorded at all: expandDepthRef stays null because only the toolbar buttons set it, and the view exposes expand()/collapse() with no getter to read the state back. Co-Authored-By: Claude Opus 5 (1M context) --- ui/src/views/Forecast.jsx | 43 ++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 1929dee..8afcbae 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -236,6 +236,26 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio lastViewIdRef.current = id } catch (err) { dbg(`probe(${where}) threw`, err) } } + // The viewer is briefly detached from its table around a refocus, and anything + // touching the view in that window throws "No table set". Poll until it is back + // rather than guessing at a delay. + async function waitForTable(deadlineMs = 5000, stepMs = 60) { + const started = Date.now() + let attempts = 0 + while (Date.now() - started < deadlineMs) { + attempts++ + try { + const viewer = viewerRef.current + if (viewer && await viewer.getTable()) { + if (attempts > 1) dbg(`table re-attached after ${attempts} polls / ${Date.now() - started}ms`) + return true + } + } catch { /* not yet */ } + await new Promise(r => setTimeout(r, stepMs)) + } + return false + } + // -------------------------------------------------------------- END DEBUG useEffect(() => { @@ -256,16 +276,25 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio 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 dbg('re-apply aborted: depth became null') + // 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 + // viewer is detached from its table for a while after a refocus, and + // set_depth on a detached viewer throws "No table set" and the tree renders + // fully expanded. queued stays set until the work is done, so a focus and a + // visibilitychange for the same switch don't both re-apply. + requestAnimationFrame(async () => { try { + const d = expandDepthRef.current + if (d == null) return dbg('re-apply aborted: depth became null') + if (!(await waitForTable())) return dbg('re-apply gave up: viewer never re-attached a table') await applyDepth(d) dbg(`re-apply done depth=${d}`) - } catch (err) { dbg('re-apply THREW', err) } - }, 60)) + } catch (err) { + dbg('re-apply THREW', err) + } finally { + queued = false + } + }) } document.addEventListener('visibilitychange', reapply) window.addEventListener('focus', reapply)