- 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>
302 lines
9.8 KiB
TypeScript
302 lines
9.8 KiB
TypeScript
// hookHarness — each simulated semantic proved by its own test (#409).
|
|
//
|
|
// The tests that rely on the harness can only be as true as it is. Here it is
|
|
// driven directly: a harness of its own, a fake React built over the real one,
|
|
// and small components written for the test.
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { readFileSync } from "node:fs";
|
|
import * as actualReact from "react";
|
|
import { createHookHarness, fakeReact, hookHarness, type HookHarness } from "./hookHarness";
|
|
|
|
let harness: HookHarness;
|
|
let R: typeof actualReact;
|
|
|
|
beforeEach(() => {
|
|
harness = createHookHarness();
|
|
R = fakeReact(actualReact, harness);
|
|
});
|
|
|
|
describe("hookHarness — the module itself", () => {
|
|
it("imports React only as a type: a value import would load the mock it builds", () => {
|
|
// The code only: the header comment shows the pattern of use.
|
|
const source = readFileSync(new URL("./hookHarness.ts", import.meta.url), "utf8")
|
|
.split("\n")
|
|
.filter((line) => !line.trimStart().startsWith("//"))
|
|
.join("\n");
|
|
const reactImports = source.split("\n").filter((line) => /from\s+["']react["']/.test(line));
|
|
expect(reactImports.length).toBeGreaterThan(0);
|
|
for (const line of reactImports) expect(line).toMatch(/^import type /);
|
|
expect(source).not.toMatch(/require\(["']react["']\)/);
|
|
expect(source).not.toMatch(/import\(["']react["']\)/);
|
|
});
|
|
|
|
it("shares one harness through its accessor", () => {
|
|
expect(hookHarness()).toBe(hookHarness());
|
|
});
|
|
|
|
it("leaves the rest of React real", () => {
|
|
expect(R.isValidElement).toBe(actualReact.isValidElement);
|
|
expect(R.Fragment).toBe(actualReact.Fragment);
|
|
expect(R.createElement).toBe(actualReact.createElement);
|
|
});
|
|
});
|
|
|
|
describe("hookHarness — useState", () => {
|
|
it("calls the initializer once, at the first render", () => {
|
|
const init = vi.fn(() => 1);
|
|
const Counter = () => R.useState(init)[0];
|
|
expect(harness.render(Counter)).toBe(1);
|
|
expect(harness.render(Counter)).toBe(1);
|
|
expect(init).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps a stable setter, applies functional updates, and does not re-render", () => {
|
|
let renders = 0;
|
|
const Counter = () => {
|
|
renders++;
|
|
return R.useState(0);
|
|
};
|
|
const [, set] = harness.render(Counter);
|
|
set(5);
|
|
set((n: number) => n + 1);
|
|
expect(renders).toBe(1);
|
|
const [value, again] = harness.render(Counter);
|
|
expect(value).toBe(6);
|
|
expect(again).toBe(set);
|
|
});
|
|
|
|
it("keeps each slot apart, by call order", () => {
|
|
const Pair = () => [R.useState("a")[0], R.useState("b")[0]];
|
|
expect(harness.render(Pair)).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("reads a slot the test preset before the first render", () => {
|
|
harness.slots[0] = "preset";
|
|
expect(harness.render(() => R.useState("init")[0])).toBe("preset");
|
|
});
|
|
});
|
|
|
|
describe("hookHarness — useReducer", () => {
|
|
const reducer = (state: number, action: { by: number }) => state + action.by;
|
|
|
|
it("applies the reducer, keeps a stable dispatch, and records every action in the spy", () => {
|
|
const Sum = () => R.useReducer(reducer, 10);
|
|
const [state, dispatch] = harness.render(Sum);
|
|
expect(state).toBe(10);
|
|
dispatch({ by: 2 });
|
|
dispatch({ by: 3 });
|
|
const [next, again] = harness.render(Sum);
|
|
expect(next).toBe(15);
|
|
expect(again).toBe(dispatch);
|
|
expect(harness.dispatch.mock.calls.map((c) => c[0])).toEqual([{ by: 2 }, { by: 3 }]);
|
|
});
|
|
|
|
it("calls the init function once", () => {
|
|
const init = vi.fn((n: number) => n * 2);
|
|
const Sum = () => R.useReducer(reducer, 4, init)[0];
|
|
expect(harness.render(Sum)).toBe(8);
|
|
expect(harness.render(Sum)).toBe(8);
|
|
expect(init).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("hookHarness — useRef", () => {
|
|
it("keeps one object from render to render", () => {
|
|
const Ref = () => R.useRef(0);
|
|
const first = harness.render(Ref);
|
|
first.current = 7;
|
|
const second = harness.render(Ref);
|
|
expect(second).toBe(first);
|
|
expect(second.current).toBe(7);
|
|
});
|
|
});
|
|
|
|
describe("hookHarness — useMemo and useCallback", () => {
|
|
it("useMemo keeps its value while the dependencies are equal, recomputes when one changes", () => {
|
|
const compute = vi.fn((n: number) => ({ n }));
|
|
let dep = 1;
|
|
const Memo = () => R.useMemo(() => compute(dep), [dep]);
|
|
const first = harness.render(Memo);
|
|
expect(harness.render(Memo)).toBe(first);
|
|
expect(compute).toHaveBeenCalledTimes(1);
|
|
dep = 2;
|
|
const second = harness.render(Memo);
|
|
expect(second).not.toBe(first);
|
|
expect(second).toEqual({ n: 2 });
|
|
expect(compute).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("compares the dependencies with Object.is", () => {
|
|
let dep: unknown = NaN;
|
|
const compute = vi.fn(() => ({}));
|
|
const Memo = () => R.useMemo(compute, [dep]);
|
|
harness.render(Memo);
|
|
harness.render(Memo);
|
|
expect(compute).toHaveBeenCalledTimes(1);
|
|
dep = { same: true };
|
|
harness.render(Memo);
|
|
dep = { same: true };
|
|
harness.render(Memo);
|
|
expect(compute).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("recomputes when the dependency array changes length, even over an equal prefix", () => {
|
|
// React warns on such an array; the harness still treats it as a change.
|
|
// Shrinking keeps every remaining item equal, and growing by `undefined`
|
|
// matches the missing item read as `undefined`: only the length differs.
|
|
let deps: unknown[] = [1, 2];
|
|
const compute = vi.fn(() => ({}));
|
|
const Memo = () => R.useMemo(compute, deps);
|
|
harness.render(Memo);
|
|
deps = [1];
|
|
harness.render(Memo);
|
|
expect(compute).toHaveBeenCalledTimes(2);
|
|
deps = [1, undefined];
|
|
harness.render(Memo);
|
|
expect(compute).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("useMemo without dependencies recomputes at every render", () => {
|
|
const compute = vi.fn(() => ({}));
|
|
const Memo = () => R.useMemo(compute, undefined as unknown as []);
|
|
harness.render(Memo);
|
|
harness.render(Memo);
|
|
expect(compute).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("useCallback keeps the first function while the dependencies are equal", () => {
|
|
let dep = "a";
|
|
const Callback = () => R.useCallback(() => dep, [dep]);
|
|
const first = harness.render(Callback);
|
|
expect(harness.render(Callback)).toBe(first);
|
|
dep = "b";
|
|
const second = harness.render(Callback);
|
|
expect(second).not.toBe(first);
|
|
expect(second()).toBe("b");
|
|
});
|
|
});
|
|
|
|
describe("hookHarness — useEffect", () => {
|
|
const log: string[] = [];
|
|
let dep = 1;
|
|
const Effects = () => {
|
|
const d = dep;
|
|
R.useEffect(() => {
|
|
log.push(`run ${d}`);
|
|
return () => {
|
|
log.push(`cleanup ${d}`);
|
|
};
|
|
}, [d]);
|
|
R.useEffect(() => {
|
|
log.push("once");
|
|
}, []);
|
|
};
|
|
|
|
beforeEach(() => {
|
|
log.length = 0;
|
|
dep = 1;
|
|
});
|
|
|
|
it("captures the effects during the render, without running them", () => {
|
|
harness.render(Effects);
|
|
expect(log).toEqual([]);
|
|
});
|
|
|
|
it("commit runs them all at the first commit, then only those whose dependencies changed", () => {
|
|
harness.render(Effects);
|
|
harness.commit();
|
|
expect(log).toEqual(["run 1", "once"]);
|
|
|
|
harness.render(Effects);
|
|
harness.commit();
|
|
expect(log).toEqual(["run 1", "once"]);
|
|
|
|
dep = 2;
|
|
harness.render(Effects);
|
|
harness.commit();
|
|
// The cleanup of the previous run, with its own closure, before the new run.
|
|
expect(log).toEqual(["run 1", "once", "cleanup 1", "run 2"]);
|
|
});
|
|
|
|
it("commits a render once", () => {
|
|
const fn = vi.fn();
|
|
const Always = () => R.useEffect(fn);
|
|
harness.render(Always);
|
|
harness.commit();
|
|
harness.commit();
|
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
harness.render(Always);
|
|
harness.commit();
|
|
expect(fn).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("runs every cleanup before any effect of the same commit", () => {
|
|
const order: string[] = [];
|
|
let n = 0;
|
|
const Two = () => {
|
|
const k = n;
|
|
R.useEffect(() => {
|
|
order.push(`a${k}`);
|
|
return () => {
|
|
order.push(`~a${k}`);
|
|
};
|
|
}, [k]);
|
|
R.useEffect(() => {
|
|
order.push(`b${k}`);
|
|
return () => {
|
|
order.push(`~b${k}`);
|
|
};
|
|
}, [k]);
|
|
};
|
|
harness.render(Two);
|
|
harness.commit();
|
|
n = 1;
|
|
harness.render(Two);
|
|
harness.commit();
|
|
expect(order).toEqual(["a0", "b0", "~a0", "~b0", "a1", "b1"]);
|
|
});
|
|
|
|
it("replayEffects plays StrictMode's double run: every cleanup, then every effect again", () => {
|
|
harness.render(Effects);
|
|
harness.commit();
|
|
harness.replayEffects();
|
|
expect(log).toEqual(["run 1", "once", "cleanup 1", "run 1", "once"]);
|
|
});
|
|
|
|
it("unmount runs every cleanup, once", () => {
|
|
harness.render(Effects);
|
|
harness.commit();
|
|
harness.unmount();
|
|
harness.unmount();
|
|
expect(log).toEqual(["run 1", "once", "cleanup 1"]);
|
|
});
|
|
|
|
it("clear starts a fresh mount", () => {
|
|
harness.render(Effects);
|
|
harness.commit();
|
|
harness.dispatch("x");
|
|
harness.clear();
|
|
expect(harness.slots).toEqual([]);
|
|
expect(harness.dispatch).not.toHaveBeenCalled();
|
|
harness.render(Effects);
|
|
harness.commit();
|
|
expect(log).toEqual(["run 1", "once", "run 1", "once"]);
|
|
});
|
|
});
|
|
|
|
describe("hookHarness — hooks it does not simulate", () => {
|
|
it.each(["useContext", "useLayoutEffect", "useSyncExternalStore", "useId", "useTransition", "use"])(
|
|
"%s throws instead of reaching the real React",
|
|
(name) => {
|
|
const hook = (R as unknown as Record<string, () => unknown>)[name];
|
|
expect(hook).toBeTypeOf("function");
|
|
expect(() => hook()).toThrow(`hookHarness: ${name} is not simulated`);
|
|
},
|
|
);
|
|
|
|
it("on the default export too", () => {
|
|
const fake = R as unknown as { default: Record<string, (...args: unknown[]) => unknown> };
|
|
expect(() => fake.default.useContext()).toThrow("hookHarness: useContext is not simulated");
|
|
expect(fake.default.useState).toBe((R as unknown as Record<string, unknown>).useState);
|
|
});
|
|
});
|