From 5b78bf2abc3436ee60d12bd62b7db395c9a1b5e8 Mon Sep 17 00:00:00 2001 From: Charles Burgess Date: Tue, 20 Oct 2026 13:27:27 -0400 Subject: [PATCH 1/4] DEX-241: Stop HabitRing animations on unmount --- src/components/HabitRing.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/HabitRing.tsx b/src/components/HabitRing.tsx index 067b0536..39d70db9 100644 --- a/src/components/HabitRing.tsx +++ b/src/components/HabitRing.tsx @@ -59,23 +59,29 @@ export function HabitRing({ // Seeded at the current offset so an already-partway-done ring doesn't // sweep in on first render. const [arc] = useState(() => new Animated.Value(dashoffset)); + // Stopped on unmount: a running timing keeps scheduling frames after the + // ring is gone (and past Jest's teardown). useEffect(() => { - Animated.timing(arc, { + const fill = Animated.timing(arc, { toValue: dashoffset, duration: FILL_DURATION_MS, useNativeDriver: false, - }).start(); + }); + fill.start(); + return () => fill.stop(); }, [arc, dashoffset]); // Cross-fade the completed state (solid fill + checkmark) in and out so // finishing the last step doesn't pop. Opacity supports the native driver. const [complete] = useState(() => new Animated.Value(isComplete ? 1 : 0)); useEffect(() => { - Animated.timing(complete, { + const fade = Animated.timing(complete, { toValue: isComplete ? 1 : 0, duration: FILL_DURATION_MS, useNativeDriver: true, - }).start(); + }); + fade.start(); + return () => fade.stop(); }, [complete, isComplete]); const emojiOpacity = complete.interpolate({ inputRange: [0, 1], From 457163e430af6544010e1ebad465bd5805d83075 Mon Sep 17 00:00:00 2001 From: Charles Burgess Date: Tue, 20 Oct 2026 13:27:27 -0400 Subject: [PATCH 2/4] DEX-241: End useNotes/useLists mutation tests on settleQueries --- src/hooks/__tests__/useLists.test.tsx | 7 +++++-- src/hooks/__tests__/useNotes.test.tsx | 20 +++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/hooks/__tests__/useLists.test.tsx b/src/hooks/__tests__/useLists.test.tsx index 3d37c9d1..cde2ce7b 100644 --- a/src/hooks/__tests__/useLists.test.tsx +++ b/src/hooks/__tests__/useLists.test.tsx @@ -3,6 +3,7 @@ import { act, renderHook, waitFor } from "@testing-library/react-native"; import { ReactNode } from "react"; import { createList, getLists, TList } from "@/api/lists"; +import { settleQueries } from "@/testUtils/settleQueries"; import { listsQueryOptions, useLists } from "../useLists"; @@ -84,7 +85,7 @@ describe("useLists", () => { }); it("refetches the lists query after a create mutation", async () => { - const { wrapper } = createWrapper(); + const { queryClient, wrapper } = createWrapper(); mockCreateList.mockResolvedValue([makeList()]); const { result } = await renderHook(() => useLists(), { wrapper }); @@ -94,6 +95,8 @@ describe("useLists", () => { result.current[1].createList({ title: "Work", emoji: "💼" }), ); - await waitFor(() => expect(mockGetLists).toHaveBeenCalledTimes(2)); + await settleQueries(queryClient); + + expect(mockGetLists).toHaveBeenCalledTimes(2); }); }); diff --git a/src/hooks/__tests__/useNotes.test.tsx b/src/hooks/__tests__/useNotes.test.tsx index 6382a2b3..62c31b53 100644 --- a/src/hooks/__tests__/useNotes.test.tsx +++ b/src/hooks/__tests__/useNotes.test.tsx @@ -68,20 +68,20 @@ describe("useNotes", () => { mockGetNote.mockResolvedValue(null); mockUpsertNote.mockResolvedValue({ date: "2026-07-12", content: "hello" }); + const { client, wrapper } = createWrapper(); const { result } = await renderHook(() => useNotes("2026-07-12"), { - wrapper: createWrapper().wrapper, + wrapper, }); await waitFor(() => expect(result.current[1].isLoading).toBe(false)); await act(() => result.current[1].upsertNote({ content: "hello" })); + await settleQueries(client); // Only the note's own column travels now — nothing has to be re-sent to // protect a sibling journal column (DEX-51). - await waitFor(() => - expect(mockUpsertNote).toHaveBeenCalledWith( - {}, - { content: "hello", date: "2026-07-12" }, - ), + expect(mockUpsertNote).toHaveBeenCalledWith( + {}, + { content: "hello", date: "2026-07-12" }, ); }); @@ -91,16 +91,18 @@ describe("useNotes", () => { .mockRejectedValueOnce(new Error("blip")) .mockResolvedValueOnce({ date: "2026-07-12", content: "typed" }); + const { client, wrapper } = createWrapper(); const { result } = await renderHook(() => useNotes("2026-07-12"), { - wrapper: createWrapper().wrapper, + wrapper, }); await waitFor(() => expect(result.current[1].isLoading).toBe(false)); await act(() => result.current[1].upsertNote({ content: "typed" })); + await settleQueries(client); - await waitFor(() => expect(mockUpsertNote).toHaveBeenCalledTimes(2)); + expect(mockUpsertNote).toHaveBeenCalledTimes(2); // The successful retry's response is written to the cache. - await waitFor(() => expect(result.current[0].content).toBe("typed")); + expect(result.current[0].content).toBe("typed"); expect(result.current[1].exists).toBe(true); }); From 0a7616ec9754db7f95e463edc72e018cadffeb50 Mon Sep 17 00:00:00 2001 From: Charles Burgess Date: Tue, 20 Oct 2026 13:27:27 -0400 Subject: [PATCH 3/4] DEX-241: Unref react-query timers in Jest and drop --forceExit --- docs/testing.md | 2 +- src/jest.setup.js | 13 +++++++++++++ src/package.json | 4 ++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index bb16b528..75f5b47e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -34,7 +34,7 @@ Not worth writing (removed en masse in DEX-143 — don't reintroduce): - **Don't close a mutation test on a mock's call count.** `toHaveBeenCalled()` goes true when the request *starts*, while `onSettled` → `invalidateQueries` → refetch → notify all land after the test returns. End on `settleQueries(client)` (`@/testUtils/settleQueries`) or on the hook's own terminal state — `hooks/__tests__/useRealtimeInvalidation.test.tsx` resolves its deferred inside `act`, then waits for `result.current[0].content`. - **Never leave a mocked mutation on a promise that never settles** — it wedges the whole run instead of failing one test. The one deliberate exception says so at the call site (`useRealtimeInvalidation.test.tsx`, simulating an autosave still retrying after unmount). - **`@shopify/flash-list` is mocked** in `jest.setup.js` as a plain view rendering every item, which is what the real one did here anyway. It schedules layout state from a `requestAnimationFrame` that always outlives a synchronous test. Its own `jestSetup` is not the fix: it only stubs `measureLayout`, and loading it globally cost the suite 5s → 90s. -- **`npm test` needs `--forceExit`** — tests build a `QueryClient` and none clears it, so react-query's gc timers hold the event loop open for minutes after the last assertion (`--detectOpenHandles` names them). +- **Don't add `--forceExit` back** — un-cleared `QueryClient` gc timers are unref'd in `jest.setup.js` instead, so a "worker failed to exit" warning now means a real leak (e.g. an animation not stopped on unmount); find it with `--detectOpenHandles`. ## Supabase (`/supabase`) diff --git a/src/jest.setup.js b/src/jest.setup.js index daf87f9e..0c226e1f 100644 --- a/src/jest.setup.js +++ b/src/jest.setup.js @@ -259,3 +259,16 @@ jest.mock("@expo/ui/swift-ui/modifiers", () => ({ tag: () => ({}), tint: () => ({}), })); + +// No test clears its QueryClient, so gc timers (5 min) would hold each worker +// open past the run; unref'd, they still fire but no longer block exit. +const unref = (id) => { + id?.unref?.(); + return id; +}; +require("@tanstack/react-query").timeoutManager.setTimeoutProvider({ + setTimeout: (callback, delay) => unref(setTimeout(callback, delay)), + clearTimeout: (id) => clearTimeout(id), + setInterval: (callback, delay) => unref(setInterval(callback, delay)), + clearInterval: (id) => clearInterval(id), +}); diff --git a/src/package.json b/src/package.json index fa9e221a..544cf084 100644 --- a/src/package.json +++ b/src/package.json @@ -21,8 +21,8 @@ "format:check": "prettier --check .", "supabase:types": "npx supabase gen types typescript --project-id \"isreileykodwkyedcewv\" --schema public > ./types/database.types.ts", "licenses": "node utils/generate-licenses.ts", - "test": "jest --forceExit", - "test:watch": "jest --watch --forceExit", + "test": "jest", + "test:watch": "jest --watch", "typecheck": "tsc --noEmit" }, "expo": { From 997e3483d6f3307b2eafc76972d0114b5292c592 Mon Sep 17 00:00:00 2001 From: Charles Burgess Date: Tue, 20 Oct 2026 13:35:28 -0400 Subject: [PATCH 4/4] DEX-241: Time out the frontend test step after 5 minutes --- .github/workflows/test-frontend.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index 9fcc233e..88cfca13 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -39,4 +39,6 @@ jobs: # No EXPO_PUBLIC_* secrets are wired in — `jest.setup.js` defaults them. - name: Test + # No --forceExit (DEX-241): a leaked handle hangs Jest, so fail fast. + timeout-minutes: 5 run: npm test