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/apps/mobile/src/reactCompilerCoverage.test.ts b/apps/mobile/src/reactCompilerCoverage.test.ts index c644fb09a4c74..62e019bcb7d4b 100644 --- a/apps/mobile/src/reactCompilerCoverage.test.ts +++ b/apps/mobile/src/reactCompilerCoverage.test.ts @@ -184,6 +184,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([]); }); 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; } {