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:
parent
9a7f67b634
commit
7b90b0c07d
@ -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 { createRoot } from 'react-dom/client'
|
||||
import { ThemeProvider } from './theme.jsx'
|
||||
|
||||
86
ui/src/observerShim.js
Normal file
86
ui/src/observerShim.js
Normal 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()
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { PF_OBSERVER_EVENT } from '../observerShim.js'
|
||||
import useTheme from '../theme.jsx'
|
||||
import OperationPanel from '../components/OperationPanel.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)
|
||||
window.addEventListener('focus', reapply)
|
||||
window.addEventListener('pageshow', reapply)
|
||||
return () => {
|
||||
clearTimeout(settle)
|
||||
window.removeEventListener(PF_OBSERVER_EVENT, onObserved)
|
||||
document.removeEventListener('visibilitychange', reapply)
|
||||
window.removeEventListener('focus', reapply)
|
||||
window.removeEventListener('pageshow', reapply)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user