Re-apply row depth from the observer callback, not from window focus

The WASM engine has no notion of focus or tab visibility. It re-renders
because IntersectionObserver or ResizeObserver told it the element is
visible again at some size, and that re-render discards the view -- taking
set_depth() and per-node expansion with it, since neither lives in
ViewConfig. Listening for window 'focus' was only a guess at when that
happened, which is why the re-apply kept landing while the viewer was still
detached from its table.

Perspective's viewer captures the constructors at module-evaluation time
(`var it=window.ResizeObserver; var st=window.IntersectionObserver`), so
replacing them on window before that import puts us in front of every
callback it gets. observerShim.js does exactly that and nothing else: the
original callback runs first and unchanged, and we re-emit as a CustomEvent
only when an entry's target is or contains a perspective-viewer. That has
to be the first import in main.jsx or there is nothing left to intercept.

focus/visibilitychange/pageshow stay as a backstop for the case where the
shim could not be installed. ResizeObserver fires on every frame of a panel
drag, so observer events trail by 150ms and act once the burst settles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-16 22:36:29 -04:00
parent 9a7f67b634
commit 7b90b0c07d
3 changed files with 122 additions and 0 deletions

View File

@ -1,3 +1,9 @@
// MUST be first: it swaps window.IntersectionObserver / window.ResizeObserver
// for wrappers, and Perspective's viewer captures those constructors when its
// module is evaluated. Any import that reaches perspective-viewer before this
// one leaves the shim with nothing to intercept.
import './observerShim.js'
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { ThemeProvider } from './theme.jsx' import { ThemeProvider } from './theme.jsx'

86
ui/src/observerShim.js Normal file
View File

@ -0,0 +1,86 @@
// 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 touchesViewer(target) {
if (!(target instanceof Element)) return false
return target.tagName === 'PERSPECTIVE-VIEWER'
|| !!target.closest?.('perspective-viewer')
|| !!target.querySelector?.('perspective-viewer')
}
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))
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()

View File

@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { PF_OBSERVER_EVENT } from '../observerShim.js'
import useTheme from '../theme.jsx' import useTheme from '../theme.jsx'
import OperationPanel from '../components/OperationPanel.jsx' import OperationPanel from '../components/OperationPanel.jsx'
import BridgeView from '../components/BridgeView.jsx' import BridgeView from '../components/BridgeView.jsx'
@ -305,10 +306,39 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
} }
}) })
} }
// The observer callback is the real trigger. The engine has no notion of
// focus -- it re-renders because IntersectionObserver or ResizeObserver told
// it the element is visible again, and that re-render is what discards the
// view. observerShim.js sits in front of those callbacks and re-emits them,
// so this fires at the rebuild rather than at a proxy for it.
//
// The focus/visibilitychange listeners stay as a backstop: if the shim was
// loaded too late to patch anything, or a browser refused the assignment,
// they still catch the common case.
// ResizeObserver fires on every frame of a panel drag, and each event would
// otherwise queue its own re-apply. Trail the burst and act once it settles.
let settle = null
const onObserved = (ev) => {
const d = ev.detail || {}
// A resize to zero, or losing intersection, is the element going away --
// nothing to re-apply to yet. Only act on it coming back.
const returning = d.entries?.some(e =>
e.isIntersecting === true || (e.width > 0 && e.height > 0))
if (!returning) return
clearTimeout(settle)
settle = setTimeout(() => {
dbg(`observer ${d.kind} settled -> viewer visible again`)
reapply({ type: `observer:${d.kind}` })
}, 150)
}
window.addEventListener(PF_OBSERVER_EVENT, onObserved)
document.addEventListener('visibilitychange', reapply) document.addEventListener('visibilitychange', reapply)
window.addEventListener('focus', reapply) window.addEventListener('focus', reapply)
window.addEventListener('pageshow', reapply) window.addEventListener('pageshow', reapply)
return () => { return () => {
clearTimeout(settle)
window.removeEventListener(PF_OBSERVER_EVENT, onObserved)
document.removeEventListener('visibilitychange', reapply) document.removeEventListener('visibilitychange', reapply)
window.removeEventListener('focus', reapply) window.removeEventListener('focus', reapply)
window.removeEventListener('pageshow', reapply) window.removeEventListener('pageshow', reapply)