From 35f9b7b0480e833ee799921a622f36380fa9b096 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Wed, 16 Sep 2026 22:48:11 -0400 Subject: [PATCH] Retry the depth re-apply, and let the shim see into the shadow root Two things the trace showed, one of them a wrong call on my part. getTable() was the wrong thing to wait on. The re-apply threw "No table set" two milliseconds after the event, meaning getTable() had already resolved while getView() still had not -- it is the view that is missing around a rebuild, not the table. There is no predicate for "the view is ready", so attempt applyDepth and retry until it stops throwing, up to 5s. And the shim matched nothing: not one callback reported a viewer. closest() stops at a shadow boundary, so it cannot climb from an element inside Perspective's shadow root out to the host, which is where the observed elements live. Walk host to host via getRootNode().host instead. The shim now also logs every callback it sees under [pf-obs], matched or not, with a description of each target. If it still reports nothing at all, the shim is not installed and the problem is import order rather than matching. Co-Authored-By: Claude Opus 5 (1M context) --- ui/src/observerShim.js | 33 ++++++++++++++++++++++++++++++--- ui/src/views/Forecast.jsx | 32 +++++++++++++++++--------------- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/ui/src/observerShim.js b/ui/src/observerShim.js index 06850a0..a068d5f 100644 --- a/ui/src/observerShim.js +++ b/ui/src/observerShim.js @@ -21,11 +21,35 @@ export const PF_OBSERVER_EVENT = 'pf-viewer-observed' +function dbg(msg, extra) { + let on = false + try { on = !!localStorage.getItem('pf_debug') } catch { /* private mode */ } + if (!on) return + const t = new Date().toISOString().slice(11, 23) + if (extra !== undefined) console.log(`[pf-obs ${t}] ${msg}`, extra) + else console.log(`[pf-obs ${t}] ${msg}`) +} + +// Perspective observes elements inside its own shadow root, and closest() stops +// at a shadow boundary -- it will not climb from a shadow child out to the host. +// So walk the tree explicitly, hopping host to host, or the match never fires. function touchesViewer(target) { if (!(target instanceof Element)) return false - return target.tagName === 'PERSPECTIVE-VIEWER' - || !!target.closest?.('perspective-viewer') - || !!target.querySelector?.('perspective-viewer') + let node = target + for (let hops = 0; node && hops < 20; hops++) { + if (node.tagName === 'PERSPECTIVE-VIEWER') return true + if (node.closest?.('perspective-viewer')) return true + const root = node.getRootNode?.() + node = root && root.host ? root.host : node.parentElement + } + return !!target.querySelector?.('perspective-viewer') +} + +function describe(target) { + if (!(target instanceof Element)) return String(target) + const root = target.getRootNode?.() + return `${target.tagName.toLowerCase()}${target.className ? '.' + String(target.className).split(' ')[0] : ''}` + + (root && root.host ? ` (in shadow of ${root.host.tagName.toLowerCase()})` : '') } function wrap(Native, kind) { @@ -41,6 +65,9 @@ function wrap(Native, kind) { callback(entries, observer) } finally { const hit = entries.some(e => touchesViewer(e.target)) + dbg(`${kind} fired on ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}` + + ` -> ${hit ? 'MATCHED viewer' : 'no viewer match'}`, + entries.map(e => describe(e.target))) if (hit) { window.dispatchEvent(new CustomEvent(PF_OBSERVER_EVENT, { detail: { diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 7004c9f..1b93c37 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -246,24 +246,28 @@ 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) { + // Around a rebuild the viewer throws "No table set" from getView() -- note it + // is the *view* that is missing, not the table: getTable() resolves happily + // through the same window, which is why gating on it did not help. There is no + // "is the view ready" predicate to poll, so just attempt the thing we want and + // retry until it stops throwing. + async function applyDepthWhenReady(d, deadlineMs = 5000, stepMs = 80) { const started = Date.now() let attempts = 0 - while (Date.now() - started < deadlineMs) { + for (;;) { 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 + await applyDepth(d) + if (attempts > 1) dbg(`depth ${d} applied on attempt ${attempts} after ${Date.now() - started}ms`) + return true + } catch (err) { + if (Date.now() - started >= deadlineMs) { + dbg(`gave up re-applying depth ${d} after ${attempts} attempts`, err) + return false } - } catch { /* not yet */ } - await new Promise(r => setTimeout(r, stepMs)) + await new Promise(r => setTimeout(r, stepMs)) + } } - return false } // -------------------------------------------------------------- END DEBUG @@ -296,9 +300,7 @@ 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 waitForTable())) return dbg('re-apply gave up: viewer never re-attached a table') - await applyDepth(d) - dbg(`re-apply done depth=${d}`) + if (await applyDepthWhenReady(d)) dbg(`re-apply done depth=${d}`) } catch (err) { dbg('re-apply THREW', err) } finally {