1import type { Dispatch, SetStateAction } from "react";
2
3/**
4 * Minimal React hook shim for tests that call components as plain functions
5 * instead of mounting a renderer. Slots are keyed by call order, mirroring
6 * React's own rules-of-hooks contract, and `useMemoCache` emulates the React
7 * Compiler runtime so compiled components can execute unmodified.
8 *
9 * This module must stay free of runtime `react` imports: it is loaded from
10 * inside `vi.mock("react", ...)` factories, and a value import would recurse
11 * into the in-progress mock. Wire it up in each test file (mock calls cannot
12 * live here because vitest hoists them per test module):
13 *
14 * ```ts
15 * import { reactHookHarness } from "~/test/reactHookHarness";
16 *
17 * vi.mock("react", async (importOriginal) => {
18 * const actual = await importOriginal<typeof import("react")>();
19 * const { reactHookHarness } = await import("~/test/reactHookHarness");
20 * return {
21 * ...actual,
22 * useCallback: reactHookHarness.useCallback,
23 * useMemo: reactHookHarness.useMemo,
24 * useRef: reactHookHarness.useRef,
25 * useState: reactHookHarness.useState,
26 * };
27 * });
28 * vi.mock("react/compiler-runtime", async () => {
29 * const { reactHookHarness } = await import("~/test/reactHookHarness");
30 * return { c: reactHookHarness.useMemoCache };
31 * });
32 * ```
33 *
34 * Call `beginRender()` before each component invocation and `reset()` in
35 * `beforeEach` to drop persisted state between tests.
36 */
37function createReactHookHarness() {
38 let cursor = 0;
39 let slots: unknown[] = [];
40 const nextIndex = () => cursor++;
41
42 return {
43 beginRender() {
44 cursor = 0;
45 },
46 reset() {
47 cursor = 0;
48 slots = [];
49 },
50 useCallback<T>(callback: T): T {
51 nextIndex();
52 return callback;
53 },
54 useMemo<T>(factory: () => T): T {
55 nextIndex();
56 return factory();
57 },
58 useMemoCache(size: number): unknown[] {
59 const index = nextIndex();
60 if (!slots[index]) {
61 slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel"));
62 }
63 return slots[index] as unknown[];
64 },
65 useRef<T>(initialValue: T): { current: T } {
66 const index = nextIndex();
67 if (!slots[index]) {
68 slots[index] = { current: initialValue };
69 }
70 return slots[index] as { current: T };
71 },
72 useState<T>(initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
73 const index = nextIndex();
74 if (index >= slots.length) {
75 slots[index] =
76 typeof initialValue === "function" ? (initialValue as () => T)() : initialValue;
77 }
78 const setValue: Dispatch<SetStateAction<T>> = (nextValue) => {
79 const previous = slots[index] as T;
80 slots[index] =
81 typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue;
82 };
83 return [slots[index] as T, setValue];
84 },
85 };
86}
87
88/** Shared instance so `vi.mock` factories and test bodies see the same slots. */
89export const reactHookHarness = createReactHookHarness();