From ed25ae5b59cc3f17d79e4e85edac69b83ccba6ce Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 2 Aug 2026 03:38:55 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AA=20ci:=20Settle=20to=20render-idle?= =?UTF-8?q?=20before=20ConversationsSection=20memo=20baselines=20(#14590)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lazy BookmarkNav's Suspense resolution commits during waitFor's polling, outside any act scope, so its follow-up render work lands in React's real scheduler as a macrotask. The single empty async act added in #14071 only drains microtasks and the act queue, so on slow Windows shards that work can still be pending when baselines are captured. The next act flushes pending root work wholesale, so the first stream tick carries the leftover pass and inflates the tag counter (Expected: 1, Received: 2). Flush full event-loop turns inside act until two consecutive turns add no renders, then capture baselines. --- .../__tests__/ConversationsSection.spec.tsx | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx b/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx index 473eb445d8..c5ca83436b 100644 --- a/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx +++ b/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx @@ -110,6 +110,31 @@ function TickController() { const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } }); +const renderCount = () => + mockUseFavorites.mock.calls.length + + mockUseGetConversationTags.mock.calls.length + + mockUseTitleGeneration.mock.calls.length; + +/** + * Yield a full event-loop turn inside act. The lazy BookmarkNav's Suspense commit + * lands during waitFor's polling, outside act, so its follow-up work sits in the real + * scheduler as a macrotask that a microtask-only `await act(async () => {})` misses. + */ +const flushEventLoopTurn = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + +/** Flush event-loop turns until two consecutive turns add no renders (bounded). */ +const settleRenders = async () => { + let stableTurns = 0; + for (let turn = 0; turn < 20 && stableTurns < 2; turn++) { + const before = renderCount(); + await flushEventLoopTurn(); + stableTurns = renderCount() === before ? stableTurns + 1 : 0; + } +}; + const renderSection = () => render( @@ -144,11 +169,10 @@ describe('ConversationsSection streaming re-renders', () => { // data hook firing is the deterministic signal that the chunk resolved). await waitFor(() => expect(mockUseGetConversationTags).toHaveBeenCalled()); - // waitFor resolves as soon as the hook has fired once, but the Suspense - // resolution commit can still have a trailing render pass pending on slow - // runners (Windows CI shards). Flush it before capturing baselines so the - // first stream tick doesn't carry it and inflate the children's counts. - await act(async () => {}); + // waitFor resolves once the hook first fires, but on loaded Windows shards the + // Suspense resolution can leave a trailing pass pending in the real scheduler, + // which the first stream tick's act would flush into the children's counts. + await settleRenders(); expect(mockUseFavorites.mock.calls.length).toBeGreaterThan(0); expect(mockUseGetConversationTags.mock.calls.length).toBeGreaterThan(0);