test(web): regression guard for hooks-order on loading→data transition
Some checks are pending
CI / verify (push) Waiting to run
Some checks are pending
CI / verify (push) Waiting to run
Locks the fix in 57e898b. The existing route-mount test mocks usePoll to permanent
loading, so it never renders the data branch and missed the memo-after-early-return
crash. This test flips usePoll loading→data for Dashboard + Calendar and asserts the
re-render doesn't throw. Verified it FAILS on the pre-fix views ("Rendered more hooks
than during the previous render") and passes on the fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5af9962eef
commit
9f804285fa
1 changed files with 86 additions and 0 deletions
86
web/src/views/hooks-order.test.tsx
Normal file
86
web/src/views/hooks-order.test.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { ThemeProvider } from '@cocotte/ui-theme';
|
||||
import { luxeDarkTheme } from '@cocotte/site-themes';
|
||||
import { render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Rules-of-Hooks regression guard.
|
||||
*
|
||||
* Dashboard and Calendar once placed `useMemo` AFTER their loading/error early
|
||||
* returns. That only crashes on the loading→data TRANSITION: the first render
|
||||
* bails at the guard (few hooks), the second render reaches the memos (more
|
||||
* hooks) → "Rendered more hooks than during the previous render" → blank panel.
|
||||
* The route-mount test (App.test.tsx) mocks usePoll to permanent loading, so it
|
||||
* never exercises the data branch and never caught this. This test does: it
|
||||
* flips usePoll from loading to populated data and asserts the re-render does
|
||||
* not throw.
|
||||
*/
|
||||
|
||||
// A single mutable usePoll result both views read; flipped mid-test.
|
||||
let pollState: { data: unknown; loading: boolean; error: string | null } = {
|
||||
data: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
vi.mock('../usePoll', () => ({
|
||||
usePoll: () => pollState,
|
||||
formatTime: (s: string) => s,
|
||||
}));
|
||||
|
||||
// API calls never resolve — views must rely on the (mocked) usePoll state only.
|
||||
vi.mock('../api', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(actual)) {
|
||||
out[key] = typeof val === 'function' ? vi.fn(() => new Promise(() => {})) : val;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
import { DashboardView } from './DashboardView';
|
||||
import { CalendarView } from './CalendarView';
|
||||
|
||||
function wrap(node: JSX.Element): JSX.Element {
|
||||
return (
|
||||
<ThemeProvider themeName="luxe" customTheme={luxeDarkTheme}>
|
||||
{node}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const DASHBOARD_DATA = {
|
||||
kpis: { sent7d: 0, held7d: 0, qualified: 0, newInRange: 0 },
|
||||
density: [
|
||||
{ hour: 9, count: 2 },
|
||||
{ hour: 11, count: 3 },
|
||||
],
|
||||
insights: ['Funnel qualified 0 in 7d'],
|
||||
actions: ['Review held in Triage'],
|
||||
};
|
||||
|
||||
const CALENDAR_DATA = {
|
||||
items: [
|
||||
{ day: 'Mon Jun 30', time: '14:00', handle: '@x', type: 'confirmed', loc: 'NYC', rate: '' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('Rules of Hooks — data views survive the loading→data transition', () => {
|
||||
afterEach(() => {
|
||||
pollState = { data: null, loading: true, error: null };
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Dashboard', () => <DashboardView />, DASHBOARD_DATA],
|
||||
['Bookings', () => <CalendarView />, CALENDAR_DATA],
|
||||
])('%s does not throw a hooks-order error when data arrives', (_name, renderView, data) => {
|
||||
pollState = { data: null, loading: true, error: null };
|
||||
const { rerender, container } = render(wrap(renderView()));
|
||||
|
||||
// The transition that crashed before the fix: populated data on a later render.
|
||||
pollState = { data, loading: false, error: null };
|
||||
expect(() => rerender(wrap(renderView()))).not.toThrow();
|
||||
|
||||
// And it actually advanced past the loading guard into real content.
|
||||
expect(container.textContent ?? '').not.toContain('Loading');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue