Skip to content

Commit 2ebb618

Browse files
committed
fix(angular-query): integrate with Angular PendingTasks for whenStable() support
Integrates TanStack Query for Angular with Angular's PendingTasks API to properly track asynchronous operations, ensuring ApplicationRef.whenStable() waits for queries and mutations to complete before resolving. Features: - Cross-version compatibility (Angular v17+ with graceful degradation) - Tracks query fetchStatus and mutation isPending state - Automatic cleanup on component destruction - Comprehensive test coverage including edge cases Benefits: - Improved SSR: Server waits for data fetching before rendering - Better testing: fixture.whenStable() properly waits for async operations - Zoneless support: Correct change detection timing
1 parent 7d370b9 commit 2ebb618

14 files changed

Lines changed: 1249 additions & 20 deletions

docs/framework/angular/zoneless.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ Because the Angular adapter for TanStack Query is built on signals, it fully sup
88
Among Zoneless benefits are improved performance and debugging experience. For details see the [Angular documentation](https://angular.dev/guide/zoneless).
99

1010
> Besides Zoneless, ZoneJS change detection is also fully supported.
11+
12+
> When using Zoneless, ensure you are on Angular v18 or later to take advantage of the `PendingTasks` integration that keeps `ApplicationRef.whenStable()` in sync with ongoing queries and mutations.

eslint.config.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,25 @@ export default [
1818
{
1919
cspell: {
2020
words: [
21+
'Promisable', // Our public interface
22+
'TSES', // @typescript-eslint package's interface
2123
'codemod', // We support our codemod
2224
'combinate', // Library name
25+
'datatag', // Query options tagging
2326
'extralight', // Our public interface
2427
'jscodeshift',
25-
'Promisable', // Our public interface
28+
'refetches', // Query refetch operations
2629
'retryer', // Our public interface
2730
'solidjs', // Our target framework
2831
'tabular-nums', // https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-numeric
2932
'tanstack', // Our package scope
3033
'todos', // Too general word to be caught as error
31-
'TSES', // @typescript-eslint package's interface
3234
'tsqd', // Our public interface (TanStack Query Devtools shorthand)
3335
'tsup', // We use tsup as builder
3436
'typecheck', // Field of vite.config.ts
3537
'vue-demi', // dependency of @tanstack/vue-query
38+
'ɵkind', // Angular specific
39+
'ɵproviders', // Angular specific
3640
],
3741
},
3842
},

packages/angular-query-experimental/eslint.config.js

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,6 @@ export default [
99
pluginJsdoc.configs['flat/recommended-typescript'],
1010
{
1111
rules: {
12-
'cspell/spellchecker': [
13-
'warn',
14-
{
15-
cspell: {
16-
ignoreRegExpList: ['\\ɵ.+'],
17-
},
18-
},
19-
],
2012
'jsdoc/require-hyphen-before-param-description': 1,
2113
'jsdoc/sort-tags': 1,
2214
'jsdoc/require-throws': 1,
@@ -36,6 +28,8 @@ export default [
3628
files: ['**/__tests__/**'],
3729
rules: {
3830
'@typescript-eslint/no-unnecessary-condition': 'off',
31+
'@typescript-eslint/require-await': 'off',
32+
'jsdoc/require-returns': 'off',
3933
},
4034
},
4135
]
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# PendingTasks Integration in TanStack Query for Angular
2+
3+
## Overview
4+
5+
TanStack Query cooperates with Angular's PendingTasks API so `ApplicationRef.whenStable()` waits for ongoing queries and mutations. Full behavior requires Angular v18+; versions without PendingTasks fall back to no-op integration.
6+
7+
## Compatibility
8+
9+
- **Angular v20+**: uses `PendingTasks`
10+
- **Angular v18–19**: uses `ExperimentalPendingTasks`
11+
- **Angular v17-**: shim returns no-op cleanup
12+
13+
The token factory in `pending-tasks-compat.ts` auto-detects the available service and returns `{ add: () => cleanup }`, ensuring a stable API either way.
14+
15+
## Implementation Notes
16+
17+
- Queries register a task when `fetchStatus === 'fetching'` and release it once the observer reports `fetchStatus === 'idle'`. Paused retries (offline/focus) therefore keep the task active until they truly settle.
18+
- Mutations do the same based on `state.isPending`.
19+
- Cleanup is mirrored in Angular's `DestroyRef` callbacks so destroyed components cannot leak tasks.
20+
21+
```ts
22+
if (state.fetchStatus === 'fetching' && !task) task = pendingTasks.add()
23+
if (state.fetchStatus === 'idle' && task) task()
24+
```
25+
26+
## Testing & Usage
27+
28+
- No additional setup is required; simply using TanStack Query registers PendingTasks when available.
29+
- With real timers, `await fixture.whenStable()` or `app.whenStable()` is enough to observe final state.
30+
- Under fake timers (Vitest), advance timers and microtasks before awaiting stability, e.g.
31+
```ts
32+
await vi.advanceTimersByTimeAsync(10)
33+
await Promise.resolve()
34+
await fixture.whenStable()
35+
```
36+
- Offline retries stay pending until `onlineManager.setOnline(true)` resumes the retry cycle.
37+
38+
## Edge Cases Covered
39+
40+
- Rapid refetches and concurrent queries each obtain their own PendingTask reference.
41+
- Cancellation restores the query to `pending/idle` without leaking tasks.
42+
- Component destruction clears outstanding tasks for both queries and mutations.
43+
- HttpClient scenarios work because PendingTasks is tracked at the query layer, even when `lastValueFrom` detaches Angular’s own PendingTasks hooks.
44+
45+
## Contributing Checklist
46+
47+
When adding new async behavior inside the Angular adapter:
48+
49+
1. Inject `PENDING_TASKS` where the async unit lives.
50+
2. Call `pendingTasks.add()` when work starts and retain the cleanup ref.
51+
3. Dispose the ref on completion, cancellation, or destruction.
52+
4. Test with `whenStable()` plus fake-timer flushing to keep observable behavior consistent.

packages/angular-query-experimental/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
"@testing-library/angular": "^18.0.0",
9898
"eslint-plugin-jsdoc": "^50.5.0",
9999
"npm-run-all2": "^5.0.0",
100+
"rxjs": "^7.8.2",
100101
"vite-plugin-dts": "4.2.3",
101102
"vite-plugin-externalize-deps": "^0.9.0",
102103
"vite-tsconfig-paths": "^5.1.4"

packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
ApplicationRef,
23
Component,
34
Injector,
45
input,
@@ -466,5 +467,230 @@ describe('injectMutation', () => {
466467
)
467468
}).not.toThrow()
468469
})
470+
471+
test('should complete mutation before whenStable() resolves', async () => {
472+
const app = TestBed.inject(ApplicationRef)
473+
let mutationStarted = false
474+
let mutationCompleted = false
475+
476+
const mutation = TestBed.runInInjectionContext(() =>
477+
injectMutation(() => ({
478+
mutationKey: ['pendingTasksTest'],
479+
mutationFn: async (data: string) => {
480+
mutationStarted = true
481+
await sleep(50)
482+
mutationCompleted = true
483+
return `processed: ${data}`
484+
},
485+
})),
486+
)
487+
488+
// Initial state
489+
expect(mutation.data()).toBeUndefined()
490+
expect(mutationStarted).toBe(false)
491+
492+
// Start mutation
493+
mutation.mutate('test')
494+
495+
// Wait for mutation to start and Angular to be "stable"
496+
const stablePromise = app.whenStable()
497+
await vi.advanceTimersByTimeAsync(60)
498+
await stablePromise
499+
500+
// After whenStable(), mutation should be complete
501+
expect(mutationStarted).toBe(true)
502+
expect(mutationCompleted).toBe(true)
503+
expect(mutation.isSuccess()).toBe(true)
504+
expect(mutation.data()).toBe('processed: test')
505+
})
506+
507+
test('should handle synchronous mutation with retry', async () => {
508+
TestBed.resetTestingModule()
509+
TestBed.configureTestingModule({
510+
providers: [
511+
provideZonelessChangeDetection(),
512+
provideTanStackQuery(queryClient),
513+
],
514+
})
515+
516+
const app = TestBed.inject(ApplicationRef)
517+
let attemptCount = 0
518+
519+
const mutation = TestBed.runInInjectionContext(() =>
520+
injectMutation(() => ({
521+
retry: 2,
522+
retryDelay: 0, // No delay for synchronous retry
523+
mutationFn: async (data: string) => {
524+
attemptCount++
525+
if (attemptCount <= 2) {
526+
throw new Error(`Sync attempt ${attemptCount} failed`)
527+
}
528+
return `processed: ${data}`
529+
},
530+
})),
531+
)
532+
533+
// Start mutation
534+
mutation.mutate('retry-test')
535+
536+
// Synchronize pending effects for each retry attempt
537+
TestBed.tick()
538+
await Promise.resolve()
539+
await vi.advanceTimersByTimeAsync(10)
540+
541+
TestBed.tick()
542+
await Promise.resolve()
543+
await vi.advanceTimersByTimeAsync(10)
544+
545+
TestBed.tick()
546+
547+
const stablePromise = app.whenStable()
548+
await Promise.resolve()
549+
await vi.advanceTimersByTimeAsync(10)
550+
await stablePromise
551+
552+
expect(mutation.isSuccess()).toBe(true)
553+
expect(mutation.data()).toBe('processed: retry-test')
554+
expect(attemptCount).toBe(3) // Initial + 2 retries
555+
})
556+
557+
test('should handle multiple synchronous mutations on same key', async () => {
558+
TestBed.resetTestingModule()
559+
TestBed.configureTestingModule({
560+
providers: [
561+
provideZonelessChangeDetection(),
562+
provideTanStackQuery(queryClient),
563+
],
564+
})
565+
566+
const app = TestBed.inject(ApplicationRef)
567+
let callCount = 0
568+
569+
const mutation1 = TestBed.runInInjectionContext(() =>
570+
injectMutation(() => ({
571+
mutationKey: ['sync-mutation-key'],
572+
mutationFn: async (data: string) => {
573+
callCount++
574+
return `mutation1: ${data}`
575+
},
576+
})),
577+
)
578+
579+
const mutation2 = TestBed.runInInjectionContext(() =>
580+
injectMutation(() => ({
581+
mutationKey: ['sync-mutation-key'],
582+
mutationFn: async (data: string) => {
583+
callCount++
584+
return `mutation2: ${data}`
585+
},
586+
})),
587+
)
588+
589+
// Start both mutations
590+
mutation1.mutate('test1')
591+
mutation2.mutate('test2')
592+
593+
// Synchronize pending effects
594+
TestBed.tick()
595+
596+
const stablePromise = app.whenStable()
597+
// Flush microtasks to allow TanStack Query's scheduled notifications to process
598+
await Promise.resolve()
599+
await vi.advanceTimersByTimeAsync(1)
600+
await stablePromise
601+
602+
expect(mutation1.isSuccess()).toBe(true)
603+
expect(mutation1.data()).toBe('mutation1: test1')
604+
expect(mutation2.isSuccess()).toBe(true)
605+
expect(mutation2.data()).toBe('mutation2: test2')
606+
expect(callCount).toBe(2)
607+
})
608+
609+
test('should handle synchronous mutation with optimistic updates', async () => {
610+
TestBed.resetTestingModule()
611+
TestBed.configureTestingModule({
612+
providers: [
613+
provideZonelessChangeDetection(),
614+
provideTanStackQuery(queryClient),
615+
],
616+
})
617+
618+
const app = TestBed.inject(ApplicationRef)
619+
const testQueryKey = ['sync-optimistic']
620+
let onMutateCalled = false
621+
let onSuccessCalled = false
622+
623+
// Set initial data
624+
queryClient.setQueryData(testQueryKey, 'initial')
625+
626+
const mutation = TestBed.runInInjectionContext(() =>
627+
injectMutation(() => ({
628+
mutationFn: async (data: string) => `final: ${data}`, // Synchronous resolution
629+
onMutate: async (variables) => {
630+
onMutateCalled = true
631+
const previousData = queryClient.getQueryData(testQueryKey)
632+
queryClient.setQueryData(testQueryKey, `optimistic: ${variables}`)
633+
return { previousData }
634+
},
635+
onSuccess: (data) => {
636+
onSuccessCalled = true
637+
queryClient.setQueryData(testQueryKey, data)
638+
},
639+
})),
640+
)
641+
642+
// Start mutation
643+
mutation.mutate('test')
644+
645+
// Synchronize pending effects
646+
TestBed.tick()
647+
648+
const stablePromise = app.whenStable()
649+
// Flush microtasks to allow TanStack Query's scheduled notifications to process
650+
await Promise.resolve()
651+
await vi.advanceTimersByTimeAsync(1)
652+
await stablePromise
653+
654+
expect(onMutateCalled).toBe(true)
655+
expect(onSuccessCalled).toBe(true)
656+
expect(mutation.isSuccess()).toBe(true)
657+
expect(mutation.data()).toBe('final: test')
658+
expect(queryClient.getQueryData(testQueryKey)).toBe('final: test')
659+
})
660+
661+
test('should handle synchronous mutation cancellation', async () => {
662+
TestBed.resetTestingModule()
663+
TestBed.configureTestingModule({
664+
providers: [
665+
provideZonelessChangeDetection(),
666+
provideTanStackQuery(queryClient),
667+
],
668+
})
669+
670+
const app = TestBed.inject(ApplicationRef)
671+
672+
const mutation = TestBed.runInInjectionContext(() =>
673+
injectMutation(() => ({
674+
mutationKey: ['cancel-sync'],
675+
mutationFn: async (data: string) => `processed: ${data}`, // Synchronous resolution
676+
})),
677+
)
678+
679+
// Start mutation
680+
mutation.mutate('test')
681+
682+
// Synchronize pending effects
683+
TestBed.tick()
684+
685+
const stablePromise = app.whenStable()
686+
// Flush microtasks to allow TanStack Query's scheduled notifications to process
687+
await Promise.resolve()
688+
await vi.advanceTimersByTimeAsync(1)
689+
await stablePromise
690+
691+
// Synchronous mutations complete immediately
692+
expect(mutation.isSuccess()).toBe(true)
693+
expect(mutation.data()).toBe('processed: test')
694+
})
469695
})
470696
})

0 commit comments

Comments
 (0)