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>
114 lines
5.0 KiB
JavaScript
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()
|