From 79a80223a2f1fbd4d649b0f393bd4e0ea3601f4d Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Sun, 24 May 2026 23:06:53 +0530 Subject: [PATCH 1/3] fix(angular-query): track pre-render query promises --- .../angular-query-preaccess-whenstable.md | 5 ++ .../src/__tests__/inject-query.test.ts | 34 +++++++++ .../src/create-base-query.ts | 75 ++++++++++++------- 3 files changed, 88 insertions(+), 26 deletions(-) create mode 100644 .changeset/angular-query-preaccess-whenstable.md diff --git a/.changeset/angular-query-preaccess-whenstable.md b/.changeset/angular-query-preaccess-whenstable.md new file mode 100644 index 00000000000..f84aaf3ef31 --- /dev/null +++ b/.changeset/angular-query-preaccess-whenstable.md @@ -0,0 +1,5 @@ +--- +'@tanstack/angular-query-experimental': patch +--- + +Keep Angular's `whenStable()` pending when a query result signal is accessed before render. 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 a8bff8527bb..6615e883ef8 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -631,6 +631,40 @@ describe('injectQuery', () => { ) }) + it('should keep whenStable pending when a query data signal is captured before render', async () => { + @Component({ + selector: 'app-fake', + template: `{{ data()?.title }}`, + }) + class FakeComponent { + query = injectQuery(() => ({ + queryKey: ['query-data-signal-pre-access'], + queryFn: () => sleep(50).then(() => ({ title: 'query-data' })), + })) + + data = this.query.data + } + + const fixture = TestBed.createComponent(FakeComponent) + fixture.detectChanges() + + let didStabilize = false + const stablePromise = fixture.whenStable().then(() => { + didStabilize = true + }) + + await Promise.resolve() + expect(didStabilize).toBe(false) + + await vi.advanceTimersByTimeAsync(60) + await stablePromise + + expect(fixture.componentInstance.data()).toEqual({ title: 'query-data' }) + expect(fixture.componentInstance.query.data()).toEqual({ + title: 'query-data', + }) + }) + describe('isRestoring', () => { it('should not fetch for the duration of the restoring period when isRestoring is true', 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..d5288babe2f 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -60,6 +60,25 @@ export function createBaseQuery< defaultedOptions._optimisticResults = isRestoring() ? 'isRestoring' : 'optimistic' + + if (!isRestoring() && typeof defaultedOptions.queryFn === 'function') { + const originalQueryFn = defaultedOptions.queryFn + + defaultedOptions.queryFn = (context) => { + const result = originalQueryFn(context) + + if (result && typeof result.then === 'function') { + const pendingTaskRef = pendingTasks.add() + void result.then( + () => pendingTaskRef(), + () => pendingTaskRef(), + ) + } + + return result + } + } + return defaultedOptions }) @@ -110,37 +129,41 @@ export function createBaseQuery< const observer = observerSignal() let pendingTaskRef: PendingTaskRef | null = null + const updateState = (state: QueryObserverResult) => { + ngZone.run(() => { + if (state.fetchStatus === 'fetching' && !pendingTaskRef) { + 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 + } + resultFromSubscriberSignal.set(state) + }) + } + const unsubscribe = isRestoring() ? () => undefined : untracked(() => ngZone.runOutsideAngular(() => { - return observer.subscribe( - notifyManager.batchCalls((state) => { - ngZone.run(() => { - if (state.fetchStatus === 'fetching' && !pendingTaskRef) { - 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 - } - resultFromSubscriberSignal.set(state) - }) - }), + const unsubscribeObserver = observer.subscribe( + notifyManager.batchCalls(updateState), ) + + return unsubscribeObserver }), ) From 78263c8104920e2bd0ed08150051de79e35e124b Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Sat, 30 May 2026 16:23:18 +0530 Subject: [PATCH 2/3] fix(angular): cleanup pending queryFn tasks on component destroy --- .../src/__tests__/pending-tasks.test.ts | 34 +++++++++++++++++++ .../src/create-base-query.ts | 23 +++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) 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 0bdd109a4a7..f068023e7e2 100644 --- a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts +++ b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts @@ -297,6 +297,18 @@ describe('PendingTasks Integration', () => { })) } + @Component({ + template: `{{ data()?.title }}`, + }) + class NeverResolvesComponent { + query = injectQuery(() => ({ + queryKey: ['never-resolve-query'], + queryFn: () => new Promise<{ title: string }>(() => {}), + })) + + data = this.query.data + } + it('should cleanup pending tasks when component with active query is destroyed', async () => { const app = TestBed.inject(ApplicationRef) const fixture = TestBed.createComponent(TestComponent) @@ -314,6 +326,28 @@ describe('PendingTasks Integration', () => { await expect(stablePromise).resolves.toEqual(undefined) }) + it('should cleanup query promise pending task when component with unresolved query is destroyed', async () => { + const app = TestBed.inject(ApplicationRef) + const fixture = TestBed.createComponent(NeverResolvesComponent) + + fixture.detectChanges() + + const stableResult = app.whenStable().then(() => true) + + fixture.destroy() + + const timeoutResult = new Promise((resolve) => { + setTimeout(() => { + resolve(false) + }, 10) + }) + + await vi.advanceTimersByTimeAsync(10) + + const isStableResolved = await Promise.race([stableResult, timeoutResult]) + expect(isStableResolved).toBe(true) + }) + it('should cleanup pending tasks when component with active mutation is destroyed', async () => { const app = TestBed.inject(ApplicationRef) const fixture = TestBed.createComponent(TestComponent) diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index d5288babe2f..dd4d6321359 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -68,10 +68,10 @@ export function createBaseQuery< const result = originalQueryFn(context) if (result && typeof result.then === 'function') { - const pendingTaskRef = pendingTasks.add() + const complete = markPendingQueryFnTask() void result.then( - () => pendingTaskRef(), - () => pendingTaskRef(), + complete, + complete, ) } @@ -82,6 +82,19 @@ export function createBaseQuery< return defaultedOptions }) + const pendingTaskRefsFromQueryFn = new Set() + + const markPendingQueryFnTask = () => { + const pendingTaskRef = pendingTasks.add() + const done = () => { + if (pendingTaskRefsFromQueryFn.delete(done)) { + pendingTaskRef() + } + } + pendingTaskRefsFromQueryFn.add(done) + return done + } + const observerSignal = (() => { let instance: QueryObserver< TQueryFnData, @@ -172,6 +185,10 @@ export function createBaseQuery< pendingTaskRef() pendingTaskRef = null } + for (const pendingTaskRefFromQueryFn of pendingTaskRefsFromQueryFn) { + pendingTaskRefFromQueryFn() + } + pendingTaskRefsFromQueryFn.clear() unsubscribe() }) }) From 9a323f197c401f6e309b404f25229ca9a901f956 Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Sat, 30 May 2026 16:57:14 +0530 Subject: [PATCH 3/3] fix(query-core): correctly refetch resetQueries matches --- .../src/__tests__/queryClient.test.tsx | 47 +++++++++++++++++++ packages/query-core/src/queryClient.ts | 6 ++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index c09db304467..57b2b71faa4 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -1692,6 +1692,53 @@ describe('queryClient', () => { expect(queryFn2).toHaveBeenCalledTimes(0) expect(didSkipTokenRun).toBe(false) }) + + it('should refetch queries matched by a state-dependent predicate even though reset() mutates state', async () => { + const key1 = queryKey() + const key2 = queryKey() + const queryFn1 = vi + .fn<(...args: Array) => string>() + .mockReturnValue('data') + const queryFn2 = vi + .fn<(...args: Array) => string>() + .mockRejectedValue('error') + const observer1 = new QueryObserver(queryClient, { + queryKey: key1, + queryFn: queryFn1, + }) + const observer2 = new QueryObserver(queryClient, { + queryKey: key2, + queryFn: queryFn2, + retry: false, + }) + + observer1.subscribe(() => undefined) + observer2.subscribe(() => undefined) + + await vi.waitFor(() => { + expect(queryClient.getQueryState(key1)?.status).toBe('success') + expect(queryClient.getQueryState(key2)?.status).toBe('error') + }) + expect(queryFn1).toHaveBeenCalledTimes(1) + expect(queryFn2).toHaveBeenCalledTimes(1) + + await queryClient.resetQueries({ + predicate: (query) => query.state.status === 'success', + }) + + expect(queryFn1).toHaveBeenCalledTimes(2) + expect(queryFn2).toHaveBeenCalledTimes(1) + + await queryClient.resetQueries({ + predicate: (query) => query.state.status === 'error', + }) + + expect(queryFn1).toHaveBeenCalledTimes(2) + expect(queryFn2).toHaveBeenCalledTimes(2) + + observer1.destroy() + observer2.destroy() + }) }) describe('focusManager and onlineManager', () => { diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index d82106c7375..097ddf95f80 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -260,13 +260,15 @@ export class QueryClient { const queryCache = this.#queryCache return notifyManager.batch(() => { - queryCache.findAll(filters).forEach((query) => { + const matched = queryCache.findAll(filters) + const matchedHashes = new Set(matched.map((query) => query.queryHash)) + matched.forEach((query) => { query.reset() }) return this.refetchQueries( { type: 'active', - ...filters, + predicate: (query) => matchedHashes.has(query.queryHash), }, options, )