From 6d99ad8a52ebd015fcce77fe5d6bdb9fc6fa83b6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 07:10:00 +0000 Subject: [PATCH 1/2] test(mobile): reproduce issue 24901 stale Working shelf after grace The React Compiler keys useThreadListV2's inbox observe cache on threads and workingShelfEnabled only, dropping the tracker version, so an expired working grace never re-observes and the thread stays in the Working shelf. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/reactCompilerCoverage.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/mobile/src/reactCompilerCoverage.test.ts b/apps/mobile/src/reactCompilerCoverage.test.ts index e5f86349d0c5c..f1b430b5ccfb0 100644 --- a/apps/mobile/src/reactCompilerCoverage.test.ts +++ b/apps/mobile/src/reactCompilerCoverage.test.ts @@ -187,6 +187,32 @@ describe("React Compiler coverage", () => { ).toEqual([true]); }); + // The compiler infers memo deps from the callback, so an unread version dep is dropped and + // an expired working grace never re-observes the Working shelf. + it("re-observes the inbox tracker when its version changes", () => { + const result = compileLikeMetro("src/features/threads/useThreadListV2.ts", [], { ast: true }); + const cacheKeys: string[][] = []; + if (typeof result === "object" && result !== null && "ast" in result) { + walkAst(result.ast, (node, ancestors) => { + if (node.type !== "CallExpression" || !isAstNode(node.callee)) return; + const property = node.callee.property; + if (!isAstNode(property) || property.name !== "observe") return; + const guard = ancestors.findLast( + (ancestor, index) => + ancestor.type === "IfStatement" && ancestors[index + 1] === ancestor.consequent, + ); + const names: string[] = []; + walkAst(guard?.test, (child) => { + if (child.type === "Identifier" && typeof child.name === "string" && child.name !== "$") + names.push(child.name); + }); + cacheKeys.push(names); + }); + } + expect(cacheKeys).toHaveLength(1); + expect(cacheKeys[0]).toContain("inboxVersion"); + }); + it("lists only files that exist", () => { expect(Object.keys(KNOWN_BAILOUTS).filter((file) => !sourceFiles.includes(file))).toEqual([]); }); From 603dc449e69aaeb9f1016c3351dbfd3d335f2e88 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 07:25:46 +0000 Subject: [PATCH 2/2] fix(mobile): working shelf releases threads after the exit grace The React Compiler keys useThreadListV2's inbox memo only on values its callback reads, so it dropped the tracker version and an expired working grace never re-observed. Pass the version to observe so it stays a key. Fixes pingdotgg/t3code#24901 Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/threads/useThreadListV2.ts | 3 +-- packages/client-runtime/src/state/threadInbox.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index 2a6f3504e2f5d..1326a0fde76e4 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -62,8 +62,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 + () => threadListInboxReturns.observe(workingShelfEnabled ? threads : null, inboxVersion), [threads, workingShelfEnabled, inboxVersion], ); // Up/down menu availability for every card, computed once per section per diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index de9cd822398d0..0869c08b79aa5 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -117,7 +117,8 @@ export const WORKING_EXIT_GRACE_MS = 2_000; /** * Keep one at module scope so placement survives routes that unmount the list. Call * `observe` when the shells or the snapshot version change, or with null while the beta - * is off. Its result stays fixed, so a list built from it can be cached. + * is off. Its result stays fixed, so a list built from it can be cached. A memoized caller + * passes the version it read: the React Compiler keys memos only on values the callback reads. */ export function createInboxReturnTracker() { const keyOf = (thread: ThreadKeyInput) => `${thread.environmentId}:${thread.id}`; @@ -166,7 +167,10 @@ export function createInboxReturnTracker() { }; }, getSnapshot: () => version, - observe(threads: readonly TrackedInboxThread[] | null): { + observe( + threads: readonly TrackedInboxThread[] | null, + _version?: number, + ): { readonly isWorking: (thread: WorkingThreadInput & ThreadKeyInput) => boolean; readonly returnedAt: (thread: ThreadKeyInput) => number | undefined; } {