Skip to content

Commit 3bf9268

Browse files
authored
feat(angular-query): integrate with Angular v19+ PendingTasks for whenStable() support (#9666)
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 v16+ 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 3bf9268

File tree

13 files changed

+1198
-23
lines changed

13 files changed

+1198
-23
lines changed

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 v19 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
]

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)