From 0459fa137d807c6ee2c3c706e8ddd70ca52abba8 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Wed, 16 Sep 2026 22:42:38 -0400 Subject: [PATCH] Stop loading the whole version twice on every page load The version re-validation effect could not tell "the versions fetch has not landed yet" from "this source has no versions", because both look like an empty array. So a versionId restored from localStorage was cleared on the first render and set straight back when the fetch returned: 29 -> '' -> 29. Forecast's load effect is keyed on [versionId, sourceId], so that round trip ran initViewer twice. Two /agg requests, two full aggregations in Postgres, two Arrow payloads -- on version 29 that is the 285k-row aggregate computed twice at ~17s each, which is most of the "it hangs before it displays" and the Postgres process sitting at the top of htop. sourcesLoaded already guarded the identical effect for sources; versions just never got the same treatment. Cleared when the source changes, since the list belongs to the previous source until the new fetch lands. Co-Authored-By: Claude Opus 5 (1M context) --- ui/src/App.jsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 64021e6..940a101 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -13,6 +13,7 @@ export default function App() { const [sourcesLoaded, setSourcesLoaded] = useState(false) const [sourceId, setSourceId] = useState(() => localStorage.getItem('pf_sourceId') || '') const [versions, setVersions] = useState([]) + const [versionsLoaded, setVersionsLoaded] = useState(false) const [versionId, setVersionId] = useState(() => localStorage.getItem('pf_versionId') || '') useEffect(() => { localStorage.setItem('pf_view', view) }, [view]) @@ -30,10 +31,11 @@ export default function App() { const refreshVersions = useCallback(async (sid) => { const id = sid ?? sourceId - if (!id) { setVersions([]); return [] } + if (!id) { setVersions([]); setVersionsLoaded(true); return [] } const data = await fetch(`/api/sources/${id}/versions`).then(r => r.json()) const list = Array.isArray(data) ? data : [] setVersions(list) + setVersionsLoaded(true) return list }, [sourceId]) @@ -52,18 +54,26 @@ export default function App() { }, [sources, sourcesLoaded, sourceId]) useEffect(() => { - if (!sourceId) { setVersions([]); setVersionId(''); return } + if (!sourceId) { setVersions([]); setVersionId(''); setVersionsLoaded(true); return } + // The list belongs to the previous source until the fetch lands. + setVersionsLoaded(false) refreshVersions(sourceId) }, [sourceId]) // Same reasoning as sources: a deleted version must not stay selected. + // + // versionsLoaded matters more than it looks: without it, the empty initial + // state reads as "this source has no versions", so a versionId restored from + // localStorage is cleared and then set straight back when the fetch lands. + // Forecast's load effect is keyed on that id, so the round trip made every + // page load fetch and aggregate the whole version twice. useEffect(() => { - if (!sourceId) return + if (!sourceId || !versionsLoaded) return if (versions.length === 0) { setVersionId(''); return } if (!versionId || !versions.some(v => String(v.id) === String(versionId))) { setVersionId(String(versions[0].id)) } - }, [versions, sourceId, versionId]) + }, [versions, versionsLoaded, sourceId, versionId]) const ctx = { sources, sourceId, setSourceId,