From 341928771c41fa7c44a695d395d56bf3c978faa5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 16:45:07 +0000 Subject: [PATCH 1/2] test(mobile): reproduce #24997, finished thread stays in the Working shelf Renders useThreadListV2 through the React Compiler (react test project), moves a thread from running to finished, and advances past the exit grace. The tracker reports the thread as no longer working, but the hook keeps the stale observation because the compiled memo ignores the inbox version, so the thread stays inside the collapsed Working shelf. Fails on main. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/useThreadListV2.test.tsx | 80 +++++++++++++++++++ apps/mobile/vite.config.ts | 1 + 2 files changed, 81 insertions(+) create mode 100644 apps/mobile/src/features/threads/useThreadListV2.test.tsx diff --git a/apps/mobile/src/features/threads/useThreadListV2.test.tsx b/apps/mobile/src/features/threads/useThreadListV2.test.tsx new file mode 100644 index 0000000000000..6640cfd570a8c --- /dev/null +++ b/apps/mobile/src/features/threads/useThreadListV2.test.tsx @@ -0,0 +1,80 @@ +import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox"; +import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { act, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +import { makeThreadShellFixture } from "../../test-fixtures"; +import { threadListInboxReturns } from "./threadListV2"; +import { useThreadListV2 } from "./useThreadListV2"; + +// The real shelf reads the app's atom registry, which imports Expo; this list has no older pages. +vi.mock("../../state/olderSettledShelf", () => ({ + useOlderSettledShelf: () => ({ isListed: () => true, unloadedCount: () => 0 }), + useOlderSettledShelfGrowth: () => {}, +})); + +const NOW = "2026-06-02T00:00:00.000Z"; +const environmentId = EnvironmentId.make("environment-1"); + +it("returns a thread from the Working shelf once its exit grace expires", async () => { + using _globals = { [Symbol.dispose]: () => vi.unstubAllGlobals() }; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + using _tracker = { [Symbol.dispose]: () => threadListInboxReturns.observe(null) }; + vi.useFakeTimers({ now: Date.parse(NOW) }); + const working = makeThreadShellFixture({ + environmentId, + id: ThreadId.make("echo"), + title: "echo", + runtime: { + status: "running", + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: NOW, + }, + }); + const completed = { ...working, runtime: null }; + const seen: Array["layout"]> = []; + function Probe(props: { readonly threads: ReadonlyArray }) { + const { layout } = useThreadListV2({ + threads: props.threads, + pendingTasks: [], + environmentId: null, + projectRefs: null, + searchQuery: "", + searchMatches: new Map(), + settlementEnvironmentIds: new Set(), + snoozeEnvironmentIds: new Set(), + pinReorderEnvironmentIds: new Set(), + activeReorderEnvironmentIds: new Set(), + queuedThreadKeys: new Set(), + settledLimit: 10, + now: NOW, + pendingOrder: null, + shelfPreferencesLoaded: true, + workingShelfEnabled: true, + workingShelfExpanded: false, + snoozedShelfExpanded: false, + settledShelfExpanded: false, + selectedThreadKey: null, + }); + useLayoutEffect(() => { + seen.push(layout); + }); + return null; + } + + const root = createRoot(document.createElement("div")); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + await act(async () => root.render()); + await act(async () => root.render()); + expect(seen.at(-1)?.workingCount).toBe(1); + + await act(async () => vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS)); + expect(threadListInboxReturns.isWorking(completed)).toBe(false); + expect(seen.at(-1)?.workingCount, "the list still shows the finished thread as working").toBe(0); + expect(seen.at(-1)?.items.map((item) => item.thread.id)).toEqual(["echo"]); +}); diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 6e24636671608..7c08d985929c0 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -35,6 +35,7 @@ const reactTests = [ "src/state/use-selected-thread-git-state.test.tsx", "src/features/terminal/useTerminalExitNavigation.test.tsx", "src/features/threads/use-thread-list-now.test.tsx", + "src/features/threads/useThreadListV2.test.tsx", "src/features/home/home-list-options.test.tsx", "src/features/connection/ConnectionsNewRouteScreen.test.tsx", ]; From 284337320764d10d2f7cf58a64d00d5d734cb0da Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 16:49:15 +0000 Subject: [PATCH 2/2] fix(mobile): finished threads leave the Working shelf after the exit grace The React Compiler memoizes on what a memo callback uses, so it dropped the inbox tracker version from the observation's cache guard and the grace timer's re-render reused the stale observation. Pass the version into the observation so the compiled guard depends on it. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/threads/useThreadListV2.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index 2a6f3504e2f5d..0b27d4481c14f 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -27,6 +27,14 @@ function useKeySet(newlineSeparatedKeys: string): ReadonlySet { type LayoutInput = Parameters[0]; +/** The React Compiler memoizes on arguments, so an unused version would not re-observe. */ +function observeInboxReturns( + threads: Parameters[0], + _version: number, +) { + return threadListInboxReturns.observe(threads); +} + /** * Home's and the thread sidebar's thread list. Its result is one value a screen only reads: * built inline, the list's inputs stayed open to mutation across the screen's other hooks, so @@ -62,8 +70,7 @@ export function useThreadListV2(input: { threadListInboxReturns.getSnapshot, ); const inbox = useMemo( - () => threadListInboxReturns.observe(workingShelfEnabled ? threads : null), - // oxlint-disable-next-line react/memo-dependencies -- the version signals an expired grace + () => observeInboxReturns(workingShelfEnabled ? threads : null, inboxVersion), [threads, workingShelfEnabled, inboxVersion], ); // Up/down menu availability for every card, computed once per section per