From 5da0ab7a3c6de3e0cd014d2a45c043125e3d09fb Mon Sep 17 00:00:00 2001 From: Thilo Aschebrock Date: Sun, 26 Oct 2025 12:13:06 +1100 Subject: [PATCH] fix(angular-query): ensure initial mutation pending state is emitted --- .changeset/puny-melons-deny.md | 5 + .../src/__tests__/inject-mutation.test.ts | 80 +++++---- .../src/inject-mutation.ts | 158 ++++++++---------- 3 files changed, 117 insertions(+), 126 deletions(-) create mode 100644 .changeset/puny-melons-deny.md diff --git a/.changeset/puny-melons-deny.md b/.changeset/puny-melons-deny.md new file mode 100644 index 0000000000..542fd9cd4f --- /dev/null +++ b/.changeset/puny-melons-deny.md @@ -0,0 +1,5 @@ +--- +'@tanstack/angular-query-experimental': minor +--- + +Ensure initial mutation pending state is emitted 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..37287108c2 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts @@ -55,8 +55,6 @@ describe('injectMutation', () => { })) }) - TestBed.tick() - mutation.mutate(result) await vi.advanceTimersByTimeAsync(0) @@ -389,11 +387,42 @@ describe('injectMutation', () => { expect(mutation2!.options.mutationKey).toEqual(['fake', 'updatedValue']) }) + test('should have pending state when mutating in constructor', async () => { + @Component({ + selector: 'app-fake', + template: ` + {{ mutation.isPending() ? 'pending' : 'not pending' }} + `, + }) + class FakeComponent { + mutation = injectMutation(() => ({ + mutationKey: ['fake'], + mutationFn: () => sleep(10).then(() => 'fake'), + })) + + constructor() { + this.mutation.mutate() + } + } + + const fixture = TestBed.createComponent(FakeComponent) + const { debugElement } = fixture + const span = debugElement.query(By.css('span')) + + await vi.advanceTimersByTimeAsync(0) + expect(span.nativeElement.textContent).toEqual('pending') + + await vi.advanceTimersByTimeAsync(11) + fixture.detectChanges() + + expect(span.nativeElement.textContent).toEqual('not pending') + }) + describe('throwOnError', () => { test('should evaluate throwOnError when mutation is expected to throw', async () => { const err = new Error('Expected mock error. All is well!') const boundaryFn = vi.fn() - const { mutate } = TestBed.runInInjectionContext(() => { + const { mutate, status, error } = TestBed.runInInjectionContext(() => { return injectMutation(() => ({ mutationKey: ['fake'], mutationFn: () => { @@ -403,14 +432,14 @@ describe('injectMutation', () => { })) }) - TestBed.tick() - mutate() await vi.advanceTimersByTimeAsync(0) expect(boundaryFn).toHaveBeenCalledTimes(1) expect(boundaryFn).toHaveBeenCalledWith(err) + expect(status()).toBe('error') + expect(error()).toBe(err) }) }) @@ -533,21 +562,8 @@ describe('injectMutation', () => { // Start mutation mutation.mutate('retry-test') - // Synchronize pending effects for each retry attempt - TestBed.tick() - await Promise.resolve() - await vi.advanceTimersByTimeAsync(10) - - TestBed.tick() - await Promise.resolve() - await vi.advanceTimersByTimeAsync(10) - - TestBed.tick() - - const stablePromise = app.whenStable() - await Promise.resolve() - await vi.advanceTimersByTimeAsync(10) - await stablePromise + await vi.advanceTimersByTimeAsync(30) + await app.whenStable() expect(mutation.isSuccess()).toBe(true) expect(mutation.data()).toBe('processed: retry-test') @@ -590,14 +606,8 @@ describe('injectMutation', () => { mutation1.mutate('test1') mutation2.mutate('test2') - // Synchronize pending effects - TestBed.tick() - - const stablePromise = app.whenStable() - // Flush microtasks to allow TanStack Query's scheduled notifications to process - await Promise.resolve() await vi.advanceTimersByTimeAsync(1) - await stablePromise + await app.whenStable() expect(mutation1.isSuccess()).toBe(true) expect(mutation1.data()).toBe('mutation1: test1') @@ -642,14 +652,8 @@ describe('injectMutation', () => { // Start mutation mutation.mutate('test') - // Synchronize pending effects - TestBed.tick() - - const stablePromise = app.whenStable() - // Flush microtasks to allow TanStack Query's scheduled notifications to process - await Promise.resolve() await vi.advanceTimersByTimeAsync(1) - await stablePromise + await app.whenStable() expect(onMutateCalled).toBe(true) expect(onSuccessCalled).toBe(true) @@ -679,14 +683,8 @@ describe('injectMutation', () => { // Start mutation mutation.mutate('test') - // Synchronize pending effects - TestBed.tick() - - const stablePromise = app.whenStable() - // Flush microtasks to allow TanStack Query's scheduled notifications to process - await Promise.resolve() await vi.advanceTimersByTimeAsync(1) - await stablePromise + await app.whenStable() // Synchronous mutations complete immediately expect(mutation.isSuccess()).toBe(true) diff --git a/packages/angular-query-experimental/src/inject-mutation.ts b/packages/angular-query-experimental/src/inject-mutation.ts index 7eb605047f..fe1e71f083 100644 --- a/packages/angular-query-experimental/src/inject-mutation.ts +++ b/packages/angular-query-experimental/src/inject-mutation.ts @@ -1,9 +1,9 @@ import { + DestroyRef, Injector, NgZone, assertInInjectionContext, computed, - effect, inject, signal, untracked, @@ -17,8 +17,7 @@ import { } from '@tanstack/query-core' import { signalProxy } from './signal-proxy' import { PENDING_TASKS } from './pending-tasks-compat' -import type { PendingTaskRef } from './pending-tasks-compat' -import type { DefaultError, MutationObserverResult } from '@tanstack/query-core' +import type { DefaultError } from '@tanstack/query-core' import type { CreateMutateFunction, CreateMutationOptions, @@ -58,6 +57,7 @@ export function injectMutation< ): CreateMutationResult { !options?.injector && assertInInjectionContext(injectMutation) const injector = options?.injector ?? inject(Injector) + const destroyRef = injector.get(DestroyRef) const ngZone = injector.get(NgZone) const pendingTasks = injector.get(PENDING_TASKS) const queryClient = injector.get(QueryClient) @@ -78,7 +78,15 @@ export function injectMutation< > | null = null return computed(() => { - return (instance ||= new MutationObserver(queryClient, optionsSignal())) + const observerOptions = optionsSignal() + return untracked(() => { + if (instance) { + instance.setOptions(observerOptions) + } else { + instance = new MutationObserver(queryClient, observerOptions) + } + return instance + }) }) })() @@ -87,97 +95,75 @@ export function injectMutation< >(() => { const observer = observerSignal() return (variables, mutateOptions) => { - observer.mutate(variables, mutateOptions).catch(noop) + void observer.mutate(variables, mutateOptions).catch(noop) } }) - /** - * Computed signal that gets result from mutation cache based on passed options - */ - const resultFromInitialOptionsSignal = computed(() => { - const observer = observerSignal() - return observer.getCurrentResult() - }) + let cleanup: () => void = noop /** - * Signal that contains result set by subscriber + * Returning a writable signal from a computed is similar to `linkedSignal`, + * but compatible with Angular < 19 + * + * Compared to `linkedSignal`, this pattern requires extra parentheses: + * - Accessing value: `result()()` + * - Setting value: `result().set(newValue)` */ - const resultFromSubscriberSignal = signal | null>(null) - - effect( - () => { - const observer = observerSignal() - const observerOptions = optionsSignal() + const linkedResultSignal = computed(() => { + const observer = observerSignal() - untracked(() => { - observer.setOptions(observerOptions) - }) - }, - { - injector, - }, - ) - - effect( - (onCleanup) => { + return untracked(() => { // observer.trackResult is not used as this optimization is not needed for Angular - const observer = observerSignal() - let pendingTaskRef: PendingTaskRef | null = null - - untracked(() => { - const unsubscribe = ngZone.runOutsideAngular(() => - observer.subscribe( - notifyManager.batchCalls((state) => { - ngZone.run(() => { - // Track pending task when mutation is pending - if (state.isPending && !pendingTaskRef) { - pendingTaskRef = pendingTasks.add() - } - - // Clear pending task when mutation is no longer pending - if (!state.isPending && pendingTaskRef) { - pendingTaskRef() - pendingTaskRef = null - } - - if ( - state.isError && - shouldThrowError(observer.options.throwOnError, [state.error]) - ) { - ngZone.onError.emit(state.error) - throw state.error - } - - resultFromSubscriberSignal.set(state) - }) - }), - ), - ) - onCleanup(() => { - // Clean up any pending task on destroy - if (pendingTaskRef) { - pendingTaskRef() - pendingTaskRef = null - } - unsubscribe() - }) - }) - }, - { - injector, - }, - ) + const currentResult = observer.getCurrentResult() + const result = signal(currentResult) + + cleanup() + let pendingTaskRef = currentResult.isPending ? pendingTasks.add() : null + + const unsubscribe = ngZone.runOutsideAngular(() => + observer.subscribe( + notifyManager.batchCalls((state) => { + ngZone.run(() => { + result.set(state) + + // Track pending task when mutation is pending + if (state.isPending && !pendingTaskRef) { + pendingTaskRef = pendingTasks.add() + } + + // Clear pending task when mutation is no longer pending + if (!state.isPending && pendingTaskRef) { + pendingTaskRef() + pendingTaskRef = null + } + + if ( + state.isError && + shouldThrowError(observer.options.throwOnError, [state.error]) + ) { + ngZone.onError.emit(state.error) + throw state.error + } + }) + }), + ), + ) + + cleanup = () => { + // Clean up any pending task on destroy + if (pendingTaskRef) { + pendingTaskRef() + pendingTaskRef = null + } + unsubscribe() + } + + return result + }) + }) const resultSignal = computed(() => { - const resultFromSubscriber = resultFromSubscriberSignal() - const resultFromInitialOptions = resultFromInitialOptionsSignal() - - const result = resultFromSubscriber ?? resultFromInitialOptions + const result = linkedResultSignal()() return { ...result, @@ -186,6 +172,8 @@ export function injectMutation< } }) + destroyRef.onDestroy(() => cleanup()) + return signalProxy(resultSignal) as CreateMutationResult< TData, TError,