Set row depth through ViewConfig instead of restoring it after every rebuild

ViewConfig carries group_by_depth -- alongside the split_by_depth this fork
added -- so row depth can be set declaratively:

    await viewer.restore({ group_by_depth: d })

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 had been imperative: getView() then view.set_depth(), which puts the
depth on an object the viewer discards whenever it re-renders. Everything
that grew around that existed only to guess when a rebuild had happened and
put the depth back -- a shim patching window.IntersectionObserver and
window.ResizeObserver, focus/visibilitychange/pageshow listeners, a retry
loop for getView() throwing "No table set" while getTable() resolved, a flag
tracking whether the viewer had "gone away", and tracing to debug all of it.

That guesswork caused three separate visible faults in a single session: the
tree fully expanding on refocus, snapping on any reflow, and snapping when
Perspective's own settings sidebar was opened. Each fix was a finer
heuristic about which browser event meant what, which is the shape of
fighting a framework rather than using it.

312 lines out, 35 in. applySplitDepth no longer re-applies the row depth
after truncating split_by, because the rebuilt view brings it along.
expand_depth stays readable on load: it is the legacy key, from when the
depth had to be stored beside the config rather than in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-17 03:05:36 -04:00
parent 8aa0ae2ece
commit 033176eb43
3 changed files with 35 additions and 312 deletions

View File

@ -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'

View File

@ -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()

View File

@ -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 => (
<button key={d} onClick={() => applyDepth(d)}
className={`border rounded px-1.5 py-0.5 transition-colors
${expandDepthRef.current === d ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
${expandDepth === d ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
{d}
</button>
))}