From a1c31a1da74e0aa6ee5ee617cc5cecfc3b768972 Mon Sep 17 00:00:00 2001 From: le king fu Date: Tue, 13 Oct 2026 19:51:20 -0400 Subject: [PATCH] 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 --- docs/architecture.md | 2 +- .../useOnboardingChecklist.wiring.test.ts | 28 ++++++++++++++++++- src/test/hookHarness.test.ts | 16 +++++++++++ src/test/hookHarness.ts | 5 +++- 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 59114fb..d134835 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/src/hooks/useOnboardingChecklist.wiring.test.ts b/src/hooks/useOnboardingChecklist.wiring.test.ts index 5655053..7371625 100644 --- a/src/hooks/useOnboardingChecklist.wiring.test.ts +++ b/src/hooks/useOnboardingChecklist.wiring.test.ts @@ -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"; diff --git a/src/test/hookHarness.test.ts b/src/test/hookHarness.test.ts index d581f38..c94bafc 100644 --- a/src/test/hookHarness.test.ts +++ b/src/test/hookHarness.test.ts @@ -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 []); diff --git a/src/test/hookHarness.ts b/src/test/hookHarness.ts index 58e6193..655d075 100644 --- a/src/test/hookHarness.ts +++ b/src/test/hookHarness.ts @@ -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.