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>
This commit is contained in:
le king fu 2026-10-13 19:51:20 -04:00
parent 74fae140b1
commit a1c31a1da7
4 changed files with 48 additions and 3 deletions

View file

@ -613,7 +613,7 @@ Sémantique simulée, chaque point prouvé par `src/test/hookHarness.test.ts` :
- `useEffect` : capturé pendant le rendu, joué seulement par `commit()` s'il a une dépendance changée, nettoyages d'abord ; `replayEffects()` joue le double passage de StrictMode au montage, `unmount()` les nettoyages ;
- tout autre hook (`useContext`, `useLayoutEffect`, `useSyncExternalStore`, `useId`, `use`…) **lève** : un test rougit sur son assertion, jamais par « Invalid hook call ». Mocker le module qui l'appelle (`useOnboarding`, `useLocation`, `useTranslation`…).
Non simulé : le nouveau rendu après un setter ou un dispatch, les enfants (un composant dans l'arbre rendu reste un élément), le DOM, le concurrent, la boucle de mises à jour pendant le rendu (le `render()` suivant la lit).
Non simulé : le nouveau rendu après un setter ou un dispatch, les enfants (un composant dans l'arbre rendu reste un élément), le DOM, le concurrent, la boucle de mises à jour pendant le rendu (le `render()` suivant la lit), et un reducer qui change d'un rendu à l'autre : le `dispatch` d'un `useReducer` garde le reducer du premier rendu (il est créé une fois, avec l'emplacement), là où React applique celui du dernier rendu. Un reducer défini au niveau du module, seul cas utilisé, n'est pas touché.
Utilisateurs : `useOnboardingChecklist.wiring.test.ts`, `UsersSettingsPage.test.tsx`, `BudgetTable.wiring.test.ts`, `BudgetPage.wiring.test.ts`, `AppShell.slot.test.tsx`, `OnboardingStepCard.test.tsx`, `PriceFetchConsentToggle.test.tsx` (#389). Hors périmètre de #409, `useImportWizard.mount.test.ts` et `useImportWizard.executeImport.test.ts` gardent leurs faux non persistants.

View file

@ -125,7 +125,8 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
vi.useFakeTimers();
harness.dispatch.mockReset();
// A route change re-runs the read, not the follower: one subscription only.
// A route change re-runs the read (that it leaves the follower and its
// pending re-read alone is pinned by the next test).
ctx.pathname = "/reports";
render();
harness.commit();
@ -146,6 +147,31 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
expect(actionsOfType("LOAD_START")).toHaveLength(1);
});
it("a route change during the 150 ms wait does not drop the deferred re-read", async () => {
render();
harness.commit();
await flush();
vi.useFakeTimers();
harness.dispatch.mockReset();
notifyDataChanged();
vi.advanceTimersByTime(100);
// The route changes while the re-read waits: the read effect runs again,
// the follower (deps: [refresh]) must not be cleaned up and resubscribed,
// which would drop its pending timer.
ctx.pathname = "/reports";
render();
harness.commit();
await flush();
expect(actionsOfType("LOAD_START")).toHaveLength(1);
vi.advanceTimersByTime(50);
await flush();
// One read for the route, one for the data event.
expect(actionsOfType("LOAD_START")).toHaveLength(2);
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(3);
});
it("stores a card latch that changed during render, and only then", () => {
mountWith({ ...ready, cardPage: "/import" });
ctx.pathname = "/reports";

View file

@ -140,6 +140,22 @@ describe("hookHarness — useMemo and useCallback", () => {
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 []);

View file

@ -55,7 +55,10 @@
// 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).
// (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.