From 194836fb507638b5e316dba9ec529557339167ed Mon Sep 17 00:00:00 2001 From: Arnoud de Vries <6420061+arnoud-dv@users.noreply.github.com> Date: Sat, 22 Nov 2025 15:14:56 +0100 Subject: [PATCH 01/10] refactor(angular-query): use angular 19+ APIs --- packages/angular-query-experimental/README.md | 2 +- .../angular-query-experimental/package.json | 4 +- .../src/create-base-query.ts | 53 ++--- .../src/devtools/with-devtools.ts | 203 +++++++++--------- .../src/inject-mutation.ts | 23 +- .../src/pending-tasks-compat.ts | 28 --- .../src/providers.ts | 23 +- 7 files changed, 148 insertions(+), 188 deletions(-) delete mode 100644 packages/angular-query-experimental/src/pending-tasks-compat.ts 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 a97df2eeea..f1442a8585 100644 --- a/packages/angular-query-experimental/package.json +++ b/packages/angular-query-experimental/package.json @@ -105,8 +105,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", diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index 4daede7684..1748402a11 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -1,6 +1,6 @@ import { NgZone, - VERSION, + PendingTasks, computed, effect, inject, @@ -14,8 +14,6 @@ import { } 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, @@ -45,7 +43,7 @@ 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() @@ -86,29 +84,22 @@ export function createBaseQuery< TError > | 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, - }, - ) + effect((onCleanup) => { + const observer = observerSignal() + const defaultedOptions = defaultedOptionsSignal() + + untracked(() => { + observer.setOptions(defaultedOptions) + }) + onCleanup(() => { + ngZone.run(() => resultFromSubscriberSignal.set(null)) + }) + }) effect((onCleanup) => { // observer.trackResult is not used as this optimization is not needed for Angular const observer = observerSignal() - let pendingTaskRef: PendingTaskRef | null = null + let taskCleanupRef: (() => void) | null = null const unsubscribe = isRestoring() ? () => undefined @@ -117,13 +108,13 @@ export function createBaseQuery< return observer.subscribe( notifyManager.batchCalls((state) => { ngZone.run(() => { - if (state.fetchStatus === 'fetching' && !pendingTaskRef) { - pendingTaskRef = pendingTasks.add() + if (state.fetchStatus === 'fetching' && !taskCleanupRef) { + taskCleanupRef = pendingTasks.add() } - if (state.fetchStatus === 'idle' && pendingTaskRef) { - pendingTaskRef() - pendingTaskRef = null + if (state.fetchStatus === 'idle' && taskCleanupRef) { + taskCleanupRef() + taskCleanupRef = null } if ( @@ -145,9 +136,9 @@ export function createBaseQuery< ) onCleanup(() => { - if (pendingTaskRef) { - pendingTaskRef() - pendingTaskRef = null + if (taskCleanupRef) { + taskCleanupRef() + taskCleanupRef = null } unsubscribe() }) diff --git a/packages/angular-query-experimental/src/devtools/with-devtools.ts b/packages/angular-query-experimental/src/devtools/with-devtools.ts index 22ee80c1ca..73e9dd854d 100644 --- a/packages/angular-query-experimental/src/devtools/with-devtools.ts +++ b/packages/angular-query-experimental/src/devtools/with-devtools.ts @@ -1,7 +1,6 @@ import { isPlatformBrowser } from '@angular/common' import { DestroyRef, - ENVIRONMENT_INITIALIZER, InjectionToken, Injector, PLATFORM_ID, @@ -9,8 +8,9 @@ import { effect, inject, isDevMode, + provideEnvironmentInitializer, } from '@angular/core' -import { QueryClient, noop, onlineManager } from '@tanstack/query-core' +import { QueryClient, onlineManager } from '@tanstack/query-core' import { queryFeature } from '../providers' import type { Signal } from '@angular/core' import type { @@ -69,112 +69,103 @@ export const withDevtools: WithDevtools = ( computed(() => withDevtoolsFn?.(...deps) ?? {}), deps: options.deps || [], }, - { - // Do not use provideEnvironmentInitializer while Angular < v19 is supported - provide: ENVIRONMENT_INITIALIZER, - multi: true, - useFactory: () => { - const devtoolsProvided = inject(DEVTOOLS_PROVIDED) - if ( - !isPlatformBrowser(inject(PLATFORM_ID)) || - devtoolsProvided.isProvided - ) - return noop - - devtoolsProvided.isProvided = true - let injectorIsDestroyed = false - inject(DestroyRef).onDestroy(() => (injectorIsDestroyed = true)) - - return () => { - const injectedClient = inject(QueryClient, { - optional: true, - }) - const destroyRef = inject(DestroyRef) - const devtoolsOptions = inject(DEVTOOLS_OPTIONS_SIGNAL) - const injector = inject(Injector) - - let devtools: TanstackQueryDevtools | null = null - let el: HTMLElement | null = null - - const shouldLoadToolsSignal = computed(() => { - const { loadDevtools } = devtoolsOptions() - return typeof loadDevtools === 'boolean' - ? loadDevtools - : isDevMode() - }) - - const getResolvedQueryClient = () => { - const client = devtoolsOptions().client ?? injectedClient - if (!client) { - throw new Error('No QueryClient found') - } - return client + provideEnvironmentInitializer(() => { + const devtoolsProvided = inject(DEVTOOLS_PROVIDED) + if ( + !isPlatformBrowser(inject(PLATFORM_ID)) || + devtoolsProvided.isProvided + ) + return + + devtoolsProvided.isProvided = true + let injectorIsDestroyed = false + inject(DestroyRef).onDestroy(() => (injectorIsDestroyed = true)) + + const injectedClient = inject(QueryClient, { + optional: true, + }) + const destroyRef = inject(DestroyRef) + const devtoolsOptions = inject(DEVTOOLS_OPTIONS_SIGNAL) + const injector = inject(Injector) + + let devtools: TanstackQueryDevtools | null = null + let el: HTMLElement | null = null + + const shouldLoadToolsSignal = computed(() => { + const { loadDevtools } = devtoolsOptions() + return typeof loadDevtools === 'boolean' ? loadDevtools : isDevMode() + }) + + const getResolvedQueryClient = () => { + const client = devtoolsOptions().client ?? injectedClient + if (!client) { + throw new Error('No QueryClient found') + } + return client + } + + const destroyDevtools = () => { + devtools?.unmount() + el?.remove() + devtools = null + } + + effect( + () => { + const shouldLoadTools = shouldLoadToolsSignal() + const { + client, + position, + errorTypes, + buttonPosition, + initialIsOpen, + } = devtoolsOptions() + + if (!shouldLoadTools) { + // Destroy or do nothing + devtools && destroyDevtools() + return } - const destroyDevtools = () => { - devtools?.unmount() - el?.remove() - devtools = null + if (devtools) { + // Update existing devtools config + client && devtools.setClient(client) + position && devtools.setPosition(position) + errorTypes && devtools.setErrorTypes(errorTypes) + buttonPosition && devtools.setButtonPosition(buttonPosition) + typeof initialIsOpen === 'boolean' && + devtools.setInitialIsOpen(initialIsOpen) + return } - effect( - () => { - const shouldLoadTools = shouldLoadToolsSignal() - const { - client, - position, - errorTypes, - buttonPosition, - initialIsOpen, - } = devtoolsOptions() - - if (!shouldLoadTools) { - // Destroy or do nothing - devtools && destroyDevtools() - return - } - - if (devtools) { - // Update existing devtools config - client && devtools.setClient(client) - position && devtools.setPosition(position) - errorTypes && devtools.setErrorTypes(errorTypes) - buttonPosition && devtools.setButtonPosition(buttonPosition) - typeof initialIsOpen === 'boolean' && - devtools.setInitialIsOpen(initialIsOpen) - return - } - - // Create devtools - import('@tanstack/query-devtools') - .then((queryDevtools) => { - // As this code runs async, the injector could have been destroyed - if (injectorIsDestroyed) return - - devtools = new queryDevtools.TanstackQueryDevtools({ - ...devtoolsOptions(), - client: getResolvedQueryClient(), - queryFlavor: 'Angular Query', - version: '5', - onlineManager, - }) - - el = document.body.appendChild(document.createElement('div')) - el.classList.add('tsqd-parent-container') - devtools.mount(el) - - destroyRef.onDestroy(destroyDevtools) - }) - .catch((error) => { - console.error( - 'Install @tanstack/query-devtools or reinstall without --omit=optional.', - error, - ) - }) - }, - { injector }, - ) - } - }, - }, + // Create devtools + import('@tanstack/query-devtools') + .then((queryDevtools) => { + // As this code runs async, the injector could have been destroyed + if (injectorIsDestroyed) return + + devtools = new queryDevtools.TanstackQueryDevtools({ + ...devtoolsOptions(), + client: getResolvedQueryClient(), + queryFlavor: 'Angular Query', + version: '5', + onlineManager, + }) + + el = document.body.appendChild(document.createElement('div')) + el.classList.add('tsqd-parent-container') + devtools.mount(el) + + destroyRef.onDestroy(destroyDevtools) + }) + .catch((error) => { + console.error( + 'Install @tanstack/query-devtools or reinstall without --omit=optional.', + error, + ) + }) + }, + { injector }, + ) + }), ]) diff --git a/packages/angular-query-experimental/src/inject-mutation.ts b/packages/angular-query-experimental/src/inject-mutation.ts index 7eb605047f..45297e5ff3 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,8 +17,6 @@ import { shouldThrowError, } 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, @@ -59,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) /** @@ -127,7 +126,7 @@ export function injectMutation< (onCleanup) => { // observer.trackResult is not used as this optimization is not needed for Angular const observer = observerSignal() - let pendingTaskRef: PendingTaskRef | null = null + let taskCleanupRef: (() => void) | null = null untracked(() => { const unsubscribe = ngZone.runOutsideAngular(() => @@ -135,14 +134,14 @@ export function injectMutation< notifyManager.batchCalls((state) => { ngZone.run(() => { // Track pending task when mutation is pending - if (state.isPending && !pendingTaskRef) { - pendingTaskRef = pendingTasks.add() + if (state.isPending && !taskCleanupRef) { + taskCleanupRef = pendingTasks.add() } // Clear pending task when mutation is no longer pending - if (!state.isPending && pendingTaskRef) { - pendingTaskRef() - pendingTaskRef = null + if (!state.isPending && taskCleanupRef) { + taskCleanupRef() + taskCleanupRef = null } if ( @@ -160,9 +159,9 @@ export function injectMutation< ) onCleanup(() => { // Clean up any pending task on destroy - if (pendingTaskRef) { - pendingTaskRef() - pendingTaskRef = null + if (taskCleanupRef) { + taskCleanupRef() + taskCleanupRef = null } unsubscribe() }) 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 e156996993..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 } - -export 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, - } - }, - }, -) diff --git a/packages/angular-query-experimental/src/providers.ts b/packages/angular-query-experimental/src/providers.ts index 076d76d0c3..28eace94cf 100644 --- a/packages/angular-query-experimental/src/providers.ts +++ b/packages/angular-query-experimental/src/providers.ts @@ -1,6 +1,11 @@ -import { DestroyRef, InjectionToken, inject } from '@angular/core' +import { + DestroyRef, + InjectionToken, + inject, + makeEnvironmentProviders, +} from '@angular/core' import { QueryClient } from '@tanstack/query-core' -import type { Provider } from '@angular/core' +import type { EnvironmentProviders, Provider } from '@angular/core' /** * Usually {@link provideTanStackQuery} is used once to set up TanStack Query and the @@ -105,11 +110,11 @@ export function provideQueryClient( export function provideTanStackQuery( queryClient: QueryClient | InjectionToken, ...features: Array -): Array { - return [ +): EnvironmentProviders { + return makeEnvironmentProviders([ provideQueryClient(queryClient), features.map((feature) => feature.ɵproviders), - ] + ]) } /** @@ -121,7 +126,9 @@ export function provideTanStackQuery( * @see https://tanstack.com/query/v5/docs/framework/angular/quick-start * @deprecated Use `provideTanStackQuery` instead. */ -export function provideAngularQuery(queryClient: QueryClient): Array { +export function provideAngularQuery( + queryClient: QueryClient, +): EnvironmentProviders { return provideTanStackQuery(queryClient) } @@ -134,7 +141,7 @@ type QueryFeatureKind = (typeof queryFeatures)[number] */ export interface QueryFeature { ɵkind: TFeatureKind - ɵproviders: Array + ɵproviders: Array } /** @@ -145,7 +152,7 @@ export interface QueryFeature { */ export function queryFeature( kind: TFeatureKind, - providers: Array, + providers: Array, ): QueryFeature { return { ɵkind: kind, ɵproviders: providers } } From 8a30ecbdff8ca7f167ef72bb01c77944447c08aa Mon Sep 17 00:00:00 2001 From: Arnoud de Vries <6420061+arnoud-dv@users.noreply.github.com> Date: Sat, 22 Nov 2025 18:56:59 +0100 Subject: [PATCH 02/10] refactor(angular-query): rely on component effect timing --- .../src/create-base-query.ts | 172 ++++++++---------- 1 file changed, 80 insertions(+), 92 deletions(-) diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index 1748402a11..33e00c08c0 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -1,10 +1,11 @@ import { + DestroyRef, NgZone, PendingTasks, computed, effect, inject, - signal, + linkedSignal, untracked, } from '@angular/core' import { @@ -15,9 +16,9 @@ import { import { signalProxy } from './signal-proxy' import { injectIsRestoring } from './inject-is-restoring' import type { + DefaultedQueryObserverOptions, QueryKey, QueryObserver, - QueryObserverResult, } from '@tanstack/query-core' import type { CreateBaseQueryOptions } from './types' @@ -46,6 +47,15 @@ export function createBaseQuery< 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 @@ -61,106 +71,84 @@ 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())) - }) - })() - - const optimisticResultSignal = computed(() => - observerSignal().getOptimisticResult(defaultedOptionsSignal()), - ) + >, + ) => { + if (observer) { + observer.setOptions(options) + return + } + + observer = new Observer(queryClient, options) + let taskCleanupRef: (() => void) | null = null - const resultFromSubscriberSignal = signal | 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) + } + + const resultSignal = linkedSignal({ + source: defaultedOptionsSignal, + computation: () => { + if (!observer) throw new Error('Observer is not initialized') + const result = observer.getOptimisticResult(defaultedOptionsSignal()) + return result + }, + }) - effect((onCleanup) => { - const observer = observerSignal() + // Effect to initialize the observer and set options when options change + effect(() => { const defaultedOptions = defaultedOptionsSignal() - untracked(() => { - observer.setOptions(defaultedOptions) - }) - onCleanup(() => { - ngZone.run(() => resultFromSubscriberSignal.set(null)) - }) - }) - - effect((onCleanup) => { - // observer.trackResult is not used as this optimization is not needed for Angular - const observer = observerSignal() - let taskCleanupRef: (() => void) | null = null - - const unsubscribe = isRestoring() - ? () => undefined - : untracked(() => - ngZone.runOutsideAngular(() => { - return 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 - } - resultFromSubscriberSignal.set(state) - }) - }), - ) - }), - ) - - onCleanup(() => { - if (taskCleanupRef) { - taskCleanupRef() - taskCleanupRef = null - } - unsubscribe() + 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, - } - }), - ) + // Effect to subscribe to the observer and update the result signal + // effect((onCleanup) => { + // let taskCleanupRef: (() => void) | null = null + + // const unsubscribe = isRestoring() + // ? () => undefined + // : untracked(() => + // ngZone.runOutsideAngular(() => { + // return tryGetObserver().subscribe( + // ) + // }), + // ) + // }), + // ) + + return signalProxy(resultSignal.asReadonly()) } From c534ca294bd9dfc7c96f0446c0babd63129967a1 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 03/10] Improve PendingTasks task cleanup, isRestoring() handling --- .../src/create-base-query.ts | 48 ++++--------------- 1 file changed, 10 insertions(+), 38 deletions(-) diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index 33e00c08c0..c752618cde 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -1,25 +1,8 @@ -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 { - DefaultedQueryObserverOptions, - QueryKey, - QueryObserver, -} from '@tanstack/query-core' +import type { DefaultedQueryObserverOptions, QueryKey, QueryObserver, } from '@tanstack/query-core' import type { CreateBaseQueryOptions } from './types' /** @@ -115,40 +98,29 @@ export function createBaseQuery< }) }), ) - destroyRef.onDestroy(unsubscribe) + destroyRef.onDestroy(() => { + unsubscribe() + taskCleanupRef?.() + }) } const resultSignal = linkedSignal({ source: defaultedOptionsSignal, computation: () => { if (!observer) throw new Error('Observer is not initialized') - const result = observer.getOptimisticResult(defaultedOptionsSignal()) - return result + return observer.getOptimisticResult(defaultedOptionsSignal()) }, }) // Effect to initialize the observer and set options when options change effect(() => { const defaultedOptions = defaultedOptionsSignal() + if (isRestoring()) return + untracked(() => { createOrUpdateObserver(defaultedOptions) }) }) - // Effect to subscribe to the observer and update the result signal - // effect((onCleanup) => { - // let taskCleanupRef: (() => void) | null = null - - // const unsubscribe = isRestoring() - // ? () => undefined - // : untracked(() => - // ngZone.runOutsideAngular(() => { - // return tryGetObserver().subscribe( - // ) - // }), - // ) - // }), - // ) - return signalProxy(resultSignal.asReadonly()) } From 99b6d01c1df4f2fefcabaaa49206a36c2ce3bd1c 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 04/10] Ensure unit tests are run using component effect scheduling --- .../__tests__/inject-infinite-query.test.ts | 63 +- .../__tests__/inject-mutation-state.test.ts | 2 + .../src/__tests__/inject-mutation.test.ts | 3 + .../src/__tests__/inject-queries.test.ts | 2 + .../src/__tests__/inject-query.test.ts | 718 +++++++++++------- .../src/__tests__/pending-tasks.test.ts | 76 +- 6 files changed, 548 insertions(+), 316 deletions(-) diff --git a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts index 7873d5261c..8812edbb62 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts @@ -1,8 +1,13 @@ import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { Injector, provideZonelessChangeDetection } from '@angular/core' +import { + ChangeDetectionStrategy, + Component, + Injector, + provideZonelessChangeDetection, +} from '@angular/core' import { sleep } from '@tanstack/query-test-utils' -import { QueryClient, injectInfiniteQuery, provideTanStackQuery } from '..' +import { injectInfiniteQuery, provideTanStackQuery, QueryClient } from '..' import { expectSignals } from './test-utils' describe('injectInfiniteQuery', () => { @@ -24,15 +29,25 @@ describe('injectInfiniteQuery', () => { }) test('should properly execute infinite query', async () => { - const query = TestBed.runInInjectionContext(() => { - return injectInfiniteQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectInfiniteQuery(() => ({ queryKey: ['infiniteQuery'], queryFn: ({ pageParam }) => sleep(10).then(() => 'data on page ' + pageParam), initialPageParam: 0, getNextPageParam: () => 12, })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expectSignals(query, { data: undefined, @@ -76,18 +91,32 @@ describe('injectInfiniteQuery', () => { }) test('can be used outside injection context when passing an injector', () => { - const query = injectInfiniteQuery( - () => ({ - queryKey: ['manualInjector'], - queryFn: ({ pageParam }) => - sleep(0).then(() => 'data on page ' + pageParam), - initialPageParam: 0, - getNextPageParam: () => 12, - }), - { - injector: TestBed.inject(Injector), - }, - ) + const injector = TestBed.inject(Injector) + + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectInfiniteQuery( + () => ({ + queryKey: ['manualInjector'], + queryFn: ({ pageParam }) => + sleep(0).then(() => 'data on page ' + pageParam), + initialPageParam: 0, + getNextPageParam: () => 12, + }), + { + injector: injector, + }, + ) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(query.status()).toBe('pending') }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts b/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts index 8b747f66f6..bb1c97b3bd 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts @@ -1,4 +1,5 @@ import { + ChangeDetectionStrategy, Component, Injector, input, @@ -145,6 +146,7 @@ describe('injectMutationState', () => { {{ mutation.status }} } `, + changeDetection: ChangeDetectionStrategy.OnPush, }) class FakeComponent { name = input.required() diff --git a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts index 2adf0ee808..d7a8d85121 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts @@ -1,5 +1,6 @@ import { ApplicationRef, + ChangeDetectionStrategy, Component, Injector, input, @@ -307,6 +308,7 @@ describe('injectMutation', () => { {{ mutation.data() }} `, + changeDetection: ChangeDetectionStrategy.OnPush, }) class FakeComponent { name = input.required() @@ -347,6 +349,7 @@ describe('injectMutation', () => { {{ mutation.data() }} `, + changeDetection: ChangeDetectionStrategy.OnPush, }) class FakeComponent { name = input.required() diff --git a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts index 3fb3d5a626..ab16d3b296 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { render } from '@testing-library/angular' import { + ChangeDetectionStrategy, Component, effect, provideZonelessChangeDetection, @@ -37,6 +38,7 @@ describe('injectQueries', () => { `, + changeDetection: ChangeDetectionStrategy.OnPush, }) class Page { toString(val: any) { diff --git a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts index 2f541788ab..474ee3d747 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -3,10 +3,10 @@ import { Component, Injector, computed, - effect, input, provideZonelessChangeDetection, signal, + ChangeDetectionStrategy, } from '@angular/core' import { TestBed } from '@angular/core/testing' import { HttpClient, provideHttpClient } from '@angular/common/http' @@ -50,227 +50,232 @@ describe('injectQuery', () => { test('should return the correct types', () => { const key = queryKey() - // unspecified query function should default to unknown - const noQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + // unspecified query function should default to unknown + noQueryFn = injectQuery(() => ({ queryKey: key, - })), - ) - expectTypeOf(noQueryFn.data()).toEqualTypeOf() - expectTypeOf(noQueryFn.error()).toEqualTypeOf() + })) - // it should infer the result type from the query function - const fromQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should infer the result type from the query function + fromQueryFn = injectQuery(() => ({ queryKey: key, queryFn: () => 'test', - })), - ) - expectTypeOf(fromQueryFn.data()).toEqualTypeOf() - expectTypeOf(fromQueryFn.error()).toEqualTypeOf() + })) - // it should be possible to specify the result type - const withResult = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should be possible to specify the result type + withResult = injectQuery(() => ({ queryKey: key, queryFn: () => 'test', - })), - ) - expectTypeOf(withResult.data()).toEqualTypeOf() - expectTypeOf(withResult.error()).toEqualTypeOf() + })) - // it should be possible to specify the error type - type CustomErrorType = { message: string } - const withError = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should be possible to specify the error type + withError = injectQuery(() => ({ queryKey: key, queryFn: () => 'test', - })), - ) - expectTypeOf(withError.data()).toEqualTypeOf() - expectTypeOf(withError.error()).toEqualTypeOf() + })) - // it should infer the result type from the configuration - const withResultInfer = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should infer the result type from the configuration + withResultInfer = injectQuery(() => ({ queryKey: key, queryFn: () => true, - })), - ) - expectTypeOf(withResultInfer.data()).toEqualTypeOf() - expectTypeOf(withResultInfer.error()).toEqualTypeOf() + })) - // it should be possible to specify a union type as result type - const unionTypeSync = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should be possible to specify a union type as result type + unionTypeSync = injectQuery(() => ({ queryKey: key, queryFn: () => (Math.random() > 0.5 ? ('a' as const) : ('b' as const)), - })), - ) - expectTypeOf(unionTypeSync.data()).toEqualTypeOf<'a' | 'b' | undefined>() - const unionTypeAsync = TestBed.runInInjectionContext(() => - injectQuery<'a' | 'b'>(() => ({ + })) + + unionTypeAsync = injectQuery<'a' | 'b'>(() => ({ queryKey: key, queryFn: () => Promise.resolve(Math.random() > 0.5 ? 'a' : 'b'), - })), - ) - expectTypeOf(unionTypeAsync.data()).toEqualTypeOf<'a' | 'b' | undefined>() + })) - // it should error when the query function result does not match with the specified type - TestBed.runInInjectionContext(() => - // @ts-expect-error - injectQuery(() => ({ queryKey: key, queryFn: () => 'test' })), - ) + // it should infer the result type from a generic query function + fromGenericQueryFn = (() => { + function queryFn(): Promise { + return Promise.resolve({} as T) + } + return injectQuery(() => ({ + queryKey: key, + queryFn: () => queryFn(), + })) + })() - // it should infer the result type from a generic query function - /** - * - */ - function queryFn(): Promise { - return Promise.resolve({} as T) - } + // todo use query options? + fromGenericOptionsQueryFn = (() => { + function queryFn(): Promise { + return Promise.resolve({} as T) + } + return injectQuery(() => ({ + queryKey: key, + queryFn: () => queryFn(), + })) + })() + + fromMyDataArrayKeyQueryFn = (() => { + type MyData = number + type MyQueryKey = readonly ['my-data', number] + const getMyDataArrayKey: QueryFunction = ({ + queryKey: [, n], + }) => { + return n + 42 + } + return injectQuery(() => ({ + queryKey: ['my-data', 100] as const, + queryFn: getMyDataArrayKey, + })) + })() - const fromGenericQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + // it should handle query-functions that return Promise + fromPromiseAnyQueryFn = injectQuery(() => ({ queryKey: key, - queryFn: () => queryFn(), - })), - ) + queryFn: () => fetch('return Promise').then((resp) => resp.json()), + })) + + fromGetMyDataStringKeyQueryFn = (() => { + type MyData = number + const getMyDataStringKey: QueryFunction = (context) => { + expectTypeOf(context.queryKey).toEqualTypeOf<['1']>() + return Number(context.queryKey[0]) + 42 + } + return injectQuery(() => ({ + queryKey: ['1'] as ['1'], + queryFn: getMyDataStringKey, + })) + })() + + // Wrapped queries + fromWrappedQuery = (() => { + const createWrappedQuery = < + TQueryKey extends [string, Record?], + TQueryFnData, + TError, + TData = TQueryFnData, + >( + qk: TQueryKey, + fetcher: ( + obj: TQueryKey[1], + token: string, + ) => Promise, + options?: OmitKeyof< + CreateQueryOptions, + 'queryKey' | 'queryFn' | 'initialData', + 'safely' + >, + ) => + injectQuery(() => ({ + queryKey: qk, + queryFn: () => fetcher(qk[1], 'token'), + ...options, + })) + return createWrappedQuery([''], () => Promise.resolve('1')) + })() + + fromWrappedFuncStyleQuery = (() => { + const createWrappedFuncStyleQuery = < + TQueryKey extends [string, Record?], + TQueryFnData, + TError, + TData = TQueryFnData, + >( + qk: TQueryKey, + fetcher: () => Promise, + options?: OmitKeyof< + CreateQueryOptions, + 'queryKey' | 'queryFn' | 'initialData', + 'safely' + >, + ) => injectQuery(() => ({ queryKey: qk, queryFn: fetcher, ...options })) + return createWrappedFuncStyleQuery([''], () => Promise.resolve(true)) + })() + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const { + noQueryFn, + fromQueryFn, + withResult, + withError, + withResultInfer, + unionTypeSync, + unionTypeAsync, + fromGenericQueryFn, + fromGenericOptionsQueryFn, + fromMyDataArrayKeyQueryFn, + fromPromiseAnyQueryFn, + fromGetMyDataStringKeyQueryFn, + fromWrappedQuery, + fromWrappedFuncStyleQuery, + } = fixture.componentInstance + + expectTypeOf(noQueryFn.data()).toEqualTypeOf() + expectTypeOf(noQueryFn.error()).toEqualTypeOf() + + expectTypeOf(fromQueryFn.data()).toEqualTypeOf() + expectTypeOf(fromQueryFn.error()).toEqualTypeOf() + + expectTypeOf(withResult.data()).toEqualTypeOf() + expectTypeOf(withResult.error()).toEqualTypeOf() + + expectTypeOf(withError.data()).toEqualTypeOf() + expectTypeOf(withError.error()).toEqualTypeOf<{ message: string } | null>() + + expectTypeOf(withResultInfer.data()).toEqualTypeOf() + expectTypeOf(withResultInfer.error()).toEqualTypeOf() + + expectTypeOf(unionTypeSync.data()).toEqualTypeOf<'a' | 'b' | undefined>() + expectTypeOf(unionTypeAsync.data()).toEqualTypeOf<'a' | 'b' | undefined>() + expectTypeOf(fromGenericQueryFn.data()).toEqualTypeOf() expectTypeOf(fromGenericQueryFn.error()).toEqualTypeOf() - // todo use query options? - const fromGenericOptionsQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key, - queryFn: () => queryFn(), - })), - ) expectTypeOf(fromGenericOptionsQueryFn.data()).toEqualTypeOf< string | undefined >() - expectTypeOf( - fromGenericOptionsQueryFn.error(), - ).toEqualTypeOf() - - type MyData = number - type MyQueryKey = readonly ['my-data', number] + expectTypeOf(fromGenericOptionsQueryFn.error()).toEqualTypeOf() - const getMyDataArrayKey: QueryFunction = ({ - queryKey: [, n], - }) => { - return n + 42 - } - - const fromMyDataArrayKeyQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: ['my-data', 100] as const, - queryFn: getMyDataArrayKey, - })), - ) expectTypeOf(fromMyDataArrayKeyQueryFn.data()).toEqualTypeOf< number | undefined >() - // it should handle query-functions that return Promise - const fromPromiseAnyQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: key, - queryFn: () => fetch('return Promise').then((resp) => resp.json()), - })), - ) expectTypeOf(fromPromiseAnyQueryFn.data()).toEqualTypeOf() - TestBed.runInInjectionContext(() => - effect(() => { - if (fromPromiseAnyQueryFn.isSuccess()) { - expect(fromMyDataArrayKeyQueryFn.data()).toBe(142) - } - }), - ) - - const getMyDataStringKey: QueryFunction = (context) => { - expectTypeOf(context.queryKey).toEqualTypeOf<['1']>() - return Number(context.queryKey[0]) + 42 - } - - const fromGetMyDataStringKeyQueryFn = TestBed.runInInjectionContext(() => - injectQuery(() => ({ - queryKey: ['1'] as ['1'], - queryFn: getMyDataStringKey, - })), - ) expectTypeOf(fromGetMyDataStringKeyQueryFn.data()).toEqualTypeOf< number | undefined >() - TestBed.runInInjectionContext(() => - effect(() => { - if (fromGetMyDataStringKeyQueryFn.isSuccess()) { - expect(fromGetMyDataStringKeyQueryFn.data()).toBe(43) - } - }), - ) - - // handles wrapped queries with custom fetcher passed as inline queryFn - const createWrappedQuery = < - TQueryKey extends [string, Record?], - TQueryFnData, - TError, - TData = TQueryFnData, - >( - qk: TQueryKey, - fetcher: ( - obj: TQueryKey[1], - token: string, - // return type must be wrapped with TQueryFnReturn - ) => Promise, - options?: OmitKeyof< - CreateQueryOptions, - 'queryKey' | 'queryFn' | 'initialData', - 'safely' - >, - ) => - injectQuery(() => ({ - queryKey: qk, - queryFn: () => fetcher(qk[1], 'token'), - ...options, - })) - const fromWrappedQuery = TestBed.runInInjectionContext(() => - createWrappedQuery([''], () => Promise.resolve('1')), - ) expectTypeOf(fromWrappedQuery.data()).toEqualTypeOf() - - // handles wrapped queries with custom fetcher passed directly to createQuery - const createWrappedFuncStyleQuery = < - TQueryKey extends [string, Record?], - TQueryFnData, - TError, - TData = TQueryFnData, - >( - qk: TQueryKey, - fetcher: () => Promise, - options?: OmitKeyof< - CreateQueryOptions, - 'queryKey' | 'queryFn' | 'initialData', - 'safely' - >, - ) => injectQuery(() => ({ queryKey: qk, queryFn: fetcher, ...options })) - const fromWrappedFuncStyleQuery = TestBed.runInInjectionContext(() => - createWrappedFuncStyleQuery([''], () => Promise.resolve(true)), - ) expectTypeOf(fromWrappedFuncStyleQuery.data()).toEqualTypeOf< boolean | undefined >() }) test('should return pending status initially', () => { - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['key1'], queryFn: () => sleep(10).then(() => 'Some data'), })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(query.status()).toBe('pending') expect(query.isPending()).toBe(true) @@ -280,12 +285,22 @@ describe('injectQuery', () => { }) test('should resolve to success and update signal: injectQuery()', async () => { - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['key2'], queryFn: () => sleep(10).then(() => 'result2'), })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query await vi.advanceTimersByTimeAsync(11) expect(query.status()).toBe('success') @@ -297,14 +312,24 @@ describe('injectQuery', () => { }) test('should reject and update signal', async () => { - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ retry: false, queryKey: ['key3'], queryFn: () => sleep(10).then(() => Promise.reject(new Error('Some error'))), })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query await vi.advanceTimersByTimeAsync(11) expect(query.status()).toBe('error') @@ -321,12 +346,24 @@ describe('injectQuery', () => { const key = signal(['key6', 'key7']) const spy = vi.fn(() => sleep(10).then(() => 'Some data')) - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ - queryKey: key(), - queryFn: spy, - })) + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) + class TestComponent { + key = key + spy = spy + query = injectQuery(() => ({ + queryKey: this.key(), + queryFn: this.spy, + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query await vi.advanceTimersByTimeAsync(0) expect(spy).toHaveBeenCalledTimes(1) @@ -335,7 +372,7 @@ describe('injectQuery', () => { expect(query.status()).toBe('success') key.set(['key8']) - TestBed.tick() + fixture.detectChanges() expect(spy).toHaveBeenCalledTimes(2) // should call queryFn with context containing the new queryKey @@ -351,13 +388,25 @@ describe('injectQuery', () => { const spy = vi.fn(() => sleep(10).then(() => 'Some data')) const enabled = signal(false) - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + enabled = enabled + spy = spy + query = injectQuery(() => ({ queryKey: ['key9'], - queryFn: spy, - enabled: enabled(), + queryFn: this.spy, + enabled: this.enabled(), })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(spy).not.toHaveBeenCalled() expect(query.status()).toBe('pending') @@ -370,26 +419,34 @@ describe('injectQuery', () => { }) test('should properly execute dependant queries', async () => { - const query1 = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ - queryKey: ['dependant1'], - queryFn: () => sleep(10).then(() => 'Some data'), - })) - }) - const dependentQueryFn = vi .fn() .mockImplementation(() => sleep(1000).then(() => 'Some data')) - const query2 = TestBed.runInInjectionContext(() => { - return injectQuery( + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query1 = injectQuery(() => ({ + queryKey: ['dependant1'], + queryFn: () => sleep(10).then(() => 'Some data'), + })) + + query2 = injectQuery( computed(() => ({ queryKey: ['dependant2'], queryFn: dependentQueryFn, - enabled: !!query1.data(), + enabled: !!this.query1.data(), })), ) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const { query1, query2 } = fixture.componentInstance expect(query1.data()).toStrictEqual(undefined) expect(query2.fetchStatus()).toStrictEqual('idle') @@ -414,13 +471,25 @@ describe('injectQuery', () => { const fetchFn = vi.fn(() => sleep(10).then(() => 'Some data')) const keySignal = signal('key11') - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ - queryKey: ['key10', keySignal()], - queryFn: fetchFn, + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + keySignal = keySignal + fetchFn = fetchFn + query = injectQuery(() => ({ + queryKey: ['key10', this.keySignal()], + queryFn: this.fetchFn, enabled: false, })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(fetchFn).not.toHaveBeenCalled() @@ -436,6 +505,7 @@ describe('injectQuery', () => { await vi.advanceTimersByTimeAsync(11) keySignal.set('key12') + fixture.detectChanges() query.refetch().then(() => { expect(fetchFn).toHaveBeenCalledTimes(2) @@ -452,15 +522,26 @@ describe('injectQuery', () => { describe('throwOnError', () => { test('should evaluate throwOnError when query is expected to throw', async () => { const boundaryFn = vi.fn() - TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + boundaryFn = boundaryFn + query = injectQuery(() => ({ queryKey: ['key12'], queryFn: () => sleep(10).then(() => Promise.reject(new Error('Some error'))), retry: false, - throwOnError: boundaryFn, + throwOnError: this.boundaryFn, })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() await vi.advanceTimersByTimeAsync(11) expect(boundaryFn).toHaveBeenCalledTimes(1) @@ -473,41 +554,67 @@ describe('injectQuery', () => { }) test('should throw when throwOnError is true', async () => { - TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['key13'], queryFn: () => sleep(0).then(() => Promise.reject(new Error('Some error'))), throwOnError: true, })) - }) + } + + TestBed.createComponent(TestComponent).detectChanges() await expect(vi.runAllTimersAsync()).rejects.toThrow('Some error') }) test('should throw when throwOnError function returns true', async () => { - TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['key14'], queryFn: () => sleep(0).then(() => Promise.reject(new Error('Some error'))), throwOnError: () => true, })) - }) + } + + TestBed.createComponent(TestComponent).detectChanges() await expect(vi.runAllTimersAsync()).rejects.toThrow('Some error') }) }) test('should set state to error when queryFn returns reject promise', async () => { - const query = TestBed.runInInjectionContext(() => { - return injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ retry: false, queryKey: ['key15'], queryFn: () => sleep(10).then(() => Promise.reject(new Error('Some error'))), })) - }) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(query.status()).toBe('pending') @@ -520,6 +627,7 @@ describe('injectQuery', () => { @Component({ selector: 'app-fake', template: `{{ query.data() }}`, + changeDetection: ChangeDetectionStrategy.OnPush, }) class FakeComponent { name = input.required() @@ -554,15 +662,29 @@ describe('injectQuery', () => { }) test('can be used outside injection context when passing an injector', () => { - const query = injectQuery( - () => ({ - queryKey: ['manualInjector'], - queryFn: () => sleep(0).then(() => 'Some data'), - }), - { - injector: TestBed.inject(Injector), - }, - ) + const injector = TestBed.inject(Injector) + + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery( + () => ({ + queryKey: ['manualInjector'], + queryFn: () => sleep(0).then(() => 'Some data'), + }), + { + injector: injector, + }, + ) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(query.status()).toBe('pending') }) @@ -570,15 +692,25 @@ describe('injectQuery', () => { test('should complete queries before whenStable() resolves', async () => { const app = TestBed.inject(ApplicationRef) - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['pendingTasksTest'], queryFn: async () => { await sleep(50) return 'test data' }, - })), - ) + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query expect(query.status()).toBe('pending') expect(query.data()).toBeUndefined() @@ -606,14 +738,24 @@ describe('injectQuery', () => { const httpClient = TestBed.inject(HttpClient) const httpTestingController = TestBed.inject(HttpTestingController) - // Create a query using HttpClient - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + httpClient = httpClient + query = injectQuery(() => ({ queryKey: ['httpClientTest'], queryFn: () => - lastValueFrom(httpClient.get<{ message: string }>('/api/test')), - })), - ) + lastValueFrom(this.httpClient.get<{ message: string }>('/api/test')), + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query // Schedule the HTTP response setTimeout(() => { @@ -646,28 +788,35 @@ describe('injectQuery', () => { }) const app = TestBed.inject(ApplicationRef) - let callCount = 0 - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + callCount = 0 + query = injectQuery(() => ({ queryKey: ['sync-stale'], staleTime: 1000, queryFn: () => { - callCount++ - return `sync-data-${callCount}` + this.callCount++ + return `sync-data-${this.callCount}` }, - })), - ) + })) + } - // Synchronize pending effects - TestBed.tick() + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const component = fixture.componentInstance + const query = component.query - const stablePromise = app.whenStable() - await stablePromise + await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-1') - expect(callCount).toBe(1) + expect(component.callCount).toBe(1) await query.refetch() await Promise.resolve() @@ -676,7 +825,7 @@ describe('injectQuery', () => { expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-2') - expect(callCount).toBe(2) + expect(component.callCount).toBe(2) }) test('should handle enabled/disabled transitions with synchronous queryFn', async () => { @@ -690,34 +839,45 @@ describe('injectQuery', () => { const app = TestBed.inject(ApplicationRef) const enabledSignal = signal(false) - let callCount = 0 - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + enabledSignal = enabledSignal + callCount = 0 + query = injectQuery(() => ({ queryKey: ['sync-enabled'], - enabled: enabledSignal(), + enabled: this.enabledSignal(), queryFn: () => { - callCount++ - return `sync-data-${callCount}` + this.callCount++ + return `sync-data-${this.callCount}` }, - })), - ) + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const component = fixture.componentInstance + const query = component.query // Initially disabled - TestBed.tick() await app.whenStable() expect(query.status()).toBe('pending') expect(query.data()).toBeUndefined() - expect(callCount).toBe(0) + expect(component.callCount).toBe(0) // Enable the query enabledSignal.set(true) - TestBed.tick() + fixture.detectChanges() await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-1') - expect(callCount).toBe(1) + expect(component.callCount).toBe(1) }) test('should handle query invalidation with synchronous data', async () => { @@ -731,39 +891,45 @@ describe('injectQuery', () => { const app = TestBed.inject(ApplicationRef) const testKey = ['sync-invalidate'] - let callCount = 0 - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + callCount = 0 + query = injectQuery(() => ({ queryKey: testKey, queryFn: () => { - callCount++ - return `sync-data-${callCount}` + this.callCount++ + return `sync-data-${this.callCount}` }, - })), - ) + })) + } - // Synchronize pending effects - TestBed.tick() + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const component = fixture.componentInstance + const query = component.query await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-1') - expect(callCount).toBe(1) + expect(component.callCount).toBe(1) // Invalidate the query queryClient.invalidateQueries({ queryKey: testKey }) - TestBed.tick() // Wait for the invalidation to trigger a refetch await Promise.resolve() await vi.advanceTimersByTimeAsync(10) - TestBed.tick() await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-2') - expect(callCount).toBe(2) + expect(component.callCount).toBe(2) }) }) }) diff --git a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts index 92f70aed9f..a98b4d6399 100644 --- a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts +++ b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts @@ -1,5 +1,6 @@ import { ApplicationRef, + ChangeDetectionStrategy, Component, provideZonelessChangeDetection, } from '@angular/core' @@ -55,12 +56,22 @@ describe('PendingTasks Integration', () => { test('should handle synchronous queryFn with whenStable()', async () => { const app = TestBed.inject(ApplicationRef) - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query = injectQuery(() => ({ queryKey: ['sync'], queryFn: () => 'instant-data', // Resolves synchronously - })), - ) + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query // Should start as pending even with synchronous data expect(query.status()).toBe('pending') @@ -183,18 +194,28 @@ describe('PendingTasks Integration', () => { test('should handle rapid refetches without task leaks', async () => { const app = TestBed.inject(ApplicationRef) - let callCount = 0 - const query = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + callCount = 0 + query = injectQuery(() => ({ queryKey: ['rapid-refetch'], queryFn: async () => { - callCount++ + this.callCount++ await sleep(10) - return `data-${callCount}` + return `data-${this.callCount}` }, - })), - ) + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const query = fixture.componentInstance.query // Trigger multiple rapid refetches query.refetch() @@ -279,6 +300,7 @@ describe('PendingTasks Integration', () => { describe('Component Destruction', () => { @Component({ template: '', + changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { query = injectQuery(() => ({ @@ -300,6 +322,7 @@ describe('PendingTasks Integration', () => { test('should cleanup pending tasks when component with active query is destroyed', async () => { const app = TestBed.inject(ApplicationRef) const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() // Start the query expect(fixture.componentInstance.query.status()).toBe('pending') @@ -317,6 +340,7 @@ describe('PendingTasks Integration', () => { test('should cleanup pending tasks when component with active mutation is destroyed', async () => { const app = TestBed.inject(ApplicationRef) const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() fixture.componentInstance.mutation.mutate('test') @@ -335,32 +359,38 @@ describe('PendingTasks Integration', () => { test('should handle multiple queries running simultaneously', async () => { const app = TestBed.inject(ApplicationRef) - const query1 = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + @Component({ + selector: 'app-test', + template: '', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestComponent { + query1 = injectQuery(() => ({ queryKey: ['concurrent-1'], queryFn: async () => { await sleep(30) return 'data-1' }, - })), - ) + })) - const query2 = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + query2 = injectQuery(() => ({ queryKey: ['concurrent-2'], queryFn: async () => { await sleep(50) return 'data-2' }, - })), - ) + })) - const query3 = TestBed.runInInjectionContext(() => - injectQuery(() => ({ + query3 = injectQuery(() => ({ queryKey: ['concurrent-3'], queryFn: () => 'instant-data', // Synchronous - })), - ) + })) + } + + const fixture = TestBed.createComponent(TestComponent) + fixture.detectChanges() + const { query1, query2, query3 } = fixture.componentInstance // All queries should start expect(query1.status()).toBe('pending') From f8ae27edd3e0983682f8697d0a9a6f8da0eefe05 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 05/10] 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 45297e5ff3..991c2ea0a1 100644 --- a/packages/angular-query-experimental/src/inject-mutation.ts +++ b/packages/angular-query-experimental/src/inject-mutation.ts @@ -124,7 +124,6 @@ export function injectMutation< effect( (onCleanup) => { - // observer.trackResult is not used as this optimization is not needed for Angular const observer = observerSignal() let taskCleanupRef: (() => void) | null = null From a2982d5c9e3293b38274fdcb1fd00eea761b63c1 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 06/10] 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 dfa8c31b197ea6a0a47215a48fde036b3cacb5d7 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 07/10] Use queueMicrotask instead of notifyManager.batchCalls to improve timing --- .../__tests__/inject-infinite-query.test.ts | 2 +- .../src/__tests__/inject-query.test.ts | 33 ++++++-- .../src/create-base-query.ts | 81 ++++++++++++------- 3 files changed, 78 insertions(+), 38 deletions(-) diff --git a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts index 8812edbb62..07360df95e 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts @@ -7,7 +7,7 @@ import { provideZonelessChangeDetection, } from '@angular/core' import { sleep } from '@tanstack/query-test-utils' -import { injectInfiniteQuery, provideTanStackQuery, QueryClient } from '..' +import { QueryClient, injectInfiniteQuery, provideTanStackQuery } from '..' import { expectSignals } from './test-utils' describe('injectInfiniteQuery', () => { diff --git a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts index 474ee3d747..a5422cb7ac 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -1,12 +1,13 @@ import { ApplicationRef, + ChangeDetectionStrategy, Component, Injector, + NgZone, computed, input, provideZonelessChangeDetection, signal, - ChangeDetectionStrategy, } from '@angular/core' import { TestBed } from '@angular/core/testing' import { HttpClient, provideHttpClient } from '@angular/common/http' @@ -554,6 +555,14 @@ describe('injectQuery', () => { }) test('should throw when throwOnError is true', async () => { + const zone = TestBed.inject(NgZone) + const errorPromise = new Promise((resolve) => { + const sub = zone.onError.subscribe((error) => { + sub.unsubscribe() + resolve(error as Error) + }) + }) + @Component({ selector: 'app-test', template: '', @@ -571,10 +580,19 @@ describe('injectQuery', () => { TestBed.createComponent(TestComponent).detectChanges() - await expect(vi.runAllTimersAsync()).rejects.toThrow('Some error') + await vi.runAllTimersAsync() + await expect(errorPromise).resolves.toEqual(Error('Some error')) }) test('should throw when throwOnError function returns true', async () => { + const zone = TestBed.inject(NgZone) + const errorPromise = new Promise((resolve) => { + const sub = zone.onError.subscribe((error) => { + sub.unsubscribe() + resolve(error as Error) + }) + }) + @Component({ selector: 'app-test', template: '', @@ -592,7 +610,8 @@ describe('injectQuery', () => { TestBed.createComponent(TestComponent).detectChanges() - await expect(vi.runAllTimersAsync()).rejects.toThrow('Some error') + await vi.runAllTimersAsync() + await expect(errorPromise).resolves.toEqual(Error('Some error')) }) }) @@ -715,9 +734,8 @@ describe('injectQuery', () => { expect(query.status()).toBe('pending') expect(query.data()).toBeUndefined() - const stablePromise = app.whenStable() await vi.advanceTimersByTimeAsync(60) - await stablePromise + await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('test data') @@ -767,9 +785,8 @@ describe('injectQuery', () => { expect(query.status()).toBe('pending') // Advance timers and wait for Angular to be "stable" - const stablePromise = app.whenStable() await vi.advanceTimersByTimeAsync(20) - await stablePromise + await app.whenStable() // Query should be complete after whenStable() thanks to PendingTasks integration expect(query.status()).toBe('success') @@ -865,6 +882,7 @@ describe('injectQuery', () => { const query = component.query // Initially disabled + await vi.advanceTimersByTimeAsync(0) await app.whenStable() expect(query.status()).toBe('pending') expect(query.data()).toBeUndefined() @@ -874,6 +892,7 @@ describe('injectQuery', () => { enabledSignal.set(true) fixture.detectChanges() + await vi.advanceTimersByTimeAsync(0) await app.whenStable() expect(query.status()).toBe('success') expect(query.data()).toBe('sync-data-1') diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index d475b55ef4..a28999308c 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -1,5 +1,18 @@ -import { DestroyRef, NgZone, PendingTasks, computed, effect, inject, linkedSignal, untracked, } from '@angular/core' -import { QueryClient, notifyManager, shouldThrowError, } from '@tanstack/query-core' +import { + DestroyRef, + NgZone, + PendingTasks, + computed, + effect, + inject, + linkedSignal, + untracked, +} from '@angular/core' +import { + QueryClient, + notifyManager, + shouldThrowError, +} from '@tanstack/query-core' import { signalProxy } from './signal-proxy' import { injectIsRestoring } from './inject-is-restoring' import type { @@ -45,6 +58,7 @@ export function createBaseQuery< TQueryKey > | null = null + let destroyed = false let taskCleanupRef: (() => void) | null = null const startPendingTask = () => { @@ -127,35 +141,39 @@ export function createBaseQuery< observer = new Observer(queryClient, options) - const unsubscribe = observer.subscribe( - notifyManager.batchCalls((state) => { - ngZone.run(() => { - if (state.fetchStatus !== 'idle') { - startPendingTask() - } else { - stopPendingTask() - } - - if ( - state.isError && - !state.isFetching && - shouldThrowError(observer!.options.throwOnError, [ - state.error, - observer!.getCurrentQuery(), - ]) - ) { - ngZone.onError.emit(state.error) - throw state.error - } - const trackedState = trackObserverResult( - state, - observer!.options.notifyOnChangeProps, - ) - resultSignal.set(trackedState) + const unsubscribe = observer.subscribe((state) => { + if (state.fetchStatus !== 'idle') { + startPendingTask() + } else { + stopPendingTask() + } + + queueMicrotask(() => { + if (destroyed) return + notifyManager.batch(() => { + ngZone.run(() => { + if ( + state.isError && + !state.isFetching && + shouldThrowError(observer!.options.throwOnError, [ + state.error, + observer!.getCurrentQuery(), + ]) + ) { + ngZone.onError.emit(state.error) + throw state.error + } + const trackedState = trackObserverResult( + state, + observer!.options.notifyOnChangeProps, + ) + resultSignal.set(trackedState) + }) }) - }), - ) + }) + }) destroyRef.onDestroy(() => { + destroyed = true unsubscribe() stopPendingTask() }) @@ -164,7 +182,10 @@ export function createBaseQuery< const resultSignal = linkedSignal({ source: defaultedOptionsSignal, computation: () => { - if (!observer) throw new Error('Observer is not initialized') + if (!observer) + throw new Error( + 'injectQuery: QueryObserver not initialized yet. Avoid reading the query result during construction', + ) const defaultedOptions = defaultedOptionsSignal() const result = observer.getOptimisticResult(defaultedOptions) return trackObserverResult(result, defaultedOptions.notifyOnChangeProps) From f3e109c57d6999c991b4b222b06a1e95daf3f3c9 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 08/10] Fix isRestoring() handling --- .../src/app/components/example.component.ts | 17 +++---- .../optimistic-updates.component.ts | 2 +- .../src/__tests__/inject-query.test.ts | 42 +++++++++++++--- .../src/create-base-query.ts | 49 ++++++++++++------- 4 files changed, 73 insertions(+), 37 deletions(-) diff --git a/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts b/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts index 71c141e3e4..bf3fd06014 100644 --- a/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts +++ b/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts @@ -30,30 +30,25 @@ export class ExampleComponent { })) readonly nextButtonDisabled = computed( - () => !this.#hasNextPage() || this.#isFetchingNextPage(), + () => !this.query.hasNextPage() || this.query.isFetchingNextPage(), ) readonly nextButtonText = computed(() => - this.#isFetchingNextPage() + this.query.isFetchingNextPage() ? 'Loading more...' - : this.#hasNextPage() + : this.query.hasNextPage() ? 'Load newer' : 'Nothing more to load', ) readonly previousButtonDisabled = computed( - () => !this.#hasPreviousPage() || this.#isFetchingNextPage(), + () => !this.query.hasPreviousPage() || this.query.isFetchingNextPage(), ) readonly previousButtonText = computed(() => - this.#isFetchingPreviousPage() + this.query.isFetchingPreviousPage() ? 'Loading more...' - : this.#hasPreviousPage() + : this.query.hasPreviousPage() ? 'Load Older' : 'Nothing more to load', ) - - readonly #hasPreviousPage = this.query.hasPreviousPage - readonly #hasNextPage = this.query.hasNextPage - readonly #isFetchingPreviousPage = this.query.isFetchingPreviousPage - readonly #isFetchingNextPage = this.query.isFetchingNextPage } diff --git a/examples/angular/optimistic-updates/src/app/components/optimistic-updates.component.ts b/examples/angular/optimistic-updates/src/app/components/optimistic-updates.component.ts index 2b0b4cc1c4..b32a0f50dc 100644 --- a/examples/angular/optimistic-updates/src/app/components/optimistic-updates.component.ts +++ b/examples/angular/optimistic-updates/src/app/components/optimistic-updates.component.ts @@ -36,7 +36,7 @@ import { TasksService } from '../services/tasks.service'
    - @for (task of tasks.data(); track task) { + @for (task of tasks.data(); track $index) {
  • {{ task }}
  • }
diff --git a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts index a5422cb7ac..7770477fd3 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -556,12 +556,20 @@ describe('injectQuery', () => { test('should throw when throwOnError is true', async () => { const zone = TestBed.inject(NgZone) - const errorPromise = new Promise((resolve) => { + const zoneErrorPromise = new Promise((resolve) => { const sub = zone.onError.subscribe((error) => { sub.unsubscribe() resolve(error as Error) }) }) + let handler: ((error: Error) => void) | null = null + const processErrorPromise = new Promise((resolve) => { + handler = (error: Error) => { + process.off('uncaughtException', handler!) + resolve(error) + } + process.on('uncaughtException', handler) + }) @Component({ selector: 'app-test', @@ -580,18 +588,33 @@ describe('injectQuery', () => { TestBed.createComponent(TestComponent).detectChanges() - await vi.runAllTimersAsync() - await expect(errorPromise).resolves.toEqual(Error('Some error')) + try { + await vi.runAllTimersAsync() + await expect(zoneErrorPromise).resolves.toEqual(Error('Some error')) + await expect(processErrorPromise).resolves.toEqual(Error('Some error')) + } finally { + if (handler) { + process.off('uncaughtException', handler) + } + } }) test('should throw when throwOnError function returns true', async () => { const zone = TestBed.inject(NgZone) - const errorPromise = new Promise((resolve) => { + const zoneErrorPromise = new Promise((resolve) => { const sub = zone.onError.subscribe((error) => { sub.unsubscribe() resolve(error as Error) }) }) + let handler: ((error: Error) => void) | null = null + const processErrorPromise = new Promise((resolve) => { + handler = (error: Error) => { + process.off('uncaughtException', handler!) + resolve(error) + } + process.on('uncaughtException', handler) + }) @Component({ selector: 'app-test', @@ -610,8 +633,15 @@ describe('injectQuery', () => { TestBed.createComponent(TestComponent).detectChanges() - await vi.runAllTimersAsync() - await expect(errorPromise).resolves.toEqual(Error('Some error')) + try { + await vi.runAllTimersAsync() + await expect(zoneErrorPromise).resolves.toEqual(Error('Some error')) + await expect(processErrorPromise).resolves.toEqual(Error('Some error')) + } finally { + if (handler) { + process.off('uncaughtException', handler) + } + } }) }) diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index a28999308c..4b1ce4ca5c 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -99,7 +99,7 @@ export function createBaseQuery< >['notifyOnChangeProps'], ) => { if (!observer) { - throw new Error('Observer is not initialized') + throw new Error(OBSERVER_NOT_READY_ERROR) } const trackedResult = observer.trackResult(result) @@ -125,7 +125,7 @@ export function createBaseQuery< } } - const createOrUpdateObserver = ( + const setObserverOptions = ( options: DefaultedQueryObserverOptions< TQueryFnData, TError, @@ -134,14 +134,23 @@ export function createBaseQuery< TQueryKey >, ) => { - if (observer) { + if (!observer) { + observer = new Observer(queryClient, options) + destroyRef.onDestroy(() => { + destroyed = true + stopPendingTask() + }) + } else { observer.setOptions(options) - return } + } - observer = new Observer(queryClient, options) + const subscribeToObserver = () => { + if (!observer) { + throw new Error(OBSERVER_NOT_READY_ERROR) + } - const unsubscribe = observer.subscribe((state) => { + return observer.subscribe((state) => { if (state.fetchStatus !== 'idle') { startPendingTask() } else { @@ -172,35 +181,37 @@ export function createBaseQuery< }) }) }) - destroyRef.onDestroy(() => { - destroyed = true - unsubscribe() - stopPendingTask() - }) } const resultSignal = linkedSignal({ source: defaultedOptionsSignal, computation: () => { - if (!observer) - throw new Error( - 'injectQuery: QueryObserver not initialized yet. Avoid reading the query result during construction', - ) + if (!observer) throw new Error(OBSERVER_NOT_READY_ERROR) const defaultedOptions = defaultedOptionsSignal() const result = observer.getOptimisticResult(defaultedOptions) return trackObserverResult(result, defaultedOptions.notifyOnChangeProps) }, }) - // Effect to initialize the observer and set options when options change effect(() => { const defaultedOptions = defaultedOptionsSignal() - if (isRestoring()) return - untracked(() => { - createOrUpdateObserver(defaultedOptions) + setObserverOptions(defaultedOptions) + }) + }) + + effect((onCleanup) => { + if (isRestoring()) { + return + } + const unsubscribe = untracked(() => subscribeToObserver()) + onCleanup(() => { + unsubscribe() + stopPendingTask() }) }) return signalProxy(resultSignal.asReadonly()) } +const OBSERVER_NOT_READY_ERROR = + 'injectQuery: QueryObserver not initialized yet. Avoid reading the query result during construction' From f911999b1a290bdebe2f234c01b0da2aabb4696a 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 09/10] 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 75cb8ca643d5119b510002f36fddd414e1f0f81e 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 10/10] Improve tests --- docs/framework/angular/guides/testing.md | 4 +- docs/framework/angular/installation.md | 2 +- docs/framework/angular/overview.md | 2 +- .../src/app/components/example.component.ts | 2 +- .../__tests__/inject-devtools-panel.test.ts | 16 +--- .../__tests__/inject-infinite-query.test.ts | 58 ++++-------- .../src/__tests__/inject-is-fetching.test.ts | 17 +--- .../src/__tests__/inject-is-mutating.test.ts | 19 +--- .../__tests__/inject-mutation-state.test.ts | 33 ++++--- .../src/__tests__/inject-mutation.test.ts | 69 ++++++++++---- .../src/__tests__/inject-queries.test.ts | 18 +--- .../src/__tests__/inject-query.test.ts | 50 ++++------ .../src/__tests__/mutation-options.test.ts | 18 ++-- .../src/__tests__/pending-tasks.test.ts | 34 ++----- .../src/__tests__/test-utils.ts | 91 ++++++++++++------- .../src/__tests__/with-devtools.test.ts | 31 ++++--- 16 files changed, 216 insertions(+), 248 deletions(-) diff --git a/docs/framework/angular/guides/testing.md b/docs/framework/angular/guides/testing.md index 7648d7f6b3..3ffb9588b8 100644 --- a/docs/framework/angular/guides/testing.md +++ b/docs/framework/angular/guides/testing.md @@ -9,8 +9,6 @@ TanStack Query's `inject*` functions integrate with [`PendingTasks`](https://ang This means tests and SSR can wait until mutations and queries resolve. In unit tests you can use `ApplicationRef.whenStable()` or `fixture.whenStable()` to await query completion. This works for both Zone.js and Zoneless setups. -> This integration requires Angular 19 or later. Earlier versions of Angular do not support `PendingTasks`. - ## TestBed setup Create a fresh `QueryClient` for every spec and provide it with `provideTanStackQuery` or `provideQueryClient`. This keeps caches isolated and lets you change default options per test: @@ -31,7 +29,7 @@ TestBed.configureTestingModule({ > If your applications actual TanStack Query config is used in unit tests, make sure `withDevtools` is not accidentally included in test providers. This can cause slow tests. It is best to keep test and production configs separate. -If you share helpers, remember to call `queryClient.clear()` (or build a new instance) in `afterEach` so data from one test never bleeds into another. +If you share helpers, remember to call `queryClient.clear()` (or build a new instance) in `afterEach` so data from one test never bleeds into another. Prefer creating a fresh `QueryClient` per test: clearing only removes cached data, not custom defaults or listeners, so a reused client can leak configuration changes between specs and make failures harder to reason about. A new client keeps setup explicit and avoids any “invisible globals” influencing results. ## First query test diff --git a/docs/framework/angular/installation.md b/docs/framework/angular/installation.md index 20667aba90..167b29789a 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 8d97b60376..831c3aee61 100644 --- a/docs/framework/angular/overview.md +++ b/docs/framework/angular/overview.md @@ -13,7 +13,7 @@ We are in the process of getting to a stable API for TanStack Query on Angular. ## Supported Angular Versions -TanStack Query is compatible with Angular v16 and higher. +TanStack Query is compatible with Angular v19 and higher. TanStack Query (FKA React Query) is often described as the missing data-fetching library for web applications, but in more technical terms, it makes **fetching, caching, synchronizing and updating server state** in your web applications a breeze. diff --git a/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts b/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts index bf3fd06014..3232f64942 100644 --- a/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts +++ b/examples/angular/infinite-query-with-max-pages/src/app/components/example.component.ts @@ -42,7 +42,7 @@ export class ExampleComponent { ) readonly previousButtonDisabled = computed( - () => !this.query.hasPreviousPage() || this.query.isFetchingNextPage(), + () => !this.query.hasPreviousPage() || this.query.isFetchingPreviousPage(), ) readonly previousButtonText = computed(() => this.query.isFetchingPreviousPage() diff --git a/packages/angular-query-experimental/src/__tests__/inject-devtools-panel.test.ts b/packages/angular-query-experimental/src/__tests__/inject-devtools-panel.test.ts index 7368deb32e..835cb01ab3 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-devtools-panel.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-devtools-panel.test.ts @@ -1,13 +1,9 @@ -import { - ElementRef, - provideZonelessChangeDetection, - signal, -} from '@angular/core' +import { ElementRef, signal } from '@angular/core' import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { QueryClient } from '@tanstack/query-core' -import { provideTanStackQuery } from '../providers' import { injectDevtoolsPanel } from '../devtools-panel' +import { setupTanStackQueryTestBed } from './test-utils' const mockDevtoolsPanelInstance = { mount: vi.fn(), @@ -40,12 +36,8 @@ describe('injectDevtoolsPanel', () => { beforeEach(() => { queryClient = new QueryClient() mockElementRef = new ElementRef(document.createElement('div')) - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - { provide: ElementRef, useValue: signal(mockElementRef) }, - ], + setupTanStackQueryTestBed(queryClient, { + providers: [{ provide: ElementRef, useValue: signal(mockElementRef) }], }) }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts index 07360df95e..f587f6a2fe 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-infinite-query.test.ts @@ -1,14 +1,9 @@ import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { - ChangeDetectionStrategy, - Component, - Injector, - provideZonelessChangeDetection, -} from '@angular/core' +import { ChangeDetectionStrategy, Component, Injector } from '@angular/core' import { sleep } from '@tanstack/query-test-utils' -import { QueryClient, injectInfiniteQuery, provideTanStackQuery } from '..' -import { expectSignals } from './test-utils' +import { QueryClient, injectInfiniteQuery } from '..' +import { expectSignals, setupTanStackQueryTestBed } from './test-utils' describe('injectInfiniteQuery', () => { let queryClient: QueryClient @@ -16,12 +11,7 @@ describe('injectInfiniteQuery', () => { beforeEach(() => { queryClient = new QueryClient() vi.useFakeTimers() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + setupTanStackQueryTestBed(queryClient) }) afterEach(() => { @@ -32,7 +22,6 @@ describe('injectInfiniteQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -93,30 +82,21 @@ describe('injectInfiniteQuery', () => { test('can be used outside injection context when passing an injector', () => { const injector = TestBed.inject(Injector) - @Component({ - selector: 'app-test', - template: '', - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - }) - class TestComponent { - query = injectInfiniteQuery( - () => ({ - queryKey: ['manualInjector'], - queryFn: ({ pageParam }) => - sleep(0).then(() => 'data on page ' + pageParam), - initialPageParam: 0, - getNextPageParam: () => 12, - }), - { - injector: injector, - }, - ) - } - - const fixture = TestBed.createComponent(TestComponent) - fixture.detectChanges() - const query = fixture.componentInstance.query + // Call injectInfiniteQuery directly outside any component + const query = injectInfiniteQuery( + () => ({ + queryKey: ['manualInjector'], + queryFn: ({ pageParam }) => + sleep(0).then(() => 'data on page ' + pageParam), + initialPageParam: 0, + getNextPageParam: () => 12, + }), + { + injector: injector, + }, + ) + + TestBed.tick() expect(query.status()).toBe('pending') }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-is-fetching.test.ts b/packages/angular-query-experimental/src/__tests__/inject-is-fetching.test.ts index 329ef6d9e3..a7461dbc26 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-is-fetching.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-is-fetching.test.ts @@ -1,13 +1,9 @@ import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { Injector, provideZonelessChangeDetection } from '@angular/core' +import { Injector } from '@angular/core' import { sleep } from '@tanstack/query-test-utils' -import { - QueryClient, - injectIsFetching, - injectQuery, - provideTanStackQuery, -} from '..' +import { QueryClient, injectIsFetching, injectQuery } from '..' +import { setupTanStackQueryTestBed } from './test-utils' describe('injectIsFetching', () => { let queryClient: QueryClient @@ -16,12 +12,7 @@ describe('injectIsFetching', () => { vi.useFakeTimers() queryClient = new QueryClient() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + setupTanStackQueryTestBed(queryClient) }) afterEach(() => { diff --git a/packages/angular-query-experimental/src/__tests__/inject-is-mutating.test.ts b/packages/angular-query-experimental/src/__tests__/inject-is-mutating.test.ts index 5a4694cb85..6d30b988f4 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-is-mutating.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-is-mutating.test.ts @@ -1,13 +1,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { TestBed } from '@angular/core/testing' -import { Injector, provideZonelessChangeDetection } from '@angular/core' +import { Injector } from '@angular/core' import { sleep } from '@tanstack/query-test-utils' -import { - QueryClient, - injectIsMutating, - injectMutation, - provideTanStackQuery, -} from '..' +import { QueryClient, injectIsMutating, injectMutation } from '..' +import { flushQueryUpdates, setupTanStackQueryTestBed } from './test-utils' describe('injectIsMutating', () => { let queryClient: QueryClient @@ -16,12 +12,7 @@ describe('injectIsMutating', () => { vi.useFakeTimers() queryClient = new QueryClient() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + setupTanStackQueryTestBed(queryClient) }) afterEach(() => { @@ -44,7 +35,7 @@ describe('injectIsMutating', () => { }) expect(isMutating()).toBe(0) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(isMutating()).toBe(1) await vi.advanceTimersByTimeAsync(11) expect(isMutating()).toBe(0) diff --git a/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts b/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts index bb1c97b3bd..e8c86c068f 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts @@ -8,7 +8,6 @@ import { } from '@angular/core' import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { By } from '@angular/platform-browser' import { sleep } from '@tanstack/query-test-utils' import { QueryClient, @@ -16,7 +15,7 @@ import { injectMutationState, provideTanStackQuery, } from '..' -import { setFixtureSignalInputs } from './test-utils' +import { registerSignalInput } from './test-utils' describe('injectMutationState', () => { let queryClient: QueryClient @@ -159,23 +158,35 @@ describe('injectMutationState', () => { })) } - const fixture = TestBed.createComponent(FakeComponent) - const { debugElement } = fixture - setFixtureSignalInputs(fixture, { name: fakeName }) + registerSignalInput(FakeComponent, 'name') + + @Component({ + template: ``, + imports: [FakeComponent], + }) + class HostComponent { + protected readonly name = signal(fakeName) + } + + const fixture = TestBed.createComponent(HostComponent) + fixture.detectChanges() await vi.advanceTimersByTimeAsync(0) - let spans = debugElement - .queryAll(By.css('span')) - .map((span) => span.nativeNode.textContent) + const readSpans = () => + Array.from( + fixture.nativeElement.querySelectorAll( + 'span', + ) as NodeListOf, + ).map((span) => span.textContent) + + let spans = readSpans() expect(spans).toEqual(['pending', 'pending']) await vi.advanceTimersByTimeAsync(11) fixture.detectChanges() - spans = debugElement - .queryAll(By.css('span')) - .map((span) => span.nativeNode.textContent) + spans = readSpans() expect(spans).toEqual(['success', 'error']) }) diff --git a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts index d7a8d85121..c1548bc634 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts @@ -9,10 +9,9 @@ import { } from '@angular/core' import { TestBed } from '@angular/core/testing' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { By } from '@angular/platform-browser' import { sleep } from '@tanstack/query-test-utils' import { QueryClient, injectMutation, provideTanStackQuery } from '..' -import { expectSignals, setFixtureSignalInputs } from './test-utils' +import { expectSignals, registerSignalInput } from './test-utils' describe('injectMutation', () => { let queryClient: QueryClient @@ -323,19 +322,32 @@ describe('injectMutation', () => { } } - const fixture = TestBed.createComponent(FakeComponent) - const { debugElement } = fixture - setFixtureSignalInputs(fixture, { name: 'value' }) + registerSignalInput(FakeComponent, 'name') - const button = debugElement.query(By.css('button')) - button.triggerEventHandler('click') + @Component({ + template: ``, + imports: [FakeComponent], + }) + class HostComponent { + protected readonly name = signal('value') + } + + const fixture = TestBed.createComponent(HostComponent) + fixture.detectChanges() + + const hostButton = fixture.nativeElement.querySelector( + 'button', + ) as HTMLButtonElement + hostButton.click() await vi.advanceTimersByTimeAsync(11) fixture.detectChanges() - const text = debugElement.query(By.css('span')).nativeElement.textContent - expect(text).toEqual('value') - const mutation = mutationCache.find({ mutationKey: ['fake', 'value'] }) + const span = fixture.nativeElement.querySelector('span') as HTMLSpanElement + expect(span.textContent).toEqual('value') + const mutation = mutationCache.find({ + mutationKey: ['fake', 'value'], + }) expect(mutation).toBeDefined() expect(mutation!.options.mutationKey).toStrictEqual(['fake', 'value']) }) @@ -364,26 +376,43 @@ describe('injectMutation', () => { } } - const fixture = TestBed.createComponent(FakeComponent) - const { debugElement } = fixture - setFixtureSignalInputs(fixture, { name: 'value' }) + registerSignalInput(FakeComponent, 'name') - const button = debugElement.query(By.css('button')) - const span = debugElement.query(By.css('span')) + @Component({ + template: ``, + imports: [FakeComponent], + }) + class HostComponent { + protected readonly name = signal('value') + + updateName(value: string): void { + this.name.set(value) + } + } - button.triggerEventHandler('click') + const fixture = TestBed.createComponent(HostComponent) + fixture.detectChanges() + + let button = fixture.nativeElement.querySelector( + 'button', + ) as HTMLButtonElement + button.click() await vi.advanceTimersByTimeAsync(11) fixture.detectChanges() - expect(span.nativeElement.textContent).toEqual('value') + let span = fixture.nativeElement.querySelector('span') as HTMLSpanElement + expect(span.textContent).toEqual('value') - setFixtureSignalInputs(fixture, { name: 'updatedValue' }) + fixture.componentInstance.updateName('updatedValue') + fixture.detectChanges() - button.triggerEventHandler('click') + button = fixture.nativeElement.querySelector('button') as HTMLButtonElement + button.click() await vi.advanceTimersByTimeAsync(11) fixture.detectChanges() - expect(span.nativeElement.textContent).toEqual('updatedValue') + span = fixture.nativeElement.querySelector('span') as HTMLSpanElement + expect(span.textContent).toEqual('updatedValue') const mutations = mutationCache.findAll() expect(mutations.length).toBe(2) diff --git a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts index ab16d3b296..44558aa743 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts @@ -1,26 +1,16 @@ import { beforeEach, describe, expect, it } from 'vitest' import { render } from '@testing-library/angular' -import { - ChangeDetectionStrategy, - Component, - effect, - provideZonelessChangeDetection, -} from '@angular/core' -import { TestBed } from '@angular/core/testing' +import { ChangeDetectionStrategy, Component, effect } from '@angular/core' import { queryKey } from '@tanstack/query-test-utils' -import { QueryClient, provideTanStackQuery } from '..' +import { QueryClient } from '..' import { injectQueries } from '../inject-queries' +import { setupTanStackQueryTestBed } from './test-utils' let queryClient: QueryClient beforeEach(() => { queryClient = new QueryClient() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + setupTanStackQueryTestBed(queryClient) }) describe('injectQueries', () => { diff --git a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts index 7770477fd3..d1befc202b 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -27,7 +27,7 @@ import { import { queryKey, sleep } from '@tanstack/query-test-utils' import { lastValueFrom } from 'rxjs' import { QueryCache, QueryClient, injectQuery, provideTanStackQuery } from '..' -import { setSignalInputs } from './test-utils' +import { registerSignalInput } from './test-utils' import type { CreateQueryOptions, OmitKeyof, QueryFunction } from '..' describe('injectQuery', () => { @@ -55,7 +55,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -162,10 +161,7 @@ describe('injectQuery', () => { TData = TQueryFnData, >( qk: TQueryKey, - fetcher: ( - obj: TQueryKey[1], - token: string, - ) => Promise, + fetcher: (obj: TQueryKey[1], token: string) => Promise, options?: OmitKeyof< CreateQueryOptions, 'queryKey' | 'queryFn' | 'initialData', @@ -242,7 +238,9 @@ describe('injectQuery', () => { expectTypeOf(fromGenericOptionsQueryFn.data()).toEqualTypeOf< string | undefined >() - expectTypeOf(fromGenericOptionsQueryFn.error()).toEqualTypeOf() + expectTypeOf( + fromGenericOptionsQueryFn.error(), + ).toEqualTypeOf() expectTypeOf(fromMyDataArrayKeyQueryFn.data()).toEqualTypeOf< number | undefined @@ -264,7 +262,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -289,7 +286,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -316,7 +312,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -350,7 +345,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -392,7 +386,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -427,7 +420,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -475,7 +467,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -527,7 +518,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -574,7 +564,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -619,7 +608,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -649,7 +637,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -687,17 +674,22 @@ describe('injectQuery', () => { })) } - const fixture = TestBed.createComponent(FakeComponent) - setSignalInputs(fixture.componentInstance, { - name: 'signal-input-required-test', + registerSignalInput(FakeComponent, 'name') + + @Component({ + template: ``, + imports: [FakeComponent], }) + class HostComponent { + protected readonly name = signal('signal-input-required-test') + } + const fixture = TestBed.createComponent(HostComponent) fixture.detectChanges() await vi.advanceTimersByTimeAsync(0) - expect(fixture.componentInstance.query.data()).toEqual( - 'signal-input-required-test', - ) + const result = fixture.nativeElement.querySelector('app-fake').textContent + expect(result).toEqual('signal-input-required-test') }) describe('injection context', () => { @@ -716,7 +708,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -744,7 +735,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -789,7 +779,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -797,7 +786,9 @@ describe('injectQuery', () => { query = injectQuery(() => ({ queryKey: ['httpClientTest'], queryFn: () => - lastValueFrom(this.httpClient.get<{ message: string }>('/api/test')), + lastValueFrom( + this.httpClient.get<{ message: string }>('/api/test'), + ), })) } @@ -839,7 +830,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -890,7 +880,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -944,7 +933,6 @@ describe('injectQuery', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { diff --git a/packages/angular-query-experimental/src/__tests__/mutation-options.test.ts b/packages/angular-query-experimental/src/__tests__/mutation-options.test.ts index ab040037d5..553df12d36 100644 --- a/packages/angular-query-experimental/src/__tests__/mutation-options.test.ts +++ b/packages/angular-query-experimental/src/__tests__/mutation-options.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { provideZonelessChangeDetection } from '@angular/core' import { TestBed } from '@angular/core/testing' import { QueryClient } from '@tanstack/query-core' import { sleep } from '@tanstack/query-test-utils' @@ -8,8 +7,8 @@ import { injectMutation, injectMutationState, mutationOptions, - provideTanStackQuery, } from '..' +import { flushQueryUpdates, setupTanStackQueryTestBed } from './test-utils' describe('mutationOptions', () => { let queryClient: QueryClient @@ -17,12 +16,7 @@ describe('mutationOptions', () => { beforeEach(() => { vi.useFakeTimers() queryClient = new QueryClient() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + setupTanStackQueryTestBed(queryClient) }) afterEach(() => { @@ -61,7 +55,7 @@ describe('mutationOptions', () => { mutation.mutate() expect(isMutating()).toBe(0) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(isMutating()).toBe(1) await vi.advanceTimersByTimeAsync(51) expect(isMutating()).toBe(0) @@ -81,7 +75,7 @@ describe('mutationOptions', () => { mutation.mutate() expect(isMutating()).toBe(0) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(isMutating()).toBe(1) await vi.advanceTimersByTimeAsync(51) expect(isMutating()).toBe(0) @@ -109,7 +103,7 @@ describe('mutationOptions', () => { mutation1.mutate() mutation2.mutate() expect(isMutating()).toBe(0) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(isMutating()).toBe(2) await vi.advanceTimersByTimeAsync(51) expect(isMutating()).toBe(0) @@ -137,7 +131,7 @@ describe('mutationOptions', () => { mutation1.mutate() mutation2.mutate() expect(isMutating()).toBe(0) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(isMutating()).toBe(1) await vi.advanceTimersByTimeAsync(51) expect(isMutating()).toBe(0) diff --git a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts index 99f1cb8eb8..7e2c737474 100644 --- a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts +++ b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts @@ -2,7 +2,6 @@ import { ApplicationRef, ChangeDetectionStrategy, Component, - provideZonelessChangeDetection, } from '@angular/core' import { TestBed } from '@angular/core/testing' import { HttpClient, provideHttpClient } from '@angular/common/http' @@ -13,13 +12,8 @@ import { import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { sleep } from '@tanstack/query-test-utils' import { lastValueFrom } from 'rxjs' -import { - QueryClient, - injectMutation, - injectQuery, - onlineManager, - provideTanStackQuery, -} from '..' +import { QueryClient, injectMutation, injectQuery, onlineManager } from '..' +import { flushQueryUpdates, setupTanStackQueryTestBed } from './test-utils' describe('PendingTasks Integration', () => { let queryClient: QueryClient @@ -38,12 +32,7 @@ describe('PendingTasks Integration', () => { }, }) - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - ], - }) + setupTanStackQueryTestBed(queryClient) }) afterEach(() => { @@ -59,7 +48,6 @@ describe('PendingTasks Integration', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -198,7 +186,6 @@ describe('PendingTasks Integration', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -248,7 +235,7 @@ describe('PendingTasks Integration', () => { // Allow query to initialize await Promise.resolve() - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() // Query should initialize directly to 'paused' (never goes through 'fetching') expect(query.status()).toBe('pending') @@ -299,7 +286,7 @@ describe('PendingTasks Integration', () => { ) // Allow the initial attempt to start and fail - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() await Promise.resolve() // Wait for the first attempt to complete and start retry delay @@ -410,7 +397,6 @@ describe('PendingTasks Integration', () => { @Component({ selector: 'app-test', template: '', - standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) class TestComponent { @@ -547,14 +533,8 @@ describe('PendingTasks Integration', () => { describe('HttpClient Integration', () => { beforeEach(() => { - TestBed.resetTestingModule() - TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - provideTanStackQuery(queryClient), - provideHttpClient(), - provideHttpClientTesting(), - ], + setupTanStackQueryTestBed(queryClient, { + providers: [provideHttpClient(), provideHttpClientTesting()], }) }) diff --git a/packages/angular-query-experimental/src/__tests__/test-utils.ts b/packages/angular-query-experimental/src/__tests__/test-utils.ts index 218cdea5f6..39884df261 100644 --- a/packages/angular-query-experimental/src/__tests__/test-utils.ts +++ b/packages/angular-query-experimental/src/__tests__/test-utils.ts @@ -1,8 +1,18 @@ -import { isSignal, untracked } from '@angular/core' -import { SIGNAL, signalSetFn } from '@angular/core/primitives/signals' -import { expect } from 'vitest' -import type { InputSignal, Signal } from '@angular/core' -import type { ComponentFixture } from '@angular/core/testing' +import { + isSignal, + provideZonelessChangeDetection, + untracked, +} from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { expect, vi } from 'vitest' +import { provideTanStackQuery } from '..' +import type { QueryClient } from '@tanstack/query-core' +import type { + EnvironmentProviders, + Provider, + Signal, + Type, +} from '@angular/core' // Evaluate all signals on an object and return the result function evaluateSignals>( @@ -35,43 +45,56 @@ export const expectSignals = >( expect(evaluateSignals(obj)).toMatchObject(expected) } -type ToSignalInputUpdatableMap = { - [K in keyof T as T[K] extends InputSignal - ? K - : never]: T[K] extends InputSignal ? Value : never +/** + * Reset Angular's TestBed and configure the standard TanStack Query providers for tests. + * Pass additional providers (including EnvironmentProviders) via the options argument. + */ +export function setupTanStackQueryTestBed( + queryClient: QueryClient, + options: { providers?: Array } = {}, +) { + TestBed.resetTestingModule() + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideTanStackQuery(queryClient), + ...(options.providers ?? []), + ], + }) } -function componentHasSignalInputProperty( - component: object, - property: TProperty, -): component is { [key in TProperty]: InputSignal } { - return ( - component.hasOwnProperty(property) && (component as any)[property][SIGNAL] - ) +/** + * TanStack Query schedules notifyManager updates with setTimeout(0); when fake timers + * are enabled, advance them so PendingTasks sees the queued work. + */ +export async function flushQueryUpdates() { + await vi.advanceTimersByTimeAsync(0) } +const SIGNAL_BASED_INPUT_FLAG = 1 + /** - * Set required signal input value to component fixture - * @see https://github.com/angular/angular/issues/54013 + * Register a signal-based input on a test-only component/dir so Angular marks the + * `input.required()` member as bound before the initial change detection run. + * + * After migrating to Angular 21 we can use the CLI to compile and run Vitest tests + * and this helper should be obsolete. */ -export function setSignalInputs>( - component: T, - inputs: ToSignalInputUpdatableMap, +export function registerSignalInput( + type: Type, + inputName: keyof T & string, ) { - for (const inputKey in inputs) { - if (componentHasSignalInputProperty(component, inputKey)) { - signalSetFn(component[inputKey][SIGNAL], inputs[inputKey]) - } + const definition = (type as any).ɵcmp ?? (type as any).ɵdir + if (!definition) { + throw new Error(`Component ${type.name} is missing its definition`) } -} -export function setFixtureSignalInputs>( - componentFixture: ComponentFixture, - inputs: ToSignalInputUpdatableMap, - options: { detectChanges: boolean } = { detectChanges: true }, -) { - setSignalInputs(componentFixture.componentInstance, inputs) - if (options.detectChanges) { - componentFixture.detectChanges() + definition.inputs = { + ...(definition.inputs ?? {}), + [inputName]: [inputName, SIGNAL_BASED_INPUT_FLAG, null], + } + definition.declaredInputs = { + ...(definition.declaredInputs ?? {}), + [inputName]: inputName, } } diff --git a/packages/angular-query-experimental/src/__tests__/with-devtools.test.ts b/packages/angular-query-experimental/src/__tests__/with-devtools.test.ts index 7091f5a0c2..8b1eb454ce 100644 --- a/packages/angular-query-experimental/src/__tests__/with-devtools.test.ts +++ b/packages/angular-query-experimental/src/__tests__/with-devtools.test.ts @@ -13,6 +13,7 @@ import { } from '@angular/core' import { provideTanStackQuery } from '../providers' import { withDevtools } from '../devtools' +import { flushQueryUpdates } from './test-utils' import type { DevtoolsButtonPosition, DevtoolsErrorType, @@ -134,7 +135,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() await vi.dynamicImportSettled() TestBed.tick() @@ -166,7 +167,7 @@ describe('withDevtools feature', () => { TestBed.inject(ENVIRONMENT_INITIALIZER) // Destroys injector TestBed.resetTestingModule() - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() await vi.dynamicImportSettled() expect(mockTanstackQueryDevtools).not.toHaveBeenCalled() @@ -186,7 +187,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(mockTanstackQueryDevtools).toHaveBeenCalledTimes(1) @@ -202,7 +203,7 @@ describe('withDevtools feature', () => { ) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(mockTanstackQueryDevtools).toHaveBeenCalledTimes(1) }) @@ -247,7 +248,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() @@ -287,7 +288,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() @@ -319,7 +320,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() @@ -350,7 +351,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() @@ -383,7 +384,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() @@ -413,7 +414,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(mockDevtoolsInstance.mount).toHaveBeenCalledTimes(1) expect(mockDevtoolsInstance.unmount).toHaveBeenCalledTimes(0) @@ -439,7 +440,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() TestBed.tick() await vi.dynamicImportSettled() @@ -469,7 +470,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(mockTanstackQueryDevtools).not.toHaveBeenCalled() expect(mockDevtoolsInstance.mount).not.toHaveBeenCalled() @@ -526,7 +527,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(withDevtoolsFn).toHaveBeenCalledWith(mockService1, mockService2) }) @@ -547,7 +548,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() expect(withDevtoolsFn).toHaveBeenCalledWith() }) @@ -577,7 +578,7 @@ describe('withDevtools feature', () => { }) TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.advanceTimersByTimeAsync(0) + await flushQueryUpdates() const service = TestBed.inject(ReactiveService)