test(history): jsdom/renderHook lane + useBackToExit back-ladder tests

First DOM-capable vitest lane for the repo: default env stays node (pure lib helpers), a test opts
into jsdom per-file with a // @vitest-environment jsdom docblock; plugin-react wired for JSX/hook tests.
Adds jsdom + @testing-library/react devDeps and 3 back-ladder tests (LIFO peel, stacks over
useBackToClose, inactive layer is transparent) driven by direct popstate dispatch — deterministic,
no dependency on jsdom's async back(). Closes the deferred R2 component-test gap (useBackToExit).
This commit is contained in:
2026-08-06 23:25:06 +02:00
parent d6d33a188d
commit 60fcc4f5f0
4 changed files with 979 additions and 7 deletions
+86
View File
@@ -0,0 +1,86 @@
// @vitest-environment jsdom
//
// Component-test lane pilot: the useBackToExit "back-ladder". This is the first jsdom/renderHook
// test in the repo (the pure-node lane can't touch window.history / popstate). It exercises the
// module-level overlay stack in history.ts through its only observable effect — which onExit fires.
//
// A GENUINE user Back is simulated by dispatching `popstate` directly: the real-Back branch in
// history.ts does NOT call history.back() (it only decrements its own historyDepth to match the
// entry the browser already dropped), so a raw popstate reproduces it deterministically without
// depending on jsdom's async back() semantics. Each test closes its layers via genuine Backs, so
// no reconcile-driven history.back() runs and the shared module state stays clean between tests.
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useBackToClose, useBackToExit } from "./history";
// Let the mount effect's coalesced reconcile microtask settle.
const settle = () => act(async () => { await Promise.resolve(); });
// A genuine user Back (browser already dropped the entry; popstate arrives with no pending back()).
const userBack = () =>
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
afterEach(() => {
vi.clearAllMocks();
});
describe("useBackToExit back-ladder", () => {
it("nested active layers peel off one genuine Back at a time, topmost first (LIFO)", async () => {
const outer = vi.fn();
const inner = vi.fn();
const ro = renderHook(() => useBackToExit(true, outer));
await settle();
const ri = renderHook(() => useBackToExit(true, inner));
await settle();
await userBack(); // peels the topmost (inner) only
expect(inner).toHaveBeenCalledTimes(1);
expect(outer).not.toHaveBeenCalled();
await userBack(); // now peels the outer
expect(outer).toHaveBeenCalledTimes(1);
expect(inner).toHaveBeenCalledTimes(1);
ro.unmount();
ri.unmount();
});
it("stacks above a useBackToClose layer — Back closes the exit layer before the close layer", async () => {
const close = vi.fn();
const exit = vi.fn();
const rc = renderHook(() => useBackToClose(close));
await settle();
const re = renderHook(() => useBackToExit(true, exit));
await settle();
await userBack();
expect(exit).toHaveBeenCalledTimes(1);
expect(close).not.toHaveBeenCalled();
await userBack();
expect(close).toHaveBeenCalledTimes(1);
rc.unmount();
re.unmount();
});
it("an inactive layer registers nothing — Back falls straight through to the layer below it", async () => {
const below = vi.fn();
const gated = vi.fn();
const rb = renderHook(() => useBackToExit(true, below));
await settle();
const rg = renderHook(() => useBackToExit(false, gated)); // gated: not active → no layer
await settle();
await userBack();
expect(gated).not.toHaveBeenCalled();
expect(below).toHaveBeenCalledTimes(1);
rb.unmount();
rg.unmount();
});
});