Skip to content

Commit 901e826

Browse files
janlatTkDodo
andauthored
fix(core): do not call mutate callbacks if mutation started after unmount (#4848)
* test: add mutation callback after unmount test * test: make test more resilient * fix(core): do not call mutate callbacks if mutation started after unmount * test: adapt tests to what we have in v5 - one test has been removed (because setState was removed entirely) - the second test has been re-written to not use internals anymore, and it works in v4 as well * fix(core): do not call mutate callbacks if mutation started after unmount by making sure the callbacks are only invoked if we have an active listener * chore: prettier again Co-authored-by: Dominik Dorfmeister <[email protected]>
1 parent bae03c0 commit 901e826

File tree

3 files changed

+89
-42
lines changed

3 files changed

+89
-42
lines changed

packages/query-core/src/mutationObserver.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ export class MutationObserver<
166166
private notify(options: NotifyOptions) {
167167
notifyManager.batch(() => {
168168
// First trigger the mutate callbacks
169-
if (this.mutateOptions) {
169+
if (this.mutateOptions && this.hasListeners()) {
170170
if (options.onSuccess) {
171171
this.mutateOptions.onSuccess?.(
172172
this.currentResult.data!,

packages/query-core/src/tests/mutations.test.tsx

Lines changed: 29 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -308,49 +308,24 @@ describe('mutations', () => {
308308
expect(onSettled).toHaveBeenCalled()
309309
})
310310

311-
test('setState should update the mutation state', async () => {
312-
const mutation = new MutationObserver(queryClient, {
313-
mutationFn: async () => {
314-
return 'update'
315-
},
316-
onMutate: (text) => text,
317-
})
318-
await mutation.mutate()
319-
expect(mutation.getCurrentResult().data).toEqual('update')
320-
321-
// Force setState usage
322-
// because no use case has been found using mutation.setState
323-
const currentMutation = mutation['currentMutation']
324-
currentMutation?.setState({
325-
context: undefined,
326-
variables: undefined,
327-
data: 'new',
328-
error: undefined,
329-
failureCount: 0,
330-
failureReason: null,
331-
isPaused: false,
332-
status: 'success',
333-
})
311+
test('addObserver should not add an existing observer', async () => {
312+
const mutationCache = queryClient.getMutationCache()
313+
const observer = new MutationObserver(queryClient, {})
314+
const currentMutation = mutationCache.build(queryClient, {})
334315

335-
expect(mutation.getCurrentResult().data).toEqual('new')
336-
})
316+
const fn = jest.fn()
337317

338-
test('addObserver should not add an existing observer', async () => {
339-
const mutation = new MutationObserver(queryClient, {
340-
mutationFn: async () => {
341-
return 'update'
342-
},
343-
onMutate: (text) => text,
318+
const unsubscribe = mutationCache.subscribe((event) => {
319+
fn(event.type)
344320
})
345-
await mutation.mutate()
346321

347-
// Force addObserver usage to add an existing observer
348-
// because no use case has been found
349-
const currentMutation = mutation['currentMutation']!
350-
expect(currentMutation['observers'].length).toEqual(1)
351-
currentMutation.addObserver(mutation)
322+
currentMutation.addObserver(observer)
323+
currentMutation.addObserver(observer)
352324

353-
expect(currentMutation['observers'].length).toEqual(1)
325+
expect(fn).toHaveBeenCalledTimes(1)
326+
expect(fn).toHaveBeenCalledWith('observerAdded')
327+
328+
unsubscribe()
354329
})
355330

356331
test('mutate should throw an error if no mutationFn found', async () => {
@@ -367,4 +342,20 @@ describe('mutations', () => {
367342
}
368343
expect(error).toEqual('No mutationFn found')
369344
})
345+
346+
test('mutate update the mutation state even without an active subscription', async () => {
347+
const onSuccess = jest.fn()
348+
const onSettled = jest.fn()
349+
350+
const mutation = new MutationObserver(queryClient, {
351+
mutationFn: async () => {
352+
return 'update'
353+
},
354+
})
355+
356+
await mutation.mutate(undefined, { onSuccess, onSettled })
357+
expect(mutation.getCurrentResult().data).toEqual('update')
358+
expect(onSuccess).not.toHaveBeenCalled()
359+
expect(onSettled).not.toHaveBeenCalled()
360+
})
370361
})

packages/react-query/src/__tests__/useMutation.test.tsx

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,7 +1044,7 @@ describe('useMutation', () => {
10441044
)
10451045
})
10461046

1047-
test('should go to error state if onSuccess callback errors', async () => {
1047+
it('should go to error state if onSuccess callback errors', async () => {
10481048
const error = new Error('error from onSuccess')
10491049
const onError = jest.fn()
10501050

@@ -1079,7 +1079,7 @@ describe('useMutation', () => {
10791079
expect(onError).toHaveBeenCalledWith(error, 'todo', undefined)
10801080
})
10811081

1082-
test('should go to error state if onError callback errors', async () => {
1082+
it('should go to error state if onError callback errors', async () => {
10831083
const error = new Error('error from onError')
10841084
const mutateFnError = new Error('mutateFnError')
10851085

@@ -1115,7 +1115,7 @@ describe('useMutation', () => {
11151115
await rendered.findByText('error: mutateFnError, status: error')
11161116
})
11171117

1118-
test('should go to error state if onSettled callback errors', async () => {
1118+
it('should go to error state if onSettled callback errors', async () => {
11191119
const error = new Error('error from onSettled')
11201120
const mutateFnError = new Error('mutateFnError')
11211121
const onError = jest.fn()
@@ -1154,4 +1154,60 @@ describe('useMutation', () => {
11541154

11551155
expect(onError).toHaveBeenCalledWith(mutateFnError, 'todo', undefined)
11561156
})
1157+
1158+
it('should not call mutate callbacks for mutations started after unmount', async () => {
1159+
const onSuccessMutate = jest.fn()
1160+
const onSuccessUseMutation = jest.fn()
1161+
const onSettledMutate = jest.fn()
1162+
const onSettledUseMutation = jest.fn()
1163+
1164+
function Page() {
1165+
const [show, setShow] = React.useState(true)
1166+
return (
1167+
<div>
1168+
<button onClick={() => setShow(false)}>hide</button>
1169+
{show && <Component />}
1170+
</div>
1171+
)
1172+
}
1173+
1174+
function Component() {
1175+
const mutation = useMutation({
1176+
mutationFn: async (text: string) => {
1177+
await sleep(10)
1178+
return text
1179+
},
1180+
onSuccess: onSuccessUseMutation,
1181+
onSettled: onSettledUseMutation,
1182+
})
1183+
1184+
return (
1185+
<div>
1186+
<button
1187+
onClick={() => {
1188+
setActTimeout(() => {
1189+
mutation.mutate('todo', {
1190+
onSuccess: onSuccessMutate,
1191+
onSettled: onSettledMutate,
1192+
})
1193+
}, 10)
1194+
}}
1195+
>
1196+
mutate
1197+
</button>
1198+
</div>
1199+
)
1200+
}
1201+
1202+
const rendered = renderWithClient(queryClient, <Page />)
1203+
1204+
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
1205+
fireEvent.click(rendered.getByRole('button', { name: /hide/i }))
1206+
1207+
await waitFor(() => expect(onSuccessUseMutation).toHaveBeenCalledTimes(1))
1208+
await waitFor(() => expect(onSettledUseMutation).toHaveBeenCalledTimes(1))
1209+
1210+
expect(onSuccessMutate).toHaveBeenCalledTimes(0)
1211+
expect(onSettledMutate).toHaveBeenCalledTimes(0)
1212+
})
11571213
})

0 commit comments

Comments
 (0)