Skip to content

Commit f426899

Browse files
authored
fix(mutations): avoid infinite loading states if callbacks return an error (#3343)
* fix(mutations): avoid infinite loading states if callbacks return an error add failing test cases * fix(mutations): avoid infinite loading states if callbacks return an error by making sure we always dispatch the error to go to error state internally; re-writing to async-await because it has better support than promise.finally, and the flow is also easier to reason about here * fix(mutations): fix merge conflicts
1 parent f53d8b1 commit f426899

File tree

2 files changed

+168
-76
lines changed

2 files changed

+168
-76
lines changed

src/core/mutation.ts

Lines changed: 57 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -160,35 +160,7 @@ export class Mutation<
160160
return this.execute()
161161
}
162162

163-
execute(): Promise<TData> {
164-
let data: TData
165-
166-
const restored = this.state.status === 'loading'
167-
168-
let promise = Promise.resolve()
169-
170-
if (!restored) {
171-
this.dispatch({ type: 'loading', variables: this.options.variables! })
172-
promise = promise
173-
.then(() => {
174-
// Notify cache callback
175-
this.mutationCache.config.onMutate?.(
176-
this.state.variables,
177-
this as Mutation<unknown, unknown, unknown, unknown>
178-
)
179-
})
180-
.then(() => this.options.onMutate?.(this.state.variables!))
181-
.then(context => {
182-
if (context !== this.state.context) {
183-
this.dispatch({
184-
type: 'loading',
185-
context,
186-
variables: this.state.variables,
187-
})
188-
}
189-
})
190-
}
191-
163+
async execute(): Promise<TData> {
192164
const executeMutation = () => {
193165
this.retryer = createRetryer({
194166
fn: () => {
@@ -214,38 +186,51 @@ export class Mutation<
214186
return this.retryer.promise
215187
}
216188

217-
return promise
218-
.then(executeMutation)
219-
.then(result => {
220-
data = result
189+
const restored = this.state.status === 'loading'
190+
try {
191+
if (!restored) {
192+
this.dispatch({ type: 'loading', variables: this.options.variables! })
221193
// Notify cache callback
222-
this.mutationCache.config.onSuccess?.(
223-
data,
194+
this.mutationCache.config.onMutate?.(
224195
this.state.variables,
225-
this.state.context,
226196
this as Mutation<unknown, unknown, unknown, unknown>
227197
)
228-
})
229-
.then(() =>
230-
this.options.onSuccess?.(
231-
data,
232-
this.state.variables!,
233-
this.state.context!
234-
)
198+
const context = await this.options.onMutate?.(this.state.variables!)
199+
if (context !== this.state.context) {
200+
this.dispatch({
201+
type: 'loading',
202+
context,
203+
variables: this.state.variables,
204+
})
205+
}
206+
}
207+
const data = await executeMutation()
208+
209+
// Notify cache callback
210+
this.mutationCache.config.onSuccess?.(
211+
data,
212+
this.state.variables,
213+
this.state.context,
214+
this as Mutation<unknown, unknown, unknown, unknown>
235215
)
236-
.then(() =>
237-
this.options.onSettled?.(
238-
data,
239-
null,
240-
this.state.variables!,
241-
this.state.context
242-
)
216+
217+
await this.options.onSuccess?.(
218+
data,
219+
this.state.variables!,
220+
this.state.context!
243221
)
244-
.then(() => {
245-
this.dispatch({ type: 'success', data })
246-
return data
247-
})
248-
.catch(error => {
222+
223+
await this.options.onSettled?.(
224+
data,
225+
null,
226+
this.state.variables!,
227+
this.state.context
228+
)
229+
230+
this.dispatch({ type: 'success', data })
231+
return data
232+
} catch (error) {
233+
try {
249234
// Notify cache callback
250235
this.mutationCache.config.onError?.(
251236
error,
@@ -258,27 +243,23 @@ export class Mutation<
258243
this.logger.error(error)
259244
}
260245

261-
return Promise.resolve()
262-
.then(() =>
263-
this.options.onError?.(
264-
error,
265-
this.state.variables!,
266-
this.state.context
267-
)
268-
)
269-
.then(() =>
270-
this.options.onSettled?.(
271-
undefined,
272-
error,
273-
this.state.variables!,
274-
this.state.context
275-
)
276-
)
277-
.then(() => {
278-
this.dispatch({ type: 'error', error })
279-
throw error
280-
})
281-
})
246+
await this.options.onError?.(
247+
error as TError,
248+
this.state.variables!,
249+
this.state.context
250+
)
251+
252+
await this.options.onSettled?.(
253+
undefined,
254+
error as TError,
255+
this.state.variables!,
256+
this.state.context
257+
)
258+
throw error
259+
} finally {
260+
this.dispatch({ type: 'error', error: error as TError })
261+
}
262+
}
282263
}
283264

284265
private dispatch(action: Action<TData, TError, TVariables, TContext>): void {

src/reactjs/tests/useMutation.test.tsx

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,4 +855,115 @@ describe('useMutation', () => {
855855
undefined
856856
)
857857
})
858+
859+
test('should go to error state if onSuccess callback errors', async () => {
860+
const error = new Error('error from onSuccess')
861+
const onError = jest.fn()
862+
863+
function Page() {
864+
const mutation = useMutation(
865+
async (_text: string) => {
866+
await sleep(10)
867+
return 'result'
868+
},
869+
{
870+
onSuccess: () => Promise.reject(error),
871+
onError,
872+
}
873+
)
874+
875+
return (
876+
<div>
877+
<button onClick={() => mutation.mutate('todo')}>mutate</button>
878+
<div>status: {mutation.status}</div>
879+
</div>
880+
)
881+
}
882+
883+
const rendered = renderWithClient(queryClient, <Page />)
884+
885+
await rendered.findByText('status: idle')
886+
887+
rendered.getByRole('button', { name: /mutate/i }).click()
888+
889+
await rendered.findByText('status: error')
890+
891+
expect(onError).toHaveBeenCalledWith(error, 'todo', undefined)
892+
})
893+
894+
test('should go to error state if onError callback errors', async () => {
895+
const error = new Error('error from onError')
896+
const mutateFnError = new Error('mutateFnError')
897+
898+
function Page() {
899+
const mutation = useMutation(
900+
async (_text: string) => {
901+
await sleep(10)
902+
throw mutateFnError
903+
},
904+
{
905+
onError: () => Promise.reject(error),
906+
}
907+
)
908+
909+
return (
910+
<div>
911+
<button onClick={() => mutation.mutate('todo')}>mutate</button>
912+
<div>
913+
error:{' '}
914+
{mutation.error instanceof Error ? mutation.error.message : 'null'},
915+
status: {mutation.status}
916+
</div>
917+
</div>
918+
)
919+
}
920+
921+
const rendered = renderWithClient(queryClient, <Page />)
922+
923+
await rendered.findByText('error: null, status: idle')
924+
925+
rendered.getByRole('button', { name: /mutate/i }).click()
926+
927+
await rendered.findByText('error: mutateFnError, status: error')
928+
})
929+
930+
test('should go to error state if onSettled callback errors', async () => {
931+
const error = new Error('error from onSettled')
932+
const mutateFnError = new Error('mutateFnError')
933+
const onError = jest.fn()
934+
935+
function Page() {
936+
const mutation = useMutation(
937+
async (_text: string) => {
938+
await sleep(10)
939+
throw mutateFnError
940+
},
941+
{
942+
onSettled: () => Promise.reject(error),
943+
onError,
944+
}
945+
)
946+
947+
return (
948+
<div>
949+
<button onClick={() => mutation.mutate('todo')}>mutate</button>
950+
<div>
951+
error:{' '}
952+
{mutation.error instanceof Error ? mutation.error.message : 'null'},
953+
status: {mutation.status}
954+
</div>
955+
</div>
956+
)
957+
}
958+
959+
const rendered = renderWithClient(queryClient, <Page />)
960+
961+
await rendered.findByText('error: null, status: idle')
962+
963+
rendered.getByRole('button', { name: /mutate/i }).click()
964+
965+
await rendered.findByText('error: mutateFnError, status: error')
966+
967+
expect(onError).toHaveBeenCalledWith(mutateFnError, 'todo', undefined)
968+
})
858969
})

0 commit comments

Comments
 (0)