From 7e64cd090384069876f8f4d1131229030a70b34e Mon Sep 17 00:00:00 2001 From: Maksym Plotnikov Date: Thu, 27 Aug 2026 10:19:19 +0200 Subject: [PATCH 1/4] fix(angular-query-experimental): keep the SSR pending task held from fetch start until the result is applied With zoneless change detection, ApplicationRef.whenStable() latches the moment the pending-task ledger touches zero, and Angular SSR serializes. Two windows left the ledger empty while query state was not yet applied: - the pending task was registered only when the subscriber saw fetchStatus 'fetching', but subscriber callbacks are delivered through notifyManager, which schedules with setTimeout(0). A fetch that starts synchronously (on subscribe, or when setOptions enables a dependent query) was untracked for at least one macrotask turn, and SSR could serialize inside it with the query still mid-fetch. - the task was released one statement before resultFromSubscriberSignal.set(state), exposing one synchronous statement of stability while the rendered view was still stale. Register the task eagerly wherever a fetch may have started synchronously, and release it in a finally after the result signal write, so the ledger stays covered from fetch start until the state is applied. Three existing tests awaited whenStable() while a fetch was in flight under fake timers; they passed only because of the registration gap and now flush the notification turn first. --- .../angular-ssr-pending-task-coverage.md | 5 + .../src/__tests__/inject-query.test.ts | 12 + .../src/__tests__/pending-tasks.test.ts | 694 ++---------------- .../src/create-base-query.ts | 60 +- 4 files changed, 136 insertions(+), 635 deletions(-) create mode 100644 .changeset/angular-ssr-pending-task-coverage.md 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..00d39020f3a 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,102 @@ -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') - }) - }) - - 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() - }) - }) - - 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..9bdd557ebc3 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -86,6 +86,21 @@ export function createBaseQuery< TError > | null>(null) + let pendingTaskRef: PendingTaskRef | null = null + // A fetch can start synchronously (on subscribe, or when setOptions enables a dependent query), + // but the first 'fetching' notification reaches the subscriber a notifyManager schedule turn + // later (setTimeout(0) by default). Registering the pending task only in the subscriber leaves + // that turn uncovered: with zoneless change detection, `ApplicationRef.whenStable()` can resolve + // inside it and Angular SSR serializes before the query's state is applied. Register eagerly at + // every point a fetch may have started instead. + const trackFetch = ( + observer: QueryObserver, + ) => { + if (observer.getCurrentResult().fetchStatus === 'fetching') { + pendingTaskRef ??= pendingTasks.add() + } + } + effect( (onCleanup) => { const observer = observerSignal() @@ -93,6 +108,7 @@ export function createBaseQuery< untracked(() => { observer.setOptions(defaultedOptions) + trackFetch(observer) }) onCleanup(() => { ngZone.run(() => resultFromSubscriberSignal.set(null)) @@ -108,7 +124,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 +136,40 @@ 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 { + // Released only after the state is written to the signal (or the error is + // rethrown): releasing first exposes one synchronous statement in which the + // pending-task ledger is empty while the rendered view is still stale — with + // zoneless change detection, `whenStable()` latches in that statement and SSR + // serializes the stale view. + if (state.fetchStatus === 'idle' && pendingTaskRef) { + pendingTaskRef() + pendingTaskRef = null + } } - resultFromSubscriberSignal.set(state) }) }), ) }), ) + if (!isRestoring()) { + untracked(() => trackFetch(observer)) + } + onCleanup(() => { if (pendingTaskRef) { pendingTaskRef() From cec39210330e0903d5eb8d1303d151bcfd622ae6 Mon Sep 17 00:00:00 2001 From: Maksym Plotnikov Date: Thu, 27 Aug 2026 11:29:02 +0200 Subject: [PATCH 2/4] fix(angular-query-experimental): track fetches started by refetch() QueryObserver.refetch() dispatches the fetching state synchronously, but the wrapper did not register the pending task eagerly there, leaving the same notifyManager delivery turn uncovered as the subscribe and setOptions paths. --- .../src/__tests__/pending-tasks.test.ts | 23 +++++++++++++++++++ .../src/create-base-query.ts | 4 +++- 2 files changed, 26 insertions(+), 1 deletion(-) 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 00d39020f3a..1c304d58e05 100644 --- a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts +++ b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts @@ -82,6 +82,29 @@ describe('pending tasks integration', () => { expect(events).toEqual(['add', 'release:ok']) }) + 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']) + }) + it('releases the task when the query errors', async () => { const key = queryKey() const query = TestBed.runInInjectionContext(() => diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index 9bdd557ebc3..d7af29f7756 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -193,7 +193,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, } }), From dcf5b414793c45623520f692cebdf260f9606da8 Mon Sep 17 00:00:00 2001 From: Maksym Plotnikov Date: Sat, 29 Aug 2026 09:57:00 +0200 Subject: [PATCH 3/4] fix(angular-query-experimental): keep task coverage across interleaved refetches Query state updates synchronously at fetch resolution while notifications are delivered a schedule turn later, so a refetch started in that window begins a new fetch that the still-held pending task must cover (trackFetch is idempotent). The release was keyed only on the delivered state snapshot, so the older queued 'idle' notification could release coverage while the newer fetch was in flight, letting whenStable() resolve and SSR serialize stale state. Guard the release on the observer's current fetch status as well, and add a regression test for the interleaved sequence. --- .../src/__tests__/pending-tasks.test.ts | 36 +++++++++++++++++++ .../src/create-base-query.ts | 12 ++++++- 2 files changed, 47 insertions(+), 1 deletion(-) 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 1c304d58e05..bf12f5e8bce 100644 --- a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts +++ b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts @@ -105,6 +105,42 @@ describe('pending tasks integration', () => { expect(events).toEqual(['add', 'release:ok']) }) + 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') + }) + it('releases the task when the query errors', async () => { const key = queryKey() const query = TestBed.runInInjectionContext(() => diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index d7af29f7756..66a32fe8ec3 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -155,7 +155,17 @@ export function createBaseQuery< // pending-task ledger is empty while the rendered view is still stale — with // zoneless change detection, `whenStable()` latches in that statement and SSR // serializes the stale view. - if (state.fetchStatus === 'idle' && pendingTaskRef) { + // + // Guarded on the observer's CURRENT fetch status, not only the delivered + // snapshot: state updates synchronously at fetch resolution while notifications + // are delivered a schedule turn later, so a refetch started in that window + // already owns this task (`trackFetch` is idempotent) — an older queued 'idle' + // snapshot must not release coverage for the newer in-flight fetch. + if ( + state.fetchStatus === 'idle' && + pendingTaskRef && + observer.getCurrentResult().fetchStatus === 'idle' + ) { pendingTaskRef() pendingTaskRef = null } From 8e3883009f77ad9cb6dfaa4dd6e8a7f1dd3a771b Mon Sep 17 00:00:00 2001 From: Maksym Plotnikov Date: Sat, 29 Aug 2026 10:11:39 +0200 Subject: [PATCH 4/4] style(angular-query-experimental): tighten comments --- .../src/create-base-query.ts | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index 66a32fe8ec3..298bb27e05a 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -87,12 +87,9 @@ export function createBaseQuery< > | null>(null) let pendingTaskRef: PendingTaskRef | null = null - // A fetch can start synchronously (on subscribe, or when setOptions enables a dependent query), - // but the first 'fetching' notification reaches the subscriber a notifyManager schedule turn - // later (setTimeout(0) by default). Registering the pending task only in the subscriber leaves - // that turn uncovered: with zoneless change detection, `ApplicationRef.whenStable()` can resolve - // inside it and Angular SSR serializes before the query's state is applied. Register eagerly at - // every point a fetch may have started instead. + // 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, ) => { @@ -150,17 +147,9 @@ export function createBaseQuery< } resultFromSubscriberSignal.set(state) } finally { - // Released only after the state is written to the signal (or the error is - // rethrown): releasing first exposes one synchronous statement in which the - // pending-task ledger is empty while the rendered view is still stale — with - // zoneless change detection, `whenStable()` latches in that statement and SSR - // serializes the stale view. - // - // Guarded on the observer's CURRENT fetch status, not only the delivered - // snapshot: state updates synchronously at fetch resolution while notifications - // are delivered a schedule turn later, so a refetch started in that window - // already owns this task (`trackFetch` is idempotent) — an older queued 'idle' - // snapshot must not release coverage for the newer in-flight fetch. + // 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 &&