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
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 71c141e3e4..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
@@ -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.isFetchingPreviousPage(),
)
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'
Create
- @for (task of tasks.data(); track task) {
+ @for (task of tasks.data(); track $index) {
{{ task }}
}
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/__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 7873d5261c..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,9 +1,9 @@
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 } 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
@@ -11,12 +11,7 @@ describe('injectInfiniteQuery', () => {
beforeEach(() => {
queryClient = new QueryClient()
vi.useFakeTimers()
- TestBed.configureTestingModule({
- providers: [
- provideZonelessChangeDetection(),
- provideTanStackQuery(queryClient),
- ],
- })
+ setupTanStackQueryTestBed(queryClient)
})
afterEach(() => {
@@ -24,15 +19,24 @@ describe('injectInfiniteQuery', () => {
})
test('should properly execute infinite query', async () => {
- const query = TestBed.runInInjectionContext(() => {
- return injectInfiniteQuery(() => ({
+ @Component({
+ selector: 'app-test',
+ template: '',
+ 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,6 +80,9 @@ describe('injectInfiniteQuery', () => {
})
test('can be used outside injection context when passing an injector', () => {
+ const injector = TestBed.inject(Injector)
+
+ // Call injectInfiniteQuery directly outside any component
const query = injectInfiniteQuery(
() => ({
queryKey: ['manualInjector'],
@@ -85,10 +92,12 @@ describe('injectInfiniteQuery', () => {
getNextPageParam: () => 12,
}),
{
- injector: TestBed.inject(Injector),
+ 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 8b747f66f6..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
@@ -1,4 +1,5 @@
import {
+ ChangeDetectionStrategy,
Component,
Injector,
input,
@@ -7,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,
@@ -15,7 +15,7 @@ import {
injectMutationState,
provideTanStackQuery,
} from '..'
-import { setFixtureSignalInputs } from './test-utils'
+import { registerSignalInput } from './test-utils'
describe('injectMutationState', () => {
let queryClient: QueryClient
@@ -145,6 +145,7 @@ describe('injectMutationState', () => {
{{ mutation.status }}
}
`,
+ changeDetection: ChangeDetectionStrategy.OnPush,
})
class FakeComponent {
name = input.required()
@@ -157,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 2adf0ee808..c1548bc634 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,
@@ -8,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
@@ -307,6 +307,7 @@ describe('injectMutation', () => {
{{ mutation.data() }}
`,
+ changeDetection: ChangeDetectionStrategy.OnPush,
})
class FakeComponent {
name = input.required()
@@ -321,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'])
})
@@ -347,6 +361,7 @@ describe('injectMutation', () => {
{{ mutation.data() }}
`,
+ changeDetection: ChangeDetectionStrategy.OnPush,
})
class FakeComponent {
name = input.required()
@@ -361,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 3fb3d5a626..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,25 +1,16 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { render } from '@testing-library/angular'
-import {
- 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', () => {
@@ -37,6 +28,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..d1befc202b 100644
--- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts
+++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts
@@ -1,9 +1,10 @@
import {
ApplicationRef,
+ ChangeDetectionStrategy,
Component,
Injector,
+ NgZone,
computed,
- effect,
input,
provideZonelessChangeDetection,
signal,
@@ -26,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', () => {
@@ -50,102 +51,190 @@ 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: '',
+ 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
>()
@@ -153,124 +242,38 @@ describe('injectQuery', () => {
fromGenericOptionsQueryFn.error(),
).toEqualTypeOf()
- type MyData = number
- type MyQueryKey = readonly ['my-data', number]
-
- const getMyDataArrayKey: QueryFunction = ({
- queryKey: [, n],
- }) => {
- return n + 42
- }
-
- const fromMyDataArrayKeyQueryFn = TestBed.runInInjectionContext(() =>
- injectQuery(() => ({
- queryKey: ['my-data', 100] as const,
- queryFn: getMyDataArrayKey,
- })),
- )
expectTypeOf(fromMyDataArrayKeyQueryFn.data()).toEqualTypeOf<
number | undefined
>()
- // it should handle query-functions that return Promise
- const fromPromiseAnyQueryFn = TestBed.runInInjectionContext(() =>
- injectQuery(() => ({
- queryKey: key,
- queryFn: () => fetch('return Promise').then((resp) => resp.json()),
- })),
- )
expectTypeOf(fromPromiseAnyQueryFn.data()).toEqualTypeOf()
- TestBed.runInInjectionContext(() =>
- effect(() => {
- 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: '',
+ 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 +283,21 @@ describe('injectQuery', () => {
})
test('should resolve to success and update signal: injectQuery()', async () => {
- const query = TestBed.runInInjectionContext(() => {
- return injectQuery(() => ({
+ @Component({
+ selector: 'app-test',
+ template: '',
+ 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 +309,23 @@ describe('injectQuery', () => {
})
test('should reject and update signal', async () => {
- const query = TestBed.runInInjectionContext(() => {
- return injectQuery(() => ({
+ @Component({
+ selector: 'app-test',
+ template: '',
+ 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 +342,23 @@ 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: '',
+ 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 +367,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 +383,24 @@ 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: '',
+ 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 +413,33 @@ 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: '',
+ 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 +464,24 @@ 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: '',
+ 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 +497,7 @@ describe('injectQuery', () => {
await vi.advanceTimersByTimeAsync(11)
keySignal.set('key12')
+ fixture.detectChanges()
query.refetch().then(() => {
expect(fetchFn).toHaveBeenCalledTimes(2)
@@ -452,15 +514,25 @@ 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: '',
+ 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 +545,112 @@ describe('injectQuery', () => {
})
test('should throw when throwOnError is true', async () => {
- TestBed.runInInjectionContext(() => {
- return injectQuery(() => ({
+ const zone = TestBed.inject(NgZone)
+ 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',
+ template: '',
+ 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')
+ 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 () => {
- TestBed.runInInjectionContext(() => {
- return injectQuery(() => ({
+ const zone = TestBed.inject(NgZone)
+ 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',
+ template: '',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ })
+ class TestComponent {
+ query = injectQuery(() => ({
queryKey: ['key14'],
queryFn: () =>
sleep(0).then(() => Promise.reject(new Error('Some error'))),
throwOnError: () => true,
}))
- })
+ }
- await expect(vi.runAllTimersAsync()).rejects.toThrow('Some error')
+ TestBed.createComponent(TestComponent).detectChanges()
+
+ 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 set state to error when queryFn returns reject promise', async () => {
- const query = TestBed.runInInjectionContext(() => {
- return injectQuery(() => ({
+ @Component({
+ selector: 'app-test',
+ template: '',
+ 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 +663,7 @@ describe('injectQuery', () => {
@Component({
selector: 'app-fake',
template: `{{ query.data() }}`,
+ changeDetection: ChangeDetectionStrategy.OnPush,
})
class FakeComponent {
name = input.required()
@@ -530,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', () => {
@@ -554,15 +703,28 @@ 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: '',
+ 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,22 +732,30 @@ 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: '',
+ 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()
- const stablePromise = app.whenStable()
await vi.advanceTimersByTimeAsync(60)
- await stablePromise
+ await app.whenStable()
expect(query.status()).toBe('success')
expect(query.data()).toBe('test data')
@@ -606,14 +776,25 @@ 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: '',
+ 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(() => {
@@ -625,9 +806,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')
@@ -646,28 +826,34 @@ describe('injectQuery', () => {
})
const app = TestBed.inject(ApplicationRef)
- let callCount = 0
- const query = TestBed.runInInjectionContext(() =>
- injectQuery(() => ({
+ @Component({
+ selector: 'app-test',
+ template: '',
+ 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 +862,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 +876,46 @@ describe('injectQuery', () => {
const app = TestBed.inject(ApplicationRef)
const enabledSignal = signal(false)
- let callCount = 0
- const query = TestBed.runInInjectionContext(() =>
- injectQuery(() => ({
+ @Component({
+ selector: 'app-test',
+ template: '',
+ 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 vi.advanceTimersByTimeAsync(0)
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 vi.advanceTimersByTimeAsync(0)
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 +929,44 @@ describe('injectQuery', () => {
const app = TestBed.inject(ApplicationRef)
const testKey = ['sync-invalidate']
- let callCount = 0
- const query = TestBed.runInInjectionContext(() =>
- injectQuery(() => ({
+ @Component({
+ selector: 'app-test',
+ template: '',
+ 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__/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 92f70aed9f..7e2c737474 100644
--- a/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts
+++ b/packages/angular-query-experimental/src/__tests__/pending-tasks.test.ts
@@ -1,7 +1,7 @@
import {
ApplicationRef,
+ ChangeDetectionStrategy,
Component,
- provideZonelessChangeDetection,
} from '@angular/core'
import { TestBed } from '@angular/core/testing'
import { HttpClient, provideHttpClient } from '@angular/common/http'
@@ -12,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
@@ -37,12 +32,7 @@ describe('PendingTasks Integration', () => {
},
})
- TestBed.configureTestingModule({
- providers: [
- provideZonelessChangeDetection(),
- provideTanStackQuery(queryClient),
- ],
- })
+ setupTanStackQueryTestBed(queryClient)
})
afterEach(() => {
@@ -55,12 +45,21 @@ 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: '',
+ 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 +182,27 @@ 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: '',
+ 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()
@@ -209,6 +217,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 flushQueryUpdates()
+
+ // 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
@@ -230,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
@@ -279,6 +335,7 @@ describe('PendingTasks Integration', () => {
describe('Component Destruction', () => {
@Component({
template: '',
+ changeDetection: ChangeDetectionStrategy.OnPush,
})
class TestComponent {
query = injectQuery(() => ({
@@ -300,6 +357,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 +375,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 +394,37 @@ 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: '',
+ 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')
@@ -469,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)
diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts
index 4daede7684..4b1ce4ca5c 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,
- VERSION,
+ PendingTasks,
computed,
effect,
inject,
- signal,
+ linkedSignal,
untracked,
} from '@angular/core'
import {
@@ -14,9 +15,8 @@ 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 {
+ DefaultedQueryObserverOptions,
QueryKey,
QueryObserver,
QueryObserverResult,
@@ -45,9 +45,34 @@ export function createBaseQuery<
Observer: typeof QueryObserver,
) {
const ngZone = inject(NgZone)
- const pendingTasks = inject(PENDING_TASKS)
+ const pendingTasks = inject(PendingTasks)
const queryClient = inject(QueryClient)
const isRestoring = injectIsRestoring()
+ const destroyRef = inject(DestroyRef)
+
+ let observer: QueryObserver<
+ TQueryFnData,
+ TError,
+ TData,
+ TQueryData,
+ TQueryKey
+ > | null = null
+
+ let destroyed = false
+ 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
@@ -63,113 +88,130 @@ export function createBaseQuery<
return defaultedOptions
})
- const observerSignal = (() => {
- let instance: QueryObserver<
+ const trackObserverResult = (
+ result: QueryObserverResult,
+ notifyOnChangeProps?: DefaultedQueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
- > | null = null
+ >['notifyOnChangeProps'],
+ ) => {
+ if (!observer) {
+ throw new Error(OBSERVER_NOT_READY_ERROR)
+ }
+
+ 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 setObserverOptions = (
+ options: DefaultedQueryObserverOptions<
+ TQueryFnData,
+ TError,
+ TData,
+ TQueryData,
+ TQueryKey
+ >,
+ ) => {
+ if (!observer) {
+ observer = new Observer(queryClient, options)
+ destroyRef.onDestroy(() => {
+ destroyed = true
+ stopPendingTask()
+ })
+ } else {
+ observer.setOptions(options)
+ }
+ }
+
+ const subscribeToObserver = () => {
+ if (!observer) {
+ throw new Error(OBSERVER_NOT_READY_ERROR)
+ }
+
+ return observer.subscribe((state) => {
+ if (state.fetchStatus !== 'idle') {
+ startPendingTask()
+ } else {
+ stopPendingTask()
+ }
- return computed(() => {
- return (instance ||= new Observer(queryClient, defaultedOptionsSignal()))
+ 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)
+ })
+ })
+ })
})
- })()
-
- const optimisticResultSignal = computed(() =>
- observerSignal().getOptimisticResult(defaultedOptionsSignal()),
- )
-
- const resultFromSubscriberSignal = signal | null>(null)
+ }
- effect(
- (onCleanup) => {
- const observer = observerSignal()
+ const resultSignal = linkedSignal({
+ source: defaultedOptionsSignal,
+ computation: () => {
+ if (!observer) throw new Error(OBSERVER_NOT_READY_ERROR)
const defaultedOptions = defaultedOptionsSignal()
-
- untracked(() => {
- observer.setOptions(defaultedOptions)
- })
- onCleanup(() => {
- ngZone.run(() => resultFromSubscriberSignal.set(null))
- })
- },
- {
- // Set allowSignalWrites to support Angular < v19
- // Set to undefined to avoid warning on newer versions
- allowSignalWrites: VERSION.major < '19' || undefined,
+ const result = observer.getOptimisticResult(defaultedOptions)
+ return trackObserverResult(result, defaultedOptions.notifyOnChangeProps)
},
- )
+ })
- effect((onCleanup) => {
- // observer.trackResult is not used as this optimization is not needed for Angular
- const observer = observerSignal()
- let pendingTaskRef: PendingTaskRef | null = null
-
- const unsubscribe = isRestoring()
- ? () => undefined
- : untracked(() =>
- ngZone.runOutsideAngular(() => {
- return observer.subscribe(
- notifyManager.batchCalls((state) => {
- ngZone.run(() => {
- if (state.fetchStatus === 'fetching' && !pendingTaskRef) {
- pendingTaskRef = pendingTasks.add()
- }
-
- if (state.fetchStatus === 'idle' && pendingTaskRef) {
- pendingTaskRef()
- pendingTaskRef = null
- }
-
- if (
- state.isError &&
- !state.isFetching &&
- shouldThrowError(observer.options.throwOnError, [
- state.error,
- observer.getCurrentQuery(),
- ])
- ) {
- ngZone.onError.emit(state.error)
- throw state.error
- }
- resultFromSubscriberSignal.set(state)
- })
- }),
- )
- }),
- )
+ effect(() => {
+ const defaultedOptions = defaultedOptionsSignal()
+ untracked(() => {
+ setObserverOptions(defaultedOptions)
+ })
+ })
+ effect((onCleanup) => {
+ if (isRestoring()) {
+ return
+ }
+ const unsubscribe = untracked(() => subscribeToObserver())
onCleanup(() => {
- if (pendingTaskRef) {
- pendingTaskRef()
- pendingTaskRef = null
- }
unsubscribe()
+ stopPendingTask()
})
})
- return signalProxy(
- computed(() => {
- const subscriberResult = resultFromSubscriberSignal()
- const optimisticResult = optimisticResultSignal()
- const result = subscriberResult ?? optimisticResult
-
- // Wrap methods to ensure observer has latest options before execution
- const observer = observerSignal()
-
- const originalRefetch = result.refetch
- return {
- ...result,
- refetch: ((...args: Parameters) => {
- observer.setOptions(defaultedOptionsSignal())
- return originalRefetch(...args)
- }) as typeof originalRefetch,
- }
- }),
- )
+ return signalProxy(resultSignal.asReadonly())
}
+const OBSERVER_NOT_READY_ERROR =
+ 'injectQuery: QueryObserver not initialized yet. Avoid reading the query result during construction'
diff --git a/packages/angular-query-experimental/src/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..991c2ea0a1 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)
/**
@@ -125,9 +124,8 @@ export function injectMutation<
effect(
(onCleanup) => {
- // observer.trackResult is not used as this optimization is not needed for Angular
const observer = observerSignal()
- let pendingTaskRef: PendingTaskRef | null = null
+ let taskCleanupRef: (() => void) | null = null
untracked(() => {
const unsubscribe = ngZone.runOutsideAngular(() =>
@@ -135,14 +133,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 +158,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 }
}