From d64460cdc76633f4de5191ad0b904fb7bf238ada 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/28] 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 deb1dc5137944d3f3612de4c28ff9761bf89af60 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/28] 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 4c82cd62718924caf02c213be2accc2dbd87052c 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/28] 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 | 716 +++++++++++------- .../src/__tests__/pending-tasks.test.ts | 76 +- 6 files changed, 548 insertions(+), 314 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 39d38140e3..4f75bfdaeb 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,223 +50,230 @@ 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() + expectTypeOf(fromGenericOptionsQueryFn.error()).toEqualTypeOf() - type MyData = number - type MyQueryKey = readonly ['my-data', number] - - 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(() => { - 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(() => { - 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) @@ -276,12 +283,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') @@ -293,14 +310,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') @@ -317,12 +344,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) @@ -331,7 +370,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 @@ -347,13 +386,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') @@ -366,26 +417,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') @@ -410,13 +469,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() @@ -432,6 +503,7 @@ describe('injectQuery', () => { await vi.advanceTimersByTimeAsync(11) keySignal.set('key12') + fixture.detectChanges() void query.refetch().then(() => { expect(fetchFn).toHaveBeenCalledTimes(2) @@ -448,15 +520,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) @@ -469,41 +552,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') @@ -516,6 +625,7 @@ describe('injectQuery', () => { @Component({ selector: 'app-fake', template: `{{ query.data() }}`, + changeDetection: ChangeDetectionStrategy.OnPush, }) class FakeComponent { name = input.required() @@ -550,15 +660,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') }) @@ -566,15 +690,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() @@ -602,14 +736,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(() => { @@ -642,28 +786,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() @@ -672,7 +823,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 () => { @@ -686,34 +837,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 () => { @@ -727,39 +889,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 da895703e8198706ceaab4afaa06db23a95e8daf 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/28] 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 4b410095cac7f5c8cfffd84bdb64852ff53a5fe4 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/28] 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 06e65079957eabe2bee533763dbae5fc534ab9af 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/28] 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 4f75bfdaeb..39cb80741c 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' @@ -552,6 +553,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: '', @@ -569,10 +578,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: '', @@ -590,7 +608,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')) }) }) @@ -713,9 +732,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') @@ -765,9 +783,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') @@ -863,6 +880,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() @@ -872,6 +890,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 3d5331eff01f4c4325fdc5422eefd8faf8b0510b 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/28] 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 39cb80741c..0c61998dd8 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -554,12 +554,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', @@ -578,18 +586,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', @@ -608,8 +631,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 e5f60287a3715395f639901d5e3a882dd22f0225 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/28] 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 d304fde37b766277d49c36fc1e1ecc1ad08369a3 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/28] 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 | 38 ++++---- 16 files changed, 216 insertions(+), 255 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 3a1fc4bca8..048e063b40 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(), @@ -45,12 +41,8 @@ describe('injectDevtoolsPanel', () => { vi.clearAllMocks() 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 0c61998dd8..9b6bb1bba4 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 @@ -262,7 +260,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -287,7 +284,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -314,7 +310,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -348,7 +343,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -390,7 +384,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -425,7 +418,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -473,7 +465,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -525,7 +516,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -572,7 +562,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -617,7 +606,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -647,7 +635,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -685,17 +672,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', () => { @@ -714,7 +706,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -742,7 +733,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -787,7 +777,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -795,7 +784,9 @@ describe('injectQuery', () => { query = injectQuery(() => ({ queryKey: ['httpClientTest'], queryFn: () => - lastValueFrom(this.httpClient.get<{ message: string }>('/api/test')), + lastValueFrom( + this.httpClient.get<{ message: string }>('/api/test'), + ), })) } @@ -837,7 +828,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -888,7 +878,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -942,7 +931,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 6c6e128115..5699d34151 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, @@ -138,7 +139,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() await vi.dynamicImportSettled() TestBed.tick() @@ -169,7 +170,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() @@ -189,8 +190,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) - await vi.dynamicImportSettled() + await flushQueryUpdates() expect(mockTanstackQueryDevtools).toHaveBeenCalledTimes(1) @@ -206,7 +206,7 @@ describe('withDevtools feature', () => { ) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(mockTanstackQueryDevtools).toHaveBeenCalledTimes(1) }) @@ -251,8 +251,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) - await vi.dynamicImportSettled() + await flushQueryUpdates() TestBed.tick() @@ -292,8 +291,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) - await vi.dynamicImportSettled() + await flushQueryUpdates() TestBed.tick() @@ -325,8 +323,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) - await vi.dynamicImportSettled() + await flushQueryUpdates() TestBed.tick() @@ -357,8 +354,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) - await vi.dynamicImportSettled() + await flushQueryUpdates() TestBed.tick() @@ -391,8 +387,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) - await vi.dynamicImportSettled() + await flushQueryUpdates() TestBed.tick() @@ -422,8 +417,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) - await vi.dynamicImportSettled() + await flushQueryUpdates() expect(mockDevtoolsInstance.mount).toHaveBeenCalledTimes(1) expect(mockDevtoolsInstance.unmount).toHaveBeenCalledTimes(0) @@ -449,7 +443,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() await vi.dynamicImportSettled() @@ -479,7 +473,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(mockTanstackQueryDevtools).not.toHaveBeenCalled() expect(mockDevtoolsInstance.mount).not.toHaveBeenCalled() @@ -536,7 +530,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(withDevtoolsFn).toHaveBeenCalledWith(mockService1, mockService2) }) @@ -557,7 +551,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(withDevtoolsFn).toHaveBeenCalledWith() }) @@ -587,7 +581,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() const service = TestBed.inject(ReactiveService) From 2969fb66e3a5913358cd88c7495377cddb823ce0 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/28] 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 9c84cc61825561457624999a57d6fecdc592fcc1 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/28] 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 9b6bb1bba4..1b023fa7b6 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -847,7 +847,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') @@ -909,8 +911,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) @@ -949,7 +953,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 bc8cd29f67e87935f5316babbb4acb69c3bcda37 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/28] 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 | 56 +++++++++++---- 3 files changed, 116 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 594ac9840a..b2ed3047c5 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 1af1216e90..ef33595a40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,7 +174,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) @@ -214,7 +214,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) @@ -260,7 +260,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) @@ -303,7 +303,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) @@ -343,7 +343,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) @@ -386,7 +386,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) @@ -426,7 +426,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) @@ -469,7 +469,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) @@ -512,7 +512,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) @@ -555,7 +555,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) @@ -595,7 +595,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) @@ -2000,7 +2000,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) @@ -2272,6 +2272,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 @@ -3023,6 +3026,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} @@ -15527,6 +15540,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'} @@ -15746,7 +15763,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) @@ -15781,6 +15798,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 @@ -15873,6 +15891,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) @@ -31029,6 +31057,8 @@ snapshots: xdg-basedir@5.1.0: {} + xhr2@0.2.1: {} + xml-name-validator@4.0.0: {} xml-name-validator@5.0.0: {} From acf3f7154900dedc0289c0c34bec0f592f2a4587 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/28] 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 fd1d8fdd92921ea00b487d0c992e2eddb1d1cd29 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/28] 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 b2ed3047c5..57f690be0a 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 220c955bcfd29af7936e834fa1d9d58541a3bc7e 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/28] 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 1b023fa7b6..4e54132ab7 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -509,6 +509,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 82ebd09d6ba21b9ab2186b84f45b2342d9feaf00 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/28] 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 f85d697743940b2bd1d5bb8176a195fa863010af 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/28] 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 10c29e17a35e5fd4f976a506bdebf288f7c68215 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/28] 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 edbe09e23c10bb688f54981b3fd0e577794548f9 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/28] 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 57f690be0a..5a97a00d5f 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 4ed0e7cc2b..083696df49 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 e46337bd76101dc37643930e4ff257c5d78694dd 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/28] 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 59dc8f75207212e1dc89475257aec6fec526489b 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/28] 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 77fb483e0179c68888f78fd669945e2f3e4b1322 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/28] 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 a318081eeab3875bd29403da1cb384704b504d21 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/28] 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 d086c9c77bc94d72fde366224bdf0d2466c7f418 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/28] 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 4e54132ab7..16b6af0b4a 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, @@ -720,6 +721,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' From 33ce3fac175568fae87ec7ca48e4920e6bcfb6f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Fri, 26 Dec 2025 21:39:26 -0300 Subject: [PATCH 25/28] docs(angular-query): fix typos --- docs/framework/angular/guides/paginated-queries.md | 4 ---- docs/framework/angular/guides/parallel-queries.md | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/framework/angular/guides/paginated-queries.md b/docs/framework/angular/guides/paginated-queries.md index 2880ed26bf..d8a5900995 100644 --- a/docs/framework/angular/guides/paginated-queries.md +++ b/docs/framework/angular/guides/paginated-queries.md @@ -68,10 +68,6 @@ const result = injectQuery(() => ({
`, }) - -@Component({ - // ... -}) export class PaginationExampleComponent { page = signal(0) queryClient = inject(QueryClient) diff --git a/docs/framework/angular/guides/parallel-queries.md b/docs/framework/angular/guides/parallel-queries.md index 5894171ad8..39bc12ce14 100644 --- a/docs/framework/angular/guides/parallel-queries.md +++ b/docs/framework/angular/guides/parallel-queries.md @@ -41,7 +41,7 @@ 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 +> IMPORTANT: `injectQueries` is experimental and is provided in its own entry point ```ts @Component({ From 92024807f13dae434dae42fa5a99d9e9737db299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sun, 8 Feb 2026 19:15:01 -0300 Subject: [PATCH 26/28] feat(angular-query): inject queries refractor + tests --- .../angular-query-experimental/package.json | 3 +- .../src/__tests__/inject-mutation.test.ts | 31 ++ .../src/__tests__/inject-queries.test-d.ts | 3 +- .../src/__tests__/inject-queries.test.ts | 448 +++++++++++++++++- .../src/__tests__/inject-query.test.ts | 90 +++- .../pending-tasks-ssr-queries.test.ts | 75 +++ .../src/__tests__/zonejs-adapter.test.ts | 96 ++++ .../angular-query-experimental/src/index.ts | 7 +- .../src/inject-queries.ts | 162 +++++-- .../src/inject-query.ts | 24 +- pnpm-lock.yaml | 76 ++- 11 files changed, 945 insertions(+), 70 deletions(-) create mode 100644 packages/angular-query-experimental/src/__tests__/pending-tasks-ssr-queries.test.ts create mode 100644 packages/angular-query-experimental/src/__tests__/zonejs-adapter.test.ts diff --git a/packages/angular-query-experimental/package.json b/packages/angular-query-experimental/package.json index 5a97a00d5f..11afc14ac4 100644 --- a/packages/angular-query-experimental/package.json +++ b/packages/angular-query-experimental/package.json @@ -95,7 +95,8 @@ "rxjs": "^7.8.2", "vite-plugin-dts": "4.2.3", "vite-plugin-externalize-deps": "^0.9.0", - "vite-tsconfig-paths": "^5.1.4" + "vite-tsconfig-paths": "^5.1.4", + "zone.js": "^0.16.0" }, "optionalDependencies": { "@tanstack/query-devtools": "workspace:*" 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 366f8a4e7d..a8d5a3981d 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts @@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, Injector, + NgZone, input, provideZonelessChangeDetection, signal, @@ -445,6 +446,36 @@ describe('injectMutation', () => { expect(boundaryFn).toHaveBeenCalledTimes(1) expect(boundaryFn).toHaveBeenCalledWith(err) }) + + test('should emit zone error when throwOnError is true and mutate is used', async () => { + const err = new Error('Expected mock error. All is well!') + const zone = TestBed.inject(NgZone) + const zoneErrorEmitSpy = vi.spyOn(zone.onError, 'emit') + const runSpy = vi.spyOn(zone, 'run').mockImplementation((callback: any) => { + try { + return callback() + } catch { + return undefined + } + }) + + const { mutate } = TestBed.runInInjectionContext(() => + injectMutation(() => ({ + mutationKey: ['fake'], + mutationFn: () => { + return sleep(0).then(() => Promise.reject(err)) + }, + throwOnError: true, + })), + ) + + mutate() + + await vi.runAllTimersAsync() + + expect(zoneErrorEmitSpy).toHaveBeenCalledWith(err) + expect(runSpy).toHaveBeenCalled() + }) }) test('should throw when throwOnError is true', async () => { 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 378ff1a334..6c2e373e44 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 @@ -1,6 +1,5 @@ import { describe, expectTypeOf, it } from 'vitest' -import { skipToken } from '..' -import { injectQueries } from '../inject-queries' +import { injectQueries, skipToken } from '..' import { queryOptions } from '../query-options' import type { CreateQueryOptions, CreateQueryResult, OmitKeyof } from '..' import type { Signal } from '@angular/core' 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 4302252bdb..7922dae4ae 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts @@ -1,16 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { render } from '@testing-library/angular' import { + ApplicationRef, ChangeDetectionStrategy, Component, + Injector, computed, effect, + input, signal, } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { sleep } from '@tanstack/query-test-utils' import { queryKey } from '@tanstack/query-test-utils' -import { QueryClient } from '..' +import { QueryClient, provideIsRestoring } from '..' import { injectQueries } from '../inject-queries' -import { setupTanStackQueryTestBed } from './test-utils' +import { registerSignalInput, setupTanStackQueryTestBed } from './test-utils' let queryClient: QueryClient @@ -25,6 +30,36 @@ afterEach(() => { }) describe('injectQueries', () => { + it('throws NG0203 with descriptive error outside injection context', () => { + expect(() => { + injectQueries(() => ({ + queries: [ + { + queryKey: ['injectionContextError'], + queryFn: () => Promise.resolve(1), + }, + ], + })) + }).toThrowError(/NG0203(.*?)injectQueries/) + }) + + it('can be used outside injection context when passing an injector', () => { + const injector = TestBed.inject(Injector) + const queries = injectQueries( + () => ({ + queries: [ + { + queryKey: ['manualInjector'], + queryFn: () => Promise.resolve(1), + }, + ], + }), + injector, + ) + + expect(queries()[0].status()).toBe('pending') + }) + it('should return the correct states', async () => { const key1 = queryKey() const key2 = queryKey() @@ -157,6 +192,179 @@ describe('injectQueries', () => { }) }) + it('should handle mixed success and error query states', async () => { + @Component({ + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class Page { + queries = injectQueries(() => ({ + queries: [ + { + queryKey: ['mixed-error'], + retry: false, + queryFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + throw new Error('mixed-error') + }, + }, + { + queryKey: ['mixed-success'], + queryFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + return 'mixed-success' + }, + }, + ], + })) + } + + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(25) + await Promise.resolve() + + const [errorQuery, successQuery] = rendered.fixture.componentInstance.queries() + expect(errorQuery.status()).toBe('error') + expect(errorQuery.error()?.message).toBe('mixed-error') + expect(successQuery.status()).toBe('success') + expect(successQuery.data()).toBe('mixed-success') + }) + + it('should cleanup pending tasks when component with active queries is destroyed', async () => { + @Component({ + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class Page { + queries = injectQueries(() => ({ + queries: [ + { + queryKey: ['destroy-query-1'], + queryFn: async () => { + await sleep(100) + return 'one' + }, + }, + { + queryKey: ['destroy-query-2'], + queryFn: async () => { + await sleep(100) + return 'two' + }, + }, + ], + })) + } + + // Use a fixture here on purpose: we need component teardown + whenStable() semantics. + const fixture = TestBed.createComponent(Page) + fixture.detectChanges() + expect(fixture.isStable()).toBe(false) + + fixture.destroy() + + const stablePromise = fixture.whenStable() + await vi.advanceTimersByTimeAsync(150) + await stablePromise + + expect(fixture.isStable()).toBe(true) + }) + + it('should react to enabled signal changes', async () => { + const enabled = signal(false) + const fetchSpy = vi.fn(() => sleep(10).then(() => 'enabled-data')) + + @Component({ + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class Page { + enabled = enabled + fetchSpy = fetchSpy + + queries = injectQueries(() => ({ + queries: [ + { + queryKey: ['enabled', this.enabled()], + queryFn: this.fetchSpy, + enabled: this.enabled(), + }, + ], + })) + } + + const rendered = await render(Page) + const query = rendered.fixture.componentInstance.queries()[0] + + expect(fetchSpy).not.toHaveBeenCalled() + expect(query.status()).toBe('pending') + + enabled.set(true) + rendered.fixture.detectChanges() + await vi.advanceTimersByTimeAsync(11) + await Promise.resolve() + + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(query.status()).toBe('success') + expect(query.data()).toBe('enabled-data') + }) + + it('should refetch only changed keys when queries length stays the same', async () => { + const ids = signal<[string, string]>(['a', 'b']) + const firstSpy = vi.fn((context: any) => + sleep(10).then(() => `first-${context.queryKey[1]}`), + ) + const secondSpy = vi.fn((context: any) => + sleep(10).then(() => `second-${context.queryKey[1]}`), + ) + + @Component({ + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class Page { + ids = ids + firstSpy = firstSpy + secondSpy = secondSpy + + queries = injectQueries(() => ({ + queries: [ + { + staleTime: Number.POSITIVE_INFINITY, + queryKey: ['first', this.ids()[0]], + queryFn: this.firstSpy, + }, + { + staleTime: Number.POSITIVE_INFINITY, + queryKey: ['second', this.ids()[1]], + queryFn: this.secondSpy, + }, + ], + })) + } + + const rendered = await render(Page) + await vi.advanceTimersByTimeAsync(11) + await Promise.resolve() + + let [firstQuery, secondQuery] = rendered.fixture.componentInstance.queries() + expect(firstQuery.data()).toBe('first-a') + expect(secondQuery.data()).toBe('second-b') + expect(firstSpy).toHaveBeenCalledTimes(1) + expect(secondSpy).toHaveBeenCalledTimes(1) + + ids.set(['c', 'b']) + rendered.fixture.detectChanges() + await vi.advanceTimersByTimeAsync(11) + await Promise.resolve() + + ;[firstQuery, secondQuery] = rendered.fixture.componentInstance.queries() + expect(firstQuery.data()).toBe('first-c') + expect(secondQuery.data()).toBe('second-b') + expect(firstSpy).toHaveBeenCalledTimes(2) + expect(secondSpy).toHaveBeenCalledTimes(1) + }) + it('should support changes on the queries array', async () => { const results: Array>> = [] @@ -217,19 +425,237 @@ describe('injectQueries', () => { 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 }]) + const hasOptimisticTransition = results.some( + (snapshot) => + snapshot.length === 2 && + snapshot[0]?.data === undefined && + snapshot[1]?.data === 4, + ) + expect(hasOptimisticTransition).toBe(true) + expect(results[results.length - 1]).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([]) + expect(results[results.length - 1]).toMatchObject([]) + }) + + it('should change the rendered component when the queries array changes', async () => { + const userIds = signal([1, 2]) + + @Component({ + template: ` +
    + @for (query of queries(); track $index) { + @if (query.data(); as data) { +
  • {{ data.value }}
  • + } + } +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class Page { + userIds = userIds + + queries = injectQueries(() => ({ + queries: this.userIds().map((id) => ({ + queryKey: ['user', id], + queryFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + return { value: String(id) } + }, + })), + })) + } + + const rendered = await render(Page) + + await rendered.findByText('1') + await rendered.findByText('2') + + userIds.set([3]) + rendered.fixture.detectChanges() + + await rendered.findByText('3') + expect(rendered.queryByText('1')).toBeNull() + expect(rendered.queryByText('2')).toBeNull() + }) + + it('should support required signal inputs', async () => { + @Component({ + selector: 'app-fake', + template: `{{ queries()[0].data() }}`, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class FakeComponent { + name = input.required() + + queries = injectQueries(() => ({ + queries: [ + { + queryKey: ['fake', this.name()], + queryFn: () => this.name(), + }, + ], + })) + } + + 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) + + const result = fixture.nativeElement.querySelector('app-fake').textContent + expect(result).toEqual('signal-input-required-test') + }) + + it('should pause fetching while restoring and fetch once restoring is disabled', async () => { + const isRestoring = signal(true) + const fetchSpy = vi.fn(() => sleep(10).then(() => 'restored-data')) + setupTanStackQueryTestBed(queryClient, { + providers: [provideIsRestoring(isRestoring.asReadonly())], + }) + + @Component({ + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class Page { + queries = injectQueries(() => ({ + queries: [ + { + queryKey: ['restoring'], + queryFn: fetchSpy, + }, + ], + })) + } + + const fixture = TestBed.createComponent(Page) + fixture.detectChanges() + + expect(fetchSpy).not.toHaveBeenCalled() + expect(fixture.componentInstance.queries()[0].status()).toBe('pending') + + const stablePromise = fixture.whenStable() + await Promise.resolve() + await stablePromise + + isRestoring.set(false) + fixture.detectChanges() + + await vi.advanceTimersByTimeAsync(11) + await fixture.whenStable() + + const result = fixture.componentInstance.queries()[0] + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(result.status()).toBe('success') + expect(result.data()).toBe('restored-data') + }) + + it('should complete queries before whenStable resolves', async () => { + const app = TestBed.inject(ApplicationRef) + + @Component({ + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class Page { + queries = injectQueries(() => ({ + queries: [ + { + queryKey: ['query-1'], + queryFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return 1 + }, + }, + { + queryKey: ['query-2'], + queryFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + return 2 + }, + }, + ], + })) + } + + const fixture = TestBed.createComponent(Page) + fixture.detectChanges() + + const stablePromise = app.whenStable() + let stableResolved = false + void stablePromise.then(() => { + stableResolved = true + }) + + await Promise.resolve() + expect(stableResolved).toBe(false) + + await vi.advanceTimersByTimeAsync(25) + await stablePromise + + const result = fixture.componentInstance.queries() + expect(result[0].status()).toBe('success') + expect(result[1].status()).toBe('success') + expect(result[0].data()).toBe(1) + expect(result[1].data()).toBe(2) + }) + + it('should use latest query key for aliased refetch function', async () => { + const key = signal('one') + const fetchSpy = vi.fn(async (context: any) => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return context.queryKey[1] + }) + + @Component({ + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class Page { + key = key + fetchSpy = fetchSpy + + queries = injectQueries(() => ({ + queries: [ + { + queryKey: ['query', this.key()], + queryFn: this.fetchSpy, + enabled: false, + }, + ], + })) + } + + const rendered = await render(Page) + const query = rendered.fixture.componentInstance.queries()[0] + const refetch = query.refetch + + key.set('two') + rendered.fixture.detectChanges() + + const refetchPromise = refetch() + await vi.advanceTimersByTimeAsync(15) + await refetchPromise + + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['query', 'two'], + }), + ) }) }) 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 16b6af0b4a..d0eabf5add 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,13 @@ import { } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' import { lastValueFrom } from 'rxjs' -import { QueryCache, QueryClient, injectQuery, provideTanStackQuery } from '..' +import { + QueryCache, + QueryClient, + injectQuery, + provideIsRestoring, + provideTanStackQuery, +} from '..' import { registerSignalInput } from './test-utils' import type { CreateQueryOptions, OmitKeyof, QueryFunction } from '..' @@ -721,6 +727,41 @@ describe('injectQuery', () => { expect(result).toEqual('signal-input-required-test') }) + test('should support aliasing query.data on required signal inputs', async () => { + @Component({ + selector: 'app-fake', + template: `{{ data() }}`, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class FakeComponent { + name = input.required() + + query = injectQuery(() => ({ + queryKey: ['fake-alias', this.name()], + queryFn: () => this.name(), + })) + + data = this.query.data + } + + registerSignalInput(FakeComponent, 'name') + + @Component({ + template: ``, + imports: [FakeComponent], + }) + class HostComponent { + protected readonly name = signal('signal-input-alias-test') + } + + const fixture = TestBed.createComponent(HostComponent) + fixture.detectChanges() + await vi.advanceTimersByTimeAsync(0) + + const result = fixture.nativeElement.querySelector('app-fake').textContent + expect(result).toEqual('signal-input-alias-test') + }) + test('should allow reading the query data on effect registered before injection', () => { const spy = vi.fn() @Component({ @@ -842,6 +883,53 @@ describe('injectQuery', () => { expect(query.data()).toBe('new data') }) + test('should pause fetching while restoring and fetch once restoring is disabled', async () => { + const isRestoring = signal(true) + const fetchSpy = vi.fn(() => sleep(10).then(() => 'restored-data')) + + TestBed.resetTestingModule() + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideTanStackQuery(queryClient), + provideIsRestoring(isRestoring.asReadonly()), + ], + }) + + @Component({ + selector: 'app-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ + queryKey: ['restoring'], + queryFn: fetchSpy, + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + + const query = fixture.componentInstance.query + expect(fetchSpy).not.toHaveBeenCalled() + expect(query.status()).toBe('pending') + + const stablePromise = fixture.whenStable() + await Promise.resolve() + await stablePromise + + isRestoring.set(false) + fixture.detectChanges() + + await vi.advanceTimersByTimeAsync(11) + await fixture.whenStable() + + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(query.status()).toBe('success') + expect(query.data()).toBe('restored-data') + }) + describe('injection context', () => { test('throws NG0203 with descriptive error outside injection context', () => { expect(() => { diff --git a/packages/angular-query-experimental/src/__tests__/pending-tasks-ssr-queries.test.ts b/packages/angular-query-experimental/src/__tests__/pending-tasks-ssr-queries.test.ts new file mode 100644 index 0000000000..86d1afe23c --- /dev/null +++ b/packages/angular-query-experimental/src/__tests__/pending-tasks-ssr-queries.test.ts @@ -0,0 +1,75 @@ +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 { sleep } from '@tanstack/query-test-utils' +import { QueryClient } from '@tanstack/query-core' +import { injectQueries } from '../inject-queries' +import { provideTanStackQuery } from '../providers' + +describe('PendingTasks SSR (injectQueries)', () => { + beforeEach(() => { + destroyPlatform() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + @Component({ + selector: 'app-root', + template: '{{ queries()[0].data() }}', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + queries = injectQueries(() => ({ + queries: [ + { + queryKey: ['ssr-queries-test'], + queryFn: async () => { + await sleep(1000) + return 'queries-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('queries-data-fetched-on-ssr') + }) +}) + diff --git a/packages/angular-query-experimental/src/__tests__/zonejs-adapter.test.ts b/packages/angular-query-experimental/src/__tests__/zonejs-adapter.test.ts new file mode 100644 index 0000000000..b7057fe63c --- /dev/null +++ b/packages/angular-query-experimental/src/__tests__/zonejs-adapter.test.ts @@ -0,0 +1,96 @@ +import 'zone.js' +import { + ChangeDetectionStrategy, + Component, + provideZoneChangeDetection, +} from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + QueryClient, + injectMutation, + injectQuery, + provideTanStackQuery, +} from '..' +import { sleep } from '@tanstack/query-test-utils' + +describe('adapter with Zone.js', () => { + let queryClient: QueryClient + + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + queryClient = new QueryClient() + + TestBed.resetTestingModule() + TestBed.configureTestingModule({ + providers: [ + provideZoneChangeDetection(), + provideTanStackQuery(queryClient), + ], + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('supports injectQuery in a Zone.js app', async () => { + @Component({ + selector: 'zone-query-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class ZoneQueryTestComponent { + query = injectQuery(() => ({ + queryKey: ['zone-query'], + queryFn: async () => { + await sleep(10) + return 'query-data' + }, + })) + } + + const fixture = TestBed.createComponent(ZoneQueryTestComponent) + fixture.detectChanges() + + const query = fixture.componentInstance.query + expect(query.status()).toBe('pending') + + const stablePromise = fixture.whenStable() + await vi.advanceTimersByTimeAsync(20) + await stablePromise + + expect(query.status()).toBe('success') + expect(query.data()).toBe('query-data') + }) + + it('supports injectMutation in a Zone.js app', async () => { + @Component({ + selector: 'zone-mutation-test', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class ZoneMutationTestComponent { + mutation = injectMutation(() => ({ + mutationKey: ['zone-mutation'], + mutationFn: async (value: string) => { + await sleep(10) + return `mutated-${value}` + }, + })) + } + + const fixture = TestBed.createComponent(ZoneMutationTestComponent) + fixture.detectChanges() + + const mutation = fixture.componentInstance.mutation + mutation.mutate('value') + + const stablePromise = fixture.whenStable() + await vi.advanceTimersByTimeAsync(20) + await stablePromise + + expect(mutation.status()).toBe('success') + expect(mutation.data()).toBe('mutated-value') + }) +}) diff --git a/packages/angular-query-experimental/src/index.ts b/packages/angular-query-experimental/src/index.ts index fc033224e5..18f7e3379b 100644 --- a/packages/angular-query-experimental/src/index.ts +++ b/packages/angular-query-experimental/src/index.ts @@ -39,7 +39,12 @@ export { injectMutation } from './inject-mutation' export type { InjectMutationStateOptions } from './inject-mutation-state' export { injectMutationState } from './inject-mutation-state' -export type { QueriesOptions, QueriesResults } from './inject-queries' +export type { + InjectQueriesOptions, + QueriesOptions, + QueriesResults, +} from './inject-queries' +export { injectQueries } from './inject-queries' export type { InjectQueryOptions } from './inject-query' export { injectQuery } from './inject-query' diff --git a/packages/angular-query-experimental/src/inject-queries.ts b/packages/angular-query-experimental/src/inject-queries.ts index 77655d7765..eb9a7af7f2 100644 --- a/packages/angular-query-experimental/src/inject-queries.ts +++ b/packages/angular-query-experimental/src/inject-queries.ts @@ -7,12 +7,13 @@ import { DestroyRef, Injector, NgZone, + PendingTasks, assertInInjectionContext, computed, effect, inject, + linkedSignal, runInInjectionContext, - signal, untracked, } from '@angular/core' import { signalProxy } from './signal-proxy' @@ -35,6 +36,7 @@ import type { DefinedCreateQueryResult, } from './types' import type { Signal } from '@angular/core' +import type { MethodKeys } from './signal-proxy' // This defines the `CreateQueryOptions` that are accepted in `QueriesOptions` & `GetOptions`. // `placeholderData` function always gets undefined passed @@ -264,6 +266,11 @@ export interface InjectQueriesOptions< combine?: (result: RawQueriesResults) => TCombinedResult } +const methodsToExclude: Array> = ['refetch'] + +const hasPendingQueriesState = (results: Array): boolean => + results.some((result) => result.fetchStatus !== 'idle') + /** * @param optionsFn - A function that returns queries' options. * @param injector - The Angular injector to use. @@ -279,8 +286,24 @@ export function injectQueries< return runInInjectionContext(injector ?? inject(Injector), () => { const destroyRef = inject(DestroyRef) const ngZone = inject(NgZone) + const pendingTasks = inject(PendingTasks) const queryClient = inject(QueryClient) const isRestoring = injectIsRestoring() + let destroyed = false + let taskCleanupRef: (() => void) | null = null + + const startPendingTask = () => { + if (!taskCleanupRef && !destroyed) { + taskCleanupRef = pendingTasks.add() + } + } + + const stopPendingTask = () => { + if (taskCleanupRef) { + taskCleanupRef() + taskCleanupRef = null + } + } /** * Signal that has the default options from query client applied @@ -306,76 +329,125 @@ export function injectQueries< }) }) - const observerSignal = (() => { - let instance: QueriesObserver | null = null + const observerOptionsSignal = computed( + () => optionsSignal() as QueriesObserverOptions, + ) - return computed(() => { - return (instance ||= new QueriesObserver( - queryClient, - defaultedQueries(), - optionsSignal() as QueriesObserverOptions, - )) - }) - })() + // Computed without deps to lazy initialize the observer + const observerSignal = computed(() => { + return new QueriesObserver( + queryClient, + untracked(defaultedQueries), + untracked(observerOptionsSignal), + ) + }) const optimisticResultSignal = computed(() => observerSignal().getOptimisticResult( defaultedQueries(), - (optionsSignal() as QueriesObserverOptions).combine, + observerOptionsSignal().combine, ), ) // Do not notify on updates because of changes in the options because // these changes should already be reflected in the optimistic result. effect(() => { - observerSignal().setQueries( - defaultedQueries(), - optionsSignal() as QueriesObserverOptions, - ) + observerSignal().setQueries(defaultedQueries(), observerOptionsSignal()) }) - const optimisticCombinedResultSignal = computed(() => { - const [_optimisticResult, getCombinedResult, trackResult] = - optimisticResultSignal() - return getCombinedResult(trackResult()) + const optimisticResultSourceSignal = computed(() => { + const options = observerOptionsSignal() + return { queries: defaultedQueries(), combine: options.combine } }) - const resultFromSubscriberSignal = signal(null) + const resultSignal = linkedSignal({ + source: optimisticResultSourceSignal, + computation: () => { + const observer = untracked(observerSignal) + const [_optimisticResult, getCombinedResult, trackResult] = + observer.getOptimisticResult( + defaultedQueries(), + observerOptionsSignal().combine, + ) + return getCombinedResult(trackResult()) + }, + }) - effect(() => { + effect((onCleanup) => { const observer = observerSignal() - const [_optimisticResult, getCombinedResult] = optimisticResultSignal() - - untracked(() => { - const unsubscribe = isRestoring() - ? () => undefined - : ngZone.runOutsideAngular(() => - observer.subscribe( - notifyManager.batchCalls((state) => { - resultFromSubscriberSignal.set(getCombinedResult(state)) - }), - ), - ) - - destroyRef.onDestroy(unsubscribe) + const [optimisticResult, getCombinedResult] = optimisticResultSignal() + + if (isRestoring()) { + stopPendingTask() + return + } + + if (hasPendingQueriesState(optimisticResult)) { + startPendingTask() + } else { + stopPendingTask() + } + + const unsubscribe = untracked(() => + ngZone.runOutsideAngular(() => + observer.subscribe((state) => { + if (hasPendingQueriesState(state)) { + startPendingTask() + } else { + stopPendingTask() + } + + queueMicrotask(() => { + if (destroyed) return + notifyManager.batch(() => { + ngZone.run(() => { + resultSignal.set(getCombinedResult(state)) + }) + }) + }) + }), + ), + ) + + onCleanup(() => { + unsubscribe() + stopPendingTask() }) }) - const resultSignal = computed(() => { - const subscriberResult = resultFromSubscriberSignal() - const optimisticResult = optimisticCombinedResultSignal() - return subscriberResult ?? optimisticResult + // Angular does not use reactive getters on plain objects, so we wrap each + // QueryObserverResult in a signal-backed proxy to keep field-level tracking + // (`result.data()`, `result.status()`, etc.). + // Solid uses a related proxy approach in useQueries, but there it proxies + // object fields for store/resource reactivity rather than callable signals. + const createResultProxy = (index: number) => + signalProxy( + computed(() => (resultSignal() as Array)[index]!), + methodsToExclude, + ) + + // Keep this positional to match QueriesObserver semantics. + // Like Solid/Vue adapters, proxies are rebuilt from current observer output. + const proxiedResultsSignal = computed(() => + (resultSignal() as Array).map((_, index) => + createResultProxy(index), + ), + ) + + destroyRef.onDestroy(() => { + destroyed = true + stopPendingTask() }) return computed(() => { const result = resultSignal() const { combine } = optionsSignal() - return combine - ? result - : (result as Array>).map((query) => - signalProxy(signal(query), ['refetch']), - ) + if (combine) { + return result + } + + return proxiedResultsSignal() as unknown as TCombinedResult }) }) 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 a1529ee35d..82923ee96f 100644 --- a/packages/angular-query-experimental/src/inject-query.ts +++ b/packages/angular-query-experimental/src/inject-query.ts @@ -36,11 +36,15 @@ export interface InjectQueryOptions { * * **Basic example** * ```ts + * import { lastValueFrom } from 'rxjs' + * * class ServiceOrComponent { * query = injectQuery(() => ({ * queryKey: ['repoData'], * queryFn: () => - * this.#http.get('https://api.github.com/repos/tanstack/query'), + * lastValueFrom( + * this.#http.get('https://api.github.com/repos/tanstack/query'), + * ), * })) * } * ``` @@ -87,11 +91,15 @@ export function injectQuery< * * **Basic example** * ```ts + * import { lastValueFrom } from 'rxjs' + * * class ServiceOrComponent { * query = injectQuery(() => ({ * queryKey: ['repoData'], * queryFn: () => - * this.#http.get('https://api.github.com/repos/tanstack/query'), + * lastValueFrom( + * this.#http.get('https://api.github.com/repos/tanstack/query'), + * ), * })) * } * ``` @@ -138,11 +146,15 @@ export function injectQuery< * * **Basic example** * ```ts + * import { lastValueFrom } from 'rxjs' + * * class ServiceOrComponent { * query = injectQuery(() => ({ * queryKey: ['repoData'], * queryFn: () => - * this.#http.get('https://api.github.com/repos/tanstack/query'), + * lastValueFrom( + * this.#http.get('https://api.github.com/repos/tanstack/query'), + * ), * })) * } * ``` @@ -189,11 +201,15 @@ export function injectQuery< * * **Basic example** * ```ts + * import { lastValueFrom } from 'rxjs' + * * class ServiceOrComponent { * query = injectQuery(() => ({ * queryKey: ['repoData'], * queryFn: () => - * this.#http.get('https://api.github.com/repos/tanstack/query'), + * lastValueFrom( + * this.#http.get('https://api.github.com/repos/tanstack/query'), + * ), * })) * } * ``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef33595a40..37ffa0372a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2262,25 +2262,25 @@ importers: devDependencies: '@angular/common': specifier: ^20.0.0 - version: 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) + version: 20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2) '@angular/compiler': specifier: ^20.0.0 version: 20.0.0 '@angular/core': specifier: ^20.0.0 - version: 20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.15.0) + version: 20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0) '@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)) + 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.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.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) + 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.16.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.16.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.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0)))(rxjs@7.8.2) '@tanstack/query-test-utils': specifier: workspace:* version: link:../query-test-utils '@testing-library/angular': specifier: ^18.0.0 - version: 18.0.0(b638270d50b9f611fb362719c9f1adf5) + version: 18.0.0(c06d8d4c2ce1594f4574b3aa35a1592e) npm-run-all2: specifier: ^5.0.0 version: 5.0.2 @@ -2296,6 +2296,9 @@ importers: vite-tsconfig-paths: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.3)(vite@6.4.1(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1)) + zone.js: + specifier: ^0.16.0 + version: 0.16.0 optionalDependencies: '@tanstack/query-devtools': specifier: workspace:* @@ -15385,6 +15388,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} @@ -15693,6 +15697,9 @@ packages: zone.js@0.15.0: resolution: {integrity: sha512-9oxn0IIjbCZkJ67L+LkhYWRyAy7axphb3VgE2MBDlOqnmHMPWGYMxJxBYFueFq/JGY2GMwS0rU+UCLunEmy5UA==} + zone.js@0.16.0: + resolution: {integrity: sha512-LqLPpIQANebrlxY6jKcYKdgN5DTXyyHAKnnWWjE5pPfEQ4n7j5zn7mOEEpwNZVKGqx3kKKmvplEmoBrvpgROTA==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -15763,6 +15770,13 @@ 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/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.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0))': + dependencies: + '@angular/common': 20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2) + '@angular/core': 20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0) + tslib: 2.8.1 + optional: true + '@angular/build@20.0.0(e7d71ef5d6f07f95ae57395d944dd75a)': dependencies: '@ampproject/remapping': 2.3.0 @@ -15847,6 +15861,12 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 + '@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2)': + dependencies: + '@angular/core': 20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0) + rxjs: 7.8.2 + tslib: 2.8.1 + '@angular/compiler-cli@20.0.0(@angular/compiler@20.0.0)(typescript@5.8.3)': dependencies: '@angular/compiler': 20.0.0 @@ -15875,6 +15895,14 @@ snapshots: '@angular/compiler': 20.0.0 zone.js: 0.15.0 + '@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0)': + dependencies: + rxjs: 7.8.2 + tslib: 2.8.1 + optionalDependencies: + '@angular/compiler': 20.0.0 + zone.js: 0.16.0 + '@angular/forms@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) @@ -15891,6 +15919,14 @@ 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-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.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0))': + dependencies: + '@angular/common': 20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2) + '@angular/core': 20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0) + tslib: 2.8.1 + 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.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.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) @@ -15900,6 +15936,17 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 xhr2: 0.2.1 + optional: true + + '@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.16.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.16.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.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.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.16.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.16.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.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.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: @@ -15909,6 +15956,14 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.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.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.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.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.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.16.0))(rxjs@7.8.2) + '@angular/core': 20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.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.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0)) + rxjs: 7.8.2 + tslib: 2.8.1 + '@arethetypeswrong/cli@0.15.3': dependencies: '@arethetypeswrong/core': 0.15.1 @@ -20136,6 +20191,15 @@ snapshots: '@testing-library/dom': 10.4.0 tslib: 2.8.1 + '@testing-library/angular@18.0.0(c06d8d4c2ce1594f4574b3aa35a1592e)': + dependencies: + '@angular/common': 20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2) + '@angular/core': 20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.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.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0)) + '@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.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.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.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0)))(@angular/common@20.0.0(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0))(rxjs@7.8.2))(@angular/core@20.0.0(@angular/compiler@20.0.0)(rxjs@7.8.2)(zone.js@0.16.0)))(rxjs@7.8.2) + '@testing-library/dom': 10.4.0 + tslib: 2.8.1 + '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.27.1 @@ -31193,4 +31257,6 @@ snapshots: zone.js@0.15.0: {} + zone.js@0.16.0: {} + zwitch@2.0.4: {} From 0c2f8ad5892b14900e5bc51b263b598fd3f9db57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sun, 8 Feb 2026 19:15:30 -0300 Subject: [PATCH 27/28] feat(angular-query): update docs and references --- .../angular/guides/dependent-queries.md | 2 - .../angular/guides/parallel-queries.md | 4 +- docs/framework/angular/guides/testing.md | 2 +- .../functions/injectInfiniteQuery.md | 6 +-- .../reference/functions/injectMutation.md | 2 +- .../reference/functions/injectQueries.md | 40 +++++++++++++++ .../reference/functions/injectQuery.md | 40 +++++++++------ .../reference/functions/queryOptions.md | 14 +++--- docs/framework/angular/reference/index.md | 6 ++- .../interfaces/BaseQueryNarrowing.md | 8 +-- .../interfaces/CreateInfiniteQueryOptions.md | 2 +- .../interfaces/CreateQueryOptions.md | 30 ----------- .../interfaces/InjectInfiniteQueryOptions.md | 4 +- .../interfaces/InjectMutationOptions.md | 4 +- .../interfaces/InjectQueriesOptions.md | 50 +++++++++++++++++++ .../interfaces/InjectQueryOptions.md | 4 +- .../CreateBaseQueryOptions.md | 10 ++-- .../type-aliases/CreateBaseQueryResult.md | 4 +- .../type-aliases/CreateInfiniteQueryResult.md | 4 +- .../type-aliases/CreateMutationResult.md | 2 +- .../type-aliases/CreateQueryOptions.md | 30 +++++++++++ .../type-aliases/CreateQueryResult.md | 2 +- .../DefinedCreateInfiniteQueryResult.md | 4 +- .../type-aliases/DefinedCreateQueryResult.md | 4 +- .../type-aliases/DefinedInitialDataOptions.md | 10 +--- .../reference/type-aliases/QueriesOptions.md | 2 +- .../reference/type-aliases/QueriesResults.md | 2 +- .../UndefinedInitialDataOptions.md | 2 +- .../type-aliases/UnusedSkipTokenOptions.md | 2 +- 29 files changed, 193 insertions(+), 103 deletions(-) create mode 100644 docs/framework/angular/reference/functions/injectQueries.md delete mode 100644 docs/framework/angular/reference/interfaces/CreateQueryOptions.md create mode 100644 docs/framework/angular/reference/interfaces/InjectQueriesOptions.md rename docs/framework/angular/reference/{interfaces => type-aliases}/CreateBaseQueryOptions.md (63%) create mode 100644 docs/framework/angular/reference/type-aliases/CreateQueryOptions.md diff --git a/docs/framework/angular/guides/dependent-queries.md b/docs/framework/angular/guides/dependent-queries.md index 2fee39bafc..8fc8123cec 100644 --- a/docs/framework/angular/guides/dependent-queries.md +++ b/docs/framework/angular/guides/dependent-queries.md @@ -28,8 +28,6 @@ projectsQuery = injectQuery(() => ({ 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 // Get the users ids userIds = injectQuery(() => ({ diff --git a/docs/framework/angular/guides/parallel-queries.md b/docs/framework/angular/guides/parallel-queries.md index 39bc12ce14..a7e7c9b6e4 100644 --- a/docs/framework/angular/guides/parallel-queries.md +++ b/docs/framework/angular/guides/parallel-queries.md @@ -41,8 +41,6 @@ TanStack Query provides `injectQueries`, which you can use to dynamically execut [//]: # 'DynamicParallelIntro' [//]: # 'Example2' -> IMPORTANT: `injectQueries` is experimental and is provided in its own entry point - ```ts @Component({ // ... @@ -51,7 +49,7 @@ export class AppComponent { users = signal>([]) userQueries = injectQueries(() => ({ - queries: users().map((user) => { + queries: this.users().map((user) => { return { queryKey: ['user', user.id], queryFn: () => fetchUserById(user.id), diff --git a/docs/framework/angular/guides/testing.md b/docs/framework/angular/guides/testing.md index 3ffb9588b8..91d9575dbc 100644 --- a/docs/framework/angular/guides/testing.md +++ b/docs/framework/angular/guides/testing.md @@ -7,7 +7,7 @@ Most Angular tests using TanStack Query will involve services or components that TanStack Query's `inject*` functions integrate with [`PendingTasks`](https://angular.dev/api/core/PendingTasks) which ensures the framework is aware of in-progress queries and mutations. -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 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. The examples below use a zoneless TestBed setup. ## TestBed setup diff --git a/docs/framework/angular/reference/functions/injectInfiniteQuery.md b/docs/framework/angular/reference/functions/injectInfiniteQuery.md index 020ef039fa..7e09345bc8 100644 --- a/docs/framework/angular/reference/functions/injectInfiniteQuery.md +++ b/docs/framework/angular/reference/functions/injectInfiniteQuery.md @@ -22,7 +22,7 @@ Additional configuration. function injectInfiniteQuery(injectInfiniteQueryFn, options?): DefinedCreateInfiniteQueryResult; ``` -Defined in: [inject-infinite-query.ts:41](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-infinite-query.ts#L41) +Defined in: [inject-infinite-query.ts:43](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-infinite-query.ts#L43) Injects an infinite query: a declarative dependency on an asynchronous source of data that is tied to a unique key. Infinite queries can additively "load more" data onto an existing set of data or "infinite scroll" @@ -75,7 +75,7 @@ The infinite query result. function injectInfiniteQuery(injectInfiniteQueryFn, options?): CreateInfiniteQueryResult; ``` -Defined in: [inject-infinite-query.ts:65](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-infinite-query.ts#L65) +Defined in: [inject-infinite-query.ts:67](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-infinite-query.ts#L67) Injects an infinite query: a declarative dependency on an asynchronous source of data that is tied to a unique key. Infinite queries can additively "load more" data onto an existing set of data or "infinite scroll" @@ -128,7 +128,7 @@ The infinite query result. function injectInfiniteQuery(injectInfiniteQueryFn, options?): CreateInfiniteQueryResult; ``` -Defined in: [inject-infinite-query.ts:89](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-infinite-query.ts#L89) +Defined in: [inject-infinite-query.ts:91](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-infinite-query.ts#L91) Injects an infinite query: a declarative dependency on an asynchronous source of data that is tied to a unique key. Infinite queries can additively "load more" data onto an existing set of data or "infinite scroll" diff --git a/docs/framework/angular/reference/functions/injectMutation.md b/docs/framework/angular/reference/functions/injectMutation.md index 5b4690eb46..8b2adfd525 100644 --- a/docs/framework/angular/reference/functions/injectMutation.md +++ b/docs/framework/angular/reference/functions/injectMutation.md @@ -9,7 +9,7 @@ title: injectMutation function injectMutation(injectMutationFn, options?): CreateMutationResult; ``` -Defined in: [inject-mutation.ts:45](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-mutation.ts#L45) +Defined in: [inject-mutation.ts:44](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-mutation.ts#L44) Injects a mutation: an imperative function that can be invoked which typically performs server side effects. diff --git a/docs/framework/angular/reference/functions/injectQueries.md b/docs/framework/angular/reference/functions/injectQueries.md new file mode 100644 index 0000000000..9c1366c917 --- /dev/null +++ b/docs/framework/angular/reference/functions/injectQueries.md @@ -0,0 +1,40 @@ +--- +id: injectQueries +title: injectQueries +--- + +# Function: injectQueries() + +```ts +function injectQueries(optionsFn, injector?): Signal; +``` + +Defined in: [inject-queries.ts:279](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-queries.ts#L279) + +## Type Parameters + +### T + +`T` *extends* `any`[] + +### TCombinedResult + +`TCombinedResult` = `T` *extends* \[\] ? \[\] : `T` *extends* \[`Head`\] ? \[`GenericGetDefinedOrUndefinedQueryResult`\<`Head`, `InferDataAndError`\<`Head`\>\[`"data"`\], [`CreateQueryResult`](../type-aliases/CreateQueryResult.md)\<`InferDataAndError`\<`Head`\>\[`"data"`\], `InferDataAndError`\<`Head`\>\[`"error"`\]\>, [`DefinedCreateQueryResult`](../type-aliases/DefinedCreateQueryResult.md)\<`InferDataAndError`\<`Head`\>\[`"data"`\], `InferDataAndError`\<`Head`\>\[`"error"`\]\>\>\] : `T` *extends* \[`Head`, `...Tails[]`\] ? \[`...Tails[]`\] *extends* \[\] ? \[\] : \[`...Tails[]`\] *extends* \[`Head`\] ? \[`GenericGetDefinedOrUndefinedQueryResult`\<`Head`, `InferDataAndError`\<`Head`\>\[`"data"`\], [`CreateQueryResult`](../type-aliases/CreateQueryResult.md)\<`InferDataAndError`\<`Head`\>\[`"data"`\], `InferDataAndError`\<`Head`\>\[`"error"`\]\>, [`DefinedCreateQueryResult`](../type-aliases/DefinedCreateQueryResult.md)\<`InferDataAndError`\<`Head`\>\[`"data"`\], `InferDataAndError`\<`Head`\>\[`"error"`\]\>\>, `GenericGetDefinedOrUndefinedQueryResult`\<`Head`, `InferDataAndError`\<`Head`\>\[`"data"`\], [`CreateQueryResult`](../type-aliases/CreateQueryResult.md)\<`InferDataAndError`\<`Head`\>\[`"data"`\], `InferDataAndError`\<`Head`\>\[`"error"`\]\>, [`DefinedCreateQueryResult`](../type-aliases/DefinedCreateQueryResult.md)\<`InferDataAndError`\<`Head`\>\[`"data"`\], `InferDataAndError`\<`Head`\>\[`"error"`\]\>\>\] : \[`...Tails[]`\] *extends* \[`Head`, `...Tails[]`\] ? \[`...Tails[]`\] *extends* \[\] ? \[\] : \[`...Tails[]`\] *extends* \[`Head`\] ? \[`GenericGetDefinedOrUndefinedQueryResult`\<`Head`, ...\[...\], [`CreateQueryResult`](../type-aliases/CreateQueryResult.md)\<..., ...\>, [`DefinedCreateQueryResult`](../type-aliases/DefinedCreateQueryResult.md)\<..., ...\>\>, `GenericGetDefinedOrUndefinedQueryResult`\<`Head`, ...\[...\], [`CreateQueryResult`](../type-aliases/CreateQueryResult.md)\<..., ...\>, [`DefinedCreateQueryResult`](../type-aliases/DefinedCreateQueryResult.md)\<..., ...\>\>, `GenericGetDefinedOrUndefinedQueryResult`\<`Head`, ...\[...\], [`CreateQueryResult`](../type-aliases/CreateQueryResult.md)\<..., ...\>, [`DefinedCreateQueryResult`](../type-aliases/DefinedCreateQueryResult.md)\<..., ...\>\>\] : \[`...Tails[]`\] *extends* \[`Head`, `...Tails[]`\] ? \[`...(...)[]`\] *extends* \[\] ? \[\] : ... *extends* ... ? ... : ... : \[`...{ [K in (...)]: (...) }[]`\] : \[...\{ \[K in string \| number \| symbol\]: GenericGetDefinedOrUndefinedQueryResult\\], InferDataAndError\<(...)\>\["data"\], CreateQueryResult\<(...)\[(...)\], (...)\[(...)\]\>, DefinedCreateQueryResult\<(...)\[(...)\], (...)\[(...)\]\>\> \}\[\]\] : \{ \[K in string \| number \| symbol\]: GenericGetDefinedOrUndefinedQueryResult\\], InferDataAndError\\]\>\["data"\], CreateQueryResult\\]\>\["data"\], InferDataAndError\\]\>\["error"\]\>, DefinedCreateQueryResult\\]\>\["data"\], InferDataAndError\\]\>\["error"\]\>\> \} + +## Parameters + +### optionsFn + +() => [`InjectQueriesOptions`](../interfaces/InjectQueriesOptions.md)\<`T`, `TCombinedResult`\> + +A function that returns queries' options. + +### injector? + +`Injector` + +The Angular injector to use. + +## Returns + +`Signal`\<`TCombinedResult`\> diff --git a/docs/framework/angular/reference/functions/injectQuery.md b/docs/framework/angular/reference/functions/injectQuery.md index e53a95962a..a2b780f0b8 100644 --- a/docs/framework/angular/reference/functions/injectQuery.md +++ b/docs/framework/angular/reference/functions/injectQuery.md @@ -14,9 +14,10 @@ import { lastValueFrom } from 'rxjs' class ServiceOrComponent { query = injectQuery(() => ({ queryKey: ['repoData'], - queryFn: () => lastValueFrom( - this.#http.get('https://api.github.com/repos/tanstack/query') - ) + queryFn: () => + lastValueFrom( + this.#http.get('https://api.github.com/repos/tanstack/query'), + ), })) } ``` @@ -47,6 +48,10 @@ A function that returns query options. Additional configuration +## Param + +Array of function property names to exclude from signal conversion + ## See https://tanstack.com/query/latest/docs/framework/angular/guides/queries @@ -57,7 +62,7 @@ https://tanstack.com/query/latest/docs/framework/angular/guides/queries function injectQuery(injectQueryFn, options?): DefinedCreateQueryResult; ``` -Defined in: [inject-query.ts:65](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-query.ts#L65) +Defined in: [inject-query.ts:74](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-query.ts#L74) Injects a query: a declarative dependency on an asynchronous source of data that is tied to a unique key. @@ -68,9 +73,10 @@ import { lastValueFrom } from 'rxjs' class ServiceOrComponent { query = injectQuery(() => ({ queryKey: ['repoData'], - queryFn: () => lastValueFrom( - this.#http.get('https://api.github.com/repos/tanstack/query') - ), + queryFn: () => + lastValueFrom( + this.#http.get('https://api.github.com/repos/tanstack/query'), + ), })) } ``` @@ -141,7 +147,7 @@ https://tanstack.com/query/latest/docs/framework/angular/guides/queries function injectQuery(injectQueryFn, options?): CreateQueryResult; ``` -Defined in: [inject-query.ts:116](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-query.ts#L116) +Defined in: [inject-query.ts:129](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-query.ts#L129) Injects a query: a declarative dependency on an asynchronous source of data that is tied to a unique key. @@ -152,9 +158,10 @@ import { lastValueFrom } from 'rxjs' class ServiceOrComponent { query = injectQuery(() => ({ queryKey: ['repoData'], - queryFn: () => lastValueFrom( - this.#http.get('https://api.github.com/repos/tanstack/query') - ), + queryFn: () => + lastValueFrom( + this.#http.get('https://api.github.com/repos/tanstack/query'), + ), })) } ``` @@ -225,7 +232,7 @@ https://tanstack.com/query/latest/docs/framework/angular/guides/queries function injectQuery(injectQueryFn, options?): CreateQueryResult; ``` -Defined in: [inject-query.ts:167](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-query.ts#L167) +Defined in: [inject-query.ts:184](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-query.ts#L184) Injects a query: a declarative dependency on an asynchronous source of data that is tied to a unique key. @@ -236,9 +243,10 @@ import { lastValueFrom } from 'rxjs' class ServiceOrComponent { query = injectQuery(() => ({ queryKey: ['repoData'], - queryFn: () => lastValueFrom( - this.#http.get('https://api.github.com/repos/tanstack/query') - ), + queryFn: () => + lastValueFrom( + this.#http.get('https://api.github.com/repos/tanstack/query'), + ), })) } ``` @@ -283,7 +291,7 @@ class ServiceOrComponent { #### injectQueryFn -() => [`CreateQueryOptions`](../interfaces/CreateQueryOptions.md)\<`TQueryFnData`, `TError`, `TData`, `TQueryKey`\> +() => [`CreateQueryOptions`](../type-aliases/CreateQueryOptions.md)\<`TQueryFnData`, `TError`, `TData`, `TQueryKey`\> A function that returns query options. diff --git a/docs/framework/angular/reference/functions/queryOptions.md b/docs/framework/angular/reference/functions/queryOptions.md index 34640111ec..70f1b3fb0e 100644 --- a/docs/framework/angular/reference/functions/queryOptions.md +++ b/docs/framework/angular/reference/functions/queryOptions.md @@ -30,10 +30,10 @@ The query options to tag with the type from `queryFn`. ## Call Signature ```ts -function queryOptions(options): Omit, "queryFn"> & object & object; +function queryOptions(options): CreateQueryOptions & object & object; ``` -Defined in: [query-options.ts:76](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/query-options.ts#L76) +Defined in: [query-options.ts:71](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/query-options.ts#L71) Allows to share and re-use query options in a type-safe way. @@ -81,7 +81,7 @@ The query options to tag with the type from `queryFn`. ### Returns -`Omit`\<[`CreateQueryOptions`](../interfaces/CreateQueryOptions.md)\<`TQueryFnData`, `TError`, `TData`, `TQueryKey`\>, `"queryFn"`\> & `object` & `object` +[`CreateQueryOptions`](../type-aliases/CreateQueryOptions.md)\<`TQueryFnData`, `TError`, `TData`, `TQueryKey`\> & `object` & `object` The tagged query options. @@ -91,7 +91,7 @@ The tagged query options. function queryOptions(options): OmitKeyof, "queryFn"> & object & object; ``` -Defined in: [query-options.ts:108](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/query-options.ts#L108) +Defined in: [query-options.ts:103](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/query-options.ts#L103) Allows to share and re-use query options in a type-safe way. @@ -139,7 +139,7 @@ The query options to tag with the type from `queryFn`. ### Returns -`OmitKeyof`\<[`CreateQueryOptions`](../interfaces/CreateQueryOptions.md)\<`TQueryFnData`, `TError`, `TData`, `TQueryKey`\>, `"queryFn"`\> & `object` & `object` +`OmitKeyof`\<[`CreateQueryOptions`](../type-aliases/CreateQueryOptions.md)\<`TQueryFnData`, `TError`, `TData`, `TQueryKey`\>, `"queryFn"`\> & `object` & `object` The tagged query options. @@ -149,7 +149,7 @@ The tagged query options. function queryOptions(options): CreateQueryOptions & object & object; ``` -Defined in: [query-options.ts:140](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/query-options.ts#L140) +Defined in: [query-options.ts:135](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/query-options.ts#L135) Allows to share and re-use query options in a type-safe way. @@ -197,6 +197,6 @@ The query options to tag with the type from `queryFn`. ### Returns -[`CreateQueryOptions`](../interfaces/CreateQueryOptions.md)\<`TQueryFnData`, `TError`, `TData`, `TQueryKey`\> & `object` & `object` +[`CreateQueryOptions`](../type-aliases/CreateQueryOptions.md)\<`TQueryFnData`, `TError`, `TData`, `TQueryKey`\> & `object` & `object` The tagged query options. diff --git a/docs/framework/angular/reference/index.md b/docs/framework/angular/reference/index.md index c74d256bce..16da72ce20 100644 --- a/docs/framework/angular/reference/index.md +++ b/docs/framework/angular/reference/index.md @@ -9,26 +9,27 @@ title: "@tanstack/angular-query-experimental" - [BaseMutationNarrowing](interfaces/BaseMutationNarrowing.md) - [BaseQueryNarrowing](interfaces/BaseQueryNarrowing.md) -- [CreateBaseQueryOptions](interfaces/CreateBaseQueryOptions.md) - [CreateInfiniteQueryOptions](interfaces/CreateInfiniteQueryOptions.md) - [CreateMutationOptions](interfaces/CreateMutationOptions.md) -- [CreateQueryOptions](interfaces/CreateQueryOptions.md) - [InjectInfiniteQueryOptions](interfaces/InjectInfiniteQueryOptions.md) - [InjectIsFetchingOptions](interfaces/InjectIsFetchingOptions.md) - [InjectIsMutatingOptions](interfaces/InjectIsMutatingOptions.md) - [InjectMutationOptions](interfaces/InjectMutationOptions.md) - [InjectMutationStateOptions](interfaces/InjectMutationStateOptions.md) +- [InjectQueriesOptions](interfaces/InjectQueriesOptions.md) - [InjectQueryOptions](interfaces/InjectQueryOptions.md) - [QueryFeature](interfaces/QueryFeature.md) ## Type Aliases - [CreateBaseMutationResult](type-aliases/CreateBaseMutationResult.md) +- [CreateBaseQueryOptions](type-aliases/CreateBaseQueryOptions.md) - [CreateBaseQueryResult](type-aliases/CreateBaseQueryResult.md) - [CreateInfiniteQueryResult](type-aliases/CreateInfiniteQueryResult.md) - [CreateMutateAsyncFunction](type-aliases/CreateMutateAsyncFunction.md) - [CreateMutateFunction](type-aliases/CreateMutateFunction.md) - [CreateMutationResult](type-aliases/CreateMutationResult.md) +- [CreateQueryOptions](type-aliases/CreateQueryOptions.md) - [CreateQueryResult](type-aliases/CreateQueryResult.md) - [DefinedCreateInfiniteQueryResult](type-aliases/DefinedCreateInfiniteQueryResult.md) - [DefinedCreateQueryResult](type-aliases/DefinedCreateQueryResult.md) @@ -53,6 +54,7 @@ title: "@tanstack/angular-query-experimental" - [injectIsRestoring](functions/injectIsRestoring.md) - [injectMutation](functions/injectMutation.md) - [injectMutationState](functions/injectMutationState.md) +- [injectQueries](functions/injectQueries.md) - [injectQuery](functions/injectQuery.md) - [~~injectQueryClient~~](functions/injectQueryClient.md) - [mutationOptions](functions/mutationOptions.md) diff --git a/docs/framework/angular/reference/interfaces/BaseQueryNarrowing.md b/docs/framework/angular/reference/interfaces/BaseQueryNarrowing.md index bc7811b6ae..24a00f39cb 100644 --- a/docs/framework/angular/reference/interfaces/BaseQueryNarrowing.md +++ b/docs/framework/angular/reference/interfaces/BaseQueryNarrowing.md @@ -5,7 +5,7 @@ title: BaseQueryNarrowing # Interface: BaseQueryNarrowing\ -Defined in: [types.ts:57](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L57) +Defined in: [types.ts:45](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L45) ## Type Parameters @@ -25,7 +25,7 @@ Defined in: [types.ts:57](https://github.com/TanStack/query/blob/main/packages/a isError: (this) => this is CreateBaseQueryResult>; ``` -Defined in: [types.ts:65](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L65) +Defined in: [types.ts:53](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L53) #### Parameters @@ -45,7 +45,7 @@ Defined in: [types.ts:65](https://github.com/TanStack/query/blob/main/packages/a isPending: (this) => this is CreateBaseQueryResult>; ``` -Defined in: [types.ts:72](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L72) +Defined in: [types.ts:60](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L60) #### Parameters @@ -65,7 +65,7 @@ Defined in: [types.ts:72](https://github.com/TanStack/query/blob/main/packages/a isSuccess: (this) => this is CreateBaseQueryResult>; ``` -Defined in: [types.ts:58](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L58) +Defined in: [types.ts:46](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L46) #### Parameters diff --git a/docs/framework/angular/reference/interfaces/CreateInfiniteQueryOptions.md b/docs/framework/angular/reference/interfaces/CreateInfiniteQueryOptions.md index ab21d5bc32..1f8883c8d7 100644 --- a/docs/framework/angular/reference/interfaces/CreateInfiniteQueryOptions.md +++ b/docs/framework/angular/reference/interfaces/CreateInfiniteQueryOptions.md @@ -5,7 +5,7 @@ title: CreateInfiniteQueryOptions # Interface: CreateInfiniteQueryOptions\ -Defined in: [types.ts:81](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L81) +Defined in: [types.ts:69](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L69) ## Extends diff --git a/docs/framework/angular/reference/interfaces/CreateQueryOptions.md b/docs/framework/angular/reference/interfaces/CreateQueryOptions.md deleted file mode 100644 index 113fbbc5d2..0000000000 --- a/docs/framework/angular/reference/interfaces/CreateQueryOptions.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -id: CreateQueryOptions -title: CreateQueryOptions ---- - -# Interface: CreateQueryOptions\ - -Defined in: [types.ts:35](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L35) - -## Extends - -- `OmitKeyof`\<[`CreateBaseQueryOptions`](CreateBaseQueryOptions.md)\<`TQueryFnData`, `TError`, `TData`, `TQueryFnData`, `TQueryKey`\>, `"suspense"`\> - -## Type Parameters - -### TQueryFnData - -`TQueryFnData` = `unknown` - -### TError - -`TError` = `DefaultError` - -### TData - -`TData` = `TQueryFnData` - -### TQueryKey - -`TQueryKey` *extends* `QueryKey` = `QueryKey` diff --git a/docs/framework/angular/reference/interfaces/InjectInfiniteQueryOptions.md b/docs/framework/angular/reference/interfaces/InjectInfiniteQueryOptions.md index 3b552aa381..33880ffae2 100644 --- a/docs/framework/angular/reference/interfaces/InjectInfiniteQueryOptions.md +++ b/docs/framework/angular/reference/interfaces/InjectInfiniteQueryOptions.md @@ -5,7 +5,7 @@ title: InjectInfiniteQueryOptions # Interface: InjectInfiniteQueryOptions -Defined in: [inject-infinite-query.ts:25](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-infinite-query.ts#L25) +Defined in: [inject-infinite-query.ts:27](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-infinite-query.ts#L27) ## Properties @@ -15,7 +15,7 @@ Defined in: [inject-infinite-query.ts:25](https://github.com/TanStack/query/blob optional injector: Injector; ``` -Defined in: [inject-infinite-query.ts:31](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-infinite-query.ts#L31) +Defined in: [inject-infinite-query.ts:33](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-infinite-query.ts#L33) The `Injector` in which to create the infinite query. diff --git a/docs/framework/angular/reference/interfaces/InjectMutationOptions.md b/docs/framework/angular/reference/interfaces/InjectMutationOptions.md index 0638baa372..c313951b25 100644 --- a/docs/framework/angular/reference/interfaces/InjectMutationOptions.md +++ b/docs/framework/angular/reference/interfaces/InjectMutationOptions.md @@ -5,7 +5,7 @@ title: InjectMutationOptions # Interface: InjectMutationOptions -Defined in: [inject-mutation.ts:28](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-mutation.ts#L28) +Defined in: [inject-mutation.ts:27](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-mutation.ts#L27) ## Properties @@ -15,7 +15,7 @@ Defined in: [inject-mutation.ts:28](https://github.com/TanStack/query/blob/main/ optional injector: Injector; ``` -Defined in: [inject-mutation.ts:34](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-mutation.ts#L34) +Defined in: [inject-mutation.ts:33](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-mutation.ts#L33) The `Injector` in which to create the mutation. diff --git a/docs/framework/angular/reference/interfaces/InjectQueriesOptions.md b/docs/framework/angular/reference/interfaces/InjectQueriesOptions.md new file mode 100644 index 0000000000..c9f860b872 --- /dev/null +++ b/docs/framework/angular/reference/interfaces/InjectQueriesOptions.md @@ -0,0 +1,50 @@ +--- +id: InjectQueriesOptions +title: InjectQueriesOptions +--- + +# Interface: InjectQueriesOptions\ + +Defined in: [inject-queries.ts:257](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-queries.ts#L257) + +## Type Parameters + +### T + +`T` *extends* `any`[] + +### TCombinedResult + +`TCombinedResult` = [`QueriesResults`](../type-aliases/QueriesResults.md)\<`T`\> + +## Properties + +### combine()? + +```ts +optional combine: (result) => TCombinedResult; +``` + +Defined in: [inject-queries.ts:266](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-queries.ts#L266) + +#### Parameters + +##### result + +`T` *extends* \[\] ? \[\] : `T` *extends* \[`Head`\] ? \[`GenericGetDefinedOrUndefinedQueryResult`\<`Head`, `InferDataAndError`\<`Head`\>\[`"data"`\], `QueryObserverResult`\<`InferDataAndError`\<`Head`\>\[`"data"`\], `InferDataAndError`\<`Head`\>\[`"error"`\]\>, `DefinedQueryObserverResult`\<`InferDataAndError`\<`Head`\>\[`"data"`\], `InferDataAndError`\<`Head`\>\[`"error"`\]\>\>\] : `T` *extends* \[`Head`, `...Tails[]`\] ? \[`...Tails[]`\] *extends* \[\] ? \[\] : \[`...Tails[]`\] *extends* \[`Head`\] ? \[`GenericGetDefinedOrUndefinedQueryResult`\<`Head`, `InferDataAndError`\<...\>\[`"data"`\], `QueryObserverResult`\<...\[...\], ...\[...\]\>, `DefinedQueryObserverResult`\<...\[...\], ...\[...\]\>\>, `GenericGetDefinedOrUndefinedQueryResult`\<`Head`, `InferDataAndError`\<...\>\[`"data"`\], `QueryObserverResult`\<...\[...\], ...\[...\]\>, `DefinedQueryObserverResult`\<...\[...\], ...\[...\]\>\>\] : \[`...Tails[]`\] *extends* \[`Head`, `...Tails[]`\] ? \[`...Tails[]`\] *extends* \[\] ? \[\] : \[`...(...)[]`\] *extends* \[...\] ? \[..., ..., ...\] : ... *extends* ... ? ... : ... : \[...\{ \[K in (...) \| (...) \| (...)\]: GenericGetDefinedOrUndefinedQueryResult\<(...), (...), (...), (...)\> \}\[\]\] : \{ \[K in string \| number \| symbol\]: GenericGetDefinedOrUndefinedQueryResult\\], InferDataAndError\\]\>\["data"\], QueryObserverResult\\["data"\], InferDataAndError\<(...)\[(...)\]\>\["error"\]\>, DefinedQueryObserverResult\\["data"\], InferDataAndError\<(...)\[(...)\]\>\["error"\]\>\> \} + +#### Returns + +`TCombinedResult` + +*** + +### queries + +```ts +queries: + | readonly [{ [K in string | number | symbol]: GetCreateQueryOptionsForCreateQueries]> }] + | readonly [T extends [] ? [] : T extends [Head] ? [GetCreateQueryOptionsForCreateQueries] : T extends [Head, ...Tails[]] ? [...Tails[]] extends [] ? [] : [...Tails[]] extends [Head] ? [GetCreateQueryOptionsForCreateQueries, GetCreateQueryOptionsForCreateQueries] : [...Tails[]] extends [Head, ...Tails[]] ? [...Tails[]] extends [] ? [] : [...(...)[]] extends [...] ? [..., ..., ...] : ... extends ... ? ... : ... : readonly unknown[] extends [...Tails[]] ? [...Tails[]] : [...(...)[]] extends ...[] ? ...[] : ...[] : readonly unknown[] extends T ? T : T extends QueryObserverOptionsForCreateQueries[] ? QueryObserverOptionsForCreateQueries[] : QueryObserverOptionsForCreateQueries[]]; +``` + +Defined in: [inject-queries.ts:261](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-queries.ts#L261) diff --git a/docs/framework/angular/reference/interfaces/InjectQueryOptions.md b/docs/framework/angular/reference/interfaces/InjectQueryOptions.md index eecbef2804..8f513c31b5 100644 --- a/docs/framework/angular/reference/interfaces/InjectQueryOptions.md +++ b/docs/framework/angular/reference/interfaces/InjectQueryOptions.md @@ -5,7 +5,7 @@ title: InjectQueryOptions # Interface: InjectQueryOptions -Defined in: [inject-query.ts:20](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-query.ts#L20) +Defined in: [inject-query.ts:25](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-query.ts#L25) ## Properties @@ -15,7 +15,7 @@ Defined in: [inject-query.ts:20](https://github.com/TanStack/query/blob/main/pac optional injector: Injector; ``` -Defined in: [inject-query.ts:26](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-query.ts#L26) +Defined in: [inject-query.ts:31](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-query.ts#L31) The `Injector` in which to create the query. diff --git a/docs/framework/angular/reference/interfaces/CreateBaseQueryOptions.md b/docs/framework/angular/reference/type-aliases/CreateBaseQueryOptions.md similarity index 63% rename from docs/framework/angular/reference/interfaces/CreateBaseQueryOptions.md rename to docs/framework/angular/reference/type-aliases/CreateBaseQueryOptions.md index 48a4b7dcc6..29bacc3ac0 100644 --- a/docs/framework/angular/reference/interfaces/CreateBaseQueryOptions.md +++ b/docs/framework/angular/reference/type-aliases/CreateBaseQueryOptions.md @@ -3,13 +3,13 @@ id: CreateBaseQueryOptions title: CreateBaseQueryOptions --- -# Interface: CreateBaseQueryOptions\ +# Type Alias: CreateBaseQueryOptions\ -Defined in: [types.ts:21](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L21) - -## Extends +```ts +type CreateBaseQueryOptions = QueryObserverOptions; +``` -- `QueryObserverOptions`\<`TQueryFnData`, `TError`, `TData`, `TQueryData`, `TQueryKey`\> +Defined in: [types.ts:21](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L21) ## Type Parameters diff --git a/docs/framework/angular/reference/type-aliases/CreateBaseQueryResult.md b/docs/framework/angular/reference/type-aliases/CreateBaseQueryResult.md index 784f89c5e1..0ed7f2524b 100644 --- a/docs/framework/angular/reference/type-aliases/CreateBaseQueryResult.md +++ b/docs/framework/angular/reference/type-aliases/CreateBaseQueryResult.md @@ -6,10 +6,10 @@ title: CreateBaseQueryResult # Type Alias: CreateBaseQueryResult\ ```ts -type CreateBaseQueryResult = BaseQueryNarrowing & MapToSignals>; +type CreateBaseQueryResult = BaseQueryNarrowing & MapToSignals, MethodKeys>>; ``` -Defined in: [types.ts:98](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L98) +Defined in: [types.ts:86](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L86) ## Type Parameters diff --git a/docs/framework/angular/reference/type-aliases/CreateInfiniteQueryResult.md b/docs/framework/angular/reference/type-aliases/CreateInfiniteQueryResult.md index f4c01a674b..fc9a51d4b0 100644 --- a/docs/framework/angular/reference/type-aliases/CreateInfiniteQueryResult.md +++ b/docs/framework/angular/reference/type-aliases/CreateInfiniteQueryResult.md @@ -6,10 +6,10 @@ title: CreateInfiniteQueryResult # Type Alias: CreateInfiniteQueryResult\ ```ts -type CreateInfiniteQueryResult = BaseQueryNarrowing & MapToSignals>; +type CreateInfiniteQueryResult = BaseQueryNarrowing & MapToSignals, MethodKeys>>; ``` -Defined in: [types.ts:117](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L117) +Defined in: [types.ts:111](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L111) ## Type Parameters diff --git a/docs/framework/angular/reference/type-aliases/CreateMutationResult.md b/docs/framework/angular/reference/type-aliases/CreateMutationResult.md index b5573544d0..86c2056181 100644 --- a/docs/framework/angular/reference/type-aliases/CreateMutationResult.md +++ b/docs/framework/angular/reference/type-aliases/CreateMutationResult.md @@ -6,7 +6,7 @@ title: CreateMutationResult # Type Alias: CreateMutationResult\ ```ts -type CreateMutationResult = BaseMutationNarrowing & MapToSignals>; +type CreateMutationResult = BaseMutationNarrowing & MapToSignals, MethodKeys>>; ``` Defined in: [types.ts:266](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L266) diff --git a/docs/framework/angular/reference/type-aliases/CreateQueryOptions.md b/docs/framework/angular/reference/type-aliases/CreateQueryOptions.md new file mode 100644 index 0000000000..799c2d3b11 --- /dev/null +++ b/docs/framework/angular/reference/type-aliases/CreateQueryOptions.md @@ -0,0 +1,30 @@ +--- +id: CreateQueryOptions +title: CreateQueryOptions +--- + +# Type Alias: CreateQueryOptions\ + +```ts +type CreateQueryOptions = OmitKeyof, "suspense">; +``` + +Defined in: [types.ts:29](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L29) + +## Type Parameters + +### TQueryFnData + +`TQueryFnData` = `unknown` + +### TError + +`TError` = `DefaultError` + +### TData + +`TData` = `TQueryFnData` + +### TQueryKey + +`TQueryKey` *extends* `QueryKey` = `QueryKey` diff --git a/docs/framework/angular/reference/type-aliases/CreateQueryResult.md b/docs/framework/angular/reference/type-aliases/CreateQueryResult.md index c532a87463..67d2f6cd86 100644 --- a/docs/framework/angular/reference/type-aliases/CreateQueryResult.md +++ b/docs/framework/angular/reference/type-aliases/CreateQueryResult.md @@ -9,7 +9,7 @@ title: CreateQueryResult type CreateQueryResult = CreateBaseQueryResult; ``` -Defined in: [types.ts:105](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L105) +Defined in: [types.ts:96](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L96) ## Type Parameters diff --git a/docs/framework/angular/reference/type-aliases/DefinedCreateInfiniteQueryResult.md b/docs/framework/angular/reference/type-aliases/DefinedCreateInfiniteQueryResult.md index 932114c7d1..6760031992 100644 --- a/docs/framework/angular/reference/type-aliases/DefinedCreateInfiniteQueryResult.md +++ b/docs/framework/angular/reference/type-aliases/DefinedCreateInfiniteQueryResult.md @@ -6,10 +6,10 @@ title: DefinedCreateInfiniteQueryResult # Type Alias: DefinedCreateInfiniteQueryResult\ ```ts -type DefinedCreateInfiniteQueryResult = MapToSignals; +type DefinedCreateInfiniteQueryResult = MapToSignals>; ``` -Defined in: [types.ts:123](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L123) +Defined in: [types.ts:120](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L120) ## Type Parameters diff --git a/docs/framework/angular/reference/type-aliases/DefinedCreateQueryResult.md b/docs/framework/angular/reference/type-aliases/DefinedCreateQueryResult.md index 60fa877491..9ef090ccf8 100644 --- a/docs/framework/angular/reference/type-aliases/DefinedCreateQueryResult.md +++ b/docs/framework/angular/reference/type-aliases/DefinedCreateQueryResult.md @@ -6,10 +6,10 @@ title: DefinedCreateQueryResult # Type Alias: DefinedCreateQueryResult\ ```ts -type DefinedCreateQueryResult = BaseQueryNarrowing & MapToSignals>; +type DefinedCreateQueryResult = BaseQueryNarrowing & MapToSignals, MethodKeys>>; ``` -Defined in: [types.ts:110](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L110) +Defined in: [types.ts:101](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/types.ts#L101) ## Type Parameters diff --git a/docs/framework/angular/reference/type-aliases/DefinedInitialDataOptions.md b/docs/framework/angular/reference/type-aliases/DefinedInitialDataOptions.md index 4bcea1da72..4c8ad5c0ab 100644 --- a/docs/framework/angular/reference/type-aliases/DefinedInitialDataOptions.md +++ b/docs/framework/angular/reference/type-aliases/DefinedInitialDataOptions.md @@ -6,10 +6,10 @@ title: DefinedInitialDataOptions # Type Alias: DefinedInitialDataOptions\ ```ts -type DefinedInitialDataOptions = Omit, "queryFn"> & object; +type DefinedInitialDataOptions = CreateQueryOptions & object; ``` -Defined in: [query-options.ts:40](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/query-options.ts#L40) +Defined in: [query-options.ts:39](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/query-options.ts#L39) ## Type Declaration @@ -21,12 +21,6 @@ initialData: | () => NonUndefinedGuard; ``` -### queryFn? - -```ts -optional queryFn: QueryFunction; -``` - ## Type Parameters ### TQueryFnData diff --git a/docs/framework/angular/reference/type-aliases/QueriesOptions.md b/docs/framework/angular/reference/type-aliases/QueriesOptions.md index 2def13c9c9..c8f95bb1a7 100644 --- a/docs/framework/angular/reference/type-aliases/QueriesOptions.md +++ b/docs/framework/angular/reference/type-aliases/QueriesOptions.md @@ -9,7 +9,7 @@ title: QueriesOptions type QueriesOptions = TDepth["length"] extends MAXIMUM_DEPTH ? QueryObserverOptionsForCreateQueries[] : T extends [] ? [] : T extends [infer Head] ? [...TResults, GetCreateQueryOptionsForCreateQueries] : T extends [infer Head, ...(infer Tails)] ? QueriesOptions<[...Tails], [...TResults, GetCreateQueryOptionsForCreateQueries], [...TDepth, 1]> : ReadonlyArray extends T ? T : T extends QueryObserverOptionsForCreateQueries[] ? QueryObserverOptionsForCreateQueries[] : QueryObserverOptionsForCreateQueries[]; ``` -Defined in: [inject-queries.ts:144](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-queries.ts#L144) +Defined in: [inject-queries.ts:178](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-queries.ts#L178) QueriesOptions reducer recursively unwraps function arguments to infer/enforce type param diff --git a/docs/framework/angular/reference/type-aliases/QueriesResults.md b/docs/framework/angular/reference/type-aliases/QueriesResults.md index 6d5ecf6dd4..b34b39cb60 100644 --- a/docs/framework/angular/reference/type-aliases/QueriesResults.md +++ b/docs/framework/angular/reference/type-aliases/QueriesResults.md @@ -9,7 +9,7 @@ title: QueriesResults type QueriesResults = TDepth["length"] extends MAXIMUM_DEPTH ? CreateQueryResult[] : T extends [] ? [] : T extends [infer Head] ? [...TResults, GetCreateQueryResult] : T extends [infer Head, ...(infer Tails)] ? QueriesResults<[...Tails], [...TResults, GetCreateQueryResult], [...TDepth, 1]> : { [K in keyof T]: GetCreateQueryResult }; ``` -Defined in: [inject-queries.ts:186](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-queries.ts#L186) +Defined in: [inject-queries.ts:220](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-queries.ts#L220) QueriesResults reducer recursively maps type param to results diff --git a/docs/framework/angular/reference/type-aliases/UndefinedInitialDataOptions.md b/docs/framework/angular/reference/type-aliases/UndefinedInitialDataOptions.md index f1a48e74e6..339c1e5684 100644 --- a/docs/framework/angular/reference/type-aliases/UndefinedInitialDataOptions.md +++ b/docs/framework/angular/reference/type-aliases/UndefinedInitialDataOptions.md @@ -9,7 +9,7 @@ title: UndefinedInitialDataOptions type UndefinedInitialDataOptions = CreateQueryOptions & object; ``` -Defined in: [query-options.ts:13](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/query-options.ts#L13) +Defined in: [query-options.ts:12](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/query-options.ts#L12) ## Type Declaration diff --git a/docs/framework/angular/reference/type-aliases/UnusedSkipTokenOptions.md b/docs/framework/angular/reference/type-aliases/UnusedSkipTokenOptions.md index 9a65d5b3f3..bebe298be7 100644 --- a/docs/framework/angular/reference/type-aliases/UnusedSkipTokenOptions.md +++ b/docs/framework/angular/reference/type-aliases/UnusedSkipTokenOptions.md @@ -9,7 +9,7 @@ title: UnusedSkipTokenOptions type UnusedSkipTokenOptions = OmitKeyof, "queryFn"> & object; ``` -Defined in: [query-options.ts:25](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/query-options.ts#L25) +Defined in: [query-options.ts:24](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/query-options.ts#L24) ## Type Declaration From 55b78eb6a33d56f347adb97ba79df70e4afb3183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sun, 8 Feb 2026 21:15:32 -0300 Subject: [PATCH 28/28] feat(angular-query): address coderabbit comments --- .../guides/invalidations-from-mutations.md | 1 + .../angular/reference/functions/injectQueries.md | 2 +- .../angular/reference/functions/injectQuery.md | 4 ---- .../src/__tests__/inject-mutation.test.ts | 8 ++++---- .../src/__tests__/inject-queries.test.ts | 15 +++++++-------- .../src/__tests__/inject-query.test.ts | 2 +- .../src/__tests__/test-utils.ts | 2 ++ .../src/__tests__/zonejs-adapter.test.ts | 2 +- .../src/inject-query.ts | 1 - 9 files changed, 17 insertions(+), 20 deletions(-) diff --git a/docs/framework/angular/guides/invalidations-from-mutations.md b/docs/framework/angular/guides/invalidations-from-mutations.md index 0c300a716d..74245f212b 100644 --- a/docs/framework/angular/guides/invalidations-from-mutations.md +++ b/docs/framework/angular/guides/invalidations-from-mutations.md @@ -22,6 +22,7 @@ mutation = injectMutation(() => ({ [//]: # 'Example2' ```ts +import { Component, inject } from '@angular/core' import { injectMutation, QueryClient, diff --git a/docs/framework/angular/reference/functions/injectQueries.md b/docs/framework/angular/reference/functions/injectQueries.md index 9c1366c917..d05f95e379 100644 --- a/docs/framework/angular/reference/functions/injectQueries.md +++ b/docs/framework/angular/reference/functions/injectQueries.md @@ -9,7 +9,7 @@ title: injectQueries function injectQueries(optionsFn, injector?): Signal; ``` -Defined in: [inject-queries.ts:279](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-queries.ts#L279) +Defined in: [inject-queries.ts:278](https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/inject-queries.ts#L278) ## Type Parameters diff --git a/docs/framework/angular/reference/functions/injectQuery.md b/docs/framework/angular/reference/functions/injectQuery.md index a2b780f0b8..63ffbeb92b 100644 --- a/docs/framework/angular/reference/functions/injectQuery.md +++ b/docs/framework/angular/reference/functions/injectQuery.md @@ -48,10 +48,6 @@ A function that returns query options. Additional configuration -## Param - -Array of function property names to exclude from signal conversion - ## See https://tanstack.com/query/latest/docs/framework/angular/guides/queries 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 a8d5a3981d..3daa657356 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts @@ -9,11 +9,11 @@ import { signal, } from '@angular/core' import { TestBed } from '@angular/core/testing' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { sleep } from '@tanstack/query-test-utils' +import { firstValueFrom } from 'rxjs' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { QueryClient, injectMutation, provideTanStackQuery } from '..' import { expectSignals, registerSignalInput } from './test-utils' -import { firstValueFrom } from 'rxjs' describe('injectMutation', () => { let queryClient: QueryClient @@ -714,11 +714,11 @@ describe('injectMutation', () => { // Flush microtasks to allow TanStack Query's scheduled notifications to process await Promise.resolve() - // Check for optimistic update in the same macrotask + // Check for optimistic update in the same macro task expect(onMutateCalled).toBe(true) expect(queryClient.getQueryData(testQueryKey)).toBe('optimistic: test') - // Check stability before the mutation completes, waiting got the next macrotask task + // Check stability before the mutation completes, waiting for the next macro task await vi.advanceTimersByTimeAsync(0) expect(mutation.isPending()).toBe(true) expect(await firstValueFrom(app.isStable)).toBe(false) 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 7922dae4ae..3007a8db86 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts @@ -11,8 +11,7 @@ import { signal, } from '@angular/core' import { TestBed } from '@angular/core/testing' -import { sleep } from '@tanstack/query-test-utils' -import { queryKey } from '@tanstack/query-test-utils' +import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryClient, provideIsRestoring } from '..' import { injectQueries } from '../inject-queries' import { registerSignalInput, setupTanStackQueryTestBed } from './test-utils' @@ -146,10 +145,10 @@ describe('injectQueries', () => { }, }, ], - combine: (results) => { + combine: (queryResults) => { return { - refetch: () => results.forEach((r) => r.refetch()), - data: results.map((r) => r.data).join(','), + refetch: () => queryResults.forEach((r) => r.refetch()), + data: queryResults.map((r) => r.data).join(','), } }, })) @@ -384,9 +383,9 @@ describe('injectQueries', () => { })) mapped = computed(() => { - const results = this.queries().map((q) => q.data()) - if (results.length === 0) return 'empty' - return results.join(',') + const queryData = this.queries().map((q) => q.data()) + if (queryData.length === 0) return 'empty' + return queryData.join(',') }) _pushResults = effect(() => { 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 d0eabf5add..e90054121b 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -863,7 +863,7 @@ describe('injectQuery', () => { expect(instance.initialStatus).toEqual('pending') }) - test('should update query data on the same macrotask when query data changes', async () => { + test('should update query data on the same macro task when query data changes', async () => { const query = TestBed.runInInjectionContext(() => injectQuery(() => ({ queryKey: ['test'], diff --git a/packages/angular-query-experimental/src/__tests__/test-utils.ts b/packages/angular-query-experimental/src/__tests__/test-utils.ts index 39884df261..879dda82b6 100644 --- a/packages/angular-query-experimental/src/__tests__/test-utils.ts +++ b/packages/angular-query-experimental/src/__tests__/test-utils.ts @@ -14,6 +14,8 @@ import type { Type, } from '@angular/core' +// cspell:ignore ɵcmp ɵdir + // Evaluate all signals on an object and return the result function evaluateSignals>( obj: T, diff --git a/packages/angular-query-experimental/src/__tests__/zonejs-adapter.test.ts b/packages/angular-query-experimental/src/__tests__/zonejs-adapter.test.ts index b7057fe63c..6c4f52714a 100644 --- a/packages/angular-query-experimental/src/__tests__/zonejs-adapter.test.ts +++ b/packages/angular-query-experimental/src/__tests__/zonejs-adapter.test.ts @@ -5,6 +5,7 @@ import { provideZoneChangeDetection, } from '@angular/core' import { TestBed } from '@angular/core/testing' +import { sleep } from '@tanstack/query-test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { QueryClient, @@ -12,7 +13,6 @@ import { injectQuery, provideTanStackQuery, } from '..' -import { sleep } from '@tanstack/query-test-utils' describe('adapter with Zone.js', () => { let queryClient: QueryClient diff --git a/packages/angular-query-experimental/src/inject-query.ts b/packages/angular-query-experimental/src/inject-query.ts index 82923ee96f..2161fb225a 100644 --- a/packages/angular-query-experimental/src/inject-query.ts +++ b/packages/angular-query-experimental/src/inject-query.ts @@ -233,7 +233,6 @@ 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 */