diff --git a/.changeset/angular-ssr-pending-task-coverage.md b/.changeset/angular-ssr-pending-task-coverage.md new file mode 100644 index 00000000000..d218c011d8b --- /dev/null +++ b/.changeset/angular-ssr-pending-task-coverage.md @@ -0,0 +1,5 @@ +--- +'@tanstack/angular-query-experimental': patch +--- + +Keep the SSR pending task registered from fetch start until the query result is applied to the result signal. Previously the task was registered and released inside the subscriber callback: registration waited for the first notifyManager delivery turn (`setTimeout(0)`), and release ran one statement before the result signal write. With zoneless change detection, `ApplicationRef.whenStable()` could resolve in either gap, so Angular SSR serialized HTML rendered from stale optimistic state even though the fetch had completed. diff --git a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts index 1e7b81ba971..fc86ef18bf5 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -772,6 +772,10 @@ describe('injectQuery', () => { // Synchronize pending effects TestBed.tick() + // The in-flight fetch now holds a pending task, so stability requires the + // notification turn and the change detection it schedules to run first + await vi.advanceTimersByTimeAsync(0) + TestBed.tick() const stablePromise = app.whenStable() await stablePromise @@ -817,6 +821,10 @@ describe('injectQuery', () => { enabledSignal.set(true) TestBed.tick() + // The in-flight fetch now holds a pending task, so stability requires the + // notification turn and the change detection it schedules to run first + await vi.advanceTimersByTimeAsync(0) + TestBed.tick() await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-1') @@ -841,6 +849,10 @@ describe('injectQuery', () => { // Synchronize pending effects TestBed.tick() + // The in-flight fetch now holds a pending task, so stability requires the + // notification turn and the change detection it schedules to run first + await vi.advanceTimersByTimeAsync(0) + TestBed.tick() await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-1') diff --git a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts index c5a1f58d81f..bf12f5e8bce 100644 --- a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts +++ b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts @@ -1,644 +1,161 @@ -import { - ApplicationRef, - Component, - provideZonelessChangeDetection, -} from '@angular/core' +import { provideZonelessChangeDetection, signal } from '@angular/core' import { TestBed } from '@angular/core/testing' -import { HttpClient, provideHttpClient } from '@angular/common/http' -import { - HttpTestingController, - provideHttpClientTesting, -} from '@angular/common/http/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' -import { lastValueFrom } from 'rxjs' -import { - QueryClient, - injectMutation, - injectQuery, - onlineManager, - provideTanStackQuery, -} from '..' +import { QueryClient, injectQuery, provideTanStackQuery } from '..' +import { PENDING_TASKS } from '../pending-tasks-compat' -describe('PendingTasks Integration', () => { +describe('pending tasks integration', () => { let queryClient: QueryClient + let events: Array + let readData: () => unknown beforeEach(() => { vi.useFakeTimers() - - queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - mutations: { - retry: false, - }, - }, - }) - + queryClient = new QueryClient() + events = [] + readData = () => undefined TestBed.configureTestingModule({ providers: [ provideZonelessChangeDetection(), provideTanStackQuery(queryClient), + { + provide: PENDING_TASKS, + useValue: { + add: () => { + events.push('add') + return () => events.push(`release:${String(readData())}`) + }, + }, + }, ], }) }) afterEach(() => { - onlineManager.setOnline(true) - queryClient.clear() vi.useRealTimers() }) - describe('Synchronous Resolution', () => { - it('should handle synchronous queryFn with whenStable()', async () => { - const app = TestBed.inject(ApplicationRef) - - const key = queryKey() - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key, - queryFn: () => 'instant-data', // Resolves synchronously - })), - ) - - // Should start as pending even with synchronous data - expect(query.status()).toBe('pending') - expect(query.data()).toBeUndefined() - - const stablePromise = app.whenStable() - // Flush microtasks to allow TanStack Query's scheduled notifications to process - await Promise.resolve() - await vi.advanceTimersByTimeAsync(10) - await stablePromise - - // Should work correctly even though queryFn was synchronous - expect(query.status()).toBe('success') - expect(query.data()).toBe('instant-data') - }) - - it('should handle synchronous error with whenStable()', async () => { - const app = TestBed.inject(ApplicationRef) - - const key = queryKey() - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key, - queryFn: () => { - throw new Error('instant-error') - }, // Throws synchronously - })), - ) - - const stablePromise = app.whenStable() - // Flush microtasks to allow TanStack Query's scheduled notifications to process - await Promise.resolve() - await vi.advanceTimersByTimeAsync(10) - await stablePromise - - expect(query.status()).toBe('error') - expect(query.error()).toEqual(new Error('instant-error')) - }) - - it('should handle synchronous mutationFn with whenStable()', async () => { - const app = TestBed.inject(ApplicationRef) - let mutationFnCalled = false - - const mutation = TestBed.runInInjectionContext(() => - injectMutation(() => ({ - mutationFn: async (data: string) => { - mutationFnCalled = true - await Promise.resolve() - return `processed: ${data}` - }, - })), - ) - - mutation.mutate('test') - - TestBed.tick() - - const stablePromise = app.whenStable() - await Promise.resolve() - await vi.advanceTimersByTimeAsync(10) - await stablePromise - - expect(mutationFnCalled).toBe(true) - expect(mutation.isSuccess()).toBe(true) - expect(mutation.data()).toBe('processed: test') - }) - - it('should handle synchronous mutation error with whenStable()', async () => { - const app = TestBed.inject(ApplicationRef) - - const mutation = TestBed.runInInjectionContext(() => - injectMutation(() => ({ - mutationFn: async () => { - await Promise.resolve() - throw new Error('sync-mutation-error') - }, - })), - ) - - mutation.mutate() - - TestBed.tick() - - const stablePromise = app.whenStable() - await Promise.resolve() - await vi.advanceTimersByTimeAsync(10) - await stablePromise - - expect(mutation.isError()).toBe(true) - expect(mutation.error()).toEqual(new Error('sync-mutation-error')) - }) + it('holds a pending task from fetch start until the result is applied', async () => { + const key = queryKey() + const query = TestBed.runInInjectionContext(() => + injectQuery(() => ({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'ok'), + })), + ) + readData = () => query.data() + TestBed.tick() + + // Registered synchronously with the fetch, not one notifyManager schedule turn later. With + // zoneless change detection that turn is invisible to `ApplicationRef.whenStable()`, so SSR + // could serialize inside it. + expect(events).toEqual(['add']) + + await vi.advanceTimersByTimeAsync(11) + + // Released only after the result was written to the signal: releasing first exposes one + // synchronous statement in which the app is stable while the rendered view is still stale. + expect(events).toEqual(['add', 'release:ok']) + expect(query.data()).toBe('ok') }) - describe('Race Conditions', () => { - it('should handle query that completes during initial subscription', async () => { - const key = queryKey() - const app = TestBed.inject(ApplicationRef) - let resolveQuery: (value: string) => void - - const queryPromise = new Promise((resolve) => { - resolveQuery = resolve - }) - - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key, - queryFn: () => queryPromise, - })), - ) - - // Resolve immediately to create potential race condition - resolveQuery!('race-data') - - const stablePromise = app.whenStable() - await Promise.resolve() - await vi.advanceTimersByTimeAsync(10) - await stablePromise - - expect(query.status()).toBe('success') - expect(query.data()).toBe('race-data') - }) - - it('should handle rapid refetches without task leaks', async () => { - const app = TestBed.inject(ApplicationRef) - let callCount = 0 - - const key = queryKey() - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key, - queryFn: async () => { - callCount++ - await sleep(10) - return `data-${callCount}` - }, - })), - ) - - // Trigger multiple rapid refetches - query.refetch() - query.refetch() - query.refetch() - - const stablePromise = app.whenStable() - await vi.advanceTimersByTimeAsync(20) - await stablePromise - - expect(query.status()).toBe('success') - expect(query.data()).toMatch(/^data-\d+$/) - }) - - it('should keep PendingTasks active while query retry is paused offline', async () => { - const app = TestBed.inject(ApplicationRef) - let attempt = 0 - - const key = queryKey() - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key, - retry: 1, - retryDelay: 50, // Longer delay to ensure we can go offline before retry - queryFn: async () => { - attempt++ - if (attempt === 1) { - throw new Error('offline-fail') - } - await sleep(10) - return 'final-data' - }, - })), - ) - - // Allow the initial attempt to start and fail - await vi.advanceTimersByTimeAsync(0) - await Promise.resolve() - - // Wait for the first attempt to complete and start retry delay - await vi.advanceTimersByTimeAsync(10) - await Promise.resolve() - - expect(query.status()).toBe('pending') - expect(query.fetchStatus()).toBe('fetching') - - // Simulate the app going offline during retry delay - onlineManager.setOnline(false) - - // Advance past the retry delay to trigger the pause - await vi.advanceTimersByTimeAsync(50) - await Promise.resolve() - - expect(query.fetchStatus()).toBe('paused') - - const stablePromise = app.whenStable() - let stableResolved = false - void stablePromise.then(() => { - stableResolved = true - }) - - await Promise.resolve() - - // PendingTasks should continue blocking stability while the fetch is paused - expect(stableResolved).toBe(false) - expect(query.status()).toBe('pending') - - // Bring the app back online so the retry can continue - onlineManager.setOnline(true) - - // Give time for the retry to resume and complete - await vi.advanceTimersByTimeAsync(20) - await Promise.resolve() - - await stablePromise - - expect(stableResolved).toBe(true) - expect(query.status()).toBe('success') - expect(query.data()).toBe('final-data') - }) + it('registers the task in the same tick a dependent query becomes enabled', async () => { + const key = queryKey() + const enabled = signal(false) + const query = TestBed.runInInjectionContext(() => + injectQuery(() => ({ + queryKey: key, + enabled: enabled(), + queryFn: () => sleep(10).then(() => 'ok'), + })), + ) + readData = () => query.data() + TestBed.tick() + expect(events).toEqual([]) + + enabled.set(true) + TestBed.tick() + expect(events).toEqual(['add']) + + await vi.advanceTimersByTimeAsync(11) + expect(events).toEqual(['add', 'release:ok']) }) - describe('Component Destruction', () => { - @Component({ - template: '', - }) - class TestComponent { - query = injectQuery(() => ({ - queryKey: ['component-query'], - queryFn: () => sleep(100).then(() => 'component-data'), - })) - - mutation = injectMutation(() => ({ - mutationFn: (data: string) => - sleep(100).then(() => `processed: ${data}`), - })) - } - - it('should cleanup pending tasks when component with active query is destroyed', async () => { - const app = TestBed.inject(ApplicationRef) - const fixture = TestBed.createComponent(TestComponent) - - // Start the query - expect(fixture.componentInstance.query.status()).toBe('pending') - - // Destroy component while query is running - fixture.destroy() - - // Angular should become stable even though component was destroyed - const stablePromise = app.whenStable() - await vi.advanceTimersByTimeAsync(150) - - await expect(stablePromise).resolves.toEqual(undefined) - }) - - it('should cleanup pending tasks when component with active mutation is destroyed', async () => { - const app = TestBed.inject(ApplicationRef) - const fixture = TestBed.createComponent(TestComponent) - - fixture.componentInstance.mutation.mutate('test') - - // Destroy component while mutation is running - fixture.destroy() - - // Angular should become stable even though component was destroyed - const stablePromise = app.whenStable() - await vi.advanceTimersByTimeAsync(150) - - await expect(stablePromise).resolves.toEqual(undefined) - }) - }) - - describe('Concurrent Operations', () => { - it('should handle multiple queries running simultaneously', async () => { - const key1 = queryKey() - const key2 = queryKey() - const key3 = queryKey() - const app = TestBed.inject(ApplicationRef) - - const query1 = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key1, - queryFn: () => sleep(30).then(() => 'data-1'), - })), - ) - - const query2 = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key2, - queryFn: () => sleep(50).then(() => 'data-2'), - })), - ) - - const query3 = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key3, - queryFn: () => 'instant-data', // Synchronous - })), - ) - - // All queries should start - expect(query1.status()).toBe('pending') - expect(query2.status()).toBe('pending') - expect(query3.status()).toBe('pending') - - const stablePromise = app.whenStable() - await vi.advanceTimersByTimeAsync(60) - await stablePromise - - // All queries should be complete - expect(query1.status()).toBe('success') - expect(query1.data()).toBe('data-1') - expect(query2.status()).toBe('success') - expect(query2.data()).toBe('data-2') - expect(query3.status()).toBe('success') - expect(query3.data()).toBe('instant-data') - }) - - it('should handle multiple mutations running simultaneously', async () => { - const app = TestBed.inject(ApplicationRef) - - const mutation1 = TestBed.runInInjectionContext(() => - injectMutation(() => ({ - mutationFn: (data: string) => - sleep(30).then(() => `processed-1: ${data}`), - })), - ) - - const mutation2 = TestBed.runInInjectionContext(() => - injectMutation(() => ({ - mutationFn: (data: string) => - sleep(50).then(() => `processed-2: ${data}`), - })), - ) - - const mutation3 = TestBed.runInInjectionContext(() => - injectMutation(() => ({ - mutationFn: async (data: string) => { - await Promise.resolve() - return `processed-3: ${data}` - }, - })), - ) - - // Start all mutations - mutation1.mutate('test1') - mutation2.mutate('test2') - mutation3.mutate('test3') - - TestBed.tick() - - const stablePromise = app.whenStable() - await vi.advanceTimersByTimeAsync(60) - await stablePromise - - // All mutations should be complete - expect(mutation1.isSuccess()).toBe(true) - expect(mutation1.data()).toBe('processed-1: test1') - expect(mutation2.isSuccess()).toBe(true) - expect(mutation2.data()).toBe('processed-2: test2') - expect(mutation3.isSuccess()).toBe(true) - expect(mutation3.data()).toBe('processed-3: test3') - }) - - it('should handle mixed queries and mutations', async () => { - const app = TestBed.inject(ApplicationRef) - - const key = queryKey() - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key, - queryFn: () => sleep(40).then(() => 'query-data'), - })), - ) - - const mutation = TestBed.runInInjectionContext(() => - injectMutation(() => ({ - mutationFn: (data: string) => - sleep(60).then(() => `mutation: ${data}`), - })), - ) - - // Start both operations - mutation.mutate('test') - - const stablePromise = app.whenStable() - await vi.advanceTimersByTimeAsync(70) - await stablePromise - - // Both should be complete - expect(query.status()).toBe('success') - expect(query.data()).toBe('query-data') - expect(mutation.isSuccess()).toBe(true) - expect(mutation.data()).toBe('mutation: test') - }) + it('registers the task when refetch() starts a fetch', async () => { + const key = queryKey() + const query = TestBed.runInInjectionContext(() => + injectQuery(() => ({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'ok'), + })), + ) + readData = () => query.data() + TestBed.tick() + await vi.advanceTimersByTimeAsync(11) + expect(events).toEqual(['add', 'release:ok']) + events.length = 0 + + void query.refetch() + + // Registered synchronously with the refetch, not one notifyManager schedule turn later + expect(events).toEqual(['add']) + + await vi.advanceTimersByTimeAsync(11) + expect(events).toEqual(['add', 'release:ok']) }) - describe('HttpClient Integration', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [provideHttpClient(), provideHttpClientTesting()], - }) - }) - - it('should handle multiple HttpClient requests with lastValueFrom', async () => { - const app = TestBed.inject(ApplicationRef) - const httpClient = TestBed.inject(HttpClient) - const httpTestingController = TestBed.inject(HttpTestingController) - - const key1 = queryKey() - const key2 = queryKey() - - const query1 = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key1, - queryFn: () => - lastValueFrom(httpClient.get<{ id: number }>('/api/1')), - })), - ) - - const query2 = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key2, - queryFn: () => - lastValueFrom(httpClient.get<{ id: number }>('/api/2')), - })), - ) - - // Schedule HTTP responses - setTimeout(() => { - const req1 = httpTestingController.expectOne('/api/1') - req1.flush({ id: 1 }) - - const req2 = httpTestingController.expectOne('/api/2') - req2.flush({ id: 2 }) - }, 10) - - const stablePromise = app.whenStable() - await vi.advanceTimersByTimeAsync(20) - await stablePromise - - expect(query1.status()).toBe('success') - expect(query1.data()).toEqual({ id: 1 }) - expect(query2.status()).toBe('success') - expect(query2.data()).toEqual({ id: 2 }) - - httpTestingController.verify() - }) - - it('should handle HttpClient request cancellation', async () => { - const app = TestBed.inject(ApplicationRef) - const httpClient = TestBed.inject(HttpClient) - const httpTestingController = TestBed.inject(HttpTestingController) - - const key = queryKey() - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key, - queryFn: () => - lastValueFrom(httpClient.get<{ data: string }>('/api/cancel')), - })), - ) - - // Cancel the request before it completes - setTimeout(() => { - const req = httpTestingController.expectOne('/api/cancel') - req.error(new ProgressEvent('error'), { - status: 0, - statusText: 'Unknown Error', - }) - }, 10) - - const stablePromise = app.whenStable() - await vi.advanceTimersByTimeAsync(20) - await stablePromise - - expect(query.status()).toBe('error') - - httpTestingController.verify() - }) + it('keeps coverage when a refetch starts before the previous idle notification is delivered', async () => { + const key = queryKey() + let resolveFetch!: (value: string) => void + const query = TestBed.runInInjectionContext(() => + injectQuery(() => ({ + queryKey: key, + queryFn: () => + new Promise((resolve) => { + resolveFetch = resolve + }), + })), + ) + readData = () => query.data() + TestBed.tick() + expect(events).toEqual(['add']) + + // First fetch resolves; query state updates synchronously, but the 'idle' notification is + // queued for the next notifyManager schedule turn. + resolveFetch('one') + await Promise.resolve() + await Promise.resolve() + + // A second fetch starts inside that window. trackFetch is idempotent, so the still-held task + // must now cover THIS fetch — the queued idle snapshot from the first fetch must not release it. + void query.refetch() + expect(events).toEqual(['add']) + + await vi.advanceTimersByTimeAsync(0) + expect(events).toEqual(['add']) // older idle snapshot delivered; coverage kept + + resolveFetch('two') + await vi.advanceTimersByTimeAsync(0) + expect(events).toEqual(['add', 'release:two']) + expect(query.data()).toBe('two') }) - describe('Edge Cases', () => { - it('should handle query cancellation mid-flight', async () => { - const key = queryKey() - const app = TestBed.inject(ApplicationRef) - - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key, - queryFn: () => sleep(100).then(() => 'data'), - })), - ) - - // Cancel the query after a short delay - setTimeout(() => { - queryClient.cancelQueries({ queryKey: key }) - }, 20) - - // Advance to the cancellation point - await vi.advanceTimersByTimeAsync(20) - - TestBed.tick() - - const stablePromise = app.whenStable() - await vi.advanceTimersByTimeAsync(130) - await stablePromise - - // Cancellation should restore the pre-fetch state - expect(query.status()).toBe('pending') - expect(query.fetchStatus()).toBe('idle') - }) - - it('should handle query retry and pending task tracking', async () => { - const app = TestBed.inject(ApplicationRef) - let attemptCount = 0 - - const key = queryKey() - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key, - retry: 2, - retryDelay: 10, - queryFn: async () => { - attemptCount++ - if (attemptCount <= 2) { - throw new Error(`Attempt ${attemptCount} failed`) - } - return 'success-data' - }, - })), - ) - - const stablePromise = app.whenStable() - await vi.advanceTimersByTimeAsync(50) - await stablePromise - - expect(query.status()).toBe('success') - expect(query.data()).toBe('success-data') - expect(attemptCount).toBe(3) // Initial + 2 retries - }) - - it('should handle mutation with optimistic updates', async () => { - const app = TestBed.inject(ApplicationRef) - const testQueryKey = queryKey() - - queryClient.setQueryData(testQueryKey, 'initial-data') - - const mutation = TestBed.runInInjectionContext(() => - injectMutation(() => ({ - mutationFn: (newData: string) => sleep(50).then(() => newData), - onMutate: async (newData) => { - // Optimistic update - const previousData = queryClient.getQueryData(testQueryKey) - queryClient.setQueryData(testQueryKey, newData) - return { previousData } - }, - onError: (_err, _newData, context) => { - // Rollback on error - if (context?.previousData) { - queryClient.setQueryData(testQueryKey, context.previousData) - } - }, - })), - ) - - mutation.mutate('optimistic-data') - - await Promise.resolve() - - // Data should be optimistically updated immediately - expect(queryClient.getQueryData(testQueryKey)).toBe('optimistic-data') - - const stablePromise = app.whenStable() - await vi.advanceTimersByTimeAsync(60) - await stablePromise - - expect(mutation.isSuccess()).toBe(true) - expect(mutation.data()).toBe('optimistic-data') - expect(queryClient.getQueryData(testQueryKey)).toBe('optimistic-data') - }) + it('releases the task when the query errors', async () => { + const key = queryKey() + const query = TestBed.runInInjectionContext(() => + injectQuery(() => ({ + queryKey: key, + retry: false, + queryFn: () => sleep(10).then(() => Promise.reject(new Error('boom'))), + })), + ) + readData = () => query.data() + TestBed.tick() + expect(events).toEqual(['add']) + + await vi.advanceTimersByTimeAsync(11) + expect(events).toEqual(['add', 'release:undefined']) + expect(query.status()).toBe('error') }) }) diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index 4daede76844..298bb27e05a 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -86,6 +86,18 @@ export function createBaseQuery< TError > | null>(null) + let pendingTaskRef: PendingTaskRef | null = null + // Fetches start synchronously but notifyManager delivers 'fetching' a schedule turn later; + // registering only in the subscriber leaves that turn uncovered and zoneless SSR can serialize + // mid-fetch. Register eagerly wherever a fetch may have started. + const trackFetch = ( + observer: QueryObserver, + ) => { + if (observer.getCurrentResult().fetchStatus === 'fetching') { + pendingTaskRef ??= pendingTasks.add() + } + } + effect( (onCleanup) => { const observer = observerSignal() @@ -93,6 +105,7 @@ export function createBaseQuery< untracked(() => { observer.setOptions(defaultedOptions) + trackFetch(observer) }) onCleanup(() => { ngZone.run(() => resultFromSubscriberSignal.set(null)) @@ -108,7 +121,6 @@ export function createBaseQuery< effect((onCleanup) => { // observer.trackResult is not used as this optimization is not needed for Angular const observer = observerSignal() - let pendingTaskRef: PendingTaskRef | null = null const unsubscribe = isRestoring() ? () => undefined @@ -121,29 +133,42 @@ export function createBaseQuery< pendingTaskRef = pendingTasks.add() } - if (state.fetchStatus === 'idle' && pendingTaskRef) { - pendingTaskRef() - pendingTaskRef = null - } - - if ( - state.isError && - !state.isFetching && - shouldThrowError(observer.options.throwOnError, [ - state.error, - observer.getCurrentQuery(), - ]) - ) { - ngZone.onError.emit(state.error) - throw state.error + try { + if ( + state.isError && + !state.isFetching && + shouldThrowError(observer.options.throwOnError, [ + state.error, + observer.getCurrentQuery(), + ]) + ) { + ngZone.onError.emit(state.error) + throw state.error + } + resultFromSubscriberSignal.set(state) + } finally { + // Release only after the signal write (`whenStable()` latches on a momentary + // empty ledger), and only when the observer is CURRENTLY idle — an older queued + // 'idle' snapshot must not release coverage for a newer in-flight refetch. + if ( + state.fetchStatus === 'idle' && + pendingTaskRef && + observer.getCurrentResult().fetchStatus === 'idle' + ) { + pendingTaskRef() + pendingTaskRef = null + } } - resultFromSubscriberSignal.set(state) }) }), ) }), ) + if (!isRestoring()) { + untracked(() => trackFetch(observer)) + } + onCleanup(() => { if (pendingTaskRef) { pendingTaskRef() @@ -167,7 +192,9 @@ export function createBaseQuery< ...result, refetch: ((...args: Parameters) => { observer.setOptions(defaultedOptionsSignal()) - return originalRefetch(...args) + const refetchResult = originalRefetch(...args) + trackFetch(observer) + return refetchResult }) as typeof originalRefetch, } }),