Skip to content

Commit a058bf7

Browse files
fix(angular-query): ensure initial mutation pending state is emitted
1 parent 38b4008 commit a058bf7

File tree

2 files changed

+99
-81
lines changed

2 files changed

+99
-81
lines changed

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,37 @@ describe('injectMutation', () => {
389389
expect(mutation2!.options.mutationKey).toEqual(['fake', 'updatedValue'])
390390
})
391391

392+
test('should have pending state when mutating in constructor', async () => {
393+
@Component({
394+
selector: 'app-fake',
395+
template: `
396+
<span>{{ mutation.isPending() ? 'pending' : 'not pending' }}</span>
397+
`,
398+
})
399+
class FakeComponent {
400+
mutation = injectMutation(() => ({
401+
mutationKey: ['fake'],
402+
mutationFn: () => sleep(10).then(() => 'fake'),
403+
}))
404+
405+
constructor() {
406+
this.mutation.mutate()
407+
}
408+
}
409+
410+
const fixture = TestBed.createComponent(FakeComponent)
411+
const { debugElement } = fixture
412+
const span = debugElement.query(By.css('span'))
413+
414+
vi.advanceTimersByTime(1)
415+
expect(span.nativeElement.textContent).toEqual('pending')
416+
417+
await vi.advanceTimersByTimeAsync(11)
418+
fixture.detectChanges()
419+
420+
expect(span.nativeElement.textContent).toEqual('not pending')
421+
})
422+
392423
describe('throwOnError', () => {
393424
test('should evaluate throwOnError when mutation is expected to throw', async () => {
394425
const err = new Error('Expected mock error. All is well!')

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

Lines changed: 68 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import {
2+
DestroyRef,
23
Injector,
34
NgZone,
45
assertInInjectionContext,
56
computed,
6-
effect,
77
inject,
88
signal,
99
untracked,
@@ -18,7 +18,7 @@ import {
1818
import { signalProxy } from './signal-proxy'
1919
import { PENDING_TASKS } from './pending-tasks-compat'
2020
import type { PendingTaskRef } from './pending-tasks-compat'
21-
import type { DefaultError, MutationObserverResult } from '@tanstack/query-core'
21+
import type { DefaultError } from '@tanstack/query-core'
2222
import type {
2323
CreateMutateFunction,
2424
CreateMutationOptions,
@@ -58,6 +58,7 @@ export function injectMutation<
5858
): CreateMutationResult<TData, TError, TVariables, TOnMutateResult> {
5959
!options?.injector && assertInInjectionContext(injectMutation)
6060
const injector = options?.injector ?? inject(Injector)
61+
const destroyRef = injector.get(DestroyRef)
6162
const ngZone = injector.get(NgZone)
6263
const pendingTasks = injector.get(PENDING_TASKS)
6364
const queryClient = injector.get(QueryClient)
@@ -78,7 +79,15 @@ export function injectMutation<
7879
> | null = null
7980

8081
return computed(() => {
81-
return (instance ||= new MutationObserver(queryClient, optionsSignal()))
82+
const observerOptions = optionsSignal()
83+
return untracked(() => {
84+
if (instance) {
85+
instance.setOptions(observerOptions)
86+
} else {
87+
instance = new MutationObserver(queryClient, observerOptions)
88+
}
89+
return instance
90+
})
8291
})
8392
})()
8493

@@ -91,93 +100,69 @@ export function injectMutation<
91100
}
92101
})
93102

94-
/**
95-
* Computed signal that gets result from mutation cache based on passed options
96-
*/
97-
const resultFromInitialOptionsSignal = computed(() => {
98-
const observer = observerSignal()
99-
return observer.getCurrentResult()
100-
})
103+
let cleanup: () => void = () => {}
101104

102105
/**
103-
* Signal that contains result set by subscriber
106+
* Returning a writable signal from a computed is similar to `linkedSignal`,
107+
* but compatible with Angular < 19
108+
*
109+
* Compared to `linkedSignal`, this pattern requires extra parentheses:
110+
* - Accessing value: `result()()`
111+
* - Setting value: `result().set(newValue)`
104112
*/
105-
const resultFromSubscriberSignal = signal<MutationObserverResult<
106-
TData,
107-
TError,
108-
TVariables,
109-
TOnMutateResult
110-
> | null>(null)
113+
const linkedResultSignal = computed(() => {
114+
const observer = observerSignal()
111115

112-
effect(
113-
() => {
114-
const observer = observerSignal()
115-
const observerOptions = optionsSignal()
116+
return untracked(() => {
117+
const result = signal(observer.getCurrentResult())
116118

117-
untracked(() => {
118-
observer.setOptions(observerOptions)
119-
})
120-
},
121-
{
122-
injector,
123-
},
124-
)
125-
126-
effect(
127-
(onCleanup) => {
128-
// observer.trackResult is not used as this optimization is not needed for Angular
129-
const observer = observerSignal()
119+
cleanup()
130120
let pendingTaskRef: PendingTaskRef | null = null
131121

132-
untracked(() => {
133-
const unsubscribe = ngZone.runOutsideAngular(() =>
134-
observer.subscribe(
135-
notifyManager.batchCalls((state) => {
136-
ngZone.run(() => {
137-
// Track pending task when mutation is pending
138-
if (state.isPending && !pendingTaskRef) {
139-
pendingTaskRef = pendingTasks.add()
140-
}
141-
142-
// Clear pending task when mutation is no longer pending
143-
if (!state.isPending && pendingTaskRef) {
144-
pendingTaskRef()
145-
pendingTaskRef = null
146-
}
147-
148-
if (
149-
state.isError &&
150-
shouldThrowError(observer.options.throwOnError, [state.error])
151-
) {
152-
ngZone.onError.emit(state.error)
153-
throw state.error
154-
}
155-
156-
resultFromSubscriberSignal.set(state)
157-
})
158-
}),
159-
),
160-
)
161-
onCleanup(() => {
162-
// Clean up any pending task on destroy
163-
if (pendingTaskRef) {
164-
pendingTaskRef()
165-
pendingTaskRef = null
166-
}
167-
unsubscribe()
168-
})
169-
})
170-
},
171-
{
172-
injector,
173-
},
174-
)
122+
const unsubscribe = ngZone.runOutsideAngular(() =>
123+
observer.subscribe(
124+
notifyManager.batchCalls((state) => {
125+
ngZone.run(() => {
126+
// Track pending task when mutation is pending
127+
if (state.isPending && !pendingTaskRef) {
128+
pendingTaskRef = pendingTasks.add()
129+
}
130+
131+
// Clear pending task when mutation is no longer pending
132+
if (!state.isPending && pendingTaskRef) {
133+
pendingTaskRef()
134+
pendingTaskRef = null
135+
}
136+
137+
if (
138+
state.isError &&
139+
shouldThrowError(observer.options.throwOnError, [state.error])
140+
) {
141+
ngZone.onError.emit(state.error)
142+
throw state.error
143+
}
144+
145+
result.set(state)
146+
})
147+
}),
148+
),
149+
)
150+
151+
cleanup = () => {
152+
// Clean up any pending task on destroy
153+
if (pendingTaskRef) {
154+
pendingTaskRef()
155+
pendingTaskRef = null
156+
}
157+
unsubscribe()
158+
}
159+
160+
return result
161+
})
162+
})
175163

176164
const resultSignal = computed(() => {
177-
const resultFromSubscriber = resultFromSubscriberSignal()
178-
const resultFromInitialOptions = resultFromInitialOptionsSignal()
179-
180-
const result = resultFromSubscriber ?? resultFromInitialOptions
165+
const result = linkedResultSignal()()
181166

182167
return {
183168
...result,
@@ -186,6 +171,8 @@ export function injectMutation<
186171
}
187172
})
188173

174+
destroyRef.onDestroy(cleanup)
175+
189176
return signalProxy(resultSignal) as CreateMutationResult<
190177
TData,
191178
TError,

0 commit comments

Comments
 (0)