Skip to content

Commit f804b15

Browse files
authored
fix: offline mutations fixes (#3051)
* feat: offline mutations move reducer into Mutation class to avoid passing state (and options) around * feat: offline mutations optimistically set paused state depending on if we can fetch or not to avoid an intermediate state where we are loading but not paused
1 parent 4b75108 commit f804b15

File tree

2 files changed

+153
-59
lines changed

2 files changed

+153
-59
lines changed

src/core/mutation.ts

Lines changed: 58 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { MutationObserver } from './mutationObserver'
44
import { getLogger } from './logger'
55
import { notifyManager } from './notifyManager'
66
import { Removable } from './removable'
7-
import { Retryer } from './retryer'
7+
import { canFetch, Retryer } from './retryer'
88
import { noop } from './utils'
99

1010
// TYPES
@@ -291,7 +291,7 @@ export class Mutation<
291291
}
292292

293293
private dispatch(action: Action<TData, TError, TVariables, TContext>): void {
294-
this.state = reducer(this.state, action)
294+
this.state = this.reducer(action)
295295

296296
notifyManager.batch(() => {
297297
this.observers.forEach(observer => {
@@ -304,6 +304,62 @@ export class Mutation<
304304
})
305305
})
306306
}
307+
308+
private reducer(
309+
action: Action<TData, TError, TVariables, TContext>
310+
): MutationState<TData, TError, TVariables, TContext> {
311+
switch (action.type) {
312+
case 'failed':
313+
return {
314+
...this.state,
315+
failureCount: this.state.failureCount + 1,
316+
}
317+
case 'pause':
318+
return {
319+
...this.state,
320+
isPaused: true,
321+
}
322+
case 'continue':
323+
return {
324+
...this.state,
325+
isPaused: false,
326+
}
327+
case 'loading':
328+
return {
329+
...this.state,
330+
context: action.context,
331+
data: undefined,
332+
error: null,
333+
isPaused: !canFetch(this.options.networkMode),
334+
status: 'loading',
335+
variables: action.variables,
336+
}
337+
case 'success':
338+
return {
339+
...this.state,
340+
data: action.data,
341+
error: null,
342+
status: 'success',
343+
isPaused: false,
344+
}
345+
case 'error':
346+
return {
347+
...this.state,
348+
data: undefined,
349+
error: action.error,
350+
failureCount: this.state.failureCount + 1,
351+
isPaused: false,
352+
status: 'error',
353+
}
354+
case 'setState':
355+
return {
356+
...this.state,
357+
...action.state,
358+
}
359+
default:
360+
return this.state
361+
}
362+
}
307363
}
308364

309365
export function getDefaultState<
@@ -322,60 +378,3 @@ export function getDefaultState<
322378
variables: undefined,
323379
}
324380
}
325-
326-
function reducer<TData, TError, TVariables, TContext>(
327-
state: MutationState<TData, TError, TVariables, TContext>,
328-
action: Action<TData, TError, TVariables, TContext>
329-
): MutationState<TData, TError, TVariables, TContext> {
330-
switch (action.type) {
331-
case 'failed':
332-
return {
333-
...state,
334-
failureCount: state.failureCount + 1,
335-
}
336-
case 'pause':
337-
return {
338-
...state,
339-
isPaused: true,
340-
}
341-
case 'continue':
342-
return {
343-
...state,
344-
isPaused: false,
345-
}
346-
case 'loading':
347-
return {
348-
...state,
349-
context: action.context,
350-
data: undefined,
351-
error: null,
352-
isPaused: false,
353-
status: 'loading',
354-
variables: action.variables,
355-
}
356-
case 'success':
357-
return {
358-
...state,
359-
data: action.data,
360-
error: null,
361-
status: 'success',
362-
isPaused: false,
363-
}
364-
case 'error':
365-
return {
366-
...state,
367-
data: undefined,
368-
error: action.error,
369-
failureCount: state.failureCount + 1,
370-
isPaused: false,
371-
status: 'error',
372-
}
373-
case 'setState':
374-
return {
375-
...state,
376-
...action.state,
377-
}
378-
default:
379-
return state
380-
}
381-
}

src/reactjs/tests/useMutation.test.tsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,101 @@ describe('useMutation', () => {
445445
onlineMock.mockRestore()
446446
})
447447

448+
it('should call onMutate even if paused', async () => {
449+
const onlineMock = mockNavigatorOnLine(false)
450+
const onMutate = jest.fn()
451+
let count = 0
452+
453+
function Page() {
454+
const mutation = useMutation(
455+
async (_text: string) => {
456+
count++
457+
await sleep(10)
458+
return count
459+
},
460+
{
461+
onMutate,
462+
}
463+
)
464+
465+
return (
466+
<div>
467+
<button onClick={() => mutation.mutate('todo')}>mutate</button>
468+
<div>
469+
data: {mutation.data ?? 'null'}, status: {mutation.status},
470+
isPaused: {String(mutation.isPaused)}
471+
</div>
472+
</div>
473+
)
474+
}
475+
476+
const rendered = renderWithClient(queryClient, <Page />)
477+
478+
await rendered.findByText('data: null, status: idle, isPaused: false')
479+
480+
rendered.getByRole('button', { name: /mutate/i }).click()
481+
482+
await rendered.findByText('data: null, status: loading, isPaused: true')
483+
484+
expect(onMutate).toHaveBeenCalledTimes(1)
485+
expect(onMutate).toHaveBeenCalledWith('todo')
486+
487+
onlineMock.mockReturnValue(true)
488+
window.dispatchEvent(new Event('online'))
489+
490+
await rendered.findByText('data: 1, status: success, isPaused: false')
491+
492+
expect(onMutate).toHaveBeenCalledTimes(1)
493+
expect(count).toBe(1)
494+
495+
onlineMock.mockRestore()
496+
})
497+
498+
it('should optimistically go to paused state if offline', async () => {
499+
const onlineMock = mockNavigatorOnLine(false)
500+
let count = 0
501+
const states: Array<string> = []
502+
503+
function Page() {
504+
const mutation = useMutation(async (_text: string) => {
505+
count++
506+
await sleep(10)
507+
return count
508+
})
509+
510+
states.push(`${mutation.status}, ${mutation.isPaused}`)
511+
512+
return (
513+
<div>
514+
<button onClick={() => mutation.mutate('todo')}>mutate</button>
515+
<div>
516+
data: {mutation.data ?? 'null'}, status: {mutation.status},
517+
isPaused: {String(mutation.isPaused)}
518+
</div>
519+
</div>
520+
)
521+
}
522+
523+
const rendered = renderWithClient(queryClient, <Page />)
524+
525+
await rendered.findByText('data: null, status: idle, isPaused: false')
526+
527+
rendered.getByRole('button', { name: /mutate/i }).click()
528+
529+
await rendered.findByText('data: null, status: loading, isPaused: true')
530+
531+
// no intermediate 'loading, false' state is expected because we don't start mutating!
532+
expect(states[0]).toBe('idle, false')
533+
expect(states[1]).toBe('loading, true')
534+
535+
onlineMock.mockReturnValue(true)
536+
window.dispatchEvent(new Event('online'))
537+
538+
await rendered.findByText('data: 1, status: success, isPaused: false')
539+
540+
onlineMock.mockRestore()
541+
})
542+
448543
it('should be able to retry a mutation when online', async () => {
449544
const consoleMock = mockConsoleError()
450545
const onlineMock = mockNavigatorOnLine(false)

0 commit comments

Comments
 (0)