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,