Stabilize Forecast viewer lifecycle

Reuse a single Perspective worker across version switches and delete
the previous table instead of terminating the worker — terminate was
returning a rejecting promise the sync try/catch missed, and each new
worker leaked WASM memory. applyLayout no longer leaks a view per call;
it reads schema directly from the table. An init id guards against
concurrent runs (StrictMode, rapid version switches) clobbering each
other, and a catch on "already exists" recovers via open_table+delete
when a stale table from a previous run is still hosted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-28 21:11:32 -04:00
parent e279a510d8
commit 8492557621

View File

@ -71,6 +71,7 @@ export default function Forecast({ sourceId, versionId }) {
const tableRef = useRef(null)
const colMetaRef = useRef([])
const expandDepthRef = useRef(null)
const initIdRef = useRef(0)
function onDragStart(e) {
e.preventDefault()
@ -136,6 +137,7 @@ export default function Forecast({ sourceId, versionId }) {
async function initViewer(vid, sid) {
const viewer = viewerRef.current
if (!viewer) return
const myId = ++initIdRef.current
setLoading(true)
setLargeDataset(false)
setLoadProgress(null)
@ -177,12 +179,37 @@ export default function Forecast({ sourceId, versionId }) {
if (rowCount >= 500000) setLargeDataset(true)
if (workerRef.current) { try { workerRef.current.terminate() } catch {} }
const worker = await perspective.worker()
workerRef.current = worker
tableRef.current = rowCount > 0
? await worker.table(buffer, { name: tableName, index: 'pf_id' })
: await worker.table([], { name: tableName, index: 'pf_id' })
if (myId !== initIdRef.current) return
if (!workerRef.current) workerRef.current = await perspective.worker()
const worker = workerRef.current
if (tableRef.current) {
try { await tableRef.current.delete() } catch {}
tableRef.current = null
}
const opts = { name: tableName, index: 'pf_id' }
const makeTable = async () => rowCount > 0 ? worker.table(buffer, opts) : worker.table([], opts)
try {
tableRef.current = await makeTable()
} catch (err) {
if (/already exists/i.test(String(err?.message || err))) {
try {
const existing = await worker.open_table(tableName)
if (existing) await existing.delete()
} catch {}
tableRef.current = await makeTable()
} else {
throw err
}
}
if (myId !== initIdRef.current) {
try { await tableRef.current.delete() } catch {}
tableRef.current = null
return
}
await viewer.load(worker)
viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
@ -283,7 +310,7 @@ export default function Forecast({ sourceId, versionId }) {
async function applyLayout(layout) {
const viewer = viewerRef.current
if (!viewer) return
const validCols = new Set(tableRef.current ? Object.keys(await (await tableRef.current.view()).schema()) : [])
const validCols = new Set(tableRef.current ? Object.keys(await tableRef.current.schema()) : [])
const cfg = cleanLayout(layout.config, validCols)
await viewer.restore(cfg)
if (cfg.plugin_config) {