Skip to content

Commit 66c5e21

Browse files
committed
Ensure errors of useMutation callbacks onError and onSettled are reported asynchronously
1 parent fcd23c9 commit 66c5e21

File tree

4 files changed

+368
-8
lines changed

4 files changed

+368
-8
lines changed

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

Lines changed: 319 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { queryKey, sleep } from '@tanstack/query-test-utils'
22
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
3-
import { QueryClient } from '..'
3+
import { MutationCache, QueryClient } from '..'
44
import { MutationObserver } from '../mutationObserver'
55
import { executeMutation } from './utils'
66
import type { MutationState } from '../mutation'
@@ -842,4 +842,322 @@ describe('mutations', () => {
842842
expect(mutationError).toEqual(newMutationError)
843843
})
844844
})
845+
846+
describe('erroneous mutation callback', () => {
847+
afterEach(() => {
848+
process.removeAllListeners('unhandledRejection')
849+
})
850+
851+
test('error by global onSuccess triggers onError callback', async () => {
852+
const newMutationError = new Error('mutation-error')
853+
854+
queryClient = new QueryClient({
855+
mutationCache: new MutationCache({
856+
onSuccess: () => {
857+
throw newMutationError
858+
},
859+
}),
860+
})
861+
queryClient.mount()
862+
863+
const key = queryKey()
864+
const results: Array<string> = []
865+
866+
let mutationError: Error | undefined
867+
executeMutation(
868+
queryClient,
869+
{
870+
mutationKey: key,
871+
mutationFn: () => Promise.resolve('success'),
872+
onMutate: async () => {
873+
results.push('onMutate-async')
874+
await sleep(10)
875+
return { backup: 'async-data' }
876+
},
877+
onSuccess: async () => {
878+
results.push('onSuccess-async-start')
879+
await sleep(10)
880+
throw newMutationError
881+
},
882+
onError: async () => {
883+
results.push('onError-async-start')
884+
await sleep(10)
885+
results.push('onError-async-end')
886+
},
887+
onSettled: () => {
888+
results.push('onSettled-promise')
889+
return Promise.resolve('also-ignored') // Promise<string> (should be ignored)
890+
},
891+
},
892+
'vars',
893+
).catch((error) => {
894+
mutationError = error
895+
})
896+
897+
await vi.advanceTimersByTimeAsync(30)
898+
899+
expect(results).toEqual([
900+
'onMutate-async',
901+
'onError-async-start',
902+
'onError-async-end',
903+
'onSettled-promise',
904+
])
905+
906+
expect(mutationError).toEqual(newMutationError)
907+
})
908+
909+
test('error by mutations onSuccess triggers onError callback', async () => {
910+
const key = queryKey()
911+
const results: Array<string> = []
912+
913+
const newMutationError = new Error('mutation-error')
914+
915+
let mutationError: Error | undefined
916+
executeMutation(
917+
queryClient,
918+
{
919+
mutationKey: key,
920+
mutationFn: () => Promise.resolve('success'),
921+
onMutate: async () => {
922+
results.push('onMutate-async')
923+
await sleep(10)
924+
return { backup: 'async-data' }
925+
},
926+
onSuccess: async () => {
927+
results.push('onSuccess-async-start')
928+
await sleep(10)
929+
throw newMutationError
930+
},
931+
onError: async () => {
932+
results.push('onError-async-start')
933+
await sleep(10)
934+
results.push('onError-async-end')
935+
},
936+
onSettled: () => {
937+
results.push('onSettled-promise')
938+
return Promise.resolve('also-ignored') // Promise<string> (should be ignored)
939+
},
940+
},
941+
'vars',
942+
).catch((error) => {
943+
mutationError = error
944+
})
945+
946+
await vi.advanceTimersByTimeAsync(30)
947+
948+
expect(results).toEqual([
949+
'onMutate-async',
950+
'onSuccess-async-start',
951+
'onError-async-start',
952+
'onError-async-end',
953+
'onSettled-promise',
954+
])
955+
956+
expect(mutationError).toEqual(newMutationError)
957+
})
958+
959+
test('error by global onSettled triggers onError callback, calling global onSettled callback twice', async () => {
960+
const newMutationError = new Error('mutation-error')
961+
962+
queryClient = new QueryClient({
963+
mutationCache: new MutationCache({
964+
onSettled: async () => {
965+
results.push('global-onSettled')
966+
await sleep(10)
967+
throw newMutationError
968+
},
969+
}),
970+
})
971+
queryClient.mount()
972+
973+
const unhandledRejectionFn = vi.fn()
974+
process.on('unhandledRejection', (error) => unhandledRejectionFn(error))
975+
976+
const key = queryKey()
977+
const results: Array<string> = []
978+
979+
let mutationError: Error | undefined
980+
executeMutation(
981+
queryClient,
982+
{
983+
mutationKey: key,
984+
mutationFn: () => Promise.resolve('success'),
985+
onMutate: async () => {
986+
results.push('onMutate-async')
987+
await sleep(10)
988+
return { backup: 'async-data' }
989+
},
990+
onSuccess: async () => {
991+
results.push('onSuccess-async-start')
992+
await sleep(10)
993+
results.push('onSuccess-async-end')
994+
},
995+
onError: async () => {
996+
results.push('onError-async-start')
997+
await sleep(10)
998+
results.push('onError-async-end')
999+
},
1000+
onSettled: () => {
1001+
results.push('local-onSettled')
1002+
},
1003+
},
1004+
'vars',
1005+
).catch((error) => {
1006+
mutationError = error
1007+
})
1008+
1009+
await vi.advanceTimersByTimeAsync(50)
1010+
1011+
expect(results).toEqual([
1012+
'onMutate-async',
1013+
'onSuccess-async-start',
1014+
'onSuccess-async-end',
1015+
'global-onSettled',
1016+
'onError-async-start',
1017+
'onError-async-end',
1018+
'global-onSettled',
1019+
'local-onSettled',
1020+
])
1021+
1022+
expect(unhandledRejectionFn).toHaveBeenCalledTimes(1)
1023+
expect(unhandledRejectionFn).toHaveBeenNthCalledWith(1, newMutationError)
1024+
1025+
expect(mutationError).toEqual(newMutationError)
1026+
})
1027+
1028+
test('error by mutations onSettled triggers onError callback, calling both onSettled callbacks twice', async () => {
1029+
const unhandledRejectionFn = vi.fn()
1030+
process.on('unhandledRejection', (error) => unhandledRejectionFn(error))
1031+
1032+
const key = queryKey()
1033+
const results: Array<string> = []
1034+
1035+
const newMutationError = new Error('mutation-error')
1036+
1037+
let mutationError: Error | undefined
1038+
executeMutation(
1039+
queryClient,
1040+
{
1041+
mutationKey: key,
1042+
mutationFn: () => Promise.resolve('success'),
1043+
onMutate: async () => {
1044+
results.push('onMutate-async')
1045+
await sleep(10)
1046+
return { backup: 'async-data' }
1047+
},
1048+
onSuccess: async () => {
1049+
results.push('onSuccess-async-start')
1050+
await sleep(10)
1051+
results.push('onSuccess-async-end')
1052+
},
1053+
onError: async () => {
1054+
results.push('onError-async-start')
1055+
await sleep(10)
1056+
results.push('onError-async-end')
1057+
},
1058+
onSettled: async () => {
1059+
results.push('onSettled-async-promise')
1060+
await sleep(10)
1061+
throw newMutationError
1062+
},
1063+
},
1064+
'vars',
1065+
).catch((error) => {
1066+
mutationError = error
1067+
})
1068+
1069+
await vi.advanceTimersByTimeAsync(50)
1070+
1071+
expect(results).toEqual([
1072+
'onMutate-async',
1073+
'onSuccess-async-start',
1074+
'onSuccess-async-end',
1075+
'onSettled-async-promise',
1076+
'onError-async-start',
1077+
'onError-async-end',
1078+
'onSettled-async-promise',
1079+
])
1080+
1081+
expect(unhandledRejectionFn).toHaveBeenCalledTimes(1)
1082+
expect(unhandledRejectionFn).toHaveBeenNthCalledWith(1, newMutationError)
1083+
1084+
expect(mutationError).toEqual(newMutationError)
1085+
})
1086+
1087+
test('errors by onError and consecutive onSettled callbacks are transferred to different execution context where it are reported', async () => {
1088+
const unhandledRejectionFn = vi.fn()
1089+
process.on('unhandledRejection', (error) => unhandledRejectionFn(error))
1090+
1091+
const globalErrorError = new Error('global-error-error')
1092+
const globalSettledError = new Error('global-settled-error')
1093+
1094+
queryClient = new QueryClient({
1095+
mutationCache: new MutationCache({
1096+
onError: () => {
1097+
throw globalErrorError
1098+
},
1099+
onSettled: () => {
1100+
throw globalSettledError
1101+
},
1102+
}),
1103+
})
1104+
queryClient.mount()
1105+
1106+
const key = queryKey()
1107+
const results: Array<string> = []
1108+
1109+
const newMutationError = new Error('mutation-error')
1110+
const newErrorError = new Error('error-error')
1111+
const newSettledError = new Error('settled-error')
1112+
1113+
let mutationError: Error | undefined
1114+
executeMutation(
1115+
queryClient,
1116+
{
1117+
mutationKey: key,
1118+
mutationFn: () => Promise.resolve('success'),
1119+
onMutate: async () => {
1120+
results.push('onMutate-async')
1121+
await sleep(10)
1122+
throw newMutationError
1123+
},
1124+
onSuccess: () => {
1125+
results.push('onSuccess-async-start')
1126+
},
1127+
onError: async () => {
1128+
results.push('onError-async-start')
1129+
await sleep(10)
1130+
throw newErrorError
1131+
},
1132+
onSettled: async () => {
1133+
results.push('onSettled-promise')
1134+
await sleep(10)
1135+
throw newSettledError
1136+
},
1137+
},
1138+
'vars',
1139+
).catch((error) => {
1140+
mutationError = error
1141+
})
1142+
1143+
await vi.advanceTimersByTimeAsync(30)
1144+
1145+
expect(results).toEqual([
1146+
'onMutate-async',
1147+
'onError-async-start',
1148+
'onSettled-promise',
1149+
])
1150+
1151+
expect(mutationError).toEqual(newMutationError)
1152+
1153+
expect(unhandledRejectionFn).toHaveBeenCalledTimes(4)
1154+
expect(unhandledRejectionFn).toHaveBeenNthCalledWith(1, globalErrorError)
1155+
expect(unhandledRejectionFn).toHaveBeenNthCalledWith(2, newErrorError)
1156+
expect(unhandledRejectionFn).toHaveBeenNthCalledWith(
1157+
3,
1158+
globalSettledError,
1159+
)
1160+
expect(unhandledRejectionFn).toHaveBeenNthCalledWith(4, newSettledError)
1161+
})
1162+
})
8451163
})

packages/query-core/src/mutation.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,14 +278,22 @@ export class Mutation<
278278
this as Mutation<unknown, unknown, unknown, unknown>,
279279
mutationFnContext,
280280
)
281+
} catch (e) {
282+
void Promise.reject(e)
283+
}
281284

285+
try {
282286
await this.options.onError?.(
283287
error as TError,
284288
variables,
285289
this.state.context,
286290
mutationFnContext,
287291
)
292+
} catch (e) {
293+
void Promise.reject(e)
294+
}
288295

296+
try {
289297
// Notify cache callback
290298
await this.#mutationCache.config.onSettled?.(
291299
undefined,
@@ -295,18 +303,24 @@ export class Mutation<
295303
this as Mutation<unknown, unknown, unknown, unknown>,
296304
mutationFnContext,
297305
)
306+
} catch (e) {
307+
void Promise.reject(e)
308+
}
298309

310+
try {
299311
await this.options.onSettled?.(
300312
undefined,
301313
error as TError,
302314
variables,
303315
this.state.context,
304316
mutationFnContext,
305317
)
306-
throw error
307-
} finally {
308-
this.#dispatch({ type: 'error', error: error as TError })
318+
} catch (e) {
319+
void Promise.reject(e)
309320
}
321+
322+
this.#dispatch({ type: 'error', error: error as TError })
323+
throw error
310324
} finally {
311325
this.#mutationCache.runNext(this)
312326
}

0 commit comments

Comments
 (0)