Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/puny-melons-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/angular-query-experimental': minor
---

Ensure initial mutation pending state is emitted
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ describe('injectMutation', () => {
}))
})

TestBed.tick()

mutation.mutate(result)
await vi.advanceTimersByTimeAsync(0)

Expand Down Expand Up @@ -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: `
<span>{{ mutation.isPending() ? 'pending' : 'not pending' }}</span>
`,
})
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: () => {
Expand All @@ -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)
})
})

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
158 changes: 73 additions & 85 deletions packages/angular-query-experimental/src/inject-mutation.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {
DestroyRef,
Injector,
NgZone,
assertInInjectionContext,
computed,
effect,
inject,
signal,
untracked,
Expand All @@ -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,
Expand Down Expand Up @@ -58,6 +57,7 @@ export function injectMutation<
): CreateMutationResult<TData, TError, TVariables, TOnMutateResult> {
!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)
Expand All @@ -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
})
})
})()

Expand All @@ -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<MutationObserverResult<
TData,
TError,
TVariables,
TOnMutateResult
> | 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,
Expand All @@ -186,6 +172,8 @@ export function injectMutation<
}
})

destroyRef.onDestroy(() => cleanup())

return signalProxy(resultSignal) as CreateMutationResult<
TData,
TError,
Expand Down