Simpl-Resultat/src/test/hookHarness.ts
le king fu a1c31a1da7 test(harness): follow-ups of the review of PR #434
- useOnboardingChecklist.wiring.test.ts: a data event, then a route
  change 100 ms later, still gets its deferred re-read (two LOAD_START).
  Pins the follower's deps: with `pathname` among them, the cleanup drops
  the pending timer and the test goes red (mutation M1 of the review). The
  comment that claimed "one subscription only" now points to this test.
- hookHarness.test.ts: a dependency array that shrinks, or grows by
  `undefined`, recomputes the memo (mutation M8, length branch of
  depsChanged).
- NOT SIMULATED, in hookHarness.ts and docs/architecture.md: the dispatch
  of a useReducer keeps the reducer of the first render, where React
  applies the latest one.

Refs #409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:51:20 -04:00

268 lines
11 KiB
TypeScript

// hookHarness — React's hooks with their semantics, for tests without jsdom (#409).
//
// The repository has no jsdom: tests call hooks and components as plain
// functions, under `vi.mock("react")`. A fake hook that drops React's
// semantics (an initializer called at every render, a memo recomputed every
// time) leaves one-line mutations green. This module is the one harness those
// tests share, so the semantics are written, and proved, once
// (hookHarness.test.ts).
//
// USE — one pattern, at the head of the test module, before the imports of
// the code under test (the factory of `vi.mock` is hoisted, hence the dynamic
// import inside it):
//
// vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
// import { hookHarness } from "../test/hookHarness";
// const harness = hookHarness();
//
// then, per test: `harness.clear()` in `beforeEach` (a fresh mount),
// `harness.render(() => Component(props))` for one render of the mounted
// component, `harness.commit()` to run its effects. Every test module gets its
// own copy of this module, so the singleton is shared by the mock and the test
// of one file, never across files. Values the test changes between renders
// (a pathname, a context value) live in a `vi.hoisted` object of the test.
//
// SEMANTICS
// - Slots by call order, as React: `render()` resets the cursor, so a
// component must call its hooks in the same order at every render. A test
// may preset the slot of a `useState` or `useReducer` (its value) before the
// first render.
// - `useState`: initializer called once (at the first render), stable setter,
// functional updates applied to the latest value. A setter does NOT
// re-render: the test renders again when it wants to read the new state.
// - `useReducer`: the reducer IS applied (the slot holds the new state for the
// next render), its `dispatch` is stable, and every action is also recorded
// by `harness.dispatch`, a `vi.fn()` spy shared by all the reducers.
// - `useRef`: one object per slot, kept from render to render.
// - `useMemo` / `useCallback`: recomputed only when a dependency changed
// (`Object.is`, as React); without a dependency array, at every render.
// - `useEffect`: captured during the render, never run by it. `commit()` runs
// the effects of the last render whose dependencies changed since their last
// run (all of them at the first commit, every time without an array): first
// the cleanups of those effects, then the effects, in call order, as React.
// A render is committed once. `replayEffects()` plays StrictMode's double
// run at mount (every cleanup, then every effect again); `unmount()` runs
// every cleanup.
// - Any other hook of React (`useContext`, `useLayoutEffect`,
// `useSyncExternalStore`, `useId`, `use`…) THROWS: it would otherwise reach
// the real React outside a render ("Invalid hook call"), and a test would go
// red by accident rather than on its assertion. Mock the module that calls
// it, or cover the hook here with its own test.
// - Everything else (`isValidElement`, `Fragment`, `createElement`…) is the
// real React, on the named exports and on the default export.
//
// NOT SIMULATED: re-rendering (on a setter, a dispatch, a context change),
// children (a component in the returned tree is an element, never called),
// the DOM, concurrent features, the bail-out of a setter called with the same
// value, the render-phase update loop React runs for a dispatch during render
// (the next `render()` reads it), and a reducer that changes between renders:
// the `dispatch` of a `useReducer` keeps the reducer of the first render (it is
// created once, with the slot), where React applies the reducer of the latest
// render. A module-level reducer, the only kind in use, is not affected.
//
// React is imported here only as a type: a value import, from inside the
// factory that builds the mock of "react", would load that very mock.
import type * as React from "react";
import { vi, type Mock } from "vitest";
type Deps = readonly unknown[] | undefined;
type EffectFn = () => void | (() => void);
const EFFECT = Symbol("hookHarness.effect");
interface EffectSlot {
[EFFECT]: true;
/** Dependencies at the last run, `null` before the first one. */
ran: Deps | null;
cleanup: (() => void) | undefined;
}
interface PendingEffect {
index: number;
fn: EffectFn;
deps: Deps;
}
export interface HookHarness {
/** The slots of the mounted component, by call order; a test may preset a state slot before the first render. */
slots: unknown[];
/** Every action dispatched to any `useReducer` of the component, in order. */
dispatch: Mock<(action: unknown) => void>;
/** One render of the mounted component: resets the cursor and the captured effects, then calls `fn`. */
render<T>(fn: () => T): T;
/** Starts a render by hand (cursor at 0, no captured effect), for a test that calls the component itself. */
reset(): void;
/** Runs the effects of the last render whose dependencies changed, cleanups first; once per render. */
commit(): void;
/** StrictMode at mount: every cleanup of the last render's effects, then every effect again. */
replayEffects(): void;
/** Runs every cleanup, as React does when the component goes away. */
unmount(): void;
/** A fresh mount: no slot, no effect, no recorded action. */
clear(): void;
}
type Internal = HookHarness & {
writers: Array<((arg: unknown) => void) | undefined>;
next(): number;
capture(effect: PendingEffect): void;
};
function depsChanged(previous: Deps | null, next: Deps): boolean {
if (previous === null || previous === undefined || next === undefined) return true;
if (previous.length !== next.length) return true;
return next.some((dep, k) => !Object.is(dep, previous[k]));
}
const isEffectSlot = (slot: unknown): slot is EffectSlot =>
typeof slot === "object" && slot !== null && EFFECT in slot;
/** A harness of its own, for the harness's tests; the mock uses the shared one (`hookHarness()`). */
export function createHookHarness(): HookHarness {
let cursor = 0;
let pending: PendingEffect[] = [];
/** A render is committed once, as React does. */
let committed = false;
const run = (effects: PendingEffect[]) => {
for (const { index } of effects) {
const slot = harness.slots[index] as EffectSlot;
const cleanup = slot.cleanup;
slot.cleanup = undefined;
cleanup?.();
}
for (const { index, fn, deps } of effects) {
const slot = harness.slots[index] as EffectSlot;
slot.ran = deps;
const cleanup = fn();
slot.cleanup = typeof cleanup === "function" ? cleanup : undefined;
}
};
const harness: Internal = {
slots: [],
writers: [],
dispatch: vi.fn(),
render(fn) {
harness.reset();
return fn();
},
reset() {
cursor = 0;
pending = [];
committed = false;
},
commit() {
if (committed) return;
committed = true;
run(pending.filter(({ index, deps }) => depsChanged((harness.slots[index] as EffectSlot).ran, deps)));
},
replayEffects() {
run(pending);
},
unmount() {
for (const slot of harness.slots) {
if (!isEffectSlot(slot)) continue;
const cleanup = slot.cleanup;
slot.cleanup = undefined;
cleanup?.();
}
},
clear() {
harness.slots = [];
harness.writers = [];
harness.dispatch.mockReset();
harness.reset();
},
next: () => cursor++,
capture: (effect) => {
pending.push(effect);
},
};
return harness;
}
const shared = createHookHarness();
/** The harness the mock of "react" writes to, shared with the test of the same module. */
export function hookHarness(): HookHarness {
return shared;
}
const COVERED = new Set(["useState", "useReducer", "useRef", "useMemo", "useCallback", "useEffect"]);
/**
* The module that replaces "react": the real one, with the covered hooks
* backed by `harness` (the shared one by default) and every other hook
* throwing.
*/
export function fakeReact(actual: typeof React, harness: HookHarness = shared): typeof React {
const h = harness as Internal;
/** Index of this hook's slot, filled by `init` at the first render only. */
const slot = (init: () => unknown): number => {
const index = h.next();
if (!(index in h.slots)) h.slots[index] = init();
return index;
};
const memo = (compute: () => unknown, deps: Deps): unknown => {
const index = slot(() => ({ deps: null as Deps | null, value: undefined as unknown }));
const entry = h.slots[index] as { deps: Deps | null; value: unknown };
if (depsChanged(entry.deps, deps)) {
entry.value = compute();
entry.deps = deps;
}
return entry.value;
};
/** Stable setter or dispatch of a state slot, created at its first use. */
const writer = (index: number, make: () => (arg: unknown) => void) => (h.writers[index] ??= make());
const covered = {
useState: (init: unknown) => {
const index = slot(() => (typeof init === "function" ? (init as () => unknown)() : init));
const set = writer(index, () => (next: unknown) => {
h.slots[index] =
typeof next === "function" ? (next as (previous: unknown) => unknown)(h.slots[index]) : next;
});
return [h.slots[index], set];
},
useReducer: (
reducer: (state: unknown, action: unknown) => unknown,
initialArg: unknown,
init?: (arg: unknown) => unknown,
) => {
const index = slot(() => (init ? init(initialArg) : initialArg));
const dispatch = writer(index, () => (action: unknown) => {
h.dispatch(action);
h.slots[index] = reducer(h.slots[index], action);
});
return [h.slots[index], dispatch];
},
useRef: (init: unknown) => h.slots[slot(() => ({ current: init }))],
useMemo: (compute: () => unknown, deps: Deps) => memo(compute, deps),
useCallback: (fn: unknown, deps: Deps) => memo(() => fn, deps),
useEffect: (fn: EffectFn, deps?: Deps) => {
const index = slot((): EffectSlot => ({ [EFFECT]: true, ran: null, cleanup: undefined }));
h.capture({ index, fn, deps });
},
};
const uncovered: Record<string, unknown> = {};
for (const key of Object.keys(actual)) {
if ((key === "use" || /^use[A-Z]/.test(key)) && !COVERED.has(key)) {
uncovered[key] = () => {
throw new Error(
`hookHarness: ${key} is not simulated; mock the module that calls it, or cover it in src/test/hookHarness.ts`,
);
};
}
}
const fake: Record<string, unknown> = { ...actual, ...uncovered, ...covered };
// `import React from "react"` then `React.useState`: the default export too.
const original = (actual as { default?: unknown }).default;
if (original && typeof original === "object") fake.default = { ...original, ...uncovered, ...covered };
return fake as unknown as typeof React;
}