test(web): regression guard for hooks-order on loading→data transition
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:
Natalie 2026-06-30 02:40:23 -04:00
parent 5af9962eef
commit 9f804285fa

View 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 loadingdata 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');
});
});