// 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(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 = {}; 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 = { ...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; }