perf(awards): build the logbook snapshot once, not once per caller

A field log showed three pulls of the same 123 615 QSOs inside ten seconds, the
Go heap going 725 MB → 2213 MB → 2539 MB. It is released after the idle TTL, so
not a leak — just the same work done three times with all three results alive at
once.

The cache lock was released before the logbook was read, so every caller that
arrived during a build missed the cache and started its own. Opening the Awards
panel does exactly that. One builder at a time now, with the usual re-check
after taking the lock: the second and third caller wait out the seconds they
were going to spend anyway, minus two round trips to the database.

The slice is also sized from the previous build. Growing to 123 000 structs by
doubling copies the whole thing a dozen times and holds the old and the new
array together at each step, on the largest object OpsLog keeps.

Also, the settings panel registry. The hook trap that broke the window when the
power-supply panel was opened was headed off by a comment, and the comment did
not survive contact with the next panel — so it is now structural. PanelHost is
a module-scope component that calls the selected panel and is keyed by section:
hooks inside a panel land in a component context that persists, section changes
remount it so each gets a fresh and consistent hook list, and no panel state
leaks into the next.

Rendering the panels as <Panel /> instead — the obvious refactor — would have
been wrong: they are nested inside SettingsModal and close over its state, so
each parent render makes a new component TYPE and React would unmount and
remount the panel on every keystroke.
This commit is contained in:
2026-08-16 14:40:18 +02:00
parent 7578e49573
commit 37805fe3ed
4 changed files with 166 additions and 21 deletions
+37 -16
View File
@@ -242,6 +242,29 @@ function VendorMark({ vendor }: { vendor: 'o3a' }) {
);
}
// PanelHost renders the selected settings panel, and exists so a panel may hold
// hooks of its own.
//
// The panels are nested inside SettingsModal, closing over its state — so they
// cannot be rendered as <Panel />: a nested function is a NEW component type on
// every parent render, which would unmount and remount the panel on each
// keystroke. They were therefore CALLED, `PANELS[selected]()`, and a call runs
// any hook inside them in SettingsModal's own hook list — conditionally, since
// only the open section is called. React counts those, and the window stopped
// drawing the moment such a section was opened (error #310, "rendered more
// hooks than during the previous render"). It had happened once and was headed
// off by a comment; the comment did not survive contact with the next panel.
//
// This host is module-scope, so its identity is stable and calling `render()`
// inside it puts those hooks in a component context that persists. `key` is the
// section, so switching sections REMOUNTS it — a fresh, consistent hook list per
// section, and no panel state leaking into the next one. Within a section the
// render prop is a new closure each parent render, which is how the panel keeps
// seeing current values.
function PanelHost({ render }: { render?: () => JSX.Element }) {
return render ? render() : null;
}
// buildTree returns the settings sidebar. The FlexRadio item only appears when
// the active CAT backend is a Flex (per-band antenna config is Flex-specific).
function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[] {
@@ -1534,8 +1557,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [eqslTesting, setEqslTesting] = useState(false);
const [stationLocations, setStationLocations] = useState<string[]>([]);
// Active tab in the External Services panel — lifted here because
// PANELS[selected]() is called as a function, so panels can't hold hooks.
// Active tab in the External Services panel. Lifted here back when a panel
// could not hold hooks at all; PanelHost lifted that restriction, and this
// stays put because moving it down would reset the tab on every reopen —
// a choice now, not a workaround.
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'pota'>('qrz');
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
const [potaToken, setPotaToken] = useState('');
@@ -1632,9 +1657,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
return () => { unsub?.(); };
}, []);
const [profiles, setProfiles] = useState<Profile[]>([]);
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
// the panel as a plain function, not as a JSX element, so any useState
// inside the panel function would violate the Rules of Hooks.
// State for ProfilesPanel. Lifted here back when a panel could not hold hooks
// — PanelHost lifted that restriction — and left here because the selection
// then survives switching sections, which is what an operator expects of it.
const [profileSelectedId, setProfileSelectedId] = useState<number>(0);
const [profileNameDraft, setProfileNameDraft] = useState<string>('');
@@ -3406,13 +3431,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
// register — the output on/off. The voltage and current SET points are shown
// because they are worth seeing, and are not editable here: they belong to the
// supply's front panel, and a logbook that can set them can set them wrong.
// NO HOOKS IN HERE. This panel is called as a plain function, like its
// neighbours — PANELS[x]() — so a useState of its own lands in SettingsModal's
// hook list and only while this section is open. React counts those, and it
// stopped drawing the moment the section was clicked (error #310, "rendered
// more hooks than during the previous render"). The COM ports come from the
// list SettingsModal already loads for the Winkeyer panel: one machine, one
// set of serial ports, loaded once.
// The COM ports come from the list SettingsModal already loads for the
// Winkeyer panel — one machine, one set of serial ports, fetched once. A hook
// of its own would be legal now (see PanelHost) and would fetch them twice.
function PSUPanelSettings() {
const ports = wkPorts;
const setPorts = setWkPorts;
@@ -6407,9 +6428,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
'lists-modes': ModesPanel,
cluster: ClusterPanel,
udp: UDPIntegrationsPanelWrapper,
// Rendered as a real element (not called as a bare function) so its own hooks
// — useState/useEffect/useI18n — get a proper component context; PANELS[x]()
// is a plain call and hook-holding panels must go through JSX like this.
// Module-scope components, wrapped so their props can be passed. The nested
// panels below go through PanelHost instead — which is what now lets either
// kind hold hooks.
adifmon: () => <ADIFMonitorPanel />,
webpublish: () => <WebPublishPanel />,
relayauto: () => <RelayAutoPanel />,
@@ -6452,7 +6473,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
{breadcrumb}
</div>
{PANELS[selected]?.()}
<PanelHost key={selected} render={PANELS[selected]} />
{err && (
<div className="mt-6 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-md px-3 py-2 max-w-2xl">