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 { 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
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 { 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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user