fix(web): hoist useMemo above early returns in Dashboard + Calendar (Rules of Hooks)
Some checks are pending
CI / verify (push) Waiting to run

Both views called useMemo AFTER their loading/error early-return guards. While the
backend lacked the data (or the dev proxy was unauthenticated) the views always hit
the early return, so the memos were never reached and nothing crashed. The moment a
populated response arrived, React rendered more hooks than the previous render →
"Rendered more hooks than during the previous render" → the view tree threw and the
panel went blank.

Fix: move every useMemo above the `if (error)` / `if (!data)` guards and make each
null-safe (deps key off `data?.*`). Pure refactor — no behavior change once data is
present. Introduced by the Wave-C perf pass (useMemo on derived rows); it slipped
past typecheck (hook rules aren't typed), the route-mount test (usePoll mocked to
permanent loading → early return, memo never reached), and the build.

Verified live against the running backend: Dashboard renders KPIs + density bars +
insights + actions; Bookings renders its grouped/empty state — neither throws.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-30 02:38:03 -04:00
parent c2bcd23548
commit 57e898b3f9
2 changed files with 33 additions and 32 deletions

View file

@ -11,19 +11,20 @@ import { Button, Card, HStack, Muted, Title, VStack } from '../ui';
export function CalendarView(): JSX.Element {
const { data, error, loading } = usePoll(() => getBookings(40), 30000);
if (error) return <Card><Title>Bookings</Title><Muted>{error}</Muted></Card>;
if (loading || !data) return <Card><Muted>Loading bookings</Muted></Card>;
// Group by day (already grouped in backend sample, but client can too)
// Group by day. Memoized BEFORE the early returns so hooks run in the same order
// every render (Rules of Hooks); null-safe for the loading/error renders.
const groups = useMemo(
() =>
data.items.reduce<Record<string, BookingItem[]>>((acc, b) => {
(data?.items ?? []).reduce<Record<string, BookingItem[]>>((acc, b) => {
(acc[b.day] ||= []).push(b);
return acc;
}, {}),
[data.items],
[data?.items],
);
if (error) return <Card><Title>Bookings</Title><Muted>{error}</Muted></Card>;
if (loading || !data) return <Card><Muted>Loading bookings</Muted></Card>;
return (
<VStack $gap={12}>
<Title>Bookings · sessions · holds · tour schedule</Title>

View file

@ -25,6 +25,31 @@ export function DashboardView(): JSX.Element {
const { data, error, loading } = usePoll<DashboardData>(() => getDashboard(days), 30_000);
// Derived values memoized BEFORE any early return — hooks must run in the same
// order every render (Rules of Hooks). Each is null-safe so it works while
// `data` is still loading; deps key off `data?.*` for referential stability.
const densityRows: readonly { key: string; count: number }[] = useMemo(
() => (data?.density ?? []).map((d) => ({ key: `${d.hour}h`, count: d.count })),
[data?.density],
);
const kpiEntries = useMemo(() => Object.entries(data?.kpis ?? {}), [data?.kpis]);
const volumeRows: readonly { key: string; count: number }[] = useMemo(
() =>
kpiEntries
.filter(([, v]) => typeof v === 'number')
.map(([k, v]) => ({
key: k.replace(/7d|InRange/gi, '').trim() || k,
count: Number(v),
})),
[kpiEntries],
);
const peak = useMemo(
() => (data?.density?.length ? data.density.reduce((a, b) => (b.count > a.count ? b : a)) : null),
[data?.density],
);
const showToast = (msg: string): void => {
setToast(msg);
window.setTimeout(() => setToast(null), 2400);
@ -70,32 +95,7 @@ export function DashboardView(): JSX.Element {
);
}
const { kpis, density, insights, actions } = data;
const densityRows: readonly { key: string; count: number }[] = useMemo(
() => density.map((d) => ({ key: `${d.hour}h`, count: d.count })),
[density],
);
const kpiEntries = useMemo(() => Object.entries(kpis), [kpis]);
const volumeRows: readonly { key: string; count: number }[] = useMemo(
() =>
kpiEntries
.filter(([, v]) => typeof v === 'number')
.map(([k, v]) => ({
key: k.replace(/7d|InRange/gi, '').trim() || k,
count: Number(v),
})),
[kpiEntries],
);
const peak = useMemo(
() =>
density.length
? density.reduce((a, b) => (b.count > a.count ? b : a))
: null,
[density],
);
const { insights, actions } = data;
return (
<VStack $gap={14}>