fix(web): hoist useMemo above early returns in Dashboard + Calendar (Rules of Hooks)
Some checks are pending
CI / verify (push) Waiting to run
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:
parent
c2bcd23548
commit
57e898b3f9
2 changed files with 33 additions and 32 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue