From 7b90b0c07d44807afc2ed4fb55dee29f9746d019 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Wed, 16 Sep 2026 22:36:29 -0400 Subject: [PATCH] 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) --- ui/src/main.jsx | 6 +++ ui/src/observerShim.js | 86 +++++++++++++++++++++++++++++++++++++++ ui/src/views/Forecast.jsx | 30 ++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 ui/src/observerShim.js diff --git a/ui/src/main.jsx b/ui/src/main.jsx index 149b594..14c17b1 100644 --- a/ui/src/main.jsx +++ b/ui/src/main.jsx @@ -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' diff --git a/ui/src/observerShim.js b/ui/src/observerShim.js new file mode 100644 index 0000000..06850a0 --- /dev/null +++ b/ui/src/observerShim.js @@ -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() diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index d3e298f..7004c9f 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -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)