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) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-16 22:48:11 -04:00
parent 0459fa137d
commit 35f9b7b048
2 changed files with 47 additions and 18 deletions

View File

@ -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: {

View File

@ -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`)
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))
}
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 {