From 4579364aae395491674c4ded0b4c8fd78b111391 Mon Sep 17 00:00:00 2001 From: Arnoud de Vries <6420061+arnoud-dv@users.noreply.github.com> Date: Fri, 15 Nov 2024 18:26:28 +0100 Subject: [PATCH 01/24] docs(angular-query): add TypeScript documentation --- docs/framework/angular/typescript.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/framework/angular/typescript.md b/docs/framework/angular/typescript.md index 0aa2ff2638..689c5b0673 100644 --- a/docs/framework/angular/typescript.md +++ b/docs/framework/angular/typescript.md @@ -12,6 +12,7 @@ replace: 'React Query': 'TanStack Query', '`success`': '`isSuccess()`', 'function:': 'function.', + 'separate function': 'separate function or a service', } --- @@ -70,6 +71,7 @@ class MyComponent { ``` [//]: # 'TypeInference3' +[//]: # 'TypeInference4' [//]: # 'TypeNarrowing' ```angular-ts @@ -92,6 +94,7 @@ class MyComponent { > TypeScript currently does not support discriminated unions on object methods. Narrowing on signal fields on objects such as query results only works on signals returning a boolean. Prefer using `isSuccess()` and similar boolean status signals over `status() === 'success'`. +[//]: # 'TypeInference4' [//]: # 'TypeNarrowing' [//]: # 'TypingError' @@ -153,8 +156,7 @@ import '@tanstack/angular-query-experimental' declare module '@tanstack/angular-query-experimental' { interface Register { - // Use unknown so call sites must narrow explicitly. - defaultError: unknown + defaultError: AxiosError } } @@ -165,7 +167,7 @@ const query = injectQuery(() => ({ computed(() => { const error = query.error() - // ^? error: unknown | null + // ^? error: AxiosError | null }) ``` From 79514128b68143f734af8fa71f7887b9c041bbb5 Mon Sep 17 00:00:00 2001 From: Arnoud de Vries <6420061+arnoud-dv@users.noreply.github.com> Date: Sat, 22 Nov 2025 20:12:07 +0100 Subject: [PATCH 02/24] Improve PendingTasks task cleanup, isRestoring() handling --- .../src/create-base-query.ts | 187 +++++++----------- 1 file changed, 69 insertions(+), 118 deletions(-) diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index 4daede7684..c752618cde 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -1,26 +1,8 @@ -import { - NgZone, - VERSION, - computed, - effect, - inject, - signal, - untracked, -} from '@angular/core' -import { - QueryClient, - notifyManager, - shouldThrowError, -} from '@tanstack/query-core' +import { DestroyRef, NgZone, PendingTasks, computed, effect, inject, linkedSignal, untracked, } from '@angular/core' +import { QueryClient, notifyManager, shouldThrowError, } from '@tanstack/query-core' import { signalProxy } from './signal-proxy' import { injectIsRestoring } from './inject-is-restoring' -import { PENDING_TASKS } from './pending-tasks-compat' -import type { PendingTaskRef } from './pending-tasks-compat' -import type { - QueryKey, - QueryObserver, - QueryObserverResult, -} from '@tanstack/query-core' +import type { DefaultedQueryObserverOptions, QueryKey, QueryObserver, } from '@tanstack/query-core' import type { CreateBaseQueryOptions } from './types' /** @@ -45,9 +27,18 @@ export function createBaseQuery< Observer: typeof QueryObserver, ) { const ngZone = inject(NgZone) - const pendingTasks = inject(PENDING_TASKS) + const pendingTasks = inject(PendingTasks) const queryClient = inject(QueryClient) const isRestoring = injectIsRestoring() + const destroyRef = inject(DestroyRef) + + let observer: QueryObserver< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + > | null = null /** * Signal that has the default options from query client applied @@ -63,113 +54,73 @@ export function createBaseQuery< return defaultedOptions }) - const observerSignal = (() => { - let instance: QueryObserver< + const createOrUpdateObserver = ( + options: DefaultedQueryObserverOptions< TQueryFnData, TError, TData, TQueryData, TQueryKey - > | null = null - - return computed(() => { - return (instance ||= new Observer(queryClient, defaultedOptionsSignal())) + >, + ) => { + if (observer) { + observer.setOptions(options) + return + } + + observer = new Observer(queryClient, options) + let taskCleanupRef: (() => void) | null = null + + const unsubscribe = observer.subscribe( + notifyManager.batchCalls((state) => { + ngZone.run(() => { + if (state.fetchStatus === 'fetching' && !taskCleanupRef) { + taskCleanupRef = pendingTasks.add() + } + + if (state.fetchStatus === 'idle' && taskCleanupRef) { + taskCleanupRef() + taskCleanupRef = null + } + + if ( + state.isError && + !state.isFetching && + shouldThrowError(observer!.options.throwOnError, [ + state.error, + observer!.getCurrentQuery(), + ]) + ) { + ngZone.onError.emit(state.error) + throw state.error + } + resultSignal.set(state) + }) + }), + ) + destroyRef.onDestroy(() => { + unsubscribe() + taskCleanupRef?.() }) - })() - - const optimisticResultSignal = computed(() => - observerSignal().getOptimisticResult(defaultedOptionsSignal()), - ) - - const resultFromSubscriberSignal = signal | null>(null) - - effect( - (onCleanup) => { - const observer = observerSignal() - const defaultedOptions = defaultedOptionsSignal() + } - untracked(() => { - observer.setOptions(defaultedOptions) - }) - onCleanup(() => { - ngZone.run(() => resultFromSubscriberSignal.set(null)) - }) - }, - { - // Set allowSignalWrites to support Angular < v19 - // Set to undefined to avoid warning on newer versions - allowSignalWrites: VERSION.major < '19' || undefined, + const resultSignal = linkedSignal({ + source: defaultedOptionsSignal, + computation: () => { + if (!observer) throw new Error('Observer is not initialized') + return observer.getOptimisticResult(defaultedOptionsSignal()) }, - ) - - 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 - : 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) - }) - }), - ) - }), - ) + // Effect to initialize the observer and set options when options change + effect(() => { + const defaultedOptions = defaultedOptionsSignal() + if (isRestoring()) return - onCleanup(() => { - if (pendingTaskRef) { - pendingTaskRef() - pendingTaskRef = null - } - unsubscribe() + untracked(() => { + createOrUpdateObserver(defaultedOptions) }) }) - return signalProxy( - computed(() => { - const subscriberResult = resultFromSubscriberSignal() - const optimisticResult = optimisticResultSignal() - const result = subscriberResult ?? optimisticResult - - // Wrap methods to ensure observer has latest options before execution - const observer = observerSignal() - - const originalRefetch = result.refetch - return { - ...result, - refetch: ((...args: Parameters) => { - observer.setOptions(defaultedOptionsSignal()) - return originalRefetch(...args) - }) as typeof originalRefetch, - } - }), - ) + return signalProxy(resultSignal.asReadonly()) } From d7e8d66559f5be88b8788f80206e9c558a981820 Mon Sep 17 00:00:00 2001 From: Arnoud de Vries <6420061+arnoud-dv@users.noreply.github.com> Date: Sat, 22 Nov 2025 20:12:50 +0100 Subject: [PATCH 03/24] Ensure unit tests are run using component effect scheduling --- .../__tests__/inject-infinite-query.test.ts | 63 +- .../__tests__/inject-mutation-state.test.ts | 2 + .../src/__tests__/inject-mutation.test.ts | 3 + .../src/__tests__/inject-queries.test.ts | 2 + .../src/__tests__/inject-query.test.ts | 718 +++++++++++------- .../src/__tests__/pending-tasks.test.ts | 76 +- 6 files changed, 548 insertions(+), 316 deletions(-) diff --git a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts index 7873d5261c..8812edbb62 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts @@ -1,8 +1,13 @@ import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { Injector, provideZonelessChangeDetection } from '@angular/core' +import { + ChangeDetectionStrategy, + Component, + Injector, + provideZonelessChangeDetection, +} from '@angular/core' import { sleep } from '@tanstack/query-test-utils' -import { QueryClient, injectInfiniteQuery, provideTanStackQuery } from '..' +import { injectInfiniteQuery, provideTanStackQuery, QueryClient } from '..' import { expectSignals } from './test-utils' describe('injectInfiniteQuery', () => { @@ -24,15 +29,25 @@ describe('injectInfiniteQuery', () => { }) test('should properly execute infinite query', async () => { - const query = TestBed.runInInjectionContext(() => { - return injectInfiniteQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectInfiniteQuery(() => ({ queryKey: ['infiniteQuery'], queryFn: ({ pageParam }) => sleep(10).then(() => 'data on page ' + pageParam), initialPageParam: 0, getNextPageParam: () => 12, })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expectSignals(query, { data: undefined, @@ -76,18 +91,32 @@ describe('injectInfiniteQuery', () => { }) test('can be used outside injection context when passing an injector', () => { - const query = injectInfiniteQuery( - () => ({ - queryKey: ['manualInjector'], - queryFn: ({ pageParam }) => - sleep(0).then(() => 'data on page ' + pageParam), - initialPageParam: 0, - getNextPageParam: () => 12, - }), - { - injector: TestBed.inject(Injector), - }, - ) + const injector = TestBed.inject(Injector) + + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectInfiniteQuery( + () => ({ + queryKey: ['manualInjector'], + queryFn: ({ pageParam }) => + sleep(0).then(() => 'data on page ' + pageParam), + initialPageParam: 0, + getNextPageParam: () => 12, + }), + { + injector: injector, + }, + ) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(query.status()).toBe('pending') }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts b/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts index 8b747f66f6..bb1c97b3bd 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts @@ -1,4 +1,5 @@ import { + ChangeDetectionStrategy, Component, Injector, input, @@ -145,6 +146,7 @@ describe('injectMutationState', () => { {{ mutation.status }} } `, + changeDetection: ChangeDetectionStrategy.OnPush, }) class FakeComponent { name = input.required() diff --git a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts index 2adf0ee808..d7a8d85121 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts @@ -1,5 +1,6 @@ import { ApplicationRef, + ChangeDetectionStrategy, Component, Injector, input, @@ -307,6 +308,7 @@ describe('injectMutation', () => { {{ mutation.data() }} `, + changeDetection: ChangeDetectionStrategy.OnPush, }) class FakeComponent { name = input.required() @@ -347,6 +349,7 @@ describe('injectMutation', () => { {{ mutation.data() }} `, + changeDetection: ChangeDetectionStrategy.OnPush, }) class FakeComponent { name = input.required() diff --git a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts index 3fb3d5a626..ab16d3b296 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { render } from '@testing-library/angular' import { + ChangeDetectionStrategy, Component, effect, provideZonelessChangeDetection, @@ -37,6 +38,7 @@ describe('injectQueries', () => { `, + changeDetection: ChangeDetectionStrategy.OnPush, }) class Page { toString(val: any) { 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 2f541788ab..474ee3d747 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -3,10 +3,10 @@ import { Component, Injector, computed, - effect, input, provideZonelessChangeDetection, signal, + ChangeDetectionStrategy, } from '@angular/core' import { TestBed } from '@angular/core/testing' import { HttpClient, provideHttpClient } from '@angular/common/http' @@ -50,227 +50,232 @@ describe('injectQuery', () => { test('should return the correct types', () => { const key = queryKey() - // unspecified query function should default to unknown - const noQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + // unspecified query function should default to unknown + noQueryFn = injectQuery(() => ({ queryKey: key, - })), - ) - expectTypeOf(noQueryFn.data()).toEqualTypeOf() - expectTypeOf(noQueryFn.error()).toEqualTypeOf() + })) - // it should infer the result type from the query function - const fromQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should infer the result type from the query function + fromQueryFn = injectQuery(() => ({ queryKey: key, queryFn: () => 'test', - })), - ) - expectTypeOf(fromQueryFn.data()).toEqualTypeOf() - expectTypeOf(fromQueryFn.error()).toEqualTypeOf() + })) - // it should be possible to specify the result type - const withResult = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should be possible to specify the result type + withResult = injectQuery(() => ({ queryKey: key, queryFn: () => 'test', - })), - ) - expectTypeOf(withResult.data()).toEqualTypeOf() - expectTypeOf(withResult.error()).toEqualTypeOf() + })) - // it should be possible to specify the error type - type CustomErrorType = { message: string } - const withError = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should be possible to specify the error type + withError = injectQuery(() => ({ queryKey: key, queryFn: () => 'test', - })), - ) - expectTypeOf(withError.data()).toEqualTypeOf() - expectTypeOf(withError.error()).toEqualTypeOf() + })) - // it should infer the result type from the configuration - const withResultInfer = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should infer the result type from the configuration + withResultInfer = injectQuery(() => ({ queryKey: key, queryFn: () => true, - })), - ) - expectTypeOf(withResultInfer.data()).toEqualTypeOf() - expectTypeOf(withResultInfer.error()).toEqualTypeOf() + })) - // it should be possible to specify a union type as result type - const unionTypeSync = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should be possible to specify a union type as result type + unionTypeSync = injectQuery(() => ({ queryKey: key, queryFn: () => (Math.random() > 0.5 ? ('a' as const) : ('b' as const)), - })), - ) - expectTypeOf(unionTypeSync.data()).toEqualTypeOf<'a' | 'b' | undefined>() - const unionTypeAsync = TestBed.runInInjectionContext(() => - injectQuery<'a' | 'b'>(() => ({ + })) + + unionTypeAsync = injectQuery<'a' | 'b'>(() => ({ queryKey: key, queryFn: () => Promise.resolve(Math.random() > 0.5 ? 'a' : 'b'), - })), - ) - expectTypeOf(unionTypeAsync.data()).toEqualTypeOf<'a' | 'b' | undefined>() + })) - // it should error when the query function result does not match with the specified type - TestBed.runInInjectionContext(() => - // @ts-expect-error - injectQuery(() => ({ queryKey: key, queryFn: () => 'test' })), - ) + // it should infer the result type from a generic query function + fromGenericQueryFn = (() => { + function queryFn(): Promise { + return Promise.resolve({} as T) + } + return injectQuery(() => ({ + queryKey: key, + queryFn: () => queryFn(), + })) + })() - // it should infer the result type from a generic query function - /** - * - */ - function queryFn(): Promise { - return Promise.resolve({} as T) - } + // todo use query options? + fromGenericOptionsQueryFn = (() => { + function queryFn(): Promise { + return Promise.resolve({} as T) + } + return injectQuery(() => ({ + queryKey: key, + queryFn: () => queryFn(), + })) + })() + + fromMyDataArrayKeyQueryFn = (() => { + type MyData = number + type MyQueryKey = readonly ['my-data', number] + const getMyDataArrayKey: QueryFunction = ({ + queryKey: [, n], + }) => { + return n + 42 + } + return injectQuery(() => ({ + queryKey: ['my-data', 100] as const, + queryFn: getMyDataArrayKey, + })) + })() - const fromGenericQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should handle query-functions that return Promise + fromPromiseAnyQueryFn = injectQuery(() => ({ queryKey: key, - queryFn: () => queryFn(), - })), - ) + queryFn: () => fetch('return Promise').then((resp) => resp.json()), + })) + + fromGetMyDataStringKeyQueryFn = (() => { + type MyData = number + const getMyDataStringKey: QueryFunction = (context) => { + expectTypeOf(context.queryKey).toEqualTypeOf<['1']>() + return Number(context.queryKey[0]) + 42 + } + return injectQuery(() => ({ + queryKey: ['1'] as ['1'], + queryFn: getMyDataStringKey, + })) + })() + + // Wrapped queries + fromWrappedQuery = (() => { + const createWrappedQuery = < + TQueryKey extends [string, Record?], + TQueryFnData, + TError, + TData = TQueryFnData, + >( + qk: TQueryKey, + fetcher: ( + obj: TQueryKey[1], + token: string, + ) => Promise, + options?: OmitKeyof< + CreateQueryOptions, + 'queryKey' | 'queryFn' | 'initialData', + 'safely' + >, + ) => + injectQuery(() => ({ + queryKey: qk, + queryFn: () => fetcher(qk[1], 'token'), + ...options, + })) + return createWrappedQuery([''], () => Promise.resolve('1')) + })() + + fromWrappedFuncStyleQuery = (() => { + const createWrappedFuncStyleQuery = < + TQueryKey extends [string, Record?], + TQueryFnData, + TError, + TData = TQueryFnData, + >( + qk: TQueryKey, + fetcher: () => Promise, + options?: OmitKeyof< + CreateQueryOptions, + 'queryKey' | 'queryFn' | 'initialData', + 'safely' + >, + ) => injectQuery(() => ({ queryKey: qk, queryFn: fetcher, ...options })) + return createWrappedFuncStyleQuery([''], () => Promise.resolve(true)) + })() + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const { + noQueryFn, + fromQueryFn, + withResult, + withError, + withResultInfer, + unionTypeSync, + unionTypeAsync, + fromGenericQueryFn, + fromGenericOptionsQueryFn, + fromMyDataArrayKeyQueryFn, + fromPromiseAnyQueryFn, + fromGetMyDataStringKeyQueryFn, + fromWrappedQuery, + fromWrappedFuncStyleQuery, + } = fixture.componentInstance + + expectTypeOf(noQueryFn.data()).toEqualTypeOf() + expectTypeOf(noQueryFn.error()).toEqualTypeOf() + + expectTypeOf(fromQueryFn.data()).toEqualTypeOf() + expectTypeOf(fromQueryFn.error()).toEqualTypeOf() + + expectTypeOf(withResult.data()).toEqualTypeOf() + expectTypeOf(withResult.error()).toEqualTypeOf() + + expectTypeOf(withError.data()).toEqualTypeOf() + expectTypeOf(withError.error()).toEqualTypeOf<{ message: string } | null>() + + expectTypeOf(withResultInfer.data()).toEqualTypeOf() + expectTypeOf(withResultInfer.error()).toEqualTypeOf() + + expectTypeOf(unionTypeSync.data()).toEqualTypeOf<'a' | 'b' | undefined>() + expectTypeOf(unionTypeAsync.data()).toEqualTypeOf<'a' | 'b' | undefined>() + expectTypeOf(fromGenericQueryFn.data()).toEqualTypeOf() expectTypeOf(fromGenericQueryFn.error()).toEqualTypeOf() - // todo use query options? - const fromGenericOptionsQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key, - queryFn: () => queryFn(), - })), - ) expectTypeOf(fromGenericOptionsQueryFn.data()).toEqualTypeOf< string | undefined >() - expectTypeOf( - fromGenericOptionsQueryFn.error(), - ).toEqualTypeOf() - - type MyData = number - type MyQueryKey = readonly ['my-data', number] + expectTypeOf(fromGenericOptionsQueryFn.error()).toEqualTypeOf() - const getMyDataArrayKey: QueryFunction = ({ - queryKey: [, n], - }) => { - return n + 42 - } - - const fromMyDataArrayKeyQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: ['my-data', 100] as const, - queryFn: getMyDataArrayKey, - })), - ) expectTypeOf(fromMyDataArrayKeyQueryFn.data()).toEqualTypeOf< number | undefined >() - // it should handle query-functions that return Promise - const fromPromiseAnyQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key, - queryFn: () => fetch('return Promise').then((resp) => resp.json()), - })), - ) expectTypeOf(fromPromiseAnyQueryFn.data()).toEqualTypeOf() - TestBed.runInInjectionContext(() => - effect(() => { - if (fromPromiseAnyQueryFn.isSuccess()) { - expect(fromMyDataArrayKeyQueryFn.data()).toBe(142) - } - }), - ) - - const getMyDataStringKey: QueryFunction = (context) => { - expectTypeOf(context.queryKey).toEqualTypeOf<['1']>() - return Number(context.queryKey[0]) + 42 - } - - const fromGetMyDataStringKeyQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: ['1'] as ['1'], - queryFn: getMyDataStringKey, - })), - ) expectTypeOf(fromGetMyDataStringKeyQueryFn.data()).toEqualTypeOf< number | undefined >() - TestBed.runInInjectionContext(() => - effect(() => { - if (fromGetMyDataStringKeyQueryFn.isSuccess()) { - expect(fromGetMyDataStringKeyQueryFn.data()).toBe(43) - } - }), - ) - - // handles wrapped queries with custom fetcher passed as inline queryFn - const createWrappedQuery = < - TQueryKey extends [string, Record?], - TQueryFnData, - TError, - TData = TQueryFnData, - >( - qk: TQueryKey, - fetcher: ( - obj: TQueryKey[1], - token: string, - // return type must be wrapped with TQueryFnReturn - ) => Promise, - options?: OmitKeyof< - CreateQueryOptions, - 'queryKey' | 'queryFn' | 'initialData', - 'safely' - >, - ) => - injectQuery(() => ({ - queryKey: qk, - queryFn: () => fetcher(qk[1], 'token'), - ...options, - })) - const fromWrappedQuery = TestBed.runInInjectionContext(() => - createWrappedQuery([''], () => Promise.resolve('1')), - ) expectTypeOf(fromWrappedQuery.data()).toEqualTypeOf() - - // handles wrapped queries with custom fetcher passed directly to createQuery - const createWrappedFuncStyleQuery = < - TQueryKey extends [string, Record?], - TQueryFnData, - TError, - TData = TQueryFnData, - >( - qk: TQueryKey, - fetcher: () => Promise, - options?: OmitKeyof< - CreateQueryOptions, - 'queryKey' | 'queryFn' | 'initialData', - 'safely' - >, - ) => injectQuery(() => ({ queryKey: qk, queryFn: fetcher, ...options })) - const fromWrappedFuncStyleQuery = TestBed.runInInjectionContext(() => - createWrappedFuncStyleQuery([''], () => Promise.resolve(true)), - ) expectTypeOf(fromWrappedFuncStyleQuery.data()).toEqualTypeOf< boolean | undefined >() }) test('should return pending status initially', () => { - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['key1'], queryFn: () => sleep(10).then(() => 'Some data'), })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(query.status()).toBe('pending') expect(query.isPending()).toBe(true) @@ -280,12 +285,22 @@ describe('injectQuery', () => { }) test('should resolve to success and update signal: injectQuery()', async () => { - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['key2'], queryFn: () => sleep(10).then(() => 'result2'), })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query await vi.advanceTimersByTimeAsync(11) expect(query.status()).toBe('success') @@ -297,14 +312,24 @@ describe('injectQuery', () => { }) test('should reject and update signal', async () => { - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ retry: false, queryKey: ['key3'], queryFn: () => sleep(10).then(() => Promise.reject(new Error('Some error'))), })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query await vi.advanceTimersByTimeAsync(11) expect(query.status()).toBe('error') @@ -321,12 +346,24 @@ describe('injectQuery', () => { const key = signal(['key6', 'key7']) const spy = vi.fn(() => sleep(10).then(() => 'Some data')) - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ - queryKey: key(), - queryFn: spy, - })) + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) + class TestComponent { + key = key + spy = spy + query = injectQuery(() => ({ + queryKey: this.key(), + queryFn: this.spy, + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query await vi.advanceTimersByTimeAsync(0) expect(spy).toHaveBeenCalledTimes(1) @@ -335,7 +372,7 @@ describe('injectQuery', () => { expect(query.status()).toBe('success') key.set(['key8']) - TestBed.tick() + fixture.detectChanges() expect(spy).toHaveBeenCalledTimes(2) // should call queryFn with context containing the new queryKey @@ -351,13 +388,25 @@ describe('injectQuery', () => { const spy = vi.fn(() => sleep(10).then(() => 'Some data')) const enabled = signal(false) - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + enabled = enabled + spy = spy + query = injectQuery(() => ({ queryKey: ['key9'], - queryFn: spy, - enabled: enabled(), + queryFn: this.spy, + enabled: this.enabled(), })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(spy).not.toHaveBeenCalled() expect(query.status()).toBe('pending') @@ -370,26 +419,34 @@ describe('injectQuery', () => { }) test('should properly execute dependant queries', async () => { - const query1 = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ - queryKey: ['dependant1'], - queryFn: () => sleep(10).then(() => 'Some data'), - })) - }) - const dependentQueryFn = vi .fn() .mockImplementation(() => sleep(1000).then(() => 'Some data')) - const query2 = TestBed.runInInjectionContext(() => { - return injectQuery( + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query1 = injectQuery(() => ({ + queryKey: ['dependant1'], + queryFn: () => sleep(10).then(() => 'Some data'), + })) + + query2 = injectQuery( computed(() => ({ queryKey: ['dependant2'], queryFn: dependentQueryFn, - enabled: !!query1.data(), + enabled: !!this.query1.data(), })), ) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const { query1, query2 } = fixture.componentInstance expect(query1.data()).toStrictEqual(undefined) expect(query2.fetchStatus()).toStrictEqual('idle') @@ -414,13 +471,25 @@ describe('injectQuery', () => { const fetchFn = vi.fn(() => sleep(10).then(() => 'Some data')) const keySignal = signal('key11') - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ - queryKey: ['key10', keySignal()], - queryFn: fetchFn, + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + keySignal = keySignal + fetchFn = fetchFn + query = injectQuery(() => ({ + queryKey: ['key10', this.keySignal()], + queryFn: this.fetchFn, enabled: false, })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(fetchFn).not.toHaveBeenCalled() @@ -436,6 +505,7 @@ describe('injectQuery', () => { await vi.advanceTimersByTimeAsync(11) keySignal.set('key12') + fixture.detectChanges() query.refetch().then(() => { expect(fetchFn).toHaveBeenCalledTimes(2) @@ -452,15 +522,26 @@ describe('injectQuery', () => { describe('throwOnError', () => { test('should evaluate throwOnError when query is expected to throw', async () => { const boundaryFn = vi.fn() - TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + boundaryFn = boundaryFn + query = injectQuery(() => ({ queryKey: ['key12'], queryFn: () => sleep(10).then(() => Promise.reject(new Error('Some error'))), retry: false, - throwOnError: boundaryFn, + throwOnError: this.boundaryFn, })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() await vi.advanceTimersByTimeAsync(11) expect(boundaryFn).toHaveBeenCalledTimes(1) @@ -473,41 +554,67 @@ describe('injectQuery', () => { }) test('should throw when throwOnError is true', async () => { - TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['key13'], queryFn: () => sleep(0).then(() => Promise.reject(new Error('Some error'))), throwOnError: true, })) - }) + } + + TestBed.createComponent(TestComponent).detectChanges() await expect(vi.runAllTimersAsync()).rejects.toThrow('Some error') }) test('should throw when throwOnError function returns true', async () => { - TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['key14'], queryFn: () => sleep(0).then(() => Promise.reject(new Error('Some error'))), throwOnError: () => true, })) - }) + } + + TestBed.createComponent(TestComponent).detectChanges() await expect(vi.runAllTimersAsync()).rejects.toThrow('Some error') }) }) test('should set state to error when queryFn returns reject promise', async () => { - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ retry: false, queryKey: ['key15'], queryFn: () => sleep(10).then(() => Promise.reject(new Error('Some error'))), })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(query.status()).toBe('pending') @@ -520,6 +627,7 @@ describe('injectQuery', () => { @Component({ selector: 'app-fake', template: `{{ query.data() }}`, + changeDetection: ChangeDetectionStrategy.OnPush, }) class FakeComponent { name = input.required() @@ -554,15 +662,29 @@ describe('injectQuery', () => { }) test('can be used outside injection context when passing an injector', () => { - const query = injectQuery( - () => ({ - queryKey: ['manualInjector'], - queryFn: () => sleep(0).then(() => 'Some data'), - }), - { - injector: TestBed.inject(Injector), - }, - ) + const injector = TestBed.inject(Injector) + + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery( + () => ({ + queryKey: ['manualInjector'], + queryFn: () => sleep(0).then(() => 'Some data'), + }), + { + injector: injector, + }, + ) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(query.status()).toBe('pending') }) @@ -570,15 +692,25 @@ describe('injectQuery', () => { test('should complete queries before whenStable() resolves', async () => { const app = TestBed.inject(ApplicationRef) - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['pendingTasksTest'], queryFn: async () => { await sleep(50) return 'test data' }, - })), - ) + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(query.status()).toBe('pending') expect(query.data()).toBeUndefined() @@ -606,14 +738,24 @@ describe('injectQuery', () => { const httpClient = TestBed.inject(HttpClient) const httpTestingController = TestBed.inject(HttpTestingController) - // Create a query using HttpClient - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + httpClient = httpClient + query = injectQuery(() => ({ queryKey: ['httpClientTest'], queryFn: () => - lastValueFrom(httpClient.get<{ message: string }>('/api/test')), - })), - ) + lastValueFrom(this.httpClient.get<{ message: string }>('/api/test')), + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query // Schedule the HTTP response setTimeout(() => { @@ -646,28 +788,35 @@ describe('injectQuery', () => { }) const app = TestBed.inject(ApplicationRef) - let callCount = 0 - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + callCount = 0 + query = injectQuery(() => ({ queryKey: ['sync-stale'], staleTime: 1000, queryFn: () => { - callCount++ - return `sync-data-${callCount}` + this.callCount++ + return `sync-data-${this.callCount}` }, - })), - ) + })) + } - // Synchronize pending effects - TestBed.tick() + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const component = fixture.componentInstance + const query = component.query - const stablePromise = app.whenStable() - await stablePromise + await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-1') - expect(callCount).toBe(1) + expect(component.callCount).toBe(1) await query.refetch() await Promise.resolve() @@ -676,7 +825,7 @@ describe('injectQuery', () => { expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-2') - expect(callCount).toBe(2) + expect(component.callCount).toBe(2) }) test('should handle enabled/disabled transitions with synchronous queryFn', async () => { @@ -690,34 +839,45 @@ describe('injectQuery', () => { const app = TestBed.inject(ApplicationRef) const enabledSignal = signal(false) - let callCount = 0 - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + enabledSignal = enabledSignal + callCount = 0 + query = injectQuery(() => ({ queryKey: ['sync-enabled'], - enabled: enabledSignal(), + enabled: this.enabledSignal(), queryFn: () => { - callCount++ - return `sync-data-${callCount}` + this.callCount++ + return `sync-data-${this.callCount}` }, - })), - ) + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const component = fixture.componentInstance + const query = component.query // Initially disabled - TestBed.tick() await app.whenStable() expect(query.status()).toBe('pending') expect(query.data()).toBeUndefined() - expect(callCount).toBe(0) + expect(component.callCount).toBe(0) // Enable the query enabledSignal.set(true) - TestBed.tick() + fixture.detectChanges() await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-1') - expect(callCount).toBe(1) + expect(component.callCount).toBe(1) }) test('should handle query invalidation with synchronous data', async () => { @@ -731,39 +891,45 @@ describe('injectQuery', () => { const app = TestBed.inject(ApplicationRef) const testKey = ['sync-invalidate'] - let callCount = 0 - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + callCount = 0 + query = injectQuery(() => ({ queryKey: testKey, queryFn: () => { - callCount++ - return `sync-data-${callCount}` + this.callCount++ + return `sync-data-${this.callCount}` }, - })), - ) + })) + } - // Synchronize pending effects - TestBed.tick() + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const component = fixture.componentInstance + const query = component.query await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-1') - expect(callCount).toBe(1) + expect(component.callCount).toBe(1) // Invalidate the query queryClient.invalidateQueries({ queryKey: testKey }) - TestBed.tick() // Wait for the invalidation to trigger a refetch await Promise.resolve() await vi.advanceTimersByTimeAsync(10) - TestBed.tick() await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-2') - expect(callCount).toBe(2) + expect(component.callCount).toBe(2) }) }) }) 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 92f70aed9f..a98b4d6399 100644 --- a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts +++ b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts @@ -1,5 +1,6 @@ import { ApplicationRef, + ChangeDetectionStrategy, Component, provideZonelessChangeDetection, } from '@angular/core' @@ -55,12 +56,22 @@ describe('PendingTasks Integration', () => { test('should handle synchronous queryFn with whenStable()', async () => { const app = TestBed.inject(ApplicationRef) - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['sync'], queryFn: () => 'instant-data', // Resolves synchronously - })), - ) + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query // Should start as pending even with synchronous data expect(query.status()).toBe('pending') @@ -183,18 +194,28 @@ describe('PendingTasks Integration', () => { test('should handle rapid refetches without task leaks', async () => { const app = TestBed.inject(ApplicationRef) - let callCount = 0 - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + callCount = 0 + query = injectQuery(() => ({ queryKey: ['rapid-refetch'], queryFn: async () => { - callCount++ + this.callCount++ await sleep(10) - return `data-${callCount}` + return `data-${this.callCount}` }, - })), - ) + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query // Trigger multiple rapid refetches query.refetch() @@ -279,6 +300,7 @@ describe('PendingTasks Integration', () => { describe('Component Destruction', () => { @Component({ template: '', + changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { query = injectQuery(() => ({ @@ -300,6 +322,7 @@ describe('PendingTasks Integration', () => { test('should cleanup pending tasks when component with active query is destroyed', async () => { const app = TestBed.inject(ApplicationRef) const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() // Start the query expect(fixture.componentInstance.query.status()).toBe('pending') @@ -317,6 +340,7 @@ describe('PendingTasks Integration', () => { test('should cleanup pending tasks when component with active mutation is destroyed', async () => { const app = TestBed.inject(ApplicationRef) const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() fixture.componentInstance.mutation.mutate('test') @@ -335,32 +359,38 @@ describe('PendingTasks Integration', () => { test('should handle multiple queries running simultaneously', async () => { const app = TestBed.inject(ApplicationRef) - const query1 = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query1 = injectQuery(() => ({ queryKey: ['concurrent-1'], queryFn: async () => { await sleep(30) return 'data-1' }, - })), - ) + })) - const query2 = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + query2 = injectQuery(() => ({ queryKey: ['concurrent-2'], queryFn: async () => { await sleep(50) return 'data-2' }, - })), - ) + })) - const query3 = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + query3 = injectQuery(() => ({ queryKey: ['concurrent-3'], queryFn: () => 'instant-data', // Synchronous - })), - ) + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const { query1, query2, query3 } = fixture.componentInstance // All queries should start expect(query1.status()).toBe('pending') From 0edde05617f1ba2d9998e004db311f0a107ece6a Mon Sep 17 00:00:00 2001 From: Arnoud de Vries <6420061+arnoud-dv@users.noreply.github.com> Date: Sat, 22 Nov 2025 20:51:10 +0100 Subject: [PATCH 04/24] Use tracking to fix some subtle bugs --- .../src/create-base-query.ts | 54 +++++++++++++++++-- .../src/inject-mutation.ts | 1 - 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index c752618cde..1ced677ada 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -2,7 +2,12 @@ import { DestroyRef, NgZone, PendingTasks, computed, effect, inject, linkedSigna import { QueryClient, notifyManager, shouldThrowError, } from '@tanstack/query-core' import { signalProxy } from './signal-proxy' import { injectIsRestoring } from './inject-is-restoring' -import type { DefaultedQueryObserverOptions, QueryKey, QueryObserver, } from '@tanstack/query-core' +import type { + DefaultedQueryObserverOptions, + QueryKey, + QueryObserver, + QueryObserverResult, +} from '@tanstack/query-core' import type { CreateBaseQueryOptions } from './types' /** @@ -54,6 +59,43 @@ export function createBaseQuery< return defaultedOptions }) + const trackObserverResult = ( + result: QueryObserverResult, + notifyOnChangeProps?: DefaultedQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + >['notifyOnChangeProps'], + ) => { + if (!observer) { + throw new Error('Observer is not initialized') + } + + const trackedResult = observer.trackResult(result) + + if (!notifyOnChangeProps) { + autoTrackResultProperties(trackedResult) + } + + return trackedResult + } + + const autoTrackResultProperties = ( + result: QueryObserverResult, + ) => { + for (const key of Object.keys(result) as Array< + keyof QueryObserverResult + >) { + if (key === 'promise') continue + const value = result[key] + if (typeof value === 'function') continue + // Access value once so QueryObserver knows this prop is tracked. + void value + } + } + const createOrUpdateObserver = ( options: DefaultedQueryObserverOptions< TQueryFnData, @@ -94,7 +136,11 @@ export function createBaseQuery< ngZone.onError.emit(state.error) throw state.error } - resultSignal.set(state) + const trackedState = trackObserverResult( + state, + observer!.options.notifyOnChangeProps, + ) + resultSignal.set(trackedState) }) }), ) @@ -108,7 +154,9 @@ export function createBaseQuery< source: defaultedOptionsSignal, computation: () => { if (!observer) throw new Error('Observer is not initialized') - return observer.getOptimisticResult(defaultedOptionsSignal()) + const defaultedOptions = defaultedOptionsSignal() + const result = observer.getOptimisticResult(defaultedOptions) + return trackObserverResult(result, defaultedOptions.notifyOnChangeProps) }, }) diff --git a/packages/angular-query-experimental/src/inject-mutation.ts b/packages/angular-query-experimental/src/inject-mutation.ts index 7eb605047f..a6eb71b242 100644 --- a/packages/angular-query-experimental/src/inject-mutation.ts +++ b/packages/angular-query-experimental/src/inject-mutation.ts @@ -125,7 +125,6 @@ export function injectMutation< effect( (onCleanup) => { - // observer.trackResult is not used as this optimization is not needed for Angular const observer = observerSignal() let pendingTaskRef: PendingTaskRef | null = null From 1438a8948b5d49e9d7cbd788c909c40d8ff02257 Mon Sep 17 00:00:00 2001 From: Arnoud de Vries <6420061+arnoud-dv@users.noreply.github.com> Date: Sat, 22 Nov 2025 22:44:02 +0100 Subject: [PATCH 05/24] Fix PendingTasks for offline mode --- .../src/__tests__/pending-tasks.test.ts | 48 +++++++++++++++++++ .../src/create-base-query.ts | 29 +++++++---- 2 files changed, 68 insertions(+), 9 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 a98b4d6399..99f1cb8eb8 100644 --- a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts +++ b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts @@ -230,6 +230,54 @@ describe('PendingTasks Integration', () => { expect(query.data()).toMatch(/^data-\d+$/) }) + test('should keep PendingTasks active when query starts offline (never reaches fetching)', async () => { + const app = TestBed.inject(ApplicationRef) + + onlineManager.setOnline(false) + + const query = TestBed.runInInjectionContext(() => + injectQuery(() => ({ + queryKey: ['start-offline'], + networkMode: 'online', // Default: won't fetch while offline + queryFn: async () => { + await sleep(10) + return 'online-data' + }, + })), + ) + + // Allow query to initialize + await Promise.resolve() + await vi.advanceTimersByTimeAsync(0) + + // Query should initialize directly to 'paused' (never goes through 'fetching') + expect(query.status()).toBe('pending') + expect(query.fetchStatus()).toBe('paused') + + const stablePromise = app.whenStable() + let stableResolved = false + void stablePromise.then(() => { + stableResolved = true + }) + + await Promise.resolve() + + // PendingTasks should block stability even though we never hit 'fetching' + expect(stableResolved).toBe(false) + + // Bring the app back online so the query can fetch + onlineManager.setOnline(true) + + await vi.advanceTimersByTimeAsync(20) + await Promise.resolve() + + await stablePromise + + expect(stableResolved).toBe(true) + expect(query.status()).toBe('success') + expect(query.data()).toBe('online-data') + }) + test('should keep PendingTasks active while query retry is paused offline', async () => { const app = TestBed.inject(ApplicationRef) let attempt = 0 diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index 1ced677ada..d475b55ef4 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -45,6 +45,21 @@ export function createBaseQuery< TQueryKey > | null = null + let taskCleanupRef: (() => void) | null = null + + const startPendingTask = () => { + if (!taskCleanupRef) { + taskCleanupRef = pendingTasks.add() + } + } + + const stopPendingTask = () => { + if (taskCleanupRef) { + taskCleanupRef() + taskCleanupRef = null + } + } + /** * Signal that has the default options from query client applied * computed() is used so signals can be inserted into the options @@ -111,18 +126,14 @@ export function createBaseQuery< } observer = new Observer(queryClient, options) - let taskCleanupRef: (() => void) | null = null const unsubscribe = observer.subscribe( notifyManager.batchCalls((state) => { ngZone.run(() => { - if (state.fetchStatus === 'fetching' && !taskCleanupRef) { - taskCleanupRef = pendingTasks.add() - } - - if (state.fetchStatus === 'idle' && taskCleanupRef) { - taskCleanupRef() - taskCleanupRef = null + if (state.fetchStatus !== 'idle') { + startPendingTask() + } else { + stopPendingTask() } if ( @@ -146,7 +157,7 @@ export function createBaseQuery< ) destroyRef.onDestroy(() => { unsubscribe() - taskCleanupRef?.() + stopPendingTask() }) } From e9439a5d097aab3463721096df82a1fc94b6fd63 Mon Sep 17 00:00:00 2001 From: Arnoud de Vries <6420061+arnoud-dv@users.noreply.github.com> Date: Sun, 23 Nov 2025 00:37:09 +0100 Subject: [PATCH 06/24] Use queueMicrotask instead of notifyManager.batchCalls to improve timing --- .../__tests__/inject-infinite-query.test.ts | 2 +- .../src/__tests__/inject-query.test.ts | 33 ++++++-- .../src/create-base-query.ts | 81 ++++++++++++------- 3 files changed, 78 insertions(+), 38 deletions(-) diff --git a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts index 8812edbb62..07360df95e 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts @@ -7,7 +7,7 @@ import { provideZonelessChangeDetection, } from '@angular/core' import { sleep } from '@tanstack/query-test-utils' -import { injectInfiniteQuery, provideTanStackQuery, QueryClient } from '..' +import { QueryClient, injectInfiniteQuery, provideTanStackQuery } from '..' import { expectSignals } from './test-utils' describe('injectInfiniteQuery', () => { 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 474ee3d747..a5422cb7ac 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -1,12 +1,13 @@ import { ApplicationRef, + ChangeDetectionStrategy, Component, Injector, + NgZone, computed, input, provideZonelessChangeDetection, signal, - ChangeDetectionStrategy, } from '@angular/core' import { TestBed } from '@angular/core/testing' import { HttpClient, provideHttpClient } from '@angular/common/http' @@ -554,6 +555,14 @@ describe('injectQuery', () => { }) test('should throw when throwOnError is true', async () => { + const zone = TestBed.inject(NgZone) + const errorPromise = new Promise((resolve) => { + const sub = zone.onError.subscribe((error) => { + sub.unsubscribe() + resolve(error as Error) + }) + }) + @Component({ selector: 'app-test', template: '', @@ -571,10 +580,19 @@ describe('injectQuery', () => { TestBed.createComponent(TestComponent).detectChanges() - await expect(vi.runAllTimersAsync()).rejects.toThrow('Some error') + await vi.runAllTimersAsync() + await expect(errorPromise).resolves.toEqual(Error('Some error')) }) test('should throw when throwOnError function returns true', async () => { + const zone = TestBed.inject(NgZone) + const errorPromise = new Promise((resolve) => { + const sub = zone.onError.subscribe((error) => { + sub.unsubscribe() + resolve(error as Error) + }) + }) + @Component({ selector: 'app-test', template: '', @@ -592,7 +610,8 @@ describe('injectQuery', () => { TestBed.createComponent(TestComponent).detectChanges() - await expect(vi.runAllTimersAsync()).rejects.toThrow('Some error') + await vi.runAllTimersAsync() + await expect(errorPromise).resolves.toEqual(Error('Some error')) }) }) @@ -715,9 +734,8 @@ describe('injectQuery', () => { expect(query.status()).toBe('pending') expect(query.data()).toBeUndefined() - const stablePromise = app.whenStable() await vi.advanceTimersByTimeAsync(60) - await stablePromise + await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('test data') @@ -767,9 +785,8 @@ describe('injectQuery', () => { expect(query.status()).toBe('pending') // Advance timers and wait for Angular to be "stable" - const stablePromise = app.whenStable() await vi.advanceTimersByTimeAsync(20) - await stablePromise + await app.whenStable() // Query should be complete after whenStable() thanks to PendingTasks integration expect(query.status()).toBe('success') @@ -865,6 +882,7 @@ describe('injectQuery', () => { const query = component.query // Initially disabled + await vi.advanceTimersByTimeAsync(0) await app.whenStable() expect(query.status()).toBe('pending') expect(query.data()).toBeUndefined() @@ -874,6 +892,7 @@ describe('injectQuery', () => { enabledSignal.set(true) fixture.detectChanges() + await vi.advanceTimersByTimeAsync(0) await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-1') diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index d475b55ef4..a28999308c 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -1,5 +1,18 @@ -import { DestroyRef, NgZone, PendingTasks, computed, effect, inject, linkedSignal, untracked, } from '@angular/core' -import { QueryClient, notifyManager, shouldThrowError, } from '@tanstack/query-core' +import { + DestroyRef, + NgZone, + PendingTasks, + computed, + effect, + inject, + linkedSignal, + untracked, +} from '@angular/core' +import { + QueryClient, + notifyManager, + shouldThrowError, +} from '@tanstack/query-core' import { signalProxy } from './signal-proxy' import { injectIsRestoring } from './inject-is-restoring' import type { @@ -45,6 +58,7 @@ export function createBaseQuery< TQueryKey > | null = null + let destroyed = false let taskCleanupRef: (() => void) | null = null const startPendingTask = () => { @@ -127,35 +141,39 @@ export function createBaseQuery< observer = new Observer(queryClient, options) - const unsubscribe = observer.subscribe( - notifyManager.batchCalls((state) => { - ngZone.run(() => { - if (state.fetchStatus !== 'idle') { - startPendingTask() - } else { - stopPendingTask() - } - - if ( - state.isError && - !state.isFetching && - shouldThrowError(observer!.options.throwOnError, [ - state.error, - observer!.getCurrentQuery(), - ]) - ) { - ngZone.onError.emit(state.error) - throw state.error - } - const trackedState = trackObserverResult( - state, - observer!.options.notifyOnChangeProps, - ) - resultSignal.set(trackedState) + const unsubscribe = observer.subscribe((state) => { + if (state.fetchStatus !== 'idle') { + startPendingTask() + } else { + stopPendingTask() + } + + queueMicrotask(() => { + if (destroyed) return + notifyManager.batch(() => { + ngZone.run(() => { + if ( + state.isError && + !state.isFetching && + shouldThrowError(observer!.options.throwOnError, [ + state.error, + observer!.getCurrentQuery(), + ]) + ) { + ngZone.onError.emit(state.error) + throw state.error + } + const trackedState = trackObserverResult( + state, + observer!.options.notifyOnChangeProps, + ) + resultSignal.set(trackedState) + }) }) - }), - ) + }) + }) destroyRef.onDestroy(() => { + destroyed = true unsubscribe() stopPendingTask() }) @@ -164,7 +182,10 @@ export function createBaseQuery< const resultSignal = linkedSignal({ source: defaultedOptionsSignal, computation: () => { - if (!observer) throw new Error('Observer is not initialized') + if (!observer) + throw new Error( + 'injectQuery: QueryObserver not initialized yet. Avoid reading the query result during construction', + ) const defaultedOptions = defaultedOptionsSignal() const result = observer.getOptimisticResult(defaultedOptions) return trackObserverResult(result, defaultedOptions.notifyOnChangeProps) From 2df898329478e47b3c7ada71d8462495b26cd507 Mon Sep 17 00:00:00 2001 From: Arnoud de Vries <6420061+arnoud-dv@users.noreply.github.com> Date: Sun, 23 Nov 2025 02:17:10 +0100 Subject: [PATCH 07/24] Fix isRestoring() handling --- .../src/app/components/example.component.ts | 17 +++---- .../optimistic-updates.component.ts | 2 +- .../src/__tests__/inject-query.test.ts | 42 +++++++++++++--- .../src/create-base-query.ts | 49 ++++++++++++------- 4 files changed, 73 insertions(+), 37 deletions(-) diff --git a/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts b/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts index 71c141e3e4..bf3fd06014 100644 --- a/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts +++ b/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts @@ -30,30 +30,25 @@ export class ExampleComponent { })) readonly nextButtonDisabled = computed( - () => !this.#hasNextPage() || this.#isFetchingNextPage(), + () => !this.query.hasNextPage() || this.query.isFetchingNextPage(), ) readonly nextButtonText = computed(() => - this.#isFetchingNextPage() + this.query.isFetchingNextPage() ? 'Loading more...' - : this.#hasNextPage() + : this.query.hasNextPage() ? 'Load newer' : 'Nothing more to load', ) readonly previousButtonDisabled = computed( - () => !this.#hasPreviousPage() || this.#isFetchingNextPage(), + () => !this.query.hasPreviousPage() || this.query.isFetchingNextPage(), ) readonly previousButtonText = computed(() => - this.#isFetchingPreviousPage() + this.query.isFetchingPreviousPage() ? 'Loading more...' - : this.#hasPreviousPage() + : this.query.hasPreviousPage() ? 'Load Older' : 'Nothing more to load', ) - - readonly #hasPreviousPage = this.query.hasPreviousPage - readonly #hasNextPage = this.query.hasNextPage - readonly #isFetchingPreviousPage = this.query.isFetchingPreviousPage - readonly #isFetchingNextPage = this.query.isFetchingNextPage } diff --git a/examples/angular/optimistic-updates/src/app/components/optimistic-updates.component.ts b/examples/angular/optimistic-updates/src/app/components/optimistic-updates.component.ts index 2b0b4cc1c4..b32a0f50dc 100644 --- a/examples/angular/optimistic-updates/src/app/components/optimistic-updates.component.ts +++ b/examples/angular/optimistic-updates/src/app/components/optimistic-updates.component.ts @@ -36,7 +36,7 @@ import { TasksService } from '../services/tasks.service'
    - @for (task of tasks.data(); track task) { + @for (task of tasks.data(); track $index) {
  • {{ task }}
  • }
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 a5422cb7ac..7770477fd3 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -556,12 +556,20 @@ describe('injectQuery', () => { test('should throw when throwOnError is true', async () => { const zone = TestBed.inject(NgZone) - const errorPromise = new Promise((resolve) => { + const zoneErrorPromise = new Promise((resolve) => { const sub = zone.onError.subscribe((error) => { sub.unsubscribe() resolve(error as Error) }) }) + let handler: ((error: Error) => void) | null = null + const processErrorPromise = new Promise((resolve) => { + handler = (error: Error) => { + process.off('uncaughtException', handler!) + resolve(error) + } + process.on('uncaughtException', handler) + }) @Component({ selector: 'app-test', @@ -580,18 +588,33 @@ describe('injectQuery', () => { TestBed.createComponent(TestComponent).detectChanges() - await vi.runAllTimersAsync() - await expect(errorPromise).resolves.toEqual(Error('Some error')) + try { + await vi.runAllTimersAsync() + await expect(zoneErrorPromise).resolves.toEqual(Error('Some error')) + await expect(processErrorPromise).resolves.toEqual(Error('Some error')) + } finally { + if (handler) { + process.off('uncaughtException', handler) + } + } }) test('should throw when throwOnError function returns true', async () => { const zone = TestBed.inject(NgZone) - const errorPromise = new Promise((resolve) => { + const zoneErrorPromise = new Promise((resolve) => { const sub = zone.onError.subscribe((error) => { sub.unsubscribe() resolve(error as Error) }) }) + let handler: ((error: Error) => void) | null = null + const processErrorPromise = new Promise((resolve) => { + handler = (error: Error) => { + process.off('uncaughtException', handler!) + resolve(error) + } + process.on('uncaughtException', handler) + }) @Component({ selector: 'app-test', @@ -610,8 +633,15 @@ describe('injectQuery', () => { TestBed.createComponent(TestComponent).detectChanges() - await vi.runAllTimersAsync() - await expect(errorPromise).resolves.toEqual(Error('Some error')) + try { + await vi.runAllTimersAsync() + await expect(zoneErrorPromise).resolves.toEqual(Error('Some error')) + await expect(processErrorPromise).resolves.toEqual(Error('Some error')) + } finally { + if (handler) { + process.off('uncaughtException', handler) + } + } }) }) diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index a28999308c..4b1ce4ca5c 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -99,7 +99,7 @@ export function createBaseQuery< >['notifyOnChangeProps'], ) => { if (!observer) { - throw new Error('Observer is not initialized') + throw new Error(OBSERVER_NOT_READY_ERROR) } const trackedResult = observer.trackResult(result) @@ -125,7 +125,7 @@ export function createBaseQuery< } } - const createOrUpdateObserver = ( + const setObserverOptions = ( options: DefaultedQueryObserverOptions< TQueryFnData, TError, @@ -134,14 +134,23 @@ export function createBaseQuery< TQueryKey >, ) => { - if (observer) { + if (!observer) { + observer = new Observer(queryClient, options) + destroyRef.onDestroy(() => { + destroyed = true + stopPendingTask() + }) + } else { observer.setOptions(options) - return } + } - observer = new Observer(queryClient, options) + const subscribeToObserver = () => { + if (!observer) { + throw new Error(OBSERVER_NOT_READY_ERROR) + } - const unsubscribe = observer.subscribe((state) => { + return observer.subscribe((state) => { if (state.fetchStatus !== 'idle') { startPendingTask() } else { @@ -172,35 +181,37 @@ export function createBaseQuery< }) }) }) - destroyRef.onDestroy(() => { - destroyed = true - unsubscribe() - stopPendingTask() - }) } const resultSignal = linkedSignal({ source: defaultedOptionsSignal, computation: () => { - if (!observer) - throw new Error( - 'injectQuery: QueryObserver not initialized yet. Avoid reading the query result during construction', - ) + if (!observer) throw new Error(OBSERVER_NOT_READY_ERROR) const defaultedOptions = defaultedOptionsSignal() const result = observer.getOptimisticResult(defaultedOptions) return trackObserverResult(result, defaultedOptions.notifyOnChangeProps) }, }) - // Effect to initialize the observer and set options when options change effect(() => { const defaultedOptions = defaultedOptionsSignal() - if (isRestoring()) return - untracked(() => { - createOrUpdateObserver(defaultedOptions) + setObserverOptions(defaultedOptions) + }) + }) + + effect((onCleanup) => { + if (isRestoring()) { + return + } + const unsubscribe = untracked(() => subscribeToObserver()) + onCleanup(() => { + unsubscribe() + stopPendingTask() }) }) return signalProxy(resultSignal.asReadonly()) } +const OBSERVER_NOT_READY_ERROR = + 'injectQuery: QueryObserver not initialized yet. Avoid reading the query result during construction' From a9adba6242c0c5f06ae46cd5b0ebf8d62f748f40 Mon Sep 17 00:00:00 2001 From: Arnoud de Vries <6420061+arnoud-dv@users.noreply.github.com> Date: Sun, 23 Nov 2025 02:32:32 +0100 Subject: [PATCH 08/24] add changeset --- .changeset/deep-crews-open.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/deep-crews-open.md diff --git a/.changeset/deep-crews-open.md b/.changeset/deep-crews-open.md new file mode 100644 index 0000000000..b46a786d5a --- /dev/null +++ b/.changeset/deep-crews-open.md @@ -0,0 +1,5 @@ +--- +'@tanstack/angular-query-experimental': minor +--- + +require Angular v19+ and use Angular component effect scheduling From ffb096e7b33982155063b92ee1b8fa43b01a2801 Mon Sep 17 00:00:00 2001 From: Arnoud de Vries <6420061+arnoud-dv@users.noreply.github.com> Date: Sun, 23 Nov 2025 14:31:54 +0100 Subject: [PATCH 09/24] Improve tests --- docs/framework/angular/guides/testing.md | 4 +- docs/framework/angular/installation.md | 2 +- docs/framework/angular/overview.md | 2 +- .../src/app/components/example.component.ts | 2 +- .../__tests__/inject-devtools-panel.test.ts | 16 +--- .../__tests__/inject-infinite-query.test.ts | 58 ++++-------- .../src/__tests__/inject-is-fetching.test.ts | 17 +--- .../src/__tests__/inject-is-mutating.test.ts | 19 +--- .../__tests__/inject-mutation-state.test.ts | 33 ++++--- .../src/__tests__/inject-mutation.test.ts | 69 ++++++++++---- .../src/__tests__/inject-queries.test.ts | 18 +--- .../src/__tests__/inject-query.test.ts | 50 ++++------ .../src/__tests__/mutation-options.test.ts | 18 ++-- .../src/__tests__/pending-tasks.test.ts | 34 ++----- .../src/__tests__/test-utils.ts | 91 ++++++++++++------- .../src/__tests__/with-devtools.test.ts | 31 ++++--- 16 files changed, 216 insertions(+), 248 deletions(-) diff --git a/docs/framework/angular/guides/testing.md b/docs/framework/angular/guides/testing.md index 7648d7f6b3..3ffb9588b8 100644 --- a/docs/framework/angular/guides/testing.md +++ b/docs/framework/angular/guides/testing.md @@ -9,8 +9,6 @@ TanStack Query's `inject*` functions integrate with [`PendingTasks`](https://ang This means tests and SSR can wait until mutations and queries resolve. In unit tests you can use `ApplicationRef.whenStable()` or `fixture.whenStable()` to await query completion. This works for both Zone.js and Zoneless setups. -> This integration requires Angular 19 or later. Earlier versions of Angular do not support `PendingTasks`. - ## TestBed setup Create a fresh `QueryClient` for every spec and provide it with `provideTanStackQuery` or `provideQueryClient`. This keeps caches isolated and lets you change default options per test: @@ -31,7 +29,7 @@ TestBed.configureTestingModule({ > If your applications actual TanStack Query config is used in unit tests, make sure `withDevtools` is not accidentally included in test providers. This can cause slow tests. It is best to keep test and production configs separate. -If you share helpers, remember to call `queryClient.clear()` (or build a new instance) in `afterEach` so data from one test never bleeds into another. +If you share helpers, remember to call `queryClient.clear()` (or build a new instance) in `afterEach` so data from one test never bleeds into another. Prefer creating a fresh `QueryClient` per test: clearing only removes cached data, not custom defaults or listeners, so a reused client can leak configuration changes between specs and make failures harder to reason about. A new client keeps setup explicit and avoids any “invisible globals” influencing results. ## First query test diff --git a/docs/framework/angular/installation.md b/docs/framework/angular/installation.md index dffc092e7c..14b48432f8 100644 --- a/docs/framework/angular/installation.md +++ b/docs/framework/angular/installation.md @@ -7,7 +7,7 @@ title: Installation ### NPM -_Angular Query is compatible with Angular v16 and higher_ +_Angular Query is compatible with Angular v19 and higher_ ```bash npm i @tanstack/angular-query-experimental diff --git a/docs/framework/angular/overview.md b/docs/framework/angular/overview.md index be68c08e5a..bfd93d4e89 100644 --- a/docs/framework/angular/overview.md +++ b/docs/framework/angular/overview.md @@ -13,7 +13,7 @@ We are in the process of getting to a stable API for TanStack Query on Angular. ## Supported Angular Versions -TanStack Query is compatible with Angular v16 and higher. +TanStack Query is compatible with Angular v19 and higher. TanStack Query (FKA React Query) is often described as the missing data-fetching library for web applications, but in more technical terms, it makes **fetching, caching, synchronizing and updating server state** in your web applications a breeze. diff --git a/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts b/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts index bf3fd06014..3232f64942 100644 --- a/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts +++ b/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts @@ -42,7 +42,7 @@ export class ExampleComponent { ) readonly previousButtonDisabled = computed( - () => !this.query.hasPreviousPage() || this.query.isFetchingNextPage(), + () => !this.query.hasPreviousPage() || this.query.isFetchingPreviousPage(), ) readonly previousButtonText = computed(() => this.query.isFetchingPreviousPage() diff --git a/packages/angular-query-experimental/src/__tests__/inject-devtools-panel.test.ts b/packages/angular-query-experimental/src/__tests__/inject-devtools-panel.test.ts index 7368deb32e..835cb01ab3 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-devtools-panel.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-devtools-panel.test.ts @@ -1,13 +1,9 @@ -import { - ElementRef, - provideZonelessChangeDetection, - signal, -} from '@angular/core' +import { ElementRef, signal } from '@angular/core' import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { QueryClient } from '@tanstack/query-core' -import { provideTanStackQuery } from '../providers' import { injectDevtoolsPanel } from '../devtools-panel' +import { setupTanStackQueryTestBed } from './test-utils' const mockDevtoolsPanelInstance = { mount: vi.fn(), @@ -40,12 +36,8 @@ describe('injectDevtoolsPanel', () => { beforeEach(() => { queryClient = new QueryClient() mockElementRef = new ElementRef(document.createElement('div')) - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - { provide: ElementRef, useValue: signal(mockElementRef) }, - ], + setupTanStackQueryTestBed(queryClient, { + providers: [{ provide: ElementRef, useValue: signal(mockElementRef) }], }) }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts index 07360df95e..f587f6a2fe 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts @@ -1,14 +1,9 @@ import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { - ChangeDetectionStrategy, - Component, - Injector, - provideZonelessChangeDetection, -} from '@angular/core' +import { ChangeDetectionStrategy, Component, Injector } from '@angular/core' import { sleep } from '@tanstack/query-test-utils' -import { QueryClient, injectInfiniteQuery, provideTanStackQuery } from '..' -import { expectSignals } from './test-utils' +import { QueryClient, injectInfiniteQuery } from '..' +import { expectSignals, setupTanStackQueryTestBed } from './test-utils' describe('injectInfiniteQuery', () => { let queryClient: QueryClient @@ -16,12 +11,7 @@ describe('injectInfiniteQuery', () => { beforeEach(() => { queryClient = new QueryClient() vi.useFakeTimers() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + setupTanStackQueryTestBed(queryClient) }) afterEach(() => { @@ -32,7 +22,6 @@ describe('injectInfiniteQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -93,30 +82,21 @@ describe('injectInfiniteQuery', () => { test('can be used outside injection context when passing an injector', () => { const injector = TestBed.inject(Injector) - @Component({ - selector: 'app-test', - template: '', - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - }) - class TestComponent { - query = injectInfiniteQuery( - () => ({ - queryKey: ['manualInjector'], - queryFn: ({ pageParam }) => - sleep(0).then(() => 'data on page ' + pageParam), - initialPageParam: 0, - getNextPageParam: () => 12, - }), - { - injector: injector, - }, - ) - } - - const fixture = TestBed.createComponent(TestComponent) - fixture.detectChanges() - const query = fixture.componentInstance.query + // Call injectInfiniteQuery directly outside any component + const query = injectInfiniteQuery( + () => ({ + queryKey: ['manualInjector'], + queryFn: ({ pageParam }) => + sleep(0).then(() => 'data on page ' + pageParam), + initialPageParam: 0, + getNextPageParam: () => 12, + }), + { + injector: injector, + }, + ) + + TestBed.tick() expect(query.status()).toBe('pending') }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-is-fetching.test.ts b/packages/angular-query-experimental/src/__tests__/inject-is-fetching.test.ts index 329ef6d9e3..a7461dbc26 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-is-fetching.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-is-fetching.test.ts @@ -1,13 +1,9 @@ import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { Injector, provideZonelessChangeDetection } from '@angular/core' +import { Injector } from '@angular/core' import { sleep } from '@tanstack/query-test-utils' -import { - QueryClient, - injectIsFetching, - injectQuery, - provideTanStackQuery, -} from '..' +import { QueryClient, injectIsFetching, injectQuery } from '..' +import { setupTanStackQueryTestBed } from './test-utils' describe('injectIsFetching', () => { let queryClient: QueryClient @@ -16,12 +12,7 @@ describe('injectIsFetching', () => { vi.useFakeTimers() queryClient = new QueryClient() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + setupTanStackQueryTestBed(queryClient) }) afterEach(() => { diff --git a/packages/angular-query-experimental/src/__tests__/inject-is-mutating.test.ts b/packages/angular-query-experimental/src/__tests__/inject-is-mutating.test.ts index 5a4694cb85..6d30b988f4 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-is-mutating.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-is-mutating.test.ts @@ -1,13 +1,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { TestBed } from '@angular/core/testing' -import { Injector, provideZonelessChangeDetection } from '@angular/core' +import { Injector } from '@angular/core' import { sleep } from '@tanstack/query-test-utils' -import { - QueryClient, - injectIsMutating, - injectMutation, - provideTanStackQuery, -} from '..' +import { QueryClient, injectIsMutating, injectMutation } from '..' +import { flushQueryUpdates, setupTanStackQueryTestBed } from './test-utils' describe('injectIsMutating', () => { let queryClient: QueryClient @@ -16,12 +12,7 @@ describe('injectIsMutating', () => { vi.useFakeTimers() queryClient = new QueryClient() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + setupTanStackQueryTestBed(queryClient) }) afterEach(() => { @@ -44,7 +35,7 @@ describe('injectIsMutating', () => { }) expect(isMutating()).toBe(0) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(isMutating()).toBe(1) await vi.advanceTimersByTimeAsync(11) expect(isMutating()).toBe(0) diff --git a/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts b/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts index bb1c97b3bd..e8c86c068f 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts @@ -8,7 +8,6 @@ import { } from '@angular/core' import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { By } from '@angular/platform-browser' import { sleep } from '@tanstack/query-test-utils' import { QueryClient, @@ -16,7 +15,7 @@ import { injectMutationState, provideTanStackQuery, } from '..' -import { setFixtureSignalInputs } from './test-utils' +import { registerSignalInput } from './test-utils' describe('injectMutationState', () => { let queryClient: QueryClient @@ -159,23 +158,35 @@ describe('injectMutationState', () => { })) } - const fixture = TestBed.createComponent(FakeComponent) - const { debugElement } = fixture - setFixtureSignalInputs(fixture, { name: fakeName }) + registerSignalInput(FakeComponent, 'name') + + @Component({ + template: ``, + imports: [FakeComponent], + }) + class HostComponent { + protected readonly name = signal(fakeName) + } + + const fixture = TestBed.createComponent(HostComponent) + fixture.detectChanges() await vi.advanceTimersByTimeAsync(0) - let spans = debugElement - .queryAll(By.css('span')) - .map((span) => span.nativeNode.textContent) + const readSpans = () => + Array.from( + fixture.nativeElement.querySelectorAll( + 'span', + ) as NodeListOf, + ).map((span) => span.textContent) + + let spans = readSpans() expect(spans).toEqual(['pending', 'pending']) await vi.advanceTimersByTimeAsync(11) fixture.detectChanges() - spans = debugElement - .queryAll(By.css('span')) - .map((span) => span.nativeNode.textContent) + spans = readSpans() expect(spans).toEqual(['success', 'error']) }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts index d7a8d85121..c1548bc634 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts @@ -9,10 +9,9 @@ import { } from '@angular/core' import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { By } from '@angular/platform-browser' import { sleep } from '@tanstack/query-test-utils' import { QueryClient, injectMutation, provideTanStackQuery } from '..' -import { expectSignals, setFixtureSignalInputs } from './test-utils' +import { expectSignals, registerSignalInput } from './test-utils' describe('injectMutation', () => { let queryClient: QueryClient @@ -323,19 +322,32 @@ describe('injectMutation', () => { } } - const fixture = TestBed.createComponent(FakeComponent) - const { debugElement } = fixture - setFixtureSignalInputs(fixture, { name: 'value' }) + registerSignalInput(FakeComponent, 'name') - const button = debugElement.query(By.css('button')) - button.triggerEventHandler('click') + @Component({ + template: ``, + imports: [FakeComponent], + }) + class HostComponent { + protected readonly name = signal('value') + } + + const fixture = TestBed.createComponent(HostComponent) + fixture.detectChanges() + + const hostButton = fixture.nativeElement.querySelector( + 'button', + ) as HTMLButtonElement + hostButton.click() await vi.advanceTimersByTimeAsync(11) fixture.detectChanges() - const text = debugElement.query(By.css('span')).nativeElement.textContent - expect(text).toEqual('value') - const mutation = mutationCache.find({ mutationKey: ['fake', 'value'] }) + const span = fixture.nativeElement.querySelector('span') as HTMLSpanElement + expect(span.textContent).toEqual('value') + const mutation = mutationCache.find({ + mutationKey: ['fake', 'value'], + }) expect(mutation).toBeDefined() expect(mutation!.options.mutationKey).toStrictEqual(['fake', 'value']) }) @@ -364,26 +376,43 @@ describe('injectMutation', () => { } } - const fixture = TestBed.createComponent(FakeComponent) - const { debugElement } = fixture - setFixtureSignalInputs(fixture, { name: 'value' }) + registerSignalInput(FakeComponent, 'name') - const button = debugElement.query(By.css('button')) - const span = debugElement.query(By.css('span')) + @Component({ + template: ``, + imports: [FakeComponent], + }) + class HostComponent { + protected readonly name = signal('value') + + updateName(value: string): void { + this.name.set(value) + } + } - button.triggerEventHandler('click') + const fixture = TestBed.createComponent(HostComponent) + fixture.detectChanges() + + let button = fixture.nativeElement.querySelector( + 'button', + ) as HTMLButtonElement + button.click() await vi.advanceTimersByTimeAsync(11) fixture.detectChanges() - expect(span.nativeElement.textContent).toEqual('value') + let span = fixture.nativeElement.querySelector('span') as HTMLSpanElement + expect(span.textContent).toEqual('value') - setFixtureSignalInputs(fixture, { name: 'updatedValue' }) + fixture.componentInstance.updateName('updatedValue') + fixture.detectChanges() - button.triggerEventHandler('click') + button = fixture.nativeElement.querySelector('button') as HTMLButtonElement + button.click() await vi.advanceTimersByTimeAsync(11) fixture.detectChanges() - expect(span.nativeElement.textContent).toEqual('updatedValue') + span = fixture.nativeElement.querySelector('span') as HTMLSpanElement + expect(span.textContent).toEqual('updatedValue') const mutations = mutationCache.findAll() expect(mutations.length).toBe(2) diff --git a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts index ab16d3b296..44558aa743 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts @@ -1,26 +1,16 @@ import { beforeEach, describe, expect, it } from 'vitest' import { render } from '@testing-library/angular' -import { - ChangeDetectionStrategy, - Component, - effect, - provideZonelessChangeDetection, -} from '@angular/core' -import { TestBed } from '@angular/core/testing' +import { ChangeDetectionStrategy, Component, effect } from '@angular/core' import { queryKey } from '@tanstack/query-test-utils' -import { QueryClient, provideTanStackQuery } from '..' +import { QueryClient } from '..' import { injectQueries } from '../inject-queries' +import { setupTanStackQueryTestBed } from './test-utils' let queryClient: QueryClient beforeEach(() => { queryClient = new QueryClient() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + setupTanStackQueryTestBed(queryClient) }) describe('injectQueries', () => { 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 7770477fd3..d1befc202b 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -27,7 +27,7 @@ import { import { queryKey, sleep } from '@tanstack/query-test-utils' import { lastValueFrom } from 'rxjs' import { QueryCache, QueryClient, injectQuery, provideTanStackQuery } from '..' -import { setSignalInputs } from './test-utils' +import { registerSignalInput } from './test-utils' import type { CreateQueryOptions, OmitKeyof, QueryFunction } from '..' describe('injectQuery', () => { @@ -55,7 +55,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -162,10 +161,7 @@ describe('injectQuery', () => { TData = TQueryFnData, >( qk: TQueryKey, - fetcher: ( - obj: TQueryKey[1], - token: string, - ) => Promise, + fetcher: (obj: TQueryKey[1], token: string) => Promise, options?: OmitKeyof< CreateQueryOptions, 'queryKey' | 'queryFn' | 'initialData', @@ -242,7 +238,9 @@ describe('injectQuery', () => { expectTypeOf(fromGenericOptionsQueryFn.data()).toEqualTypeOf< string | undefined >() - expectTypeOf(fromGenericOptionsQueryFn.error()).toEqualTypeOf() + expectTypeOf( + fromGenericOptionsQueryFn.error(), + ).toEqualTypeOf() expectTypeOf(fromMyDataArrayKeyQueryFn.data()).toEqualTypeOf< number | undefined @@ -264,7 +262,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -289,7 +286,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -316,7 +312,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -350,7 +345,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -392,7 +386,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -427,7 +420,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -475,7 +467,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -527,7 +518,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -574,7 +564,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -619,7 +608,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -649,7 +637,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -687,17 +674,22 @@ describe('injectQuery', () => { })) } - const fixture = TestBed.createComponent(FakeComponent) - setSignalInputs(fixture.componentInstance, { - name: 'signal-input-required-test', + registerSignalInput(FakeComponent, 'name') + + @Component({ + template: ``, + imports: [FakeComponent], }) + class HostComponent { + protected readonly name = signal('signal-input-required-test') + } + const fixture = TestBed.createComponent(HostComponent) fixture.detectChanges() await vi.advanceTimersByTimeAsync(0) - expect(fixture.componentInstance.query.data()).toEqual( - 'signal-input-required-test', - ) + const result = fixture.nativeElement.querySelector('app-fake').textContent + expect(result).toEqual('signal-input-required-test') }) describe('injection context', () => { @@ -716,7 +708,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -744,7 +735,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -789,7 +779,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -797,7 +786,9 @@ describe('injectQuery', () => { query = injectQuery(() => ({ queryKey: ['httpClientTest'], queryFn: () => - lastValueFrom(this.httpClient.get<{ message: string }>('/api/test')), + lastValueFrom( + this.httpClient.get<{ message: string }>('/api/test'), + ), })) } @@ -839,7 +830,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -890,7 +880,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -944,7 +933,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { diff --git a/packages/angular-query-experimental/src/__tests__/mutation-options.test.ts b/packages/angular-query-experimental/src/__tests__/mutation-options.test.ts index ab040037d5..553df12d36 100644 --- a/packages/angular-query-experimental/src/__tests__/mutation-options.test.ts +++ b/packages/angular-query-experimental/src/__tests__/mutation-options.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { provideZonelessChangeDetection } from '@angular/core' import { TestBed } from '@angular/core/testing' import { QueryClient } from '@tanstack/query-core' import { sleep } from '@tanstack/query-test-utils' @@ -8,8 +7,8 @@ import { injectMutation, injectMutationState, mutationOptions, - provideTanStackQuery, } from '..' +import { flushQueryUpdates, setupTanStackQueryTestBed } from './test-utils' describe('mutationOptions', () => { let queryClient: QueryClient @@ -17,12 +16,7 @@ describe('mutationOptions', () => { beforeEach(() => { vi.useFakeTimers() queryClient = new QueryClient() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + setupTanStackQueryTestBed(queryClient) }) afterEach(() => { @@ -61,7 +55,7 @@ describe('mutationOptions', () => { mutation.mutate() expect(isMutating()).toBe(0) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(isMutating()).toBe(1) await vi.advanceTimersByTimeAsync(51) expect(isMutating()).toBe(0) @@ -81,7 +75,7 @@ describe('mutationOptions', () => { mutation.mutate() expect(isMutating()).toBe(0) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(isMutating()).toBe(1) await vi.advanceTimersByTimeAsync(51) expect(isMutating()).toBe(0) @@ -109,7 +103,7 @@ describe('mutationOptions', () => { mutation1.mutate() mutation2.mutate() expect(isMutating()).toBe(0) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(isMutating()).toBe(2) await vi.advanceTimersByTimeAsync(51) expect(isMutating()).toBe(0) @@ -137,7 +131,7 @@ describe('mutationOptions', () => { mutation1.mutate() mutation2.mutate() expect(isMutating()).toBe(0) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(isMutating()).toBe(1) await vi.advanceTimersByTimeAsync(51) expect(isMutating()).toBe(0) 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 99f1cb8eb8..7e2c737474 100644 --- a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts +++ b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts @@ -2,7 +2,6 @@ import { ApplicationRef, ChangeDetectionStrategy, Component, - provideZonelessChangeDetection, } from '@angular/core' import { TestBed } from '@angular/core/testing' import { HttpClient, provideHttpClient } from '@angular/common/http' @@ -13,13 +12,8 @@ import { import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { sleep } from '@tanstack/query-test-utils' import { lastValueFrom } from 'rxjs' -import { - QueryClient, - injectMutation, - injectQuery, - onlineManager, - provideTanStackQuery, -} from '..' +import { QueryClient, injectMutation, injectQuery, onlineManager } from '..' +import { flushQueryUpdates, setupTanStackQueryTestBed } from './test-utils' describe('PendingTasks Integration', () => { let queryClient: QueryClient @@ -38,12 +32,7 @@ describe('PendingTasks Integration', () => { }, }) - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + setupTanStackQueryTestBed(queryClient) }) afterEach(() => { @@ -59,7 +48,6 @@ describe('PendingTasks Integration', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -198,7 +186,6 @@ describe('PendingTasks Integration', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -248,7 +235,7 @@ describe('PendingTasks Integration', () => { // Allow query to initialize await Promise.resolve() - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() // Query should initialize directly to 'paused' (never goes through 'fetching') expect(query.status()).toBe('pending') @@ -299,7 +286,7 @@ describe('PendingTasks Integration', () => { ) // Allow the initial attempt to start and fail - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() await Promise.resolve() // Wait for the first attempt to complete and start retry delay @@ -410,7 +397,6 @@ describe('PendingTasks Integration', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -547,14 +533,8 @@ describe('PendingTasks Integration', () => { describe('HttpClient Integration', () => { beforeEach(() => { - TestBed.resetTestingModule() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - provideHttpClient(), - provideHttpClientTesting(), - ], + setupTanStackQueryTestBed(queryClient, { + providers: [provideHttpClient(), provideHttpClientTesting()], }) }) diff --git a/packages/angular-query-experimental/src/__tests__/test-utils.ts b/packages/angular-query-experimental/src/__tests__/test-utils.ts index 218cdea5f6..39884df261 100644 --- a/packages/angular-query-experimental/src/__tests__/test-utils.ts +++ b/packages/angular-query-experimental/src/__tests__/test-utils.ts @@ -1,8 +1,18 @@ -import { isSignal, untracked } from '@angular/core' -import { SIGNAL, signalSetFn } from '@angular/core/primitives/signals' -import { expect } from 'vitest' -import type { InputSignal, Signal } from '@angular/core' -import type { ComponentFixture } from '@angular/core/testing' +import { + isSignal, + provideZonelessChangeDetection, + untracked, +} from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { expect, vi } from 'vitest' +import { provideTanStackQuery } from '..' +import type { QueryClient } from '@tanstack/query-core' +import type { + EnvironmentProviders, + Provider, + Signal, + Type, +} from '@angular/core' // Evaluate all signals on an object and return the result function evaluateSignals>( @@ -35,43 +45,56 @@ export const expectSignals = >( expect(evaluateSignals(obj)).toMatchObject(expected) } -type ToSignalInputUpdatableMap = { - [K in keyof T as T[K] extends InputSignal - ? K - : never]: T[K] extends InputSignal ? Value : never +/** + * Reset Angular's TestBed and configure the standard TanStack Query providers for tests. + * Pass additional providers (including EnvironmentProviders) via the options argument. + */ +export function setupTanStackQueryTestBed( + queryClient: QueryClient, + options: { providers?: Array } = {}, +) { + TestBed.resetTestingModule() + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideTanStackQuery(queryClient), + ...(options.providers ?? []), + ], + }) } -function componentHasSignalInputProperty( - component: object, - property: TProperty, -): component is { [key in TProperty]: InputSignal } { - return ( - component.hasOwnProperty(property) && (component as any)[property][SIGNAL] - ) +/** + * TanStack Query schedules notifyManager updates with setTimeout(0); when fake timers + * are enabled, advance them so PendingTasks sees the queued work. + */ +export async function flushQueryUpdates() { + await vi.advanceTimersByTimeAsync(0) } +const SIGNAL_BASED_INPUT_FLAG = 1 + /** - * Set required signal input value to component fixture - * @see https://github.com/angular/angular/issues/54013 + * Register a signal-based input on a test-only component/dir so Angular marks the + * `input.required()` member as bound before the initial change detection run. + * + * After migrating to Angular 21 we can use the CLI to compile and run Vitest tests + * and this helper should be obsolete. */ -export function setSignalInputs>( - component: T, - inputs: ToSignalInputUpdatableMap, +export function registerSignalInput( + type: Type, + inputName: keyof T & string, ) { - for (const inputKey in inputs) { - if (componentHasSignalInputProperty(component, inputKey)) { - signalSetFn(component[inputKey][SIGNAL], inputs[inputKey]) - } + const definition = (type as any).ɵcmp ?? (type as any).ɵdir + if (!definition) { + throw new Error(`Component ${type.name} is missing its definition`) } -} -export function setFixtureSignalInputs>( - componentFixture: ComponentFixture, - inputs: ToSignalInputUpdatableMap, - options: { detectChanges: boolean } = { detectChanges: true }, -) { - setSignalInputs(componentFixture.componentInstance, inputs) - if (options.detectChanges) { - componentFixture.detectChanges() + definition.inputs = { + ...(definition.inputs ?? {}), + [inputName]: [inputName, SIGNAL_BASED_INPUT_FLAG, null], + } + definition.declaredInputs = { + ...(definition.declaredInputs ?? {}), + [inputName]: inputName, } } diff --git a/packages/angular-query-experimental/src/__tests__/with-devtools.test.ts b/packages/angular-query-experimental/src/__tests__/with-devtools.test.ts index 7091f5a0c2..8b1eb454ce 100644 --- a/packages/angular-query-experimental/src/__tests__/with-devtools.test.ts +++ b/packages/angular-query-experimental/src/__tests__/with-devtools.test.ts @@ -13,6 +13,7 @@ import { } from '@angular/core' import { provideTanStackQuery } from '../providers' import { withDevtools } from '../devtools' +import { flushQueryUpdates } from './test-utils' import type { DevtoolsButtonPosition, DevtoolsErrorType, @@ -134,7 +135,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() await vi.dynamicImportSettled() TestBed.tick() @@ -166,7 +167,7 @@ describe('withDevtools feature', () => { TestBed.inject(ENVIRONMENT_INITIALIZER) // Destroys injector TestBed.resetTestingModule() - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() await vi.dynamicImportSettled() expect(mockTanstackQueryDevtools).not.toHaveBeenCalled() @@ -186,7 +187,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(mockTanstackQueryDevtools).toHaveBeenCalledTimes(1) @@ -202,7 +203,7 @@ describe('withDevtools feature', () => { ) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(mockTanstackQueryDevtools).toHaveBeenCalledTimes(1) }) @@ -247,7 +248,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() @@ -287,7 +288,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() @@ -319,7 +320,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() @@ -350,7 +351,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() @@ -383,7 +384,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() @@ -413,7 +414,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(mockDevtoolsInstance.mount).toHaveBeenCalledTimes(1) expect(mockDevtoolsInstance.unmount).toHaveBeenCalledTimes(0) @@ -439,7 +440,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() await vi.dynamicImportSettled() @@ -469,7 +470,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(mockTanstackQueryDevtools).not.toHaveBeenCalled() expect(mockDevtoolsInstance.mount).not.toHaveBeenCalled() @@ -526,7 +527,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(withDevtoolsFn).toHaveBeenCalledWith(mockService1, mockService2) }) @@ -547,7 +548,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(withDevtoolsFn).toHaveBeenCalledWith() }) @@ -577,7 +578,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() const service = TestBed.inject(ReactiveService) From c1bc243c2df0a900babeafe11344ba2bf72479d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Thu, 18 Dec 2025 20:51:39 -0300 Subject: [PATCH 10/24] fix(angular-query): statically split proxy objects to avoid reading input signal too soon --- .../src/__tests__/signal-proxy.test.ts | 76 ++++++++++++++++++- .../src/create-base-query.ts | 9 ++- .../src/inject-infinite-query.ts | 25 +++++- .../src/inject-mutation.ts | 11 ++- .../src/inject-queries.ts | 5 +- .../src/inject-query.ts | 12 ++- .../src/signal-proxy.ts | 34 ++++++--- .../angular-query-experimental/src/types.ts | 75 +++++++++++------- 8 files changed, 192 insertions(+), 55 deletions(-) diff --git a/packages/angular-query-experimental/src/__tests__/signal-proxy.test.ts b/packages/angular-query-experimental/src/__tests__/signal-proxy.test.ts index d06aef6723..0bd552bb75 100644 --- a/packages/angular-query-experimental/src/__tests__/signal-proxy.test.ts +++ b/packages/angular-query-experimental/src/__tests__/signal-proxy.test.ts @@ -1,10 +1,21 @@ -import { isSignal, signal } from '@angular/core' -import { describe, expect, test } from 'vitest' +import { + ChangeDetectionStrategy, + Component, + computed, + input, + isSignal, + provideZonelessChangeDetection, + untracked, + signal, +} from '@angular/core' +import { beforeEach, describe, expect, test } from 'vitest' import { signalProxy } from '../signal-proxy' +import { registerSignalInput } from './test-utils' +import { TestBed } from '@angular/core/testing' describe('signalProxy', () => { const inputSignal = signal({ fn: () => 'bar', baz: 'qux' }) - const proxy = signalProxy(inputSignal) + const proxy = signalProxy(inputSignal, ['fn']) test('should have computed fields', () => { expect(proxy.baz()).toEqual('qux') @@ -24,4 +35,63 @@ describe('signalProxy', () => { test('supports "Object.keys"', () => { expect(Object.keys(proxy)).toEqual(['fn', 'baz']) }) + + describe('in component fixture', () => { + @Component({ + selector: 'app-test', + template: '{{ proxy.baz() }}', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + number = input.required() + obj = computed(() => ({ + number: this.number(), + fn: () => untracked(this.number) + 1, + })) + proxy = signalProxy(this.obj, ['fn']) + shortNumber = this.proxy.number + shortFn = this.proxy.fn + } + registerSignalInput(TestComponent, 'number') + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideZonelessChangeDetection()], + }) + }) + + test('should generate fixed fields after initial change detection run', () => { + const fixture = TestBed.createComponent(TestComponent) + const instance = fixture.componentInstance + + expect(() => instance.shortNumber).not.throw() + expect(() => instance.shortNumber()).toThrow() + + fixture.componentRef.setInput('number', 1) + fixture.detectChanges() + + expect(isSignal(instance.proxy.number)).toBe(true) + expect(instance.proxy.number()).toBe(1) + expect(instance.shortNumber).toBe(instance.proxy.number) + + expect(instance.proxy.fn()).toBe(2) + expect(isSignal(instance.proxy.fn)).toBe(false) + expect(instance.shortFn).toBe(instance.proxy.fn) + }) + + test('should reflect updates on the proxy', () => { + const fixture = TestBed.createComponent(TestComponent) + const instance = fixture.componentInstance + fixture.componentRef.setInput('number', 0) + fixture.detectChanges() + + expect(instance.shortNumber()).toBe(0) + expect(instance.shortFn()).toBe(1) + + fixture.componentRef.setInput('number', 1) + + expect(instance.shortNumber()).toBe(1) + expect(instance.shortFn()).toBe(2) + }) + }) }) diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index 4b1ce4ca5c..e24a1cb553 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -13,7 +13,7 @@ import { notifyManager, shouldThrowError, } from '@tanstack/query-core' -import { signalProxy } from './signal-proxy' +import { MethodKeys, signalProxy } from './signal-proxy' import { injectIsRestoring } from './inject-is-restoring' import type { DefaultedQueryObserverOptions, @@ -27,6 +27,7 @@ import type { CreateBaseQueryOptions } from './types' * Base implementation for `injectQuery` and `injectInfiniteQuery`. * @param optionsFn * @param Observer + * @param excludeFunctions */ export function createBaseQuery< TQueryFnData, @@ -43,6 +44,7 @@ export function createBaseQuery< TQueryKey >, Observer: typeof QueryObserver, + excludeFunctions: ReadonlyArray, ) { const ngZone = inject(NgZone) const pendingTasks = inject(PendingTasks) @@ -211,7 +213,10 @@ export function createBaseQuery< }) }) - return signalProxy(resultSignal.asReadonly()) + return signalProxy( + resultSignal.asReadonly(), + excludeFunctions as MethodKeys>[], + ) } const OBSERVER_NOT_READY_ERROR = 'injectQuery: QueryObserver not initialized yet. Avoid reading the query result during construction' diff --git a/packages/angular-query-experimental/src/inject-infinite-query.ts b/packages/angular-query-experimental/src/inject-infinite-query.ts index ee6de03240..57a2677983 100644 --- a/packages/angular-query-experimental/src/inject-infinite-query.ts +++ b/packages/angular-query-experimental/src/inject-infinite-query.ts @@ -9,6 +9,7 @@ import { createBaseQuery } from './create-base-query' import type { DefaultError, InfiniteData, + InfiniteQueryObserverResult, QueryKey, QueryObserver, } from '@tanstack/query-core' @@ -21,6 +22,7 @@ import type { DefinedInitialDataInfiniteOptions, UndefinedInitialDataInfiniteOptions, } from './infinite-query-options' +import { MethodKeys } from './signal-proxy' export interface InjectInfiniteQueryOptions { /** @@ -110,8 +112,20 @@ export function injectInfiniteQuery< * @param options - Additional configuration. * @returns The infinite query result. */ -export function injectInfiniteQuery( - injectInfiniteQueryFn: () => CreateInfiniteQueryOptions, +export function injectInfiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + injectInfiniteQueryFn: () => CreateInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, options?: InjectInfiniteQueryOptions, ) { !options?.injector && assertInInjectionContext(injectInfiniteQuery) @@ -120,6 +134,13 @@ export function injectInfiniteQuery( createBaseQuery( injectInfiniteQueryFn, InfiniteQueryObserver as typeof QueryObserver, + methodsToExclude, ), ) } + +const methodsToExclude: Array> = [ + 'fetchNextPage', + 'fetchPreviousPage', + 'refetch', +] diff --git a/packages/angular-query-experimental/src/inject-mutation.ts b/packages/angular-query-experimental/src/inject-mutation.ts index a6eb71b242..368c4a9690 100644 --- a/packages/angular-query-experimental/src/inject-mutation.ts +++ b/packages/angular-query-experimental/src/inject-mutation.ts @@ -185,10 +185,9 @@ export function injectMutation< } }) - return signalProxy(resultSignal) as CreateMutationResult< - TData, - TError, - TVariables, - TOnMutateResult - > + return signalProxy(resultSignal, [ + 'mutate', + 'mutateAsync', + 'reset', + ]) as CreateMutationResult } diff --git a/packages/angular-query-experimental/src/inject-queries.ts b/packages/angular-query-experimental/src/inject-queries.ts index 2f201799e7..51878b7e66 100644 --- a/packages/angular-query-experimental/src/inject-queries.ts +++ b/packages/angular-query-experimental/src/inject-queries.ts @@ -25,6 +25,7 @@ import type { QueryFunction, QueryKey, QueryObserverOptions, + QueryObserverResult, ThrowOnError, } from '@tanstack/query-core' import type { @@ -322,8 +323,8 @@ export function injectQueries< return combine ? result - : (result as QueriesResults).map((query) => - signalProxy(signal(query)), + : (result as QueryObserverResult[]).map((query) => + signalProxy(signal(query), ['refetch']), ) }) }) as unknown as Signal diff --git a/packages/angular-query-experimental/src/inject-query.ts b/packages/angular-query-experimental/src/inject-query.ts index 1dac0ab694..2d0951beac 100644 --- a/packages/angular-query-experimental/src/inject-query.ts +++ b/packages/angular-query-experimental/src/inject-query.ts @@ -6,7 +6,11 @@ import { runInInjectionContext, } from '@angular/core' import { createBaseQuery } from './create-base-query' -import type { DefaultError, QueryKey } from '@tanstack/query-core' +import type { + DefaultError, + QueryKey, + QueryObserverResult, +} from '@tanstack/query-core' import type { CreateQueryOptions, CreateQueryResult, @@ -16,6 +20,7 @@ import type { DefinedInitialDataOptions, UndefinedInitialDataOptions, } from './query-options' +import { MethodKeys } from './signal-proxy' export interface InjectQueryOptions { /** @@ -212,6 +217,7 @@ export function injectQuery< * ``` * @param injectQueryFn - A function that returns query options. * @param options - Additional configuration + * @param excludeFunctions - Array of function property names to exclude from signal conversion * @returns The query result. * @see https://tanstack.com/query/latest/docs/framework/angular/guides/queries */ @@ -221,6 +227,8 @@ export function injectQuery( ) { !options?.injector && assertInInjectionContext(injectQuery) return runInInjectionContext(options?.injector ?? inject(Injector), () => - createBaseQuery(injectQueryFn, QueryObserver), + createBaseQuery(injectQueryFn, QueryObserver, methodsToExclude), ) as unknown as CreateQueryResult } + +const methodsToExclude: Array> = ['refetch'] diff --git a/packages/angular-query-experimental/src/signal-proxy.ts b/packages/angular-query-experimental/src/signal-proxy.ts index e2a9de345f..1fdfe2141e 100644 --- a/packages/angular-query-experimental/src/signal-proxy.ts +++ b/packages/angular-query-experimental/src/signal-proxy.ts @@ -1,32 +1,44 @@ import { computed, untracked } from '@angular/core' import type { Signal } from '@angular/core' -export type MapToSignals = { - [K in keyof T]: T[K] extends Function ? T[K] : Signal +export type MethodKeys = { + [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never +}[keyof T] + +export type MapToSignals = never> = { + [K in keyof T]: K extends ExcludeFields ? T[K] : Signal } /** * Exposes fields of an object passed via an Angular `Signal` as `Computed` signals. * Functions on the object are passed through as-is. * @param inputSignal - `Signal` that must return an object. + * @param excludeFields - Array of function property names that should NOT be converted to signals. * @returns A proxy object with the same fields as the input object, but with each field wrapped in a `Computed` signal. */ -export function signalProxy>( - inputSignal: Signal, -) { - const internalState = {} as MapToSignals +export function signalProxy< + TInput extends Record, + const ExcludeFields extends ReadonlyArray> = [], +>(inputSignal: Signal, excludeFields: ExcludeFields) { + const internalState = {} as MapToSignals + const excludeFieldsArray = excludeFields as ReadonlyArray - return new Proxy>(internalState, { + return new Proxy>(internalState, { get(target, prop) { // first check if we have it in our internal state and return it const computedField = target[prop] if (computedField) return computedField - // then, check if it's a function on the resultState and return it - const targetField = untracked(inputSignal)[prop] - if (typeof targetField === 'function') return targetField + // if it is an expluded function, return it without tracking + if (excludeFieldsArray.includes(prop as string)) { + const fn = (...args: Parameters) => + untracked(inputSignal)[prop](...args) + // @ts-expect-error + target[prop] = fn + return fn + } - // finally, create a computed field, store it and return it + // otherwise, make a computed field // @ts-expect-error return (target[prop] = computed(() => inputSignal()[prop])) }, diff --git a/packages/angular-query-experimental/src/types.ts b/packages/angular-query-experimental/src/types.ts index d71bec248f..b49f03f483 100644 --- a/packages/angular-query-experimental/src/types.ts +++ b/packages/angular-query-experimental/src/types.ts @@ -16,7 +16,7 @@ import type { QueryObserverResult, } from '@tanstack/query-core' import type { Signal } from '@angular/core' -import type { MapToSignals } from './signal-proxy' +import type { MapToSignals, MethodKeys } from './signal-proxy' export interface CreateBaseQueryOptions< TQueryFnData = unknown, @@ -25,12 +25,12 @@ export interface CreateBaseQueryOptions< TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > extends QueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey -> {} + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + > {} export interface CreateQueryOptions< TQueryFnData = unknown, @@ -38,9 +38,15 @@ export interface CreateQueryOptions< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > extends OmitKeyof< - CreateBaseQueryOptions, - 'suspense' -> {} + CreateBaseQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey + >, + 'suspense' + > {} type CreateStatusBasedQueryResult< TStatus extends QueryObserverResult['status'], @@ -79,22 +85,25 @@ export interface CreateInfiniteQueryOptions< TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, > extends OmitKeyof< - InfiniteQueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryKey, - TPageParam - >, - 'suspense' -> {} + InfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + 'suspense' + > {} export type CreateBaseQueryResult< TData = unknown, TError = DefaultError, TState = QueryObserverResult, > = BaseQueryNarrowing & - MapToSignals> + MapToSignals< + OmitKeyof, + MethodKeys> + > export type CreateQueryResult< TData = unknown, @@ -106,13 +115,19 @@ export type DefinedCreateQueryResult< TError = DefaultError, TState = DefinedQueryObserverResult, > = BaseQueryNarrowing & - MapToSignals> + MapToSignals< + OmitKeyof, + MethodKeys> + > export type CreateInfiniteQueryResult< TData = unknown, TError = DefaultError, > = BaseQueryNarrowing & - MapToSignals> + MapToSignals< + InfiniteQueryObserverResult, + MethodKeys> + > export type DefinedCreateInfiniteQueryResult< TData = unknown, @@ -121,7 +136,10 @@ export type DefinedCreateInfiniteQueryResult< TData, TError >, -> = MapToSignals +> = MapToSignals< + TDefinedInfiniteQueryObserver, + MethodKeys +> export interface CreateMutationOptions< TData = unknown, @@ -129,9 +147,9 @@ export interface CreateMutationOptions< TVariables = void, TOnMutateResult = unknown, > extends OmitKeyof< - MutationObserverOptions, - '_defaulted' -> {} + MutationObserverOptions, + '_defaulted' + > {} export type CreateMutateFunction< TData = unknown, @@ -270,4 +288,7 @@ export type CreateMutationResult< TOnMutateResult >, > = BaseMutationNarrowing & - MapToSignals> + MapToSignals< + OmitKeyof, + MethodKeys> + > From c727bc7d2be2fe7db57c79a4dbea933db0886453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sat, 20 Dec 2025 10:30:10 -0300 Subject: [PATCH 11/24] fix(angular-query): start pending task early and don't start on destroyed --- .../src/__tests__/inject-query.test.ts | 15 ++++++-- .../src/__tests__/pending-tasks.test.ts | 21 +++++----- .../src/create-base-query.ts | 9 ++++- .../src/inject-mutation.ts | 38 +++++++++++-------- 4 files changed, 53 insertions(+), 30 deletions(-) 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 d1befc202b..5a24a07f92 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -849,7 +849,9 @@ describe('injectQuery', () => { const component = fixture.componentInstance const query = component.query - await app.whenStable() + const stablePromise = app.whenStable() + await vi.advanceTimersToNextTimerAsync() + await stablePromise expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-1') @@ -911,8 +913,10 @@ describe('injectQuery', () => { enabledSignal.set(true) fixture.detectChanges() - await vi.advanceTimersByTimeAsync(0) - await app.whenStable() + const stablePromise = app.whenStable() + await vi.advanceTimersToNextTimerAsync() + await stablePromise + expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-1') expect(component.callCount).toBe(1) @@ -951,7 +955,10 @@ describe('injectQuery', () => { const component = fixture.componentInstance const query = component.query - await app.whenStable() + const stablePromise = app.whenStable() + await vi.advanceTimersToNextTimerAsync() + await stablePromise + expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-1') expect(component.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 7e2c737474..5448bbbcac 100644 --- a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts +++ b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts @@ -139,7 +139,6 @@ describe('PendingTasks Integration', () => { ) mutation.mutate() - TestBed.tick() const stablePromise = app.whenStable() @@ -355,38 +354,42 @@ describe('PendingTasks Integration', () => { } test('should cleanup pending tasks when component with active query is destroyed', async () => { - const app = TestBed.inject(ApplicationRef) const fixture = TestBed.createComponent(TestComponent) fixture.detectChanges() // Start the query expect(fixture.componentInstance.query.status()).toBe('pending') + expect(fixture.isStable()).toBe(false) // Destroy component while query is running fixture.destroy() // Angular should become stable even though component was destroyed - const stablePromise = app.whenStable() + const stablePromise = fixture.whenStable() await vi.advanceTimersByTimeAsync(150) - - await expect(stablePromise).resolves.toEqual(undefined) + await stablePromise + expect(fixture.isStable()).toBe(true) }) test('should cleanup pending tasks when component with active mutation is destroyed', async () => { - const app = TestBed.inject(ApplicationRef) const fixture = TestBed.createComponent(TestComponent) fixture.detectChanges() fixture.componentInstance.mutation.mutate('test') + fixture.detectChanges() + expect(fixture.isStable()).toBe(false) // Destroy component while mutation is running fixture.destroy() + fixture.detectChanges() + expect(fixture.isStable()).toBe(true) // Angular should become stable even though component was destroyed - const stablePromise = app.whenStable() - await vi.advanceTimersByTimeAsync(150) + const stablePromise = fixture.whenStable() + await vi.advanceTimersByTimeAsync(200) + await stablePromise - await expect(stablePromise).resolves.toEqual(undefined) + expect(fixture.isStable()).toBe(true) }) }) diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index e24a1cb553..2def448264 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -64,7 +64,7 @@ export function createBaseQuery< let taskCleanupRef: (() => void) | null = null const startPendingTask = () => { - if (!taskCleanupRef) { + if (!taskCleanupRef && !destroyed) { taskCleanupRef = pendingTasks.add() } } @@ -152,6 +152,11 @@ export function createBaseQuery< throw new Error(OBSERVER_NOT_READY_ERROR) } + const initialState = observer.getCurrentResult() + if (initialState.fetchStatus !== 'idle') { + startPendingTask() + } + return observer.subscribe((state) => { if (state.fetchStatus !== 'idle') { startPendingTask() @@ -219,4 +224,4 @@ export function createBaseQuery< ) } const OBSERVER_NOT_READY_ERROR = - 'injectQuery: QueryObserver not initialized yet. Avoid reading the query result during construction' + 'injectQuery: QueryObserver not initialized yet. Avoid reading the query result or running methods during construction' diff --git a/packages/angular-query-experimental/src/inject-mutation.ts b/packages/angular-query-experimental/src/inject-mutation.ts index 368c4a9690..8dc9e28256 100644 --- a/packages/angular-query-experimental/src/inject-mutation.ts +++ b/packages/angular-query-experimental/src/inject-mutation.ts @@ -17,7 +17,6 @@ import { } from '@tanstack/query-core' import { signalProxy } from './signal-proxy' import { PENDING_TASKS } from './pending-tasks-compat' -import type { PendingTaskRef } from './pending-tasks-compat' import type { DefaultError, MutationObserverResult } from '@tanstack/query-core' import type { CreateMutateFunction, @@ -82,6 +81,22 @@ export function injectMutation< }) })() + let destroyed = false + let taskCleanupRef: (() => void) | null = null + + const startPendingTask = () => { + if (!taskCleanupRef && !destroyed) { + taskCleanupRef = pendingTasks.add() + } + } + + const stopPendingTask = () => { + if (taskCleanupRef) { + taskCleanupRef() + taskCleanupRef = null + } + } + const mutateFnSignal = computed< CreateMutateFunction >(() => { @@ -126,22 +141,18 @@ export function injectMutation< effect( (onCleanup) => { const observer = observerSignal() - let pendingTaskRef: PendingTaskRef | null = null untracked(() => { const unsubscribe = ngZone.runOutsideAngular(() => observer.subscribe( notifyManager.batchCalls((state) => { ngZone.run(() => { - // Track pending task when mutation is pending - if (state.isPending && !pendingTaskRef) { - pendingTaskRef = pendingTasks.add() - } + if (destroyed) return - // Clear pending task when mutation is no longer pending - if (!state.isPending && pendingTaskRef) { - pendingTaskRef() - pendingTaskRef = null + if (state.isPending) { + startPendingTask() + } else { + stopPendingTask() } if ( @@ -158,11 +169,8 @@ export function injectMutation< ), ) onCleanup(() => { - // Clean up any pending task on destroy - if (pendingTaskRef) { - pendingTaskRef() - pendingTaskRef = null - } + destroyed = true + stopPendingTask() unsubscribe() }) }) From 060f9ac386871c91eb5a1a423b725f5f60328eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sat, 20 Dec 2025 12:15:00 -0300 Subject: [PATCH 12/24] tests(angular-query): add pending task ssr render test --- .../angular-query-experimental/package.json | 1 + .../src/__tests__/pending-tasks-ssr.test.ts | 72 +++++++++++++++++++ pnpm-lock.yaml | 57 +++++++++++---- 3 files changed, 117 insertions(+), 13 deletions(-) create mode 100644 packages/angular-query-experimental/src/__tests__/pending-tasks-ssr.test.ts diff --git a/packages/angular-query-experimental/package.json b/packages/angular-query-experimental/package.json index a122c4bcb7..9e505d69f9 100644 --- a/packages/angular-query-experimental/package.json +++ b/packages/angular-query-experimental/package.json @@ -93,6 +93,7 @@ "@angular/compiler": "^20.0.0", "@angular/core": "^20.0.0", "@angular/platform-browser": "^20.0.0", + "@angular/platform-server": "^20.0.0", "@tanstack/query-test-utils": "workspace:*", "@testing-library/angular": "^18.0.0", "npm-run-all2": "^5.0.0", diff --git a/packages/angular-query-experimental/src/__tests__/pending-tasks-ssr.test.ts b/packages/angular-query-experimental/src/__tests__/pending-tasks-ssr.test.ts new file mode 100644 index 0000000000..d1081d191e --- /dev/null +++ b/packages/angular-query-experimental/src/__tests__/pending-tasks-ssr.test.ts @@ -0,0 +1,72 @@ +import { + ChangeDetectionStrategy, + Component, + destroyPlatform, + provideZonelessChangeDetection, +} from '@angular/core' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { + provideServerRendering, + renderApplication, +} from '@angular/platform-server' +import { bootstrapApplication } from '@angular/platform-browser' + +import { injectQuery } from '../inject-query' +import { sleep } from '@tanstack/query-test-utils' +import { provideTanStackQuery } from '../providers' +import { QueryClient } from '@tanstack/query-core' + +describe('PendingTasks SSR', () => { + beforeEach(() => { + destroyPlatform() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + @Component({ + selector: 'app-root', + template: '{{ query.data() }}', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ + queryKey: ['ssr-test'], + queryFn: async () => { + await sleep(1000) + return 'data-fetched-on-ssr' + }, + })) + } + + test('should wait for stability of queries', async () => { + const htmlPromise = renderApplication( + () => + bootstrapApplication(TestComponent, { + providers: [ + provideServerRendering(), + provideZonelessChangeDetection(), + provideTanStackQuery( + new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }), + ), + ], + }), + { + url: '/', + document: + '', + }, + ) + + await vi.runAllTimersAsync() + const html = await htmlPromise + + expect(html).toContain('data-fetched-on-ssr') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f56dd1ed45..77c5c03e5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,7 +165,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -205,7 +205,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -251,7 +251,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -294,7 +294,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -334,7 +334,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -377,7 +377,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -417,7 +417,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -460,7 +460,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -503,7 +503,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -546,7 +546,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -586,7 +586,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -1991,7 +1991,7 @@ importers: devDependencies: '@angular/build': specifier: ^20.0.0 - version: 20.0.0(8379d9408f101aa649c5ec9eae189324) + version: 20.0.0(e7d71ef5d6f07f95ae57395d944dd75a) '@angular/cli': specifier: ^20.0.0 version: 20.0.0(@types/node@22.15.3)(chokidar@4.0.3) @@ -2263,6 +2263,9 @@ importers: '@angular/platform-browser': specifier: ^20.0.0 version: 20.0.0(@angular/animations@20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/platform-server': + specifier: ^20.0.0 + version: 20.3.15(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@20.0.0)(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@20.0.0(@angular/animations@20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) '@tanstack/query-test-utils': specifier: workspace:* version: link:../query-test-utils @@ -3011,6 +3014,16 @@ packages: '@angular/animations': optional: true + '@angular/platform-server@20.3.15': + resolution: {integrity: sha512-OB3/ztCREeZ0pe2P+43Nah9Xq2Y79fN6mbsOY1JwwYxkM8ZN1WkSP11xlHHwAcoquHP7uFPhXwJqgTHBqGqkcw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/common': 20.3.15 + '@angular/compiler': 20.3.15 + '@angular/core': 20.3.15 + '@angular/platform-browser': 20.3.15 + rxjs: ^6.5.3 || ^7.4.0 + '@angular/router@20.0.0': resolution: {integrity: sha512-RQ7rU4NaZDSvvOfMZQmB50q7de+jrHYb+f0ExLKBvr80B1MK3oc9VvI2BzBkGfM4aGx71MMa0UizjOiT/31kqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -13136,6 +13149,7 @@ packages: react-native-vector-icons@10.1.0: resolution: {integrity: sha512-fdQjCHIdoXmRoTZ5gvN1FmT4sGLQ2wmQiNZHKJQUYnE2tkIwjGnxNch+6Nd4lHAACvMWO7LOzBNot2u/zlOmkw==} + deprecated: react-native-vector-icons package has moved to a new model of per-icon-family packages. See the https://github.com/oblador/react-native-vector-icons/blob/master/MIGRATION.md on how to migrate hasBin: true react-native-web@0.19.13: @@ -15713,6 +15727,10 @@ packages: resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} engines: {node: '>=12'} + xhr2@0.2.1: + resolution: {integrity: sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==} + engines: {node: '>= 6'} + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} @@ -15932,7 +15950,7 @@ snapshots: '@angular/core': 20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0) tslib: 2.8.1 - '@angular/build@20.0.0(8379d9408f101aa649c5ec9eae189324)': + '@angular/build@20.0.0(e7d71ef5d6f07f95ae57395d944dd75a)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2000.0(chokidar@4.0.3) @@ -15967,6 +15985,7 @@ snapshots: optionalDependencies: '@angular/core': 20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0) '@angular/platform-browser': 20.0.0(@angular/animations@20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/platform-server': 20.3.15(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@20.0.0)(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@20.0.0(@angular/animations@20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) less: 4.3.0 lmdb: 3.3.0 postcss: 8.5.6 @@ -16059,6 +16078,16 @@ snapshots: optionalDependencies: '@angular/animations': 20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/platform-server@20.3.15(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@20.0.0)(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@20.0.0(@angular/animations@20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2)': + dependencies: + '@angular/common': 20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/compiler': 20.0.0 + '@angular/core': 20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/platform-browser': 20.0.0(@angular/animations@20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)) + rxjs: 7.8.2 + tslib: 2.8.1 + xhr2: 0.2.1 + '@angular/router@20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@20.0.0(@angular/animations@20.0.0(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2)': dependencies: '@angular/common': 20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) @@ -31254,6 +31283,8 @@ snapshots: xdg-basedir@5.1.0: {} + xhr2@0.2.1: {} + xml-name-validator@4.0.0: {} xml-name-validator@5.0.0: {} From e7bb141ca099d06f497582f15b9c874882cd9583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sat, 20 Dec 2025 19:59:02 -0300 Subject: [PATCH 13/24] fix(angular-query): inject query types and tests --- .../src/__tests__/inject-queries.test-d.ts | 85 +++++++++ .../src/__tests__/inject-queries.test.ts | 168 +++++++++++++++++- .../src/inject-queries.ts | 116 ++++++++---- .../angular-query-experimental/src/types.ts | 48 +++-- 4 files changed, 355 insertions(+), 62 deletions(-) diff --git a/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts b/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts index 62547fd9e0..58b46085dc 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts @@ -175,3 +175,88 @@ describe('InjectQueries config object overload', () => { >() }) }) + +describe('InjectQueries combine', () => { + it('should provide the correct type for the combine function', () => { + injectQueries(() => ({ + queries: [ + { + queryKey: ['key'], + queryFn: () => Promise.resolve(1), + }, + { + queryKey: ['key2'], + queryFn: () => Promise.resolve(true), + }, + ], + combine: (results) => { + expectTypeOf(results[0].data).toEqualTypeOf() + expectTypeOf(results[0].refetch).toBeCallableWith() + expectTypeOf(results[1].data).toEqualTypeOf() + expectTypeOf(results[1].refetch).toBeCallableWith() + }, + })) + }) + + it('should provide the correct types on the combined result with initial data', () => { + injectQueries(() => ({ + queries: [ + { + queryKey: ['key'], + queryFn: () => Promise.resolve(1), + initialData: 1, + }, + ], + combine: (results) => { + expectTypeOf(results[0].data).toEqualTypeOf() + expectTypeOf(results[0].refetch).toBeCallableWith() + }, + })) + }) + + it('should provide the correct result type', () => { + const queryResults = injectQueries(() => ({ + queries: [ + { + queryKey: ['key'], + queryFn: () => Promise.resolve(1), + }, + { + queryKey: ['key2'], + queryFn: () => Promise.resolve(true), + }, + ], + combine: (results) => ({ + data: { + 1: results[0].data, + 2: results[1].data, + }, + fn: () => {}, + }), + })) + + expectTypeOf(queryResults).branded.toEqualTypeOf< + Signal<{ + data: { + 1: number | undefined + 2: boolean | undefined + } + fn: () => void + }> + >() + }) + + it('should provide the correct types on the combined result with initial data', () => { + const queryResults = injectQueries(() => ({ + queries: [ + { + queryKey: ['key'], + queryFn: () => Promise.resolve(1), + initialData: 1, + }, + ], + })) + + expectTypeOf(queryResults()[0].data()).toEqualTypeOf() + }) +}) diff --git a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts index 44558aa743..5803018e86 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts @@ -1,6 +1,12 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, vi, expect, it } from 'vitest' import { render } from '@testing-library/angular' -import { ChangeDetectionStrategy, Component, effect } from '@angular/core' +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + signal, +} from '@angular/core' import { queryKey } from '@tanstack/query-test-utils' import { QueryClient } from '..' import { injectQueries } from '../inject-queries' @@ -10,9 +16,14 @@ let queryClient: QueryClient beforeEach(() => { queryClient = new QueryClient() + vi.useFakeTimers({ shouldAdvanceTime: true }) setupTanStackQueryTestBed(queryClient) }) +afterEach(() => { + vi.useRealTimers() +}) + describe('injectQueries', () => { it('should return the correct states', async () => { const key1 = queryKey() @@ -68,4 +79,157 @@ describe('injectQueries', () => { expect(results[1]).toMatchObject([{ data: 1 }, { data: undefined }]) expect(results[2]).toMatchObject([{ data: 1 }, { data: 2 }]) }) + + it('should support combining results', async () => { + const key1 = queryKey() + const key2 = queryKey() + let count = 0 + + const results: Array<{ data: string; refetch: () => void }> = [] + + @Component({ + template: `
data: {{ result().data }}
`, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class Page { + result = injectQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: async () => { + await new Promise((r) => setTimeout(r, 10)) + count++ + return count + }, + }, + { + queryKey: key2, + queryFn: async () => { + await new Promise((r) => setTimeout(r, 100)) + count++ + return count + }, + }, + ], + combine: (results) => { + return { + refetch: () => results.forEach((r) => r.refetch()), + data: results.map((r) => r.data).join(','), + } + }, + })) + + _pushResults = effect(() => { + results.push(this.result()) + }) + } + + const rendered = await render(Page) + const instance = rendered.fixture.componentInstance + await rendered.findByText('data: 1,2') + expect(instance.result().data).toBe('1,2') + + instance.result().refetch() + + await rendered.findByText('data: 3,4') + expect(instance.result().data).toBe('3,4') + + expect(results).toHaveLength(5) + expect(results[0]).toMatchObject({ + data: ',', + refetch: expect.any(Function), + }) + expect(results[1]).toMatchObject({ + data: '1,', + refetch: expect.any(Function), + }) + expect(results[2]).toMatchObject({ + data: '1,2', + refetch: expect.any(Function), + }) + expect(results[3]).toMatchObject({ + data: '3,2', + refetch: expect.any(Function), + }) + expect(results[4]).toMatchObject({ + data: '3,4', + refetch: expect.any(Function), + }) + }) + + it('should support changes on the queries array', async () => { + const results: Array>> = [] + + @Component({ + template: `
data: {{ mapped() }}
`, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class Page { + result = injectQueries(() => ({ + queries: queries().map((q) => ({ + queryKey: ['query', q], + queryFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 20 * q)) + return q + }, + })), + })) + + mapped = computed(() => { + const results = this.result().map((q) => q.data()) + if (results.length === 0) return 'empty' + return results.join(',') + }) + + _pushResults = effect(() => { + const snapshot = this.result().map((q) => ({ data: q.data() })) + results.push(snapshot) + }) + } + + const queries = signal([1, 2, 4]) + + const rendered = await render(Page) + const instance = rendered.fixture.componentInstance + + await rendered.findByText('data: 1,2,4') + expect(instance.mapped()).toBe('1,2,4') + + expect(results.length).toBe(4) + expect(results[0]).toMatchObject([ + { data: undefined }, + { data: undefined }, + { data: undefined }, + ]) + expect(results[1]).toMatchObject([ + { data: 1 }, + { data: undefined }, + { data: undefined }, + ]) + expect(results[2]).toMatchObject([ + { data: 1 }, + { data: 2 }, + { data: undefined }, + ]) + expect(results[3]).toMatchObject([{ data: 1 }, { data: 2 }, { data: 4 }]) + + queries.set([3, 4]) + await rendered.findByText('data: 3,4') + expect(instance.mapped()).toBe('3,4') + + // findByText causes another change detection cycle + expect(results.length).toBe(7) + expect(results[4]).toMatchObject([{ data: 1 }, { data: 2 }, { data: 4 }]) + expect(results[5]).toMatchObject([{ data: undefined }, { data: 4 }]) + expect(results[6]).toMatchObject([{ data: 3 }, { data: 4 }]) + + queries.set([]) + await rendered.findByText('data: empty') + expect(instance.mapped()).toBe('empty') + + // findByText causes another change detection cycle + expect(results.length).toBe(9) + expect(results[7]).toMatchObject([{ data: 3 }, { data: 4 }]) + expect(results[8]).toMatchObject([]) + }) }) diff --git a/packages/angular-query-experimental/src/inject-queries.ts b/packages/angular-query-experimental/src/inject-queries.ts index 51878b7e66..dff27fcd2b 100644 --- a/packages/angular-query-experimental/src/inject-queries.ts +++ b/packages/angular-query-experimental/src/inject-queries.ts @@ -19,6 +19,7 @@ import { signalProxy } from './signal-proxy' import { injectIsRestoring } from './inject-is-restoring' import type { DefaultError, + DefinedQueryObserverResult, OmitKeyof, QueriesObserverOptions, QueriesPlaceholderDataFunction, @@ -91,39 +92,42 @@ type GetCreateQueryOptionsForCreateQueries = : // Fallback QueryObserverOptionsForCreateQueries -// A defined initialData setting should return a DefinedCreateQueryResult rather than CreateQueryResult -type GetDefinedOrUndefinedQueryResult = T extends { - initialData?: infer TInitialData -} - ? unknown extends TInitialData - ? CreateQueryResult - : TInitialData extends TData - ? DefinedCreateQueryResult - : TInitialData extends () => infer TInitialDataResult - ? unknown extends TInitialDataResult - ? CreateQueryResult - : TInitialDataResult extends TData - ? DefinedCreateQueryResult - : CreateQueryResult - : CreateQueryResult - : CreateQueryResult +// Generic wrapper that handles initialData logic for any result type pair +type GenericGetDefinedOrUndefinedQueryResult = + T extends { + initialData?: infer TInitialData + } + ? unknown extends TInitialData + ? TUndefined + : TInitialData extends TData + ? TDefined + : TInitialData extends () => infer TInitialDataResult + ? unknown extends TInitialDataResult + ? TUndefined + : TInitialDataResult extends TData + ? TDefined + : TUndefined + : TUndefined + : TUndefined -type GetCreateQueryResult = - // Part 1: responsible for mapping explicit type parameter to function result, if object +// Infer TData and TError from query options +// Shared type between the results with and without the combine function +type InferDataAndError = + // Part 1: explicit type parameter as object { queryFnData, error, data } T extends { queryFnData: any; error?: infer TError; data: infer TData } - ? GetDefinedOrUndefinedQueryResult + ? { data: TData; error: TError } : T extends { queryFnData: infer TQueryFnData; error?: infer TError } - ? GetDefinedOrUndefinedQueryResult + ? { data: TQueryFnData; error: TError } : T extends { data: infer TData; error?: infer TError } - ? GetDefinedOrUndefinedQueryResult - : // Part 2: responsible for mapping explicit type parameter to function result, if tuple + ? { data: TData; error: TError } + : // Part 2: explicit type parameter as tuple [TQueryFnData, TError, TData] T extends [any, infer TError, infer TData] - ? GetDefinedOrUndefinedQueryResult + ? { data: TData; error: TError } : T extends [infer TQueryFnData, infer TError] - ? GetDefinedOrUndefinedQueryResult + ? { data: TQueryFnData; error: TError } : T extends [infer TQueryFnData] - ? GetDefinedOrUndefinedQueryResult - : // Part 3: responsible for mapping inferred type to results, if no explicit parameter was provided + ? { data: TQueryFnData; error: unknown } + : // Part 3: infer from queryFn, select, throwOnError T extends { queryFn?: | QueryFunction @@ -131,13 +135,40 @@ type GetCreateQueryResult = select?: (data: any) => infer TData throwOnError?: ThrowOnError } - ? GetDefinedOrUndefinedQueryResult< - T, - unknown extends TData ? TQueryFnData : TData, - unknown extends TError ? DefaultError : TError - > + ? { + data: unknown extends TData ? TQueryFnData : TData + error: unknown extends TError ? DefaultError : TError + } : // Fallback - CreateQueryResult + { data: unknown; error: DefaultError } + +// Maps query options to Angular's signal-wrapped CreateQueryResult +type GetCreateQueryResult = GenericGetDefinedOrUndefinedQueryResult< + T, + InferDataAndError['data'], + CreateQueryResult< + InferDataAndError['data'], + InferDataAndError['error'] + >, + DefinedCreateQueryResult< + InferDataAndError['data'], + InferDataAndError['error'] + > +> + +// Maps query options to plain QueryObserverResult for combine function +type GetQueryObserverResult = GenericGetDefinedOrUndefinedQueryResult< + T, + InferDataAndError['data'], + QueryObserverResult< + InferDataAndError['data'], + InferDataAndError['error'] + >, + DefinedQueryObserverResult< + InferDataAndError['data'], + InferDataAndError['error'] + > +> /** * QueriesOptions reducer recursively unwraps function arguments to infer/enforce type param @@ -202,6 +233,25 @@ export type QueriesResults< > : { [K in keyof T]: GetCreateQueryResult } +// Maps query options array to plain QueryObserverResult types for combine function +type RawQueriesResults< + T extends Array, + TResults extends Array = [], + TDepth extends ReadonlyArray = [], +> = TDepth['length'] extends MAXIMUM_DEPTH + ? Array + : T extends [] + ? [] + : T extends [infer Head] + ? [...TResults, GetQueryObserverResult] + : T extends [infer Head, ...infer Tails] + ? RawQueriesResults< + [...Tails], + [...TResults, GetQueryObserverResult], + [...TDepth, 1] + > + : { [K in keyof T]: GetQueryObserverResult } + export interface InjectQueriesOptions< T extends Array, TCombinedResult = QueriesResults, @@ -211,7 +261,7 @@ export interface InjectQueriesOptions< | readonly [ ...{ [K in keyof T]: GetCreateQueryOptionsForCreateQueries }, ] - combine?: (result: QueriesResults) => TCombinedResult + combine?: (result: RawQueriesResults) => TCombinedResult } /** diff --git a/packages/angular-query-experimental/src/types.ts b/packages/angular-query-experimental/src/types.ts index b49f03f483..be3efc25cb 100644 --- a/packages/angular-query-experimental/src/types.ts +++ b/packages/angular-query-experimental/src/types.ts @@ -25,12 +25,12 @@ export interface CreateBaseQueryOptions< TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > extends QueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey - > {} + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey +> {} export interface CreateQueryOptions< TQueryFnData = unknown, @@ -38,15 +38,9 @@ export interface CreateQueryOptions< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > extends OmitKeyof< - CreateBaseQueryOptions< - TQueryFnData, - TError, - TData, - TQueryFnData, - TQueryKey - >, - 'suspense' - > {} + CreateBaseQueryOptions, + 'suspense' +> {} type CreateStatusBasedQueryResult< TStatus extends QueryObserverResult['status'], @@ -85,15 +79,15 @@ export interface CreateInfiniteQueryOptions< TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, > extends OmitKeyof< - InfiniteQueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryKey, - TPageParam - >, - 'suspense' - > {} + InfiniteQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + 'suspense' +> {} export type CreateBaseQueryResult< TData = unknown, @@ -147,9 +141,9 @@ export interface CreateMutationOptions< TVariables = void, TOnMutateResult = unknown, > extends OmitKeyof< - MutationObserverOptions, - '_defaulted' - > {} + MutationObserverOptions, + '_defaulted' +> {} export type CreateMutateFunction< TData = unknown, From 800311b31e9f0337707451e8246805070affdd77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sat, 20 Dec 2025 20:25:13 -0300 Subject: [PATCH 14/24] chore(angular-query): require angular 19 peer --- packages/angular-query-experimental/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/angular-query-experimental/package.json b/packages/angular-query-experimental/package.json index 9e505d69f9..6532557425 100644 --- a/packages/angular-query-experimental/package.json +++ b/packages/angular-query-experimental/package.json @@ -106,8 +106,8 @@ "@tanstack/query-devtools": "workspace:*" }, "peerDependencies": { - "@angular/common": ">=16.0.0", - "@angular/core": ">=16.0.0" + "@angular/common": ">=19.0.0", + "@angular/core": ">=19.0.0" }, "publishConfig": { "directory": "dist", From 0fd6dbd10a90a9bffc102bfd4f7723661c22c483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sat, 20 Dec 2025 23:13:45 -0300 Subject: [PATCH 15/24] fix(angular-query): infer select instead of skip-token like other signal based adapters --- docs/framework/angular/guides/caching.md | 2 +- .../angular/guides/default-query-function.md | 16 +++++++--- .../framework/angular/guides/query-retries.md | 6 ++-- .../src/__tests__/inject-query.test-d.ts | 12 ++++++++ .../src/__tests__/inject-query.test.ts | 30 +++++++++++++++++++ .../src/query-options.ts | 7 +---- .../angular-query-experimental/src/types.ts | 16 ++++------ 7 files changed, 64 insertions(+), 25 deletions(-) diff --git a/docs/framework/angular/guides/caching.md b/docs/framework/angular/guides/caching.md index 32c6e6d58d..5de89421a5 100644 --- a/docs/framework/angular/guides/caching.md +++ b/docs/framework/angular/guides/caching.md @@ -27,7 +27,7 @@ Let's assume we are using the default `gcTime` of **5 minutes** and the default - When the request completes successfully, the cache's data under the `['todos']` key is updated with the new data, and both instances are updated with the new data. - Both instances of the `injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodos })` query are destroyed and no longer in use. - Since there are no more active instances of this query, a garbage collection timeout is set using `gcTime` to delete and garbage collect the query (defaults to **5 minutes**). -- Before the cache timeout has completed, another instance of `injectQuery(() => ({ queryKey: ['todos'], queyFn: fetchTodos })` mounts. The query immediately returns the available cached data while the `fetchTodos` function is being run in the background. When it completes successfully, it will populate the cache with fresh data. +- Before the cache timeout has completed, another instance of `injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodos })` mounts. The query immediately returns the available cached data while the `fetchTodos` function is being run in the background. When it completes successfully, it will populate the cache with fresh data. - The final instance of `injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodos })` gets destroyed. - No more instances of `injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodos })` appear within **5 minutes**. - The cached data under the `['todos']` key is deleted and garbage collected. diff --git a/docs/framework/angular/guides/default-query-function.md b/docs/framework/angular/guides/default-query-function.md index 75e0fa3c48..4db44c9828 100644 --- a/docs/framework/angular/guides/default-query-function.md +++ b/docs/framework/angular/guides/default-query-function.md @@ -28,7 +28,10 @@ bootstrapApplication(MyAppComponent, { providers: [provideTanStackQuery(queryClient)], }) -export class PostsComponent { +@Component({ + // ... +}) +class PostsComponent { // All you have to do now is pass a key! postsQuery = injectQuery>(() => ({ queryKey: ['/posts'], @@ -36,11 +39,16 @@ export class PostsComponent { // ... } -export class PostComponent { +@Component({ + // ... +}) +class PostComponent { + postId = input(0) + // You can even leave out the queryFn and just go straight into options postQuery = injectQuery(() => ({ - enabled: this.postIdSignal() > 0, - queryKey: [`/posts/${this.postIdSignal()}`], + enabled: this.postId() > 0, + queryKey: [`/posts/${this.postId()}`], })) // ... } diff --git a/docs/framework/angular/guides/query-retries.md b/docs/framework/angular/guides/query-retries.md index 45228d10bb..c6c73fb280 100644 --- a/docs/framework/angular/guides/query-retries.md +++ b/docs/framework/angular/guides/query-retries.md @@ -31,10 +31,10 @@ const result = injectQuery(() => ({ ```ts // Configure for all queries import { - QueryCache, QueryClient, - QueryClientProvider, + provideTanStackQuery, } from '@tanstack/angular-query-experimental' +import { bootstrapApplication } from '@angular/platform-browser' const queryClient = new QueryClient({ defaultOptions: { @@ -51,7 +51,7 @@ bootstrapApplication(AppComponent, { [//]: # 'Example2' -Though it is not recommended, you can obviously override the `retryDelay` function/integer in both the Provider and individual query options. If set to an integer instead of a function the delay will always be the same amount of time: +Though it is not recommended, you can obviously override the `retryDelay` function/integer in both the QueryClient default options and individual query options. If set to an integer instead of a function the delay will always be the same amount of time: [//]: # 'Example3' diff --git a/packages/angular-query-experimental/src/__tests__/inject-query.test-d.ts b/packages/angular-query-experimental/src/__tests__/inject-query.test-d.ts index 541ad65f14..353890bff4 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test-d.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test-d.ts @@ -33,6 +33,18 @@ describe('initialData', () => { expectTypeOf(data).toEqualTypeOf>() }) + it('should support selection function with select', () => { + const options = injectQuery(() => ({ + queryKey: ['key'], + queryFn: () => '1', + select: (data) => { + expectTypeOf(data).toEqualTypeOf() + return parseInt(data) + }, + })) + expectTypeOf(options.data).toEqualTypeOf>() + }) + it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into useQuery', () => { const options = queryOptions({ queryKey: ['key'], 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 5a24a07f92..03ed5dfa8b 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -511,6 +511,36 @@ describe('injectQuery', () => { await vi.advanceTimersByTimeAsync(11) }) + test('should support selection function with select', async () => { + const app = TestBed.inject(ApplicationRef) + + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ + queryKey: ['key13'], + queryFn: () => [{ id: 1 }, { id: 2 }], + select: (data) => data.map((item) => item.id), + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query + + // Wait for query to complete (even synchronous queryFn needs time to process) + const stablePromise = app.whenStable() + await Promise.resolve() + await vi.advanceTimersByTimeAsync(10) + await stablePromise + + expect(query.status()).toBe('success') + expect(query.data()).toEqual([1, 2]) + }) + describe('throwOnError', () => { test('should evaluate throwOnError when query is expected to throw', async () => { const boundaryFn = vi.fn() diff --git a/packages/angular-query-experimental/src/query-options.ts b/packages/angular-query-experimental/src/query-options.ts index 069472b903..780e14ce1b 100644 --- a/packages/angular-query-experimental/src/query-options.ts +++ b/packages/angular-query-experimental/src/query-options.ts @@ -4,7 +4,6 @@ import type { InitialDataFunction, NonUndefinedGuard, OmitKeyof, - QueryFunction, QueryKey, SkipToken, } from '@tanstack/query-core' @@ -42,14 +41,10 @@ export type DefinedInitialDataOptions< TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, -> = Omit< - CreateQueryOptions, - 'queryFn' -> & { +> = CreateQueryOptions & { initialData: | NonUndefinedGuard | (() => NonUndefinedGuard) - queryFn?: QueryFunction } /** diff --git a/packages/angular-query-experimental/src/types.ts b/packages/angular-query-experimental/src/types.ts index be3efc25cb..5c36a6bb27 100644 --- a/packages/angular-query-experimental/src/types.ts +++ b/packages/angular-query-experimental/src/types.ts @@ -18,29 +18,23 @@ import type { import type { Signal } from '@angular/core' import type { MapToSignals, MethodKeys } from './signal-proxy' -export interface CreateBaseQueryOptions< +export type CreateBaseQueryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, -> extends QueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey -> {} +> = QueryObserverOptions -export interface CreateQueryOptions< +export type CreateQueryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, -> extends OmitKeyof< +> = OmitKeyof< CreateBaseQueryOptions, 'suspense' -> {} +> type CreateStatusBasedQueryResult< TStatus extends QueryObserverResult['status'], From 1dad9675b809780a288af618f3ee3fbec083c595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sat, 20 Dec 2025 23:26:00 -0300 Subject: [PATCH 16/24] docs(angular-query): consistency on examples and small improvements --- .../angular/guides/dependent-queries.md | 25 ++++++++++++++++--- .../angular/guides/disabling-queries.md | 2 +- .../guides/does-this-replace-client-state.md | 2 ++ .../angular/guides/important-defaults.md | 1 - .../angular/guides/infinite-queries.md | 2 ++ .../angular/guides/initial-query-data.md | 10 +++++--- docs/framework/angular/guides/mutations.md | 7 ++++-- .../angular/guides/paginated-queries.md | 6 ++++- .../angular/guides/parallel-queries.md | 9 ++++++- .../angular/guides/placeholder-query-data.md | 17 ++++++++++--- docs/framework/angular/guides/queries.md | 3 +++ .../angular/guides/query-functions.md | 20 +++++++++++---- .../angular/guides/query-invalidation.md | 4 +++ .../framework/angular/guides/query-options.md | 14 +++++------ 14 files changed, 93 insertions(+), 29 deletions(-) diff --git a/docs/framework/angular/guides/dependent-queries.md b/docs/framework/angular/guides/dependent-queries.md index 38afbd491f..2fee39bafc 100644 --- a/docs/framework/angular/guides/dependent-queries.md +++ b/docs/framework/angular/guides/dependent-queries.md @@ -10,14 +10,14 @@ replace: { 'useQuery': 'injectQuery', 'useQueries': 'injectQueries' } ```ts // Get the user userQuery = injectQuery(() => ({ - queryKey: ['user', email], - queryFn: getUserByEmail, + queryKey: ['user', this.email()], + queryFn: this.getUserByEmail, })) // Then get the user's projects projectsQuery = injectQuery(() => ({ queryKey: ['projects', this.userQuery.data()?.id], - queryFn: getProjectsByUser, + queryFn: this.getProjectsByUser, // The query will not execute until the user id exists enabled: !!this.userQuery.data()?.id, })) @@ -26,8 +26,25 @@ projectsQuery = injectQuery(() => ({ [//]: # 'Example' [//]: # 'Example2' +Dynamic parallel query - `injectQueries` can depend on a previous query also, here's how to achieve this: + +> IMPORTANT: `injectQueries` is experimental and is provided in it's own entry point + ```ts -// injectQueries is under development for Angular Query +// Get the users ids +userIds = injectQuery(() => ({ + queryKey: ['users'], + queryFn: getUserData, + select: (users) => users.map((user) => user.id), +})) + +// Then get the users messages +userQueries = injectQueries(() => ({ + queries: (this.userIds() ?? []).map((userId) => ({ + queryKey: ['user', userId], + queryFn: () => getUserById(userId), + })), +})) ``` [//]: # 'Example2' diff --git a/docs/framework/angular/guides/disabling-queries.md b/docs/framework/angular/guides/disabling-queries.md index 35da0225de..80b3c0a523 100644 --- a/docs/framework/angular/guides/disabling-queries.md +++ b/docs/framework/angular/guides/disabling-queries.md @@ -70,7 +70,7 @@ export class TodosComponent { [//]: # 'Example3' ```angular-ts -import { skipToken, injectQuery } from '@tanstack/query-angular' +import { skipToken, injectQuery } from '@tanstack/angular-query-experimental' @Component({ selector: 'todos', diff --git a/docs/framework/angular/guides/does-this-replace-client-state.md b/docs/framework/angular/guides/does-this-replace-client-state.md index 3872115c94..0c16fb3568 100644 --- a/docs/framework/angular/guides/does-this-replace-client-state.md +++ b/docs/framework/angular/guides/does-this-replace-client-state.md @@ -7,5 +7,7 @@ replace: 'useQuery': 'injectQuery', 'useMutation': 'injectMutation', 'hook': 'function', + 'Redux, MobX or': 'NgRx Store or', + 'Redux, MobX, Zustand': 'NgRx Store, custom services with RxJS', } --- diff --git a/docs/framework/angular/guides/important-defaults.md b/docs/framework/angular/guides/important-defaults.md index 886792038b..b8af966642 100644 --- a/docs/framework/angular/guides/important-defaults.md +++ b/docs/framework/angular/guides/important-defaults.md @@ -4,7 +4,6 @@ title: Important Defaults ref: docs/framework/react/guides/important-defaults.md replace: { - 'React': 'Angular', 'react-query': 'angular-query', 'useQuery': 'injectQuery', 'useInfiniteQuery': 'injectInfiniteQuery', diff --git a/docs/framework/angular/guides/infinite-queries.md b/docs/framework/angular/guides/infinite-queries.md index 9fdde83e26..35371a5ff4 100644 --- a/docs/framework/angular/guides/infinite-queries.md +++ b/docs/framework/angular/guides/infinite-queries.md @@ -75,6 +75,8 @@ export class Example { template: ` `, }) export class Example { + projectsService = inject(ProjectsService) + query = injectInfiniteQuery(() => ({ queryKey: ['projects'], queryFn: async ({ pageParam }) => { diff --git a/docs/framework/angular/guides/initial-query-data.md b/docs/framework/angular/guides/initial-query-data.md index 28673844be..7cd6472c4f 100644 --- a/docs/framework/angular/guides/initial-query-data.md +++ b/docs/framework/angular/guides/initial-query-data.md @@ -81,7 +81,7 @@ result = injectQuery(() => ({ ```ts result = injectQuery(() => ({ queryKey: ['todo', this.todoId()], - queryFn: () => fetch('/todos'), + queryFn: () => fetch(`/todos/${this.todoId()}`), initialData: () => { // Use a todo from the 'todos' query as the initial data for this todo query return this.queryClient @@ -99,9 +99,11 @@ result = injectQuery(() => ({ queryKey: ['todos', this.todoId()], queryFn: () => fetch(`/todos/${this.todoId()}`), initialData: () => - queryClient.getQueryData(['todos'])?.find((d) => d.id === this.todoId()), + this.queryClient + .getQueryData(['todos']) + ?.find((d) => d.id === this.todoId()), initialDataUpdatedAt: () => - queryClient.getQueryState(['todos'])?.dataUpdatedAt, + this.queryClient.getQueryState(['todos'])?.dataUpdatedAt, })) ``` @@ -114,7 +116,7 @@ result = injectQuery(() => ({ queryFn: () => fetch(`/todos/${this.todoId()}`), initialData: () => { // Get the query state - const state = queryClient.getQueryState(['todos']) + const state = this.queryClient.getQueryState(['todos']) // If the query exists and has data that is no older than 10 seconds... if (state && Date.now() - state.dataUpdatedAt <= 10 * 1000) { diff --git a/docs/framework/angular/guides/mutations.md b/docs/framework/angular/guides/mutations.md index f511808231..b769ad8ee1 100644 --- a/docs/framework/angular/guides/mutations.md +++ b/docs/framework/angular/guides/mutations.md @@ -243,12 +243,15 @@ queryClient.setMutationDefaults(['addTodo'], { retry: 3, }) -class someComponent { +@Component({ + // ... +}) +class SomeComponent { // Start mutation in some component: mutation = injectMutation(() => ({ mutationKey: ['addTodo'] })) someMethod() { - mutation.mutate({ title: 'title' }) + this.mutation.mutate({ title: 'title' }) } } diff --git a/docs/framework/angular/guides/paginated-queries.md b/docs/framework/angular/guides/paginated-queries.md index 4baa3d4cfb..87e9efc353 100644 --- a/docs/framework/angular/guides/paginated-queries.md +++ b/docs/framework/angular/guides/paginated-queries.md @@ -68,6 +68,10 @@ const result = injectQuery(() => ({ `, }) + +@Component({ + // ... +}) export class PaginationExampleComponent { page = signal(0) queryClient = inject(QueryClient) @@ -83,7 +87,7 @@ export class PaginationExampleComponent { effect(() => { // Prefetch the next page! if (!this.query.isPlaceholderData() && this.query.data()?.hasMore) { - this.#queryClient.prefetchQuery({ + this.queryClient.prefetchQuery({ queryKey: ['projects', this.page() + 1], queryFn: () => lastValueFrom(fetchProjects(this.page() + 1)), }) diff --git a/docs/framework/angular/guides/parallel-queries.md b/docs/framework/angular/guides/parallel-queries.md index f88756a2a8..5894171ad8 100644 --- a/docs/framework/angular/guides/parallel-queries.md +++ b/docs/framework/angular/guides/parallel-queries.md @@ -17,6 +17,9 @@ replace: [//]: # 'Example' ```ts +@Component({ + // ... +}) export class AppComponent { // The following queries will execute in parallel usersQuery = injectQuery(() => ({ queryKey: ['users'], queryFn: fetchUsers })) @@ -38,11 +41,15 @@ TanStack Query provides `injectQueries`, which you can use to dynamically execut [//]: # 'DynamicParallelIntro' [//]: # 'Example2' +> IMPORTANT: `injectQueries` is experimental and is provided in it's own entry point + ```ts +@Component({ + // ... +}) export class AppComponent { users = signal>([]) - // Please note injectQueries is under development and this code does not work yet userQueries = injectQueries(() => ({ queries: users().map((user) => { return { diff --git a/docs/framework/angular/guides/placeholder-query-data.md b/docs/framework/angular/guides/placeholder-query-data.md index ba92fbc4ed..2298d7bd2c 100644 --- a/docs/framework/angular/guides/placeholder-query-data.md +++ b/docs/framework/angular/guides/placeholder-query-data.md @@ -7,6 +7,9 @@ ref: docs/framework/react/guides/placeholder-query-data.md [//]: # 'ExampleValue' ```ts +@Component({ + // ... +}) class TodosComponent { result = injectQuery(() => ({ queryKey: ['todos'], @@ -22,10 +25,15 @@ class TodosComponent { [//]: # 'ExampleFunction' ```ts -class TodosComponent { +@Component({ + // ... +}) +export class TodosComponent { + todoId = signal(1) + result = injectQuery(() => ({ - queryKey: ['todos', id()], - queryFn: () => fetch(`/todos/${id}`), + queryKey: ['todos', this.todoId()], + queryFn: () => fetch(`/todos/${this.todoId()}`), placeholderData: (previousData, previousQuery) => previousData, })) } @@ -35,6 +43,9 @@ class TodosComponent { [//]: # 'ExampleCache' ```ts +@Component({ + // ... +}) export class BlogPostComponent { postId = input.required() queryClient = inject(QueryClient) diff --git a/docs/framework/angular/guides/queries.md b/docs/framework/angular/guides/queries.md index 336da2e171..dfffc457f1 100644 --- a/docs/framework/angular/guides/queries.md +++ b/docs/framework/angular/guides/queries.md @@ -19,6 +19,9 @@ replace: ```ts import { injectQuery } from '@tanstack/angular-query-experimental' +@Component({ + // ... +}) export class TodosComponent { info = injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodoList })) } diff --git a/docs/framework/angular/guides/query-functions.md b/docs/framework/angular/guides/query-functions.md index ae5f9e6c99..c5fca5d848 100644 --- a/docs/framework/angular/guides/query-functions.md +++ b/docs/framework/angular/guides/query-functions.md @@ -8,7 +8,10 @@ ref: docs/framework/react/guides/query-functions.md ```ts injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchAllTodos })) -injectQuery(() => ({ queryKey: ['todos', todoId], queryFn: () => fetchTodoById(todoId) }) +injectQuery(() => ({ + queryKey: ['todos', todoId], + queryFn: () => fetchTodoById(todoId), +})) injectQuery(() => ({ queryKey: ['todos', todoId], queryFn: async () => { @@ -26,8 +29,10 @@ injectQuery(() => ({ [//]: # 'Example2' ```ts +todoId = signal(1) + todos = injectQuery(() => ({ - queryKey: ['todos', todoId()], + queryKey: ['todos', this.todoId()], queryFn: async () => { if (somethingGoesWrong) { throw new Error('Oh no!') @@ -45,10 +50,12 @@ todos = injectQuery(() => ({ [//]: # 'Example3' ```ts +todoId = signal(1) + todos = injectQuery(() => ({ - queryKey: ['todos', todoId()], + queryKey: ['todos', this.todoId()], queryFn: async () => { - const response = await fetch('/todos/' + todoId) + const response = await fetch('/todos/' + this.todoId()) if (!response.ok) { throw new Error('Network response was not ok') } @@ -61,8 +68,11 @@ todos = injectQuery(() => ({ [//]: # 'Example4' ```ts +status = signal('active') +page = signal(1) + result = injectQuery(() => ({ - queryKey: ['todos', { status: status(), page: page() }], + queryKey: ['todos', { status: this.status(), page: this.page() }], queryFn: fetchTodoList, })) diff --git a/docs/framework/angular/guides/query-invalidation.md b/docs/framework/angular/guides/query-invalidation.md index 9cd8ea5809..d5419eaf45 100644 --- a/docs/framework/angular/guides/query-invalidation.md +++ b/docs/framework/angular/guides/query-invalidation.md @@ -8,8 +8,12 @@ replace: { 'useQuery': 'injectQuery', 'hooks': 'functions' } [//]: # 'Example2' ```ts +import { Component, inject } from '@angular/core' import { injectQuery, QueryClient } from '@tanstack/angular-query-experimental' +@Component({ + // ... +}) class QueryInvalidationExample { queryClient = inject(QueryClient) diff --git a/docs/framework/angular/guides/query-options.md b/docs/framework/angular/guides/query-options.md index d63753bbfc..6d6ad6b3e9 100644 --- a/docs/framework/angular/guides/query-options.md +++ b/docs/framework/angular/guides/query-options.md @@ -31,26 +31,26 @@ export class QueriesService { // usage: -postId = input.required({ - transform: numberAttribute, -}) +postId = input.required({ transform: numberAttribute }) queries = inject(QueriesService) +queryClient = inject(QueryClient) postQuery = injectQuery(() => this.queries.post(this.postId())) -queryClient.prefetchQuery(this.queries.post(23)) -queryClient.setQueryData(this.queries.post(42).queryKey, newPost) +someMethod() { + this.queryClient.prefetchQuery(this.queries.post(23)) + this.queryClient.setQueryData(this.queries.post(42).queryKey, newPost) +} ``` [//]: # 'Example1' [//]: # 'Example2' ```ts -// Type inference still works, so query.data will be the return type of select instead of queryFn queries = inject(QueriesService) query = injectQuery(() => ({ - ...groupOptions(1), + ...this.queries.post(1), select: (data) => data.title, })) ``` From b31faf70101e079b1165a92227f72fa7a24393cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sun, 21 Dec 2025 10:04:31 -0300 Subject: [PATCH 17/24] chore(angular-query): fix eslint and knip warnings --- .../__tests__/inject-infinite-query.test-d.ts | 23 +++++- .../src/__tests__/inject-queries.test-d.ts | 2 +- .../src/__tests__/inject-queries.test.ts | 28 +++---- .../src/__tests__/pending-tasks-ssr.test.ts | 4 +- .../src/__tests__/signal-proxy.test.ts | 4 +- .../src/create-base-query.ts | 5 +- .../src/inject-infinite-query.ts | 2 +- .../src/inject-queries.ts | 2 +- .../src/inject-query.ts | 2 +- .../src/pending-tasks-compat.ts | 2 +- .../src/signal-proxy.ts | 73 ++++++++++--------- 11 files changed, 86 insertions(+), 61 deletions(-) diff --git a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test-d.ts b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test-d.ts index 7ec133adfb..40eb48b918 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test-d.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test-d.ts @@ -1,8 +1,9 @@ +import { afterEach, beforeEach, describe, expectTypeOf, it, test, vi } from 'vitest' import { TestBed } from '@angular/core/testing' -import { afterEach, beforeEach, describe, expectTypeOf, test, vi } from 'vitest' import { provideZonelessChangeDetection } from '@angular/core' import { sleep } from '@tanstack/query-test-utils' import { QueryClient, injectInfiniteQuery, provideTanStackQuery } from '..' +import type { Signal } from '@angular/core'; import type { InfiniteData } from '@tanstack/query-core' describe('injectInfiniteQuery', () => { @@ -39,4 +40,24 @@ describe('injectInfiniteQuery', () => { expectTypeOf(data).toEqualTypeOf>() } }) + + it('should provide the correct types to the select function', () => { + const query = TestBed.runInInjectionContext(() => { + return injectInfiniteQuery(() => ({ + queryKey: ['infiniteQuery'], + queryFn: ({ pageParam }) => + sleep(0).then(() => 'data on page ' + pageParam), + initialPageParam: 0, + getNextPageParam: () => 12, + select: (data) => { + expectTypeOf(data).toEqualTypeOf>() + return data + }, + })) + }) + + expectTypeOf(query.data).toEqualTypeOf< + Signal | Signal> + >() + }) }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts b/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts index 58b46085dc..378ff1a334 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-queries.test-d.ts @@ -246,7 +246,7 @@ describe('InjectQueries combine', () => { >() }) - it('should provide the correct types on the combined result with initial data', () => { + it('should provide the correct types on the result with initial data', () => { const queryResults = injectQueries(() => ({ queries: [ { diff --git a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts index 5803018e86..4302252bdb 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, vi, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { render } from '@testing-library/angular' import { ChangeDetectionStrategy, @@ -34,8 +34,8 @@ describe('injectQueries', () => { template: `
- data1: {{ result()[0].data() ?? 'null' }}, data2: - {{ result()[1].data() ?? 'null' }} + data1: {{ queries()[0].data() ?? 'null' }}, data2: + {{ queries()[1].data() ?? 'null' }}
`, @@ -45,7 +45,7 @@ describe('injectQueries', () => { toString(val: any) { return String(val) } - result = injectQueries(() => ({ + queries = injectQueries(() => ({ queries: [ { queryKey: key1, @@ -65,7 +65,7 @@ describe('injectQueries', () => { })) _pushResults = effect(() => { - const snapshot = this.result().map((q) => ({ data: q.data() })) + const snapshot = this.queries().map((q) => ({ data: q.data() })) results.push(snapshot) }) } @@ -88,11 +88,11 @@ describe('injectQueries', () => { const results: Array<{ data: string; refetch: () => void }> = [] @Component({ - template: `
data: {{ result().data }}
`, + template: `
data: {{ queries().data }}
`, changeDetection: ChangeDetectionStrategy.OnPush, }) class Page { - result = injectQueries(() => ({ + queries = injectQueries(() => ({ queries: [ { queryKey: key1, @@ -120,19 +120,19 @@ describe('injectQueries', () => { })) _pushResults = effect(() => { - results.push(this.result()) + results.push(this.queries()) }) } const rendered = await render(Page) const instance = rendered.fixture.componentInstance await rendered.findByText('data: 1,2') - expect(instance.result().data).toBe('1,2') + expect(instance.queries().data).toBe('1,2') - instance.result().refetch() + instance.queries().refetch() await rendered.findByText('data: 3,4') - expect(instance.result().data).toBe('3,4') + expect(instance.queries().data).toBe('3,4') expect(results).toHaveLength(5) expect(results[0]).toMatchObject({ @@ -165,7 +165,7 @@ describe('injectQueries', () => { changeDetection: ChangeDetectionStrategy.OnPush, }) class Page { - result = injectQueries(() => ({ + queries = injectQueries(() => ({ queries: queries().map((q) => ({ queryKey: ['query', q], queryFn: async () => { @@ -176,13 +176,13 @@ describe('injectQueries', () => { })) mapped = computed(() => { - const results = this.result().map((q) => q.data()) + const results = this.queries().map((q) => q.data()) if (results.length === 0) return 'empty' return results.join(',') }) _pushResults = effect(() => { - const snapshot = this.result().map((q) => ({ data: q.data() })) + const snapshot = this.queries().map((q) => ({ data: q.data() })) results.push(snapshot) }) } diff --git a/packages/angular-query-experimental/src/__tests__/pending-tasks-ssr.test.ts b/packages/angular-query-experimental/src/__tests__/pending-tasks-ssr.test.ts index d1081d191e..40c972bc6f 100644 --- a/packages/angular-query-experimental/src/__tests__/pending-tasks-ssr.test.ts +++ b/packages/angular-query-experimental/src/__tests__/pending-tasks-ssr.test.ts @@ -12,10 +12,10 @@ import { } from '@angular/platform-server' import { bootstrapApplication } from '@angular/platform-browser' -import { injectQuery } from '../inject-query' import { sleep } from '@tanstack/query-test-utils' -import { provideTanStackQuery } from '../providers' import { QueryClient } from '@tanstack/query-core' +import { injectQuery } from '../inject-query' +import { provideTanStackQuery } from '../providers' describe('PendingTasks SSR', () => { beforeEach(() => { diff --git a/packages/angular-query-experimental/src/__tests__/signal-proxy.test.ts b/packages/angular-query-experimental/src/__tests__/signal-proxy.test.ts index 0bd552bb75..1e039d33f4 100644 --- a/packages/angular-query-experimental/src/__tests__/signal-proxy.test.ts +++ b/packages/angular-query-experimental/src/__tests__/signal-proxy.test.ts @@ -5,13 +5,13 @@ import { input, isSignal, provideZonelessChangeDetection, - untracked, signal, + untracked, } from '@angular/core' import { beforeEach, describe, expect, test } from 'vitest' +import { TestBed } from '@angular/core/testing' import { signalProxy } from '../signal-proxy' import { registerSignalInput } from './test-utils' -import { TestBed } from '@angular/core/testing' describe('signalProxy', () => { const inputSignal = signal({ fn: () => 'bar', baz: 'qux' }) diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index 2def448264..e00d62b1fd 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -13,8 +13,9 @@ import { notifyManager, shouldThrowError, } from '@tanstack/query-core' -import { MethodKeys, signalProxy } from './signal-proxy' +import { signalProxy } from './signal-proxy' import { injectIsRestoring } from './inject-is-restoring' +import type { MethodKeys} from './signal-proxy'; import type { DefaultedQueryObserverOptions, QueryKey, @@ -220,7 +221,7 @@ export function createBaseQuery< return signalProxy( resultSignal.asReadonly(), - excludeFunctions as MethodKeys>[], + excludeFunctions as Array>>, ) } const OBSERVER_NOT_READY_ERROR = diff --git a/packages/angular-query-experimental/src/inject-infinite-query.ts b/packages/angular-query-experimental/src/inject-infinite-query.ts index 57a2677983..aa4a26cf4f 100644 --- a/packages/angular-query-experimental/src/inject-infinite-query.ts +++ b/packages/angular-query-experimental/src/inject-infinite-query.ts @@ -6,6 +6,7 @@ import { runInInjectionContext, } from '@angular/core' import { createBaseQuery } from './create-base-query' +import type { MethodKeys } from './signal-proxy' import type { DefaultError, InfiniteData, @@ -22,7 +23,6 @@ import type { DefinedInitialDataInfiniteOptions, UndefinedInitialDataInfiniteOptions, } from './infinite-query-options' -import { MethodKeys } from './signal-proxy' export interface InjectInfiniteQueryOptions { /** diff --git a/packages/angular-query-experimental/src/inject-queries.ts b/packages/angular-query-experimental/src/inject-queries.ts index dff27fcd2b..77655d7765 100644 --- a/packages/angular-query-experimental/src/inject-queries.ts +++ b/packages/angular-query-experimental/src/inject-queries.ts @@ -373,7 +373,7 @@ export function injectQueries< return combine ? result - : (result as QueryObserverResult[]).map((query) => + : (result as Array>).map((query) => signalProxy(signal(query), ['refetch']), ) }) diff --git a/packages/angular-query-experimental/src/inject-query.ts b/packages/angular-query-experimental/src/inject-query.ts index 2d0951beac..a1529ee35d 100644 --- a/packages/angular-query-experimental/src/inject-query.ts +++ b/packages/angular-query-experimental/src/inject-query.ts @@ -6,6 +6,7 @@ import { runInInjectionContext, } from '@angular/core' import { createBaseQuery } from './create-base-query' +import type { MethodKeys } from './signal-proxy' import type { DefaultError, QueryKey, @@ -20,7 +21,6 @@ import type { DefinedInitialDataOptions, UndefinedInitialDataOptions, } from './query-options' -import { MethodKeys } from './signal-proxy' export interface InjectQueryOptions { /** diff --git a/packages/angular-query-experimental/src/pending-tasks-compat.ts b/packages/angular-query-experimental/src/pending-tasks-compat.ts index e156996993..d07317e696 100644 --- a/packages/angular-query-experimental/src/pending-tasks-compat.ts +++ b/packages/angular-query-experimental/src/pending-tasks-compat.ts @@ -4,7 +4,7 @@ import { noop } from '@tanstack/query-core' type PendingTasksCompat = { add: () => PendingTaskRef } -export type PendingTaskRef = () => void +type PendingTaskRef = () => void export const PENDING_TASKS = new InjectionToken( 'PENDING_TASKS', diff --git a/packages/angular-query-experimental/src/signal-proxy.ts b/packages/angular-query-experimental/src/signal-proxy.ts index 1fdfe2141e..fa857dcda2 100644 --- a/packages/angular-query-experimental/src/signal-proxy.ts +++ b/packages/angular-query-experimental/src/signal-proxy.ts @@ -2,11 +2,11 @@ import { computed, untracked } from '@angular/core' import type { Signal } from '@angular/core' export type MethodKeys = { - [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never + [K in keyof T]: T[K] extends (...args: Array) => any ? K : never }[keyof T] -export type MapToSignals = never> = { - [K in keyof T]: K extends ExcludeFields ? T[K] : Signal +export type MapToSignals = never> = { + [K in keyof T]: K extends TExcludeFields ? T[K] : Signal } /** @@ -18,41 +18,44 @@ export type MapToSignals = never> = { */ export function signalProxy< TInput extends Record, - const ExcludeFields extends ReadonlyArray> = [], ->(inputSignal: Signal, excludeFields: ExcludeFields) { - const internalState = {} as MapToSignals + const TExcludeFields extends ReadonlyArray> = [], +>(inputSignal: Signal, excludeFields: TExcludeFields) { + const internalState = {} as MapToSignals const excludeFieldsArray = excludeFields as ReadonlyArray - return new Proxy>(internalState, { - get(target, prop) { - // first check if we have it in our internal state and return it - const computedField = target[prop] - if (computedField) return computedField + return new Proxy>( + internalState, + { + get(target, prop) { + // first check if we have it in our internal state and return it + const computedField = target[prop] + if (computedField) return computedField - // if it is an expluded function, return it without tracking - if (excludeFieldsArray.includes(prop as string)) { - const fn = (...args: Parameters) => - untracked(inputSignal)[prop](...args) - // @ts-expect-error - target[prop] = fn - return fn - } + // if it is an excluded function, return it without tracking + if (excludeFieldsArray.includes(prop as string)) { + const fn = (...args: Parameters) => + untracked(inputSignal)[prop](...args) + // @ts-expect-error + target[prop] = fn + return fn + } - // otherwise, make a computed field - // @ts-expect-error - return (target[prop] = computed(() => inputSignal()[prop])) - }, - has(_, prop) { - return !!untracked(inputSignal)[prop] - }, - ownKeys() { - return Reflect.ownKeys(untracked(inputSignal)) - }, - getOwnPropertyDescriptor() { - return { - enumerable: true, - configurable: true, - } + // otherwise, make a computed field + // @ts-expect-error + return (target[prop] = computed(() => inputSignal()[prop])) + }, + has(_, prop) { + return !!untracked(inputSignal)[prop] + }, + ownKeys() { + return Reflect.ownKeys(untracked(inputSignal)) + }, + getOwnPropertyDescriptor() { + return { + enumerable: true, + configurable: true, + } + }, }, - }) + ) } From 608f89862408cc2da1f1124b050f24b57ba5cc96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sun, 21 Dec 2025 11:31:37 -0300 Subject: [PATCH 18/24] chore(angular-query): remove compat pending tasks for angular <19 --- .../src/inject-mutation.ts | 4 +-- .../src/pending-tasks-compat.ts | 28 ------------------- 2 files changed, 2 insertions(+), 30 deletions(-) delete mode 100644 packages/angular-query-experimental/src/pending-tasks-compat.ts diff --git a/packages/angular-query-experimental/src/inject-mutation.ts b/packages/angular-query-experimental/src/inject-mutation.ts index 8dc9e28256..4e08431548 100644 --- a/packages/angular-query-experimental/src/inject-mutation.ts +++ b/packages/angular-query-experimental/src/inject-mutation.ts @@ -1,6 +1,7 @@ import { Injector, NgZone, + PendingTasks, assertInInjectionContext, computed, effect, @@ -16,7 +17,6 @@ import { shouldThrowError, } from '@tanstack/query-core' import { signalProxy } from './signal-proxy' -import { PENDING_TASKS } from './pending-tasks-compat' import type { DefaultError, MutationObserverResult } from '@tanstack/query-core' import type { CreateMutateFunction, @@ -58,7 +58,7 @@ export function injectMutation< !options?.injector && assertInInjectionContext(injectMutation) const injector = options?.injector ?? inject(Injector) const ngZone = injector.get(NgZone) - const pendingTasks = injector.get(PENDING_TASKS) + const pendingTasks = injector.get(PendingTasks) const queryClient = injector.get(QueryClient) /** diff --git a/packages/angular-query-experimental/src/pending-tasks-compat.ts b/packages/angular-query-experimental/src/pending-tasks-compat.ts deleted file mode 100644 index d07317e696..0000000000 --- a/packages/angular-query-experimental/src/pending-tasks-compat.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { InjectionToken, inject } from '@angular/core' -import * as ng from '@angular/core' -import { noop } from '@tanstack/query-core' - -type PendingTasksCompat = { add: () => PendingTaskRef } - -type PendingTaskRef = () => void - -export const PENDING_TASKS = new InjectionToken( - 'PENDING_TASKS', - { - factory: (): PendingTasksCompat => { - // Access via Reflect so bundlers stay quiet when the token is absent (Angular < 19). - const token = Reflect.get(ng, 'PendingTasks') as unknown as - | Parameters[0] - | undefined - - const svc: PendingTasksCompat | null = token - ? (inject(token, { optional: true }) as PendingTasksCompat | null) - : null - - // Without PendingTasks we fall back to a stable no-op shim. - return { - add: svc ? () => svc.add() : () => noop, - } - }, - }, -) From f768ce541b3cf439c171fe54a5baf94ee4e6ae86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sun, 21 Dec 2025 11:32:44 -0300 Subject: [PATCH 19/24] tests(angular-query): only test supported ts versions of angular --- packages/angular-query-experimental/README.md | 2 +- packages/angular-query-experimental/package.json | 5 ----- packages/angular-query-persist-client/package.json | 5 ----- 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/angular-query-experimental/README.md b/packages/angular-query-experimental/README.md index 6ed2dfa05a..c35238b2cd 100644 --- a/packages/angular-query-experimental/README.md +++ b/packages/angular-query-experimental/README.md @@ -29,7 +29,7 @@ Visit https://tanstack.com/query/latest/docs/framework/angular/overview # Quick Start -> The Angular adapter for TanStack Query requires Angular 16 or higher. +> The Angular adapter for TanStack Query requires Angular 19 or higher. 1. Install `angular-query` diff --git a/packages/angular-query-experimental/package.json b/packages/angular-query-experimental/package.json index 6532557425..a97a10d278 100644 --- a/packages/angular-query-experimental/package.json +++ b/packages/angular-query-experimental/package.json @@ -31,11 +31,6 @@ "compile": "tsc --build", "test:eslint": "eslint --concurrency=auto ./src", "test:types": "npm-run-all --serial test:types:*", - "test:types:ts50": "node ../../node_modules/typescript50/lib/tsc.js --build", - "test:types:ts51": "node ../../node_modules/typescript51/lib/tsc.js --build", - "test:types:ts52": "node ../../node_modules/typescript52/lib/tsc.js --build", - "test:types:ts53": "node ../../node_modules/typescript53/lib/tsc.js --build", - "test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js --build", "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js --build", "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js --build", "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js --build", diff --git a/packages/angular-query-persist-client/package.json b/packages/angular-query-persist-client/package.json index 44a436bc88..0bb74e9a29 100644 --- a/packages/angular-query-persist-client/package.json +++ b/packages/angular-query-persist-client/package.json @@ -20,11 +20,6 @@ "compile": "tsc --build", "test:eslint": "eslint --concurrency=auto ./src", "test:types": "npm-run-all --serial test:types:*", - "test:types:ts50": "node ../../node_modules/typescript50/lib/tsc.js --build", - "test:types:ts51": "node ../../node_modules/typescript51/lib/tsc.js --build", - "test:types:ts52": "node ../../node_modules/typescript52/lib/tsc.js --build", - "test:types:ts53": "node ../../node_modules/typescript53/lib/tsc.js --build", - "test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js --build", "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js --build", "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js --build", "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js --build", From 0b9b1a75acff0d52dcb35eb3a50fa02fcd989ba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sun, 21 Dec 2025 12:09:27 -0300 Subject: [PATCH 20/24] tests(angular-query): assert other status narrowing on infinite queries --- .../__tests__/inject-infinite-query.test-d.ts | 108 ++++++++++++------ 1 file changed, 71 insertions(+), 37 deletions(-) diff --git a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test-d.ts b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test-d.ts index 40eb48b918..c34c580ddc 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test-d.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test-d.ts @@ -1,60 +1,94 @@ -import { afterEach, beforeEach, describe, expectTypeOf, it, test, vi } from 'vitest' -import { TestBed } from '@angular/core/testing' -import { provideZonelessChangeDetection } from '@angular/core' -import { sleep } from '@tanstack/query-test-utils' -import { QueryClient, injectInfiniteQuery, provideTanStackQuery } from '..' -import type { Signal } from '@angular/core'; +import { describe, expectTypeOf, it, test } from 'vitest' +import { injectInfiniteQuery } from '..' +import type { Signal } from '@angular/core' import type { InfiniteData } from '@tanstack/query-core' describe('injectInfiniteQuery', () => { - let queryClient: QueryClient - - beforeEach(() => { - queryClient = new QueryClient() - vi.useFakeTimers() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], + describe('Discriminated union return type', () => { + test('data should be possibly undefined by default', () => { + const query = injectInfiniteQuery(() => ({ + queryKey: ['infiniteQuery'], + queryFn: ({ pageParam }) => + Promise.resolve('data on page ' + pageParam), + initialPageParam: 0, + getNextPageParam: () => 12, + })) + + expectTypeOf(query.data).toEqualTypeOf< + Signal | Signal> + >() }) - }) - afterEach(() => { - vi.useRealTimers() - }) + test('data should be defined when query is success', () => { + const query = injectInfiniteQuery(() => ({ + queryKey: ['infiniteQuery'], + queryFn: ({ pageParam }) => + Promise.resolve('data on page ' + pageParam), + initialPageParam: 0, + getNextPageParam: () => 12, + })) + + if (query.isSuccess()) { + expectTypeOf(query.data).toEqualTypeOf< + Signal> + >() + } + }) - test('should narrow type after isSuccess', () => { - const query = TestBed.runInInjectionContext(() => { - return injectInfiniteQuery(() => ({ + test('error should be null when query is success', () => { + const query = injectInfiniteQuery(() => ({ queryKey: ['infiniteQuery'], queryFn: ({ pageParam }) => - sleep(0).then(() => 'data on page ' + pageParam), + Promise.resolve('data on page ' + pageParam), initialPageParam: 0, getNextPageParam: () => 12, })) + + if (query.isSuccess()) { + expectTypeOf(query.error).toEqualTypeOf>() + } }) - if (query.isSuccess()) { - const data = query.data() - expectTypeOf(data).toEqualTypeOf>() - } - }) + test('data should be undefined when query is pending', () => { + const query = injectInfiniteQuery(() => ({ + queryKey: ['infiniteQuery'], + queryFn: ({ pageParam }) => + Promise.resolve('data on page ' + pageParam), + initialPageParam: 0, + getNextPageParam: () => 12, + })) - it('should provide the correct types to the select function', () => { - const query = TestBed.runInInjectionContext(() => { - return injectInfiniteQuery(() => ({ + if (query.isPending()) { + expectTypeOf(query.data).toEqualTypeOf>() + } + }) + + test('error should be defined when query is error', () => { + const query = injectInfiniteQuery(() => ({ queryKey: ['infiniteQuery'], queryFn: ({ pageParam }) => - sleep(0).then(() => 'data on page ' + pageParam), + Promise.resolve('data on page ' + pageParam), initialPageParam: 0, getNextPageParam: () => 12, - select: (data) => { - expectTypeOf(data).toEqualTypeOf>() - return data - }, })) + + if (query.isError()) { + expectTypeOf(query.error).toEqualTypeOf>() + } }) + }) + + it('should provide the correct types to the select function', () => { + const query = injectInfiniteQuery(() => ({ + queryKey: ['infiniteQuery'], + queryFn: ({ pageParam }) => Promise.resolve('data on page ' + pageParam), + initialPageParam: 0, + getNextPageParam: () => 12, + select: (data) => { + expectTypeOf(data).toEqualTypeOf>() + return data + }, + })) expectTypeOf(query.data).toEqualTypeOf< Signal | Signal> From 85c37fd1f947e2ba9198b9f10837f733e22a15a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sun, 21 Dec 2025 12:56:32 -0300 Subject: [PATCH 21/24] docs(tanstack-query): other docs improvements --- ...pclient-and-other-data-fetching-clients.md | 2 -- .../angular/guides/disabling-queries.md | 4 +-- .../guides/invalidations-from-mutations.md | 10 ++++++- .../angular/guides/paginated-queries.md | 4 +-- .../angular/guides/query-functions.md | 10 +++---- .../angular/guides/query-invalidation.md | 2 +- docs/framework/angular/overview.md | 7 ++--- docs/framework/angular/quick-start.md | 2 +- .../reference/functions/injectQuery.md | 28 +++++++++++++------ docs/framework/angular/typescript.md | 4 +-- packages/angular-query-experimental/README.md | 3 +- 11 files changed, 47 insertions(+), 29 deletions(-) diff --git a/docs/framework/angular/angular-httpclient-and-other-data-fetching-clients.md b/docs/framework/angular/angular-httpclient-and-other-data-fetching-clients.md index 0784526cb5..7bd3764e0e 100644 --- a/docs/framework/angular/angular-httpclient-and-other-data-fetching-clients.md +++ b/docs/framework/angular/angular-httpclient-and-other-data-fetching-clients.md @@ -36,8 +36,6 @@ class ExampleComponent { ``` > Since Angular is moving towards RxJS as an optional dependency, it's expected that `HttpClient` will also support promises in the future. -> -> Support for observables in TanStack Query for Angular is planned. ## Comparison table diff --git a/docs/framework/angular/guides/disabling-queries.md b/docs/framework/angular/guides/disabling-queries.md index 80b3c0a523..2977491196 100644 --- a/docs/framework/angular/guides/disabling-queries.md +++ b/docs/framework/angular/guides/disabling-queries.md @@ -13,9 +13,9 @@ replace: { 'useQuery': 'injectQuery' } template: `
- @if (query.data()) { + @if (query.data(); as data) {
    - @for (todo of query.data(); track todo.id) { + @for (todo of data; track todo.id) {
  • {{ todo.title }}
  • }
diff --git a/docs/framework/angular/guides/invalidations-from-mutations.md b/docs/framework/angular/guides/invalidations-from-mutations.md index 18f84e9ec3..0c300a716d 100644 --- a/docs/framework/angular/guides/invalidations-from-mutations.md +++ b/docs/framework/angular/guides/invalidations-from-mutations.md @@ -2,7 +2,12 @@ id: invalidations-from-mutations title: Invalidations from Mutations ref: docs/framework/react/guides/invalidations-from-mutations.md -replace: { 'useMutation': 'injectMutation', 'hook': 'function' } +replace: + { + 'react-query': 'angular-query-experimental', + 'useMutation': 'injectMutation', + 'hook': 'function', + } --- [//]: # 'Example' @@ -22,6 +27,9 @@ import { QueryClient, } from '@tanstack/angular-query-experimental' +@Component({ + // ... +}) export class TodosComponent { queryClient = inject(QueryClient) diff --git a/docs/framework/angular/guides/paginated-queries.md b/docs/framework/angular/guides/paginated-queries.md index 87e9efc353..2880ed26bf 100644 --- a/docs/framework/angular/guides/paginated-queries.md +++ b/docs/framework/angular/guides/paginated-queries.md @@ -35,9 +35,9 @@ const result = injectQuery(() => ({ instantaneously while they are also re-fetched invisibly in the background.

- @if (query.status() === 'pending') { + @if (query.isPending()) {
Loading...
- } @else if (query.status() === 'error') { + } @else if (query.isError()) {
Error: {{ query.error().message }}
} @else { diff --git a/docs/framework/angular/guides/query-functions.md b/docs/framework/angular/guides/query-functions.md index c5fca5d848..c8c3bd992a 100644 --- a/docs/framework/angular/guides/query-functions.md +++ b/docs/framework/angular/guides/query-functions.md @@ -9,18 +9,18 @@ ref: docs/framework/react/guides/query-functions.md ```ts injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchAllTodos })) injectQuery(() => ({ - queryKey: ['todos', todoId], - queryFn: () => fetchTodoById(todoId), + queryKey: ['todos', todoId()], + queryFn: () => fetchTodoById(todoId()), })) injectQuery(() => ({ - queryKey: ['todos', todoId], + queryKey: ['todos', todoId()], queryFn: async () => { - const data = await fetchTodoById(todoId) + const data = await fetchTodoById(todoId()) return data }, })) injectQuery(() => ({ - queryKey: ['todos', todoId], + queryKey: ['todos', todoId()], queryFn: ({ queryKey }) => fetchTodoById(queryKey[1]), })) ``` diff --git a/docs/framework/angular/guides/query-invalidation.md b/docs/framework/angular/guides/query-invalidation.md index d5419eaf45..e174d0a4b4 100644 --- a/docs/framework/angular/guides/query-invalidation.md +++ b/docs/framework/angular/guides/query-invalidation.md @@ -76,7 +76,7 @@ todoListQuery = injectQuery(() => ({ })) // However, the following query below will NOT be invalidated -const todoListQuery = injectQuery(() => ({ +todoListQuery = injectQuery(() => ({ queryKey: ['todos', { type: 'done' }], queryFn: fetchTodoList, })) diff --git a/docs/framework/angular/overview.md b/docs/framework/angular/overview.md index bfd93d4e89..2eba20cab0 100644 --- a/docs/framework/angular/overview.md +++ b/docs/framework/angular/overview.md @@ -73,11 +73,10 @@ import { lastValueFrom } from 'rxjs' template: ` @if (query.isPending()) { Loading... - } - @if (query.error()) { + } @else if (query.isError()) { An error has occurred: {{ query.error().message }} - } - @if (query.data(); as data) { + } @else if (query.isSuccess()) { + @let data = query.data();

{{ data.name }}

{{ data.description }}

👀 {{ data.subscribers_count }} diff --git a/docs/framework/angular/quick-start.md b/docs/framework/angular/quick-start.md index b711f92b2b..41b246498d 100644 --- a/docs/framework/angular/quick-start.md +++ b/docs/framework/angular/quick-start.md @@ -35,7 +35,7 @@ import { @NgModule({ declarations: [AppComponent], imports: [BrowserModule], - providers: [provideTanStackQuery(new QueryClient())], + providers: [provideTanStackQuery(new QueryClient()), provideHttpClient()], bootstrap: [AppComponent], }) export class AppModule {} diff --git a/docs/framework/angular/reference/functions/injectQuery.md b/docs/framework/angular/reference/functions/injectQuery.md index 8fa6832b09..e53a95962a 100644 --- a/docs/framework/angular/reference/functions/injectQuery.md +++ b/docs/framework/angular/reference/functions/injectQuery.md @@ -9,11 +9,14 @@ Injects a query: a declarative dependency on an asynchronous source of data that **Basic example** ```ts +import { lastValueFrom } from 'rxjs' + class ServiceOrComponent { query = injectQuery(() => ({ queryKey: ['repoData'], - queryFn: () => - this.#http.get('https://api.github.com/repos/tanstack/query'), + queryFn: () => lastValueFrom( + this.#http.get('https://api.github.com/repos/tanstack/query') + ) })) } ``` @@ -60,11 +63,14 @@ Injects a query: a declarative dependency on an asynchronous source of data that **Basic example** ```ts +import { lastValueFrom } from 'rxjs' + class ServiceOrComponent { query = injectQuery(() => ({ queryKey: ['repoData'], - queryFn: () => - this.#http.get('https://api.github.com/repos/tanstack/query'), + queryFn: () => lastValueFrom( + this.#http.get('https://api.github.com/repos/tanstack/query') + ), })) } ``` @@ -141,11 +147,14 @@ Injects a query: a declarative dependency on an asynchronous source of data that **Basic example** ```ts +import { lastValueFrom } from 'rxjs' + class ServiceOrComponent { query = injectQuery(() => ({ queryKey: ['repoData'], - queryFn: () => - this.#http.get('https://api.github.com/repos/tanstack/query'), + queryFn: () => lastValueFrom( + this.#http.get('https://api.github.com/repos/tanstack/query') + ), })) } ``` @@ -222,11 +231,14 @@ Injects a query: a declarative dependency on an asynchronous source of data that **Basic example** ```ts +import { lastValueFrom } from 'rxjs' + class ServiceOrComponent { query = injectQuery(() => ({ queryKey: ['repoData'], - queryFn: () => - this.#http.get('https://api.github.com/repos/tanstack/query'), + queryFn: () => lastValueFrom( + this.#http.get('https://api.github.com/repos/tanstack/query') + ), })) } ``` diff --git a/docs/framework/angular/typescript.md b/docs/framework/angular/typescript.md index 689c5b0673..0dea29de35 100644 --- a/docs/framework/angular/typescript.md +++ b/docs/framework/angular/typescript.md @@ -53,7 +53,7 @@ class MyComponent { [//]: # 'TypeInference2' [//]: # 'TypeInference3' -In this example we pass Group[] to the type parameter of HttpClient's `get` method. +In this example we pass `Group[]` to the type parameter of HttpClient's `get` method. ```angular-ts @Component({ @@ -92,7 +92,7 @@ class MyComponent { } ``` -> TypeScript currently does not support discriminated unions on object methods. Narrowing on signal fields on objects such as query results only works on signals returning a boolean. Prefer using `isSuccess()` and similar boolean status signals over `status() === 'success'`. +> TypeScript currently does not support discriminated unions on object methods. Narrowing on signal fields on objects such as query results only works on signals returning a boolean. Prefer using `isSuccess()`, `isError()` and `isPending()` over `status() === 'success'`. [//]: # 'TypeInference4' [//]: # 'TypeNarrowing' diff --git a/packages/angular-query-experimental/README.md b/packages/angular-query-experimental/README.md index c35238b2cd..7a60650d6d 100644 --- a/packages/angular-query-experimental/README.md +++ b/packages/angular-query-experimental/README.md @@ -78,9 +78,10 @@ import { @NgModule({ declarations: [AppComponent], imports: [BrowserModule], - providers: [provideTanStackQuery(new QueryClient())], + providers: [provideTanStackQuery(new QueryClient()), provideHttpClient()], bootstrap: [AppComponent], }) +export class AppModule {} ``` 3. Inject query From 03f8a7d63b54adb224c58d828c1f0083b2d69e08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sun, 21 Dec 2025 12:57:05 -0300 Subject: [PATCH 22/24] docs(angular-query): remove status type narrowing example --- docs/framework/angular/guides/queries.md | 31 ------------------------ 1 file changed, 31 deletions(-) diff --git a/docs/framework/angular/guides/queries.md b/docs/framework/angular/guides/queries.md index dfffc457f1..8192f927f0 100644 --- a/docs/framework/angular/guides/queries.md +++ b/docs/framework/angular/guides/queries.md @@ -64,38 +64,7 @@ export class PostsComponent { ``` [//]: # 'Example3' - -If booleans aren't your thing, you can always use the `status` state as well: - [//]: # 'Example4' - -```angular-ts -@Component({ - selector: 'todos', - template: ` - @switch (todos.status()) { - @case ('pending') { - Loading... - } - @case ('error') { - Error: {{ todos.error()?.message }} - } - - @default { -
    - @for (todo of todos.data(); track todo.id) { -
  • {{ todo.title }}
  • - } @empty { -
  • No todos found
  • - } -
- } - } - `, -}) -class TodosComponent {} -``` - [//]: # 'Example4' [//]: # 'Materials' [//]: # 'Materials' From 3528b4ba94d88d98a591e198bf16cb682c13353c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Mon, 22 Dec 2025 19:10:15 -0300 Subject: [PATCH 23/24] tests(angular-query): improve optimistic mutation test --- .../src/__tests__/inject-mutation.test.ts | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts index c1548bc634..366f8a4e7d 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { sleep } from '@tanstack/query-test-utils' import { QueryClient, injectMutation, provideTanStackQuery } from '..' import { expectSignals, registerSignalInput } from './test-utils' +import { firstValueFrom } from 'rxjs' describe('injectMutation', () => { let queryClient: QueryClient @@ -657,12 +658,13 @@ describe('injectMutation', () => { const mutation = TestBed.runInInjectionContext(() => injectMutation(() => ({ - mutationFn: async (data: string) => `final: ${data}`, // Synchronous resolution + mutationFn: async (data: string) => { + await sleep(50) + return `final: ${data}` + }, onMutate: async (variables) => { onMutateCalled = true - const previousData = queryClient.getQueryData(testQueryKey) queryClient.setQueryData(testQueryKey, `optimistic: ${variables}`) - return { previousData } }, onSuccess: (data) => { onSuccessCalled = true @@ -671,19 +673,30 @@ describe('injectMutation', () => { })), ) + // Run effects + TestBed.tick() + // Start mutation + expect(queryClient.getQueryData(testQueryKey)).toBe('initial') mutation.mutate('test') - // Synchronize pending effects - TestBed.tick() - - const stablePromise = app.whenStable() // Flush microtasks to allow TanStack Query's scheduled notifications to process await Promise.resolve() - await vi.advanceTimersByTimeAsync(1) - await stablePromise + // Check for optimistic update in the same macrotask expect(onMutateCalled).toBe(true) + expect(queryClient.getQueryData(testQueryKey)).toBe('optimistic: test') + + // Check stability before the mutation completes, waiting got the next macrotask task + await vi.advanceTimersByTimeAsync(0) + expect(mutation.isPending()).toBe(true) + expect(await firstValueFrom(app.isStable)).toBe(false) + + // Wait for the mutation to complete + const stablePromise = app.whenStable() + await vi.advanceTimersByTimeAsync(60) + await stablePromise + expect(onSuccessCalled).toBe(true) expect(mutation.isSuccess()).toBe(true) expect(mutation.data()).toBe('final: test') From 00ba5b42de624c9cbf73d403104165a248cae6a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Mon, 22 Dec 2025 19:20:54 -0300 Subject: [PATCH 24/24] fix(angular-query): use signal observer in createBaseQuery --- .../src/__tests__/inject-query.test.ts | 122 ++++++++++++++++++ .../src/create-base-query.ts | 72 ++++------- 2 files changed, 144 insertions(+), 50 deletions(-) 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 03ed5dfa8b..5ad9472714 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -5,6 +5,7 @@ import { Injector, NgZone, computed, + effect, input, provideZonelessChangeDetection, signal, @@ -722,6 +723,127 @@ describe('injectQuery', () => { expect(result).toEqual('signal-input-required-test') }) + test('should allow reading the query data on effect registered before injection', () => { + const spy = vi.fn() + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + readEffect = effect(() => { + spy(this.query.data()) + }) + + query = injectQuery(() => ({ + queryKey: ['effect-before-injection'], + queryFn: () => sleep(0).then(() => 'Some data'), + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + expect(spy).toHaveBeenCalledWith(undefined) + }) + + test('should render with an initial value for input signal if available before change detection', () => { + const key1 = queryKey() + const key2 = queryKey() + + queryClient.setQueryData(key1, 'value 1') + queryClient.setQueryData(key2, 'value 2') + + @Component({ + selector: 'app-test', + template: '{{ query.data() }}', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + inputKey = input.required<[string]>() + query = injectQuery(() => ({ + queryKey: this.inputKey(), + queryFn: () => sleep(0).then(() => 'Some data'), + })) + } + registerSignalInput(TestComponent, 'inputKey') + + const fixture = TestBed.createComponent(TestComponent) + fixture.componentRef.setInput('inputKey', key1) + + const instance = fixture.componentInstance + const query = instance.query + + expect(() => instance.inputKey()).not.toThrow() + + expect(instance.inputKey()).toEqual(key1) + expect(query.data()).toEqual('value 1') + + fixture.componentRef.setInput('inputKey', key2) + + expect(instance.inputKey()).toEqual(key2) + expect(query.data()).toEqual('value 2') + }) + + test('should allow reading the query data on component ngOnInit with required signal input', async () => { + const spy = vi.fn() + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + key = input.required<[string]>() + query = injectQuery(() => ({ + queryKey: this.key(), + queryFn: () => Promise.resolve(() => 'Some data'), + })) + + initialStatus!: string + + ngOnInit() { + this.initialStatus = this.query.status() + + // effect should not have been called yet + expect(spy).not.toHaveBeenCalled() + } + + _spyEffect = effect(() => { + spy() + }) + } + + registerSignalInput(TestComponent, 'key') + + const fixture = TestBed.createComponent(TestComponent) + fixture.componentRef.setInput('key', ['ngOnInitTest']) + + fixture.detectChanges() + expect(spy).toHaveBeenCalled() + + const instance = fixture.componentInstance + expect(instance.initialStatus).toEqual('pending') + }) + + test('should update query data on the same macrotask when query data changes', async () => { + const query = TestBed.runInInjectionContext(() => + injectQuery(() => ({ + queryKey: ['test'], + initialData: 'initial data', + })), + ) + + // Run effects + TestBed.tick() + + expect(query.data()).toBe('initial data') + queryClient.setQueryData(['test'], 'new data') + + // Flush microtasks + await Promise.resolve() + + expect(query.data()).toBe('new data') + }) + describe('injection context', () => { test('throws NG0203 with descriptive error outside injection context', () => { expect(() => { diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index e00d62b1fd..697fe7ae2b 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -15,7 +15,7 @@ import { } from '@tanstack/query-core' import { signalProxy } from './signal-proxy' import { injectIsRestoring } from './inject-is-restoring' -import type { MethodKeys} from './signal-proxy'; +import type { MethodKeys } from './signal-proxy' import type { DefaultedQueryObserverOptions, QueryKey, @@ -53,14 +53,6 @@ export function createBaseQuery< const isRestoring = injectIsRestoring() const destroyRef = inject(DestroyRef) - let observer: QueryObserver< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey - > | null = null - let destroyed = false let taskCleanupRef: (() => void) | null = null @@ -91,6 +83,15 @@ export function createBaseQuery< return defaultedOptions }) + // Computed without deps to lazy initialize the observer + const observerSignal = computed(() => { + return new Observer(queryClient, untracked(defaultedOptionsSignal)) + }) + + effect(() => { + observerSignal().setOptions(defaultedOptionsSignal()) + }) + const trackObserverResult = ( result: QueryObserverResult, notifyOnChangeProps?: DefaultedQueryObserverOptions< @@ -101,10 +102,7 @@ export function createBaseQuery< TQueryKey >['notifyOnChangeProps'], ) => { - if (!observer) { - throw new Error(OBSERVER_NOT_READY_ERROR) - } - + const observer = untracked(observerSignal) const trackedResult = observer.trackResult(result) if (!notifyOnChangeProps) { @@ -128,31 +126,8 @@ export function createBaseQuery< } } - const setObserverOptions = ( - options: DefaultedQueryObserverOptions< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey - >, - ) => { - if (!observer) { - observer = new Observer(queryClient, options) - destroyRef.onDestroy(() => { - destroyed = true - stopPendingTask() - }) - } else { - observer.setOptions(options) - } - } - const subscribeToObserver = () => { - if (!observer) { - throw new Error(OBSERVER_NOT_READY_ERROR) - } - + const observer = untracked(observerSignal) const initialState = observer.getCurrentResult() if (initialState.fetchStatus !== 'idle') { startPendingTask() @@ -172,9 +147,9 @@ export function createBaseQuery< if ( state.isError && !state.isFetching && - shouldThrowError(observer!.options.throwOnError, [ + shouldThrowError(observer.options.throwOnError, [ state.error, - observer!.getCurrentQuery(), + observer.getCurrentQuery(), ]) ) { ngZone.onError.emit(state.error) @@ -182,7 +157,7 @@ export function createBaseQuery< } const trackedState = trackObserverResult( state, - observer!.options.notifyOnChangeProps, + observer.options.notifyOnChangeProps, ) resultSignal.set(trackedState) }) @@ -194,20 +169,14 @@ export function createBaseQuery< const resultSignal = linkedSignal({ source: defaultedOptionsSignal, computation: () => { - if (!observer) throw new Error(OBSERVER_NOT_READY_ERROR) + const observer = untracked(observerSignal) const defaultedOptions = defaultedOptionsSignal() + const result = observer.getOptimisticResult(defaultedOptions) return trackObserverResult(result, defaultedOptions.notifyOnChangeProps) }, }) - effect(() => { - const defaultedOptions = defaultedOptionsSignal() - untracked(() => { - setObserverOptions(defaultedOptions) - }) - }) - effect((onCleanup) => { if (isRestoring()) { return @@ -219,10 +188,13 @@ export function createBaseQuery< }) }) + destroyRef.onDestroy(() => { + destroyed = true + stopPendingTask() + }) + return signalProxy( resultSignal.asReadonly(), excludeFunctions as Array>>, ) } -const OBSERVER_NOT_READY_ERROR = - 'injectQuery: QueryObserver not initialized yet. Avoid reading the query result or running methods during construction'