pf_app/ui/src/observerShim.js
Paul Trowbridge 35f9b7b048 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>
2026-09-16 22:48:11 -04:00

114 lines
5.0 KiB
JavaScript

// Perspective's viewer captures the observer constructors at module-evaluation
// time:
//
// var it = window.ResizeObserver; var st = window.IntersectionObserver;
//
// so replacing them on `window` before that module is imported puts us in front
// of every callback it receives. This file must therefore be the FIRST import in
// main.jsx -- ES modules evaluate in declaration order, and an import that lands
// after the chain reaching perspective-viewer is too late to matter.
//
// Why bother: the WASM engine has no notion of focus or tab visibility. All it
// ever sees is an observer callback saying "you are visible again, at this
// size", and it re-renders in response -- which discards the view, and with it
// set_depth() and per-node expansion, neither of which lives in ViewConfig.
// Listening for window 'focus' is only a guess at when that happens. This is the
// actual event.
//
// Everything is passed through to the native observer untouched. We add one
// CustomEvent, and only for targets that are (or contain) a perspective-viewer,
// so nothing else on the page pays for this.
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
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) {
if (typeof Native !== 'function') return Native
return class PfObserver extends Native {
constructor(callback, options) {
super((entries, observer) => {
// Perspective's own handler runs first and unchanged. If it throws,
// that is its business -- we still report, so a failure upstream is
// visible rather than silently swallowing our notification too.
try {
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: {
kind,
at: Date.now(),
// IntersectionObserver entries carry visibility; Resize
// ones carry geometry. Report whichever exists so the
// listener can tell a re-show from a relayout.
entries: entries.map(e => ({
isIntersecting: e.isIntersecting,
intersectionRatio: e.intersectionRatio,
width: e.contentRect?.width,
height: e.contentRect?.height,
})),
},
}))
}
}
}, options)
}
}
}
let installed = false
export function installObserverShim() {
if (installed) return
installed = true
try {
if (window.IntersectionObserver) {
window.IntersectionObserver = wrap(window.IntersectionObserver, 'intersection')
}
if (window.ResizeObserver) {
window.ResizeObserver = wrap(window.ResizeObserver, 'resize')
}
} catch {
// A browser that refuses the assignment just means we fall back to the
// focus/visibilitychange listeners, which still work -- they are only
// less precise about when the rebuild happened.
}
}
installObserverShim()