From b0c5051430035199c837b6a53efd229e9484c1f9 Mon Sep 17 00:00:00 2001 From: Tyler Dixon Date: Wed, 12 Aug 2026 12:14:22 -0700 Subject: [PATCH] test(firestore): let the offline fallback rescue two cache-miss tests test/firestore.test.tsx flakes in CI at roughly 18% per run (11 of 60 iterations, 2026-08-11). The Firestore emulator intermittently corrupts a Listen frame, so grpc-js reads four body bytes as a length prefix and reports RESOURCE_EXHAUSTED with an absurd size. The SDK special-cases that code with backoff.resetToMax(), parking the stream on a 60 second maximum backoff. This is an unresolved upstream emulator bug, firebase/firebase-tools#8654. There is no fixed version to pin, so the goal is to survive it rather than prevent it. A reconnect is not what rescues the affected tests. The same failure sends the client to OnlineState.Offline after ONLINE_STATE_TIMEOUT_MS (10s), and an offline client raises the pending snapshot from the local cache, empty cache included. Two tests assert that a document is absent, which is exactly what the empty cache reports, so they reach success about ten seconds after the failure with no server involved. They are the only two in the file whose first snapshot cannot be served from local data. Those two currently abandon the wait after one second, before the fallback can fire, and vitest would kill them at five seconds regardless. Raising both ceilings lets the fallback do its work. waitFor polls every 50ms, so a larger budget costs nothing when the stream is healthy: healthy runs measure 80-95ms (9 samples) and 90-140ms (11 samples) per test with the change, against 82-136ms and 101-120ms without it across 3 each. The bands overlap; the wider upper tail on the larger sample is sampling, not cost. The budget is 120s, far above the ~10s the fallback needs, because a ceiling is not a delay. Each test's own timeout clears the sum of the budgets beneath it, or only the first wait could ever spend one. The third wait in useFirestoreDocOnce keeps the 1000ms default deliberately. It waits on the client's own write, raised from the local cache before the acknowledgement returns; measured at 8ms with the client offline, against a control that fails at 1000ms when no write is issued. Verified: the desync reproduced locally twice during this work, having never been seen off CI before, and was rescued both times. One of the two has a full log, carrying both the RESOURCE_EXHAUSTED line and the maximum backoff line, with the vulnerable test taking 9878ms and passing; the other is a 9872ms sample from a batch that discarded output. CI showed the same rescue on this branch at 9795ms. Note that all three observations are with the fix in place: that the old ceilings would have killed a 9878ms wait follows from testing-library's 1000ms default and vitest's 5000ms, not from an observed failure. A simulated 65 second stall survives the new budget while failing under the default, and the per-test ceilings were confirmed to apply by shortening one until it failed. Both typechecks clean and the firestore suite green against the emulator. Scoped cost: a genuine regression in those two tests now takes up to 120s to surface instead of 1s. Refs #776 --- test/firestore.test.tsx | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/test/firestore.test.tsx b/test/firestore.test.tsx index 17ced14d..0dcf0c23 100644 --- a/test/firestore.test.tsx +++ b/test/firestore.test.tsx @@ -30,6 +30,22 @@ describe('Firestore', () => { ); + // The Firestore emulator intermittently corrupts a Listen frame + // (firebase/firebase-tools#8654, unresolved upstream). The SDK reads it as + // RESOURCE_EXHAUSTED and parks the stream on a 60s maximum backoff, but a + // reconnect is not what rescues these tests: the same failure drives the + // client to OnlineState.Offline after ONLINE_STATE_TIMEOUT_MS (10s), and an + // offline client raises the pending snapshot from the local cache, empty + // cache included. These two tests assert that a document is absent, which is + // what the empty cache reports, so they go green at ~10s with no server + // involved. They are the only two whose first snapshot cannot be served from + // local data. The budget is a ceiling, not a delay, since `waitFor` polls. + // Remove when #8654 is fixed upstream. See #776. + const WAIT_FOR_OFFLINE_FALLBACK = 120_000; + // vitest enforces its own per-test ceiling, so each test below gets more than + // the sum of the budgets under it; otherwise only the first `waitFor` could + // ever spend what it is given. + afterEach(async () => { cleanup(); @@ -108,11 +124,11 @@ describe('Firestore', () => { const { result } = renderHook(() => useFirestoreDocData(ref, { idField: 'id' }), { wrapper: Provider }); - await waitFor(() => expect(result.current.status).toEqual('success')); + await waitFor(() => expect(result.current.status).toEqual('success'), { timeout: WAIT_FOR_OFFLINE_FALLBACK }); expect(result.current.status).toEqual('success'); expect(result.current.data).toBeUndefined(); - }); + }, 150_000); it('goes back into a loading state if you swap the query', async () => { const mockData = { a: 'hello' }; @@ -177,17 +193,20 @@ describe('Firestore', () => { const { result: subscribeResult } = renderHook(() => useFirestoreDoc(ref), { wrapper: Provider }); const { result: onceResult } = renderHook(() => useFirestoreDocOnce(ref), { wrapper: Provider }); - await waitFor(() => expect(subscribeResult.current.status).toEqual('success')); - await waitFor(() => expect(onceResult.current.status).toEqual('success')); + await waitFor(() => expect(subscribeResult.current.status).toEqual('success'), { timeout: WAIT_FOR_OFFLINE_FALLBACK }); + await waitFor(() => expect(onceResult.current.status).toEqual('success'), { timeout: WAIT_FOR_OFFLINE_FALLBACK }); expect(onceResult.current.data.exists()).toEqual(false); await act(() => setDoc(ref, { a: 'test' })); + // No budget: this waits on the client's own write, which is raised from + // the local cache before the acknowledgement returns (measured at 8ms + // with the client offline). await waitFor(() => expect(subscribeResult.current.data.exists()).toEqual(true)); expect(onceResult.current.data.exists()).toEqual(false); - }); + }, 270_000); }); describe('useFirestoreDocDataOnce', () => {