From e1979e93f5a49ea7a055370d37b0ea662e0af9a5 Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Date: Thu, 13 Aug 2026 11:11:00 +0530 Subject: [PATCH] fix(angular-query): hold a pending task while a query subscription fetches The pending task that keeps the application unstable was only registered from inside the observer subscription callback. That callback is batched through the notify manager, so it runs in a later task than the fetch it reports. Between subscribing and that first notification the application looks stable, which lets 'ApplicationRef.whenStable()' and 'fixture.whenStable()' resolve while the query is still loading. Register the task right after subscribing when the observer is already fetching, so the window is covered. Two existing tests awaited 'whenStable()' without letting the batched notification run, and only passed because of this gap. They now advance the timers while waiting, matching the other pending task tests. --- .changeset/afraid-plums-shave.md | 5 +++ .../src/__tests__/inject-query.test.ts | 9 ++++- .../src/__tests__/pending-tasks.test.ts | 39 +++++++++++++++++++ .../src/create-base-query.ts | 13 +++++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 .changeset/afraid-plums-shave.md diff --git a/.changeset/afraid-plums-shave.md b/.changeset/afraid-plums-shave.md new file mode 100644 index 00000000000..54317645e9a --- /dev/null +++ b/.changeset/afraid-plums-shave.md @@ -0,0 +1,5 @@ +--- +'@tanstack/angular-query-experimental': patch +--- + +Register the Angular pending task as soon as a query subscription starts a fetch, so `whenStable()` no longer resolves while the query is still loading 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..23befc39c2d 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -773,6 +773,9 @@ describe('injectQuery', () => { TestBed.tick() const stablePromise = app.whenStable() + // Let the batched observer notification run so the finished fetch is + // reported and the pending task is released + await vi.advanceTimersByTimeAsync(10) await stablePromise expect(query.status()).toBe('success') @@ -841,7 +844,11 @@ describe('injectQuery', () => { // Synchronize pending effects TestBed.tick() - await app.whenStable() + const stablePromise = app.whenStable() + // Let the batched observer notification run so the finished fetch is + // reported and the pending task is released + await vi.advanceTimersByTimeAsync(10) + await stablePromise expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-1') expect(callCount).toBe(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..d722a7982af 100644 --- a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts +++ b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts @@ -1,6 +1,7 @@ import { ApplicationRef, Component, + effect, provideZonelessChangeDetection, } from '@angular/core' import { TestBed } from '@angular/core/testing' @@ -155,6 +156,44 @@ describe('PendingTasks Integration', () => { }) }) + // The observer reports a running fetch through a batched notification that + // only lands in a later task. This test uses real timers because it asserts + // on what `whenStable()` does before that notification arrives. + describe('Task registration timing', () => { + it('should let whenStable wait for the fetch a component just started', async () => { + vi.useRealTimers() + + const key = queryKey() + const seenInEffect: Array = [] + + @Component({ + selector: 'app-timing-query', + template: `{{ query.isSuccess() }}`, + standalone: true, + }) + class TimingQueryComponent { + query = injectQuery(() => ({ + queryKey: key, + queryFn: () => Promise.resolve('component-data'), + })) + + constructor() { + effect(() => { + seenInEffect.push(this.query.isSuccess()) + }) + } + } + + const fixture = TestBed.createComponent(TimingQueryComponent) + fixture.detectChanges() + await fixture.whenStable() + + expect(fixture.componentInstance.query.status()).toBe('success') + expect(fixture.componentInstance.query.data()).toBe('component-data') + expect(seenInEffect).toContain(true) + }) + }) + describe('Race Conditions', () => { it('should handle query that completes during initial subscription', async () => { const key = queryKey() diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index 4daede76844..d69c9ccad3a 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -144,6 +144,19 @@ export function createBaseQuery< }), ) + // Subscribing can start a fetch straight away, but the first observer + // notification is batched and only arrives in a later task. Without this + // check the app is considered stable in between, so `whenStable()` resolves + // while the query is still fetching. + untracked(() => { + if ( + !pendingTaskRef && + observer.getCurrentResult().fetchStatus !== 'idle' + ) { + pendingTaskRef = pendingTasks.add() + } + }) + onCleanup(() => { if (pendingTaskRef) { pendingTaskRef()