From 8492557621a438570898e3253d8a379db486af41 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Tue, 28 Apr 2026 21:11:32 -0400 Subject: [PATCH] Stabilize Forecast viewer lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui/src/views/Forecast.jsx | 41 ++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 1f17dc9..f6a8e54 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -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) {