diff --git a/ui/src/main.jsx b/ui/src/main.jsx index 14c17b1..149b594 100644 --- a/ui/src/main.jsx +++ b/ui/src/main.jsx @@ -1,9 +1,3 @@ -// 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 deleted file mode 100644 index a068d5f..0000000 --- a/ui/src/observerShim.js +++ /dev/null @@ -1,113 +0,0 @@ -// 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() diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 384f784..f3bc304 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -1,5 +1,4 @@ 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' @@ -187,7 +186,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const workerRef = useRef(null) const tableRef = useRef(null) const colMetaRef = useRef([]) - const expandDepthRef = useRef(null) + // Mirrors ViewConfig.group_by_depth for the toolbar's active state. The + // config is the source of truth; this only exists so the buttons can render. + const [expandDepth, setExpandDepth] = useState(null) // The column axis has no set_depth() — collapsing it means restoring a shorter // split_by, so the full hierarchy has to be remembered separately to expand again. const splitFullRef = useRef([]) @@ -215,177 +216,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // Intersection/ResizeObserver triggers when you switch back to the tab — and the // fresh view has no depth set, so the whole tree appears expanded. Re-apply the // depth we last set once the redraw has settled. - // ---------------------------------------------------------------- DEBUG - // Tracing for the "row groups re-expand on refocus" bug. set_depth() and - // per-node expand/collapse live on the *view*, not in ViewConfig, so every - // view rebuild starts fully expanded and we have to re-apply depth ourselves. - // Silent unless localStorage.pf_debug is set, since this is the kind of thing - // you want back the next time the timing shifts. - // - // localStorage.setItem('pf_debug', '1') // then reload - const viewSeqRef = useRef(0) - const viewIdsRef = useRef(new WeakMap()) - const lastViewIdRef = useRef(null) - // set when the viewer stops being visible; only then is there anything to - // restore, and only then may a callback re-apply depth - const wentAwayRef = useRef(false) - - 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-depth ${t}] ${msg}`, extra) - else console.log(`[pf-depth ${t}] ${msg}`) - } - - function viewId(v) { - if (!v) return 'none' - if (!viewIdsRef.current.has(v)) viewIdsRef.current.set(v, ++viewSeqRef.current) - return '#' + viewIdsRef.current.get(v) - } - - // Read the viewer's current view and report whether it is the same object we - // saw last time. A changed id means the view was discarded and rebuilt. - async function probeView(where) { - let on = false - try { on = !!localStorage.getItem('pf_debug') } catch { /* private mode */ } - if (!on) return - const viewer = viewerRef.current - if (!viewer) return dbg(`probe(${where}): no viewer`) - try { - const v = await viewer.getView() - const id = viewId(v) - const changed = lastViewIdRef.current !== null && lastViewIdRef.current !== id - dbg(`probe(${where}): view ${id}${changed ? ` <-- REBUILT (was ${lastViewIdRef.current})` : ''}`) - lastViewIdRef.current = id - } catch (err) { dbg(`probe(${where}) threw`, err) } - } - // Around a rebuild the viewer throws "No table set" from getView() -- note it - // is the *view* that is missing, not the table: getTable() resolves happily - // through the same window, which is why gating on it did not help. There is no - // "is the view ready" predicate to poll, so just attempt the thing we want and - // retry until it stops throwing. - async function applyDepthWhenReady(d, deadlineMs = 5000, stepMs = 80) { - const started = Date.now() - let attempts = 0 - for (;;) { - attempts++ - try { - await applyDepth(d) - if (attempts > 1) dbg(`depth ${d} applied on attempt ${attempts} after ${Date.now() - started}ms`) - return true - } catch (err) { - if (Date.now() - started >= deadlineMs) { - dbg(`gave up re-applying depth ${d} after ${attempts} attempts`, err) - return false - } - await new Promise(r => setTimeout(r, stepMs)) - } - } - } - - // -------------------------------------------------------------- END DEBUG - - useEffect(() => { - let queued = false - const reapply = async (ev) => { - dbg(`event ${ev?.type || '?'}`, { - visibility: document.visibilityState, - expandDepthRef: expandDepthRef.current, - hasViewer: !!viewerRef.current, - }) - // Probe the view identity before touching anything: if the id has changed - // since the last log line, the viewer rebuilt its view and that is where - // the depth went, rather than the re-apply below losing a race. - await probeView('on ' + (ev?.type || '?')) - - if (document.visibilityState !== 'visible') return dbg('bail: not visible') - if (!viewerRef.current) return dbg('bail: no viewer') - if (expandDepthRef.current == null) return dbg('bail: no depth recorded (toolbar EXPAND never used, or per-node +/- only)') - if (queued) return dbg('bail: already queued') - // Only after the viewer has actually been away. Re-applying on every - // callback meant a panel drag, an appearing scrollbar or any reflow reset - // the tree to the stored depth, discarding whatever had just been expanded - // by hand -- the pivot "snapping to a different layout" while clicking - // around. Going away is what discards the view; a reflow does not. - if (!wentAwayRef.current) return dbg('bail: viewer never went away, nothing to restore') - queued = true - // Let the viewer finish its own redraw first, then wait for it to actually - // have a table again. A fixed delay loses this race most of the time -- the - // viewer is detached from its table for a while after a refocus, and - // set_depth on a detached viewer throws "No table set" and the tree renders - // fully expanded. queued stays set until the work is done, so a focus and a - // visibilitychange for the same switch don't both re-apply. - requestAnimationFrame(async () => { - try { - const d = expandDepthRef.current - if (d == null) return dbg('re-apply aborted: depth became null') - if (await applyDepthWhenReady(d)) { - wentAwayRef.current = false - dbg(`re-apply done depth=${d}`) - } - } catch (err) { - dbg('re-apply THREW', err) - } finally { - queued = false - } - }) - } - // 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 || {} - // Only intersection counts as going away. A zero size is a transient of - // layout -- opening Perspective's own settings sidebar collapses the - // datagrid for a frame -- and treating it as a departure meant the return - // re-applied depth, so adjusting the layout snapped the tree back. - const leaving = d.entries?.some(e => e.isIntersecting === false) - if (leaving) { wentAwayRef.current = true; return dbg(`observer ${d.kind} -> viewer went away`) } - - // Everything else is a reflow. Not a restoration, so it restores nothing. - if (!wentAwayRef.current) return - - clearTimeout(settle) - settle = setTimeout(() => { - dbg(`observer ${d.kind} settled -> viewer back after going away`) - reapply({ type: `observer:${d.kind}` }) - }, 150) - } - - // A hidden tab is the other way the viewer goes away, and the one the - // observers may not report on every browser. - const onVisibility = (ev) => { - if (document.visibilityState !== 'visible') { - wentAwayRef.current = true - return dbg('document hidden -> viewer went away') - } - reapply(ev) - } - - window.addEventListener(PF_OBSERVER_EVENT, onObserved) - document.addEventListener('visibilitychange', onVisibility) - window.addEventListener('focus', reapply) - window.addEventListener('pageshow', reapply) - return () => { - clearTimeout(settle) - window.removeEventListener(PF_OBSERVER_EVENT, onObserved) - document.removeEventListener('visibilitychange', onVisibility) - window.removeEventListener('focus', reapply) - window.removeEventListener('pageshow', reapply) - } - }, []) - // perspective-click fires as a CustomEvent with no modifier state of its own, // and carries no cell coordinates either, so record both from the mousedown that // precedes it. The grid lives in a shadow root, so the real target is the head of @@ -529,7 +359,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio useEffect(() => { if (viewerRef.current) { - dbg(`theme effect -> ${dark ? 'Pro Dark' : 'Pro Light'} (a theme change rebuilds the view)`) viewerRef.current.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light') } }, [dark, versionId]) @@ -765,13 +594,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio async function initViewer(vid, sid) { const viewer = viewerRef.current if (!viewer) return - dbg(`initViewer(version=${vid}, source=${sid}) -- FULL RELOAD, depth reset to null`) const myId = ++initIdRef.current setLoading(true) setLargeDataset(false) setLoadProgress(null) setSlices([]) - expandDepthRef.current = null + setExpandDepth(null) adoptSplit([], 0) try { // col_meta first — it decides which endpoint to load from, and it is a tiny @@ -845,7 +673,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // split_full outlives split_by: a layout saved while collapsed still knows // the levels it was collapsed from adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length) - if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) + // restore() has already applied group_by_depth; this only syncs the + // toolbar. expand_depth is the legacy key, from when depth was imperative + // and had to be stored beside the config rather than in it. + if (cfg.group_by_depth != null) setExpandDepth(cfg.group_by_depth) + else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) } else { const sourceDefault = sources.find(s => String(s.id) === String(sid))?.default_layout let cfg @@ -868,8 +700,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // auto-persist viewer state (formatting, columns, etc.) to the last-used cache if (viewer._pspUpdate) viewer.removeEventListener('perspective-config-update', viewer._pspUpdate) viewer._pspUpdate = async () => { - dbg(`perspective-config-update (collapsing=${collapsingRef.current})`) - await probeView('config-update') try { // A split_by change that is not ours is the user rearranging the pivot, and // it redefines the hierarchy. Ours is a collapse, and must not overwrite it. @@ -885,7 +715,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const isCollapsedPrefix = next.length < full.length && next.every((c, i) => c === full[i]) if (!isCollapsedPrefix) adoptSplit(next, next.length) - else dbg(`config-update: split_by is our collapse (${next.length}/${full.length}), keeping the full hierarchy`) + // else: our own collapse, so the full hierarchy stands } const cfg = await captureConfig() if (cfg) await persistLayout(vid, cfg) @@ -966,8 +796,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio try { await viewer.restore({ split_by: full.slice(0, depth) }) setSplitDepth(depth) - // restore() rebuilt the view, so the row depth that lived on the old one is gone - if (expandDepthRef.current != null) await applyDepth(expandDepthRef.current) + // No need to re-apply the row depth: group_by_depth is part of the config the + // view is rebuilt from, so it comes back with it. } catch (err) { console.error('[applySplitDepth]', err) flash(err.message || String(err), 'error') @@ -986,23 +816,34 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio } } + // Row depth is a ViewConfig field, so it is set through restore() rather than + // by calling set_depth() on the view. That is the whole difference: the config + // is what the viewer rebuilds its view *from*, so the depth survives every + // rebuild by construction, and viewer.save() carries it into the persisted and + // named layouts for free. + // + // It used to be imperative -- getView() then view.set_depth() -- which put the + // depth on an object the viewer discards whenever it re-renders. Everything + // that grew around that (an observer shim over two global browser APIs, focus + // and visibility listeners, a retry loop for "No table set", and a flag + // tracking whether the viewer had "gone away") existed only to guess when a + // rebuild had happened and put the depth back. None of it is needed now, and + // all of it is gone. async function applyDepth(d) { const viewer = viewerRef.current if (!viewer) return - const view = await viewer.getView() - dbg(`applyDepth(${d}) on view ${viewId(view)}`) - lastViewIdRef.current = viewId(view) - await view.set_depth(d) - const plugin = await viewer.getPlugin() - await plugin.draw(view) - expandDepthRef.current = d + await viewer.restore({ group_by_depth: d }) + setExpandDepth(d) } async function captureConfig() { const viewer = viewerRef.current if (!viewer) return null const cfg = await viewer.save() - return { ...cfg, expand_depth: expandDepthRef.current, split_full: splitFullRef.current } + // group_by_depth is already in cfg, straight from save(). Only split_full is + // ours: it outlives split_by, so a layout saved while collapsed still knows + // the levels it was collapsed from. + return { ...cfg, split_full: splitFullRef.current } } async function persistLayout(vid, cfg) { @@ -1028,7 +869,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio async function saveAsSourceDefault() { const cfg = await captureConfig() if (!cfg) return - const { table, expand_depth, ...rest } = cfg + const { table, ...rest } = cfg try { const res = await fetch(`/api/sources/${sourceId}/default-layout`, { method: 'PUT', @@ -1061,7 +902,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' } await viewer.restore(cfg) adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length) - if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) + if (cfg.group_by_depth != null) setExpandDepth(cfg.group_by_depth) + else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) setActiveLayoutId(layout.id) await persistLayout(versionId, cfg) } @@ -1456,7 +1298,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio {[0, 1, 2, 3].map(d => ( ))}