Skip to content

Commit

Permalink
fix: offline mutations fixes (#3051)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
TkDodo committed Dec 4, 2021
1 parent 4b75108 commit f804b15
Show file tree
Hide file tree
Showing 2 changed files with 153 additions and 59 deletions.
117 changes: 58 additions & 59 deletions src/core/mutation.ts
Expand Up @@ -4,7 +4,7 @@ import type { MutationObserver } from './mutationObserver'
import { getLogger } from './logger'
import { notifyManager } from './notifyManager'
import { Removable } from './removable'
import { Retryer } from './retryer'
import { canFetch, Retryer } from './retryer'
import { noop } from './utils'

// TYPES
Expand Down Expand Up @@ -291,7 +291,7 @@ export class Mutation<
}

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

notifyManager.batch(() => {
this.observers.forEach(observer => {
Expand All @@ -304,6 +304,62 @@ export class Mutation<
})
})
}

private reducer(
action: Action<TData, TError, TVariables, TContext>
): MutationState<TData, TError, TVariables, TContext> {
switch (action.type) {
case 'failed':
return {
...this.state,
failureCount: this.state.failureCount + 1,
}
case 'pause':
return {
...this.state,
isPaused: true,
}
case 'continue':
return {
...this.state,
isPaused: false,
}
case 'loading':
return {
...this.state,
context: action.context,
data: undefined,
error: null,
isPaused: !canFetch(this.options.networkMode),
status: 'loading',
variables: action.variables,
}
case 'success':
return {
...this.state,
data: action.data,
error: null,
status: 'success',
isPaused: false,
}
case 'error':
return {
...this.state,
data: undefined,
error: action.error,
failureCount: this.state.failureCount + 1,
isPaused: false,
status: 'error',
}
case 'setState':
return {
...this.state,
...action.state,
}
default:
return this.state
}
}
}

export function getDefaultState<
Expand All @@ -322,60 +378,3 @@ export function getDefaultState<
variables: undefined,
}
}

function reducer<TData, TError, TVariables, TContext>(
state: MutationState<TData, TError, TVariables, TContext>,
action: Action<TData, TError, TVariables, TContext>
): MutationState<TData, TError, TVariables, TContext> {
switch (action.type) {
case 'failed':
return {
...state,
failureCount: state.failureCount + 1,
}
case 'pause':
return {
...state,
isPaused: true,
}
case 'continue':
return {
...state,
isPaused: false,
}
case 'loading':
return {
...state,
context: action.context,
data: undefined,
error: null,
isPaused: false,
status: 'loading',
variables: action.variables,
}
case 'success':
return {
...state,
data: action.data,
error: null,
status: 'success',
isPaused: false,
}
case 'error':
return {
...state,
data: undefined,
error: action.error,
failureCount: state.failureCount + 1,
isPaused: false,
status: 'error',
}
case 'setState':
return {
...state,
...action.state,
}
default:
return state
}
}
95 changes: 95 additions & 0 deletions src/reactjs/tests/useMutation.test.tsx
Expand Up @@ -445,6 +445,101 @@ describe('useMutation', () => {
onlineMock.mockRestore()
})

it('should call onMutate even if paused', async () => {
const onlineMock = mockNavigatorOnLine(false)
const onMutate = jest.fn()
let count = 0

function Page() {
const mutation = useMutation(
async (_text: string) => {
count++
await sleep(10)
return count
},
{
onMutate,
}
)

return (
<div>
<button onClick={() => mutation.mutate('todo')}>mutate</button>
<div>
data: {mutation.data ?? 'null'}, status: {mutation.status},
isPaused: {String(mutation.isPaused)}
</div>
</div>
)
}

const rendered = renderWithClient(queryClient, <Page />)

await rendered.findByText('data: null, status: idle, isPaused: false')

rendered.getByRole('button', { name: /mutate/i }).click()

await rendered.findByText('data: null, status: loading, isPaused: true')

expect(onMutate).toHaveBeenCalledTimes(1)
expect(onMutate).toHaveBeenCalledWith('todo')

onlineMock.mockReturnValue(true)
window.dispatchEvent(new Event('online'))

await rendered.findByText('data: 1, status: success, isPaused: false')

expect(onMutate).toHaveBeenCalledTimes(1)
expect(count).toBe(1)

onlineMock.mockRestore()
})

it('should optimistically go to paused state if offline', async () => {
const onlineMock = mockNavigatorOnLine(false)
let count = 0
const states: Array<string> = []

function Page() {
const mutation = useMutation(async (_text: string) => {
count++
await sleep(10)
return count
})

states.push(`${mutation.status}, ${mutation.isPaused}`)

return (
<div>
<button onClick={() => mutation.mutate('todo')}>mutate</button>
<div>
data: {mutation.data ?? 'null'}, status: {mutation.status},
isPaused: {String(mutation.isPaused)}
</div>
</div>
)
}

const rendered = renderWithClient(queryClient, <Page />)

await rendered.findByText('data: null, status: idle, isPaused: false')

rendered.getByRole('button', { name: /mutate/i }).click()

await rendered.findByText('data: null, status: loading, isPaused: true')

// no intermediate 'loading, false' state is expected because we don't start mutating!
expect(states[0]).toBe('idle, false')
expect(states[1]).toBe('loading, true')

onlineMock.mockReturnValue(true)
window.dispatchEvent(new Event('online'))

await rendered.findByText('data: 1, status: success, isPaused: false')

onlineMock.mockRestore()
})

it('should be able to retry a mutation when online', async () => {
const consoleMock = mockConsoleError()
const onlineMock = mockNavigatorOnLine(false)
Expand Down

0 comments on commit f804b15

Please sign in to comment.