pf_app/ui/src/App.jsx
Paul Trowbridge 0459fa137d 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) <noreply@anthropic.com>
2026-09-16 22:42:38 -04:00

98 lines
4.2 KiB
JavaScript

import { useState, useEffect, useCallback } from 'react'
import Sidebar from './components/Sidebar.jsx'
import StatusBar from './components/StatusBar.jsx'
import Setup from './views/Setup.jsx'
import Baseline from './views/Baseline.jsx'
import Forecast from './views/Forecast.jsx'
export default function App() {
const [view, setView] = useState(() => localStorage.getItem('pf_view') || 'forecast')
const [sidebarExpanded, setSidebarExpanded] = useState(() => localStorage.getItem('pf_sidebar') !== 'collapsed')
const [sources, setSources] = useState([])
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])
useEffect(() => { localStorage.setItem('pf_sidebar', sidebarExpanded ? 'expanded' : 'collapsed') }, [sidebarExpanded])
useEffect(() => { localStorage.setItem('pf_sourceId', sourceId || '') }, [sourceId])
useEffect(() => { localStorage.setItem('pf_versionId', versionId || '') }, [versionId])
const refreshSources = useCallback(async () => {
const data = await fetch('/api/sources').then(r => r.json())
const list = Array.isArray(data) ? data : []
setSources(list)
setSourcesLoaded(true)
return list
}, [])
const refreshVersions = useCallback(async (sid) => {
const id = sid ?? sourceId
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])
useEffect(() => { refreshSources() }, [])
// The selection is restored from localStorage and survives a deregister, so it
// has to be re-checked against the list itself rather than only at mount:
// deleting the selected source otherwise leaves a dead id behind and every
// call 404s "Source not found" until the page is reloaded.
useEffect(() => {
if (!sourcesLoaded) return
if (sources.length === 0) { setSourceId(''); return }
if (!sourceId || !sources.some(s => String(s.id) === String(sourceId))) {
setSourceId(String(sources[0].id))
}
}, [sources, sourcesLoaded, sourceId])
useEffect(() => {
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 || !versionsLoaded) return
if (versions.length === 0) { setVersionId(''); return }
if (!versionId || !versions.some(v => String(v.id) === String(versionId))) {
setVersionId(String(versions[0].id))
}
}, [versions, versionsLoaded, sourceId, versionId])
const ctx = {
sources, sourceId, setSourceId,
versions, versionId, setVersionId,
refreshSources, refreshVersions, setVersions,
}
return (
<div className="flex h-screen w-full text-sm overflow-hidden">
<Sidebar view={view} setView={setView} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
<div className="flex flex-col flex-1 overflow-hidden min-w-0">
<StatusBar view={view} {...ctx} />
<div className="flex-1 overflow-hidden">
{view === 'setup' && <Setup {...ctx} />}
{view === 'baseline' && <Baseline {...ctx} />}
{view === 'forecast' && <Forecast {...ctx} />}
</div>
</div>
</div>
)
}