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 {