import { fireEvent, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import * as React from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import type { QueryClient } from '..'
import { MutationCache, QueryCache, useMutation } from '..'
import type { UseMutationResult } from '../types'
import {
createQueryClient,
mockNavigatorOnLine,
queryKey,
renderWithClient,
setActTimeout,
sleep,
} from './utils'
describe('useMutation', () => {
const queryCache = new QueryCache()
const mutationCache = new MutationCache()
const queryClient = createQueryClient({ queryCache, mutationCache })
it('should be able to reset `data`', async () => {
function Page() {
const {
mutate,
data = 'empty',
reset,
} = useMutation(() => Promise.resolve('mutation'))
return (
{data}
)
}
const { getByRole } = renderWithClient(queryClient, )
expect(getByRole('heading').textContent).toBe('empty')
fireEvent.click(getByRole('button', { name: /mutate/i }))
await waitFor(() => {
expect(getByRole('heading').textContent).toBe('mutation')
})
fireEvent.click(getByRole('button', { name: /reset/i }))
await waitFor(() => {
expect(getByRole('heading').textContent).toBe('empty')
})
})
it('should be able to reset `error`', async () => {
function Page() {
const { mutate, error, reset } = useMutation(() => {
const err = new Error('Expected mock error. All is well!')
err.stack = ''
return Promise.reject(err)
})
return (
{error &&
{error.message}
}
)
}
const { getByRole, queryByRole } = renderWithClient(queryClient, )
await waitFor(() => {
expect(queryByRole('heading')).toBeNull()
})
fireEvent.click(getByRole('button', { name: /mutate/i }))
await waitFor(() => {
expect(getByRole('heading').textContent).toBe(
'Expected mock error. All is well!',
)
})
fireEvent.click(getByRole('button', { name: /reset/i }))
await waitFor(() => {
expect(queryByRole('heading')).toBeNull()
})
})
it('should be able to call `onSuccess` and `onSettled` after each successful mutate', async () => {
let count = 0
const onSuccessMock = jest.fn()
const onSettledMock = jest.fn()
function Page() {
const { mutate } = useMutation(
(vars: { count: number }) => Promise.resolve(vars.count),
{
onSuccess: (data) => {
onSuccessMock(data)
},
onSettled: (data) => {
onSettledMock(data)
},
},
)
return (
{count}
)
}
const { getByRole } = renderWithClient(queryClient, )
expect(getByRole('heading').textContent).toBe('0')
fireEvent.click(getByRole('button', { name: /mutate/i }))
fireEvent.click(getByRole('button', { name: /mutate/i }))
fireEvent.click(getByRole('button', { name: /mutate/i }))
await waitFor(() => {
expect(getByRole('heading').textContent).toBe('3')
})
await waitFor(() => {
expect(onSuccessMock).toHaveBeenCalledTimes(3)
})
expect(onSuccessMock).toHaveBeenCalledWith(1)
expect(onSuccessMock).toHaveBeenCalledWith(2)
expect(onSuccessMock).toHaveBeenCalledWith(3)
await waitFor(() => {
expect(onSettledMock).toHaveBeenCalledTimes(3)
})
expect(onSettledMock).toHaveBeenCalledWith(1)
expect(onSettledMock).toHaveBeenCalledWith(2)
expect(onSettledMock).toHaveBeenCalledWith(3)
})
it('should set correct values for `failureReason` and `failureCount` on multiple mutate calls', async () => {
let count = 0
type Value = { count: number }
const mutateFn = jest.fn, [value: Value]>()
mutateFn.mockImplementationOnce(() => {
return Promise.reject('Error test Jonas')
})
mutateFn.mockImplementation(async (value) => {
await sleep(10)
return Promise.resolve(value)
})
function Page() {
const { mutate, failureCount, failureReason, data, status } = useMutation<
Value,
string,
Value
>(mutateFn)
return (
Data {data?.count}
Status {status}
Failed {failureCount} times
Failed because {failureReason ?? 'null'}
)
}
const rendered = renderWithClient(queryClient, )
await waitFor(() => rendered.getByText('Data'))
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await waitFor(() => rendered.getByText('Data'))
await waitFor(() => rendered.getByText('Status error'))
await waitFor(() => rendered.getByText('Failed 1 times'))
await waitFor(() => rendered.getByText('Failed because Error test Jonas'))
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await waitFor(() => rendered.getByText('Status loading'))
await waitFor(() => rendered.getByText('Status success'))
await waitFor(() => rendered.getByText('Data 2'))
await waitFor(() => rendered.getByText('Failed 0 times'))
await waitFor(() => rendered.getByText('Failed because null'))
})
it('should be able to call `onError` and `onSettled` after each failed mutate', async () => {
const onErrorMock = jest.fn()
const onSettledMock = jest.fn()
let count = 0
function Page() {
const { mutate } = useMutation(
(vars: { count: number }) => {
const error = new Error(
`Expected mock error. All is well! ${vars.count}`,
)
error.stack = ''
return Promise.reject(error)
},
{
onError: (error: Error) => {
onErrorMock(error.message)
},
onSettled: (_data, error) => {
onSettledMock(error?.message)
},
},
)
return (
{count}
)
}
const { getByRole } = renderWithClient(queryClient, )
expect(getByRole('heading').textContent).toBe('0')
fireEvent.click(getByRole('button', { name: /mutate/i }))
fireEvent.click(getByRole('button', { name: /mutate/i }))
fireEvent.click(getByRole('button', { name: /mutate/i }))
await waitFor(() => {
expect(getByRole('heading').textContent).toBe('3')
})
await waitFor(() => {
expect(onErrorMock).toHaveBeenCalledTimes(3)
})
expect(onErrorMock).toHaveBeenCalledWith(
'Expected mock error. All is well! 1',
)
expect(onErrorMock).toHaveBeenCalledWith(
'Expected mock error. All is well! 2',
)
expect(onErrorMock).toHaveBeenCalledWith(
'Expected mock error. All is well! 3',
)
await waitFor(() => {
expect(onSettledMock).toHaveBeenCalledTimes(3)
})
expect(onSettledMock).toHaveBeenCalledWith(
'Expected mock error. All is well! 1',
)
expect(onSettledMock).toHaveBeenCalledWith(
'Expected mock error. All is well! 2',
)
expect(onSettledMock).toHaveBeenCalledWith(
'Expected mock error. All is well! 3',
)
})
it('should be able to override the useMutation success callbacks', async () => {
const callbacks: string[] = []
function Page() {
const { mutateAsync } = useMutation(async (text: string) => text, {
onSuccess: async () => {
callbacks.push('useMutation.onSuccess')
},
onSettled: async () => {
callbacks.push('useMutation.onSettled')
},
})
React.useEffect(() => {
setActTimeout(async () => {
try {
const result = await mutateAsync('todo', {
onSuccess: async () => {
callbacks.push('mutateAsync.onSuccess')
},
onSettled: async () => {
callbacks.push('mutateAsync.onSettled')
},
})
callbacks.push(`mutateAsync.result:${result}`)
} catch {}
}, 10)
}, [mutateAsync])
return null
}
renderWithClient(queryClient, )
await sleep(100)
expect(callbacks).toEqual([
'useMutation.onSuccess',
'useMutation.onSettled',
'mutateAsync.onSuccess',
'mutateAsync.onSettled',
'mutateAsync.result:todo',
])
})
it('should be able to override the error callbacks when using mutateAsync', async () => {
const callbacks: string[] = []
function Page() {
const { mutateAsync } = useMutation(
async (_text: string) => Promise.reject('oops'),
{
onError: async () => {
callbacks.push('useMutation.onError')
},
onSettled: async () => {
callbacks.push('useMutation.onSettled')
},
},
)
React.useEffect(() => {
setActTimeout(async () => {
try {
await mutateAsync('todo', {
onError: async () => {
callbacks.push('mutateAsync.onError')
},
onSettled: async () => {
callbacks.push('mutateAsync.onSettled')
},
})
} catch (error) {
callbacks.push(`mutateAsync.error:${error}`)
}
}, 10)
}, [mutateAsync])
return null
}
renderWithClient(queryClient, )
await sleep(100)
expect(callbacks).toEqual([
'useMutation.onError',
'useMutation.onSettled',
'mutateAsync.onError',
'mutateAsync.onSettled',
'mutateAsync.error:oops',
])
})
it('should be able to use mutation defaults', async () => {
const key = queryKey()
queryClient.setMutationDefaults(key, {
mutationFn: async (text: string) => {
await sleep(10)
return text
},
})
const states: UseMutationResult[] = []
function Page() {
const state = useMutation(key)
states.push(state)
const { mutate } = state
React.useEffect(() => {
setActTimeout(() => {
mutate('todo')
}, 10)
}, [mutate])
return null
}
renderWithClient(queryClient, )
await sleep(100)
expect(states.length).toBe(3)
expect(states[0]).toMatchObject({ data: undefined, isLoading: false })
expect(states[1]).toMatchObject({ data: undefined, isLoading: true })
expect(states[2]).toMatchObject({ data: 'todo', isLoading: false })
})
it('should be able to retry a failed mutation', async () => {
let count = 0
function Page() {
const { mutate } = useMutation(
(_text: string) => {
count++
return Promise.reject('oops')
},
{
retry: 1,
retryDelay: 5,
},
)
React.useEffect(() => {
setActTimeout(() => {
mutate('todo')
}, 10)
}, [mutate])
return null
}
renderWithClient(queryClient, )
await sleep(100)
expect(count).toBe(2)
})
it('should not retry mutations while offline', async () => {
const onlineMock = mockNavigatorOnLine(false)
let count = 0
function Page() {
const mutation = useMutation(
(_text: string) => {
count++
return Promise.reject(new Error('oops'))
},
{
retry: 1,
retryDelay: 5,
},
)
return (
error:{' '}
{mutation.error instanceof Error ? mutation.error.message : 'null'},
status: {mutation.status}, isPaused: {String(mutation.isPaused)}
)
}
const rendered = renderWithClient(queryClient, )
await waitFor(() => {
expect(
rendered.getByText('error: null, status: idle, isPaused: false'),
).toBeInTheDocument()
})
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await waitFor(() => {
expect(
rendered.getByText('error: null, status: loading, isPaused: true'),
).toBeInTheDocument()
})
expect(count).toBe(0)
onlineMock.mockReturnValue(true)
window.dispatchEvent(new Event('online'))
await sleep(100)
await waitFor(() => {
expect(
rendered.getByText('error: oops, status: error, isPaused: false'),
).toBeInTheDocument()
})
expect(count).toBe(2)
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 (
data: {mutation.data ?? 'null'}, status: {mutation.status},
isPaused: {String(mutation.isPaused)}
)
}
const rendered = renderWithClient(queryClient, )
await rendered.findByText('data: null, status: idle, isPaused: false')
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
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 = []
function Page() {
const mutation = useMutation(async (_text: string) => {
count++
await sleep(10)
return count
})
states.push(`${mutation.status}, ${mutation.isPaused}`)
return (
data: {mutation.data ?? 'null'}, status: {mutation.status},
isPaused: {String(mutation.isPaused)}
)
}
const rendered = renderWithClient(queryClient, )
await rendered.findByText('data: null, status: idle, isPaused: false')
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
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 onlineMock = mockNavigatorOnLine(false)
let count = 0
const states: UseMutationResult[] = []
function Page() {
const state = useMutation(
async (_text: string) => {
await sleep(1)
count++
return count > 1 ? Promise.resolve('data') : Promise.reject('oops')
},
{
retry: 1,
retryDelay: 5,
networkMode: 'offlineFirst',
},
)
states.push(state)
const { mutate } = state
React.useEffect(() => {
setActTimeout(() => {
mutate('todo')
}, 10)
}, [mutate])
return null
}
renderWithClient(queryClient, )
await sleep(50)
expect(states.length).toBe(4)
expect(states[0]).toMatchObject({
isLoading: false,
isPaused: false,
failureCount: 0,
failureReason: null,
})
expect(states[1]).toMatchObject({
isLoading: true,
isPaused: false,
failureCount: 0,
failureReason: null,
})
expect(states[2]).toMatchObject({
isLoading: true,
isPaused: false,
failureCount: 1,
failureReason: 'oops',
})
expect(states[3]).toMatchObject({
isLoading: true,
isPaused: true,
failureCount: 1,
failureReason: 'oops',
})
onlineMock.mockReturnValue(true)
window.dispatchEvent(new Event('online'))
await sleep(50)
expect(states.length).toBe(6)
expect(states[4]).toMatchObject({
isLoading: true,
isPaused: false,
failureCount: 1,
failureReason: 'oops',
})
expect(states[5]).toMatchObject({
isLoading: false,
isPaused: false,
failureCount: 0,
failureReason: null,
data: 'data',
})
onlineMock.mockRestore()
})
it('should not change state if unmounted', async () => {
function Mutates() {
const { mutate } = useMutation(() => sleep(10))
return
}
function Page() {
const [mounted, setMounted] = React.useState(true)
return (
{mounted && }
)
}
const { getByText } = renderWithClient(queryClient, )
fireEvent.click(getByText('mutate'))
fireEvent.click(getByText('unmount'))
})
it('should be able to throw an error when useErrorBoundary is set to true', async () => {
function Page() {
const { mutate } = useMutation(
() => {
const err = new Error('Expected mock error. All is well!')
err.stack = ''
return Promise.reject(err)
},
{ useErrorBoundary: true },
)
return (
)
}
const { getByText, queryByText } = renderWithClient(
queryClient,
(
error
)}
>
,
)
fireEvent.click(getByText('mutate'))
await waitFor(() => {
expect(queryByText('error')).not.toBeNull()
})
})
it('should be able to throw an error when useErrorBoundary is a function that returns true', async () => {
let boundary = false
function Page() {
const { mutate, error } = useMutation(
() => {
const err = new Error('mock error')
err.stack = ''
return Promise.reject(err)
},
{
useErrorBoundary: () => {
boundary = !boundary
return !boundary
},
},
)
return (
{error && error.message}
)
}
const { getByText, queryByText } = renderWithClient(
queryClient,
(
error boundary
)}
>
,
)
// first error goes to component
fireEvent.click(getByText('mutate'))
await waitFor(() => {
expect(queryByText('mock error')).not.toBeNull()
})
// second error goes to boundary
fireEvent.click(getByText('mutate'))
await waitFor(() => {
expect(queryByText('error boundary')).not.toBeNull()
})
})
it('should pass meta to mutation', async () => {
const errorMock = jest.fn()
const successMock = jest.fn()
const queryClientMutationMeta = createQueryClient({
mutationCache: new MutationCache({
onSuccess: (_, __, ___, mutation) => {
successMock(mutation.meta?.metaSuccessMessage)
},
onError: (_, __, ___, mutation) => {
errorMock(mutation.meta?.metaErrorMessage)
},
}),
})
const metaSuccessMessage = 'mutation succeeded'
const metaErrorMessage = 'mutation failed'
function Page() {
const { mutate: succeed, isSuccess } = useMutation(async () => '', {
meta: { metaSuccessMessage },
})
const { mutate: error, isError } = useMutation(
async () => {
throw new Error('')
},
{
meta: { metaErrorMessage },
},
)
return (
{isSuccess &&
successTest
}
{isError &&
errorTest
}
)
}
const { getByText, queryByText } = renderWithClient(
queryClientMutationMeta,
,
)
fireEvent.click(getByText('succeed'))
fireEvent.click(getByText('error'))
await waitFor(() => {
expect(queryByText('successTest')).not.toBeNull()
expect(queryByText('errorTest')).not.toBeNull()
})
expect(successMock).toHaveBeenCalledTimes(1)
expect(successMock).toHaveBeenCalledWith(metaSuccessMessage)
expect(errorMock).toHaveBeenCalledTimes(1)
expect(errorMock).toHaveBeenCalledWith(metaErrorMessage)
})
it('should call cache callbacks when unmounted', async () => {
const onSuccess = jest.fn()
const onSuccessMutate = jest.fn()
const onSettled = jest.fn()
const onSettledMutate = jest.fn()
const mutationKey = queryKey()
let count = 0
function Page() {
const [show, setShow] = React.useState(true)
return (
{show && }
)
}
function Component() {
const mutation = useMutation(
async (_text: string) => {
count++
await sleep(10)
return count
},
{
mutationKey,
cacheTime: 0,
onSuccess,
onSettled,
},
)
return (
data: {mutation.data ?? 'null'}, status: {mutation.status},
isPaused: {String(mutation.isPaused)}
)
}
const rendered = renderWithClient(queryClient, )
await rendered.findByText('data: null, status: idle, isPaused: false')
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
fireEvent.click(rendered.getByRole('button', { name: /hide/i }))
await waitFor(() => {
expect(
queryClient.getMutationCache().findAll({ mutationKey }),
).toHaveLength(0)
})
expect(count).toBe(1)
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onSettled).toHaveBeenCalledTimes(1)
expect(onSuccessMutate).toHaveBeenCalledTimes(0)
expect(onSettledMutate).toHaveBeenCalledTimes(0)
})
describe('with custom context', () => {
it('should be able to reset `data`', async () => {
const context = React.createContext(undefined)
function Page() {
const {
mutate,
data = 'empty',
reset,
} = useMutation(() => Promise.resolve('mutation'), { context })
return (
{data}
)
}
const { getByRole } = renderWithClient(queryClient, , { context })
expect(getByRole('heading').textContent).toBe('empty')
fireEvent.click(getByRole('button', { name: /mutate/i }))
await waitFor(() => {
expect(getByRole('heading').textContent).toBe('mutation')
})
fireEvent.click(getByRole('button', { name: /reset/i }))
await waitFor(() => {
expect(getByRole('heading').textContent).toBe('empty')
})
})
it('should throw if the context is not passed to useMutation', async () => {
const context = React.createContext(undefined)
function Page() {
const { data = '' } = useMutation(() => Promise.resolve('mutation'))
return (
{data}
)
}
const rendered = renderWithClient(
queryClient,
error boundary
}>
,
{ context },
)
await waitFor(() => rendered.getByText('error boundary'))
})
})
it('should call mutate callbacks only for the last observer', async () => {
const onSuccess = jest.fn()
const onSuccessMutate = jest.fn()
const onSettled = jest.fn()
const onSettledMutate = jest.fn()
let count = 0
function Page() {
const mutation = useMutation(
async (_text: string) => {
count++
await sleep(10)
return `result${count}`
},
{
onSuccess,
onSettled,
},
)
return (
data: {mutation.data ?? 'null'}, status: {mutation.status}
)
}
const rendered = renderWithClient(queryClient, )
await rendered.findByText('data: null, status: idle')
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await rendered.findByText('data: result2, status: success')
expect(count).toBe(2)
expect(onSuccess).toHaveBeenCalledTimes(2)
expect(onSettled).toHaveBeenCalledTimes(2)
expect(onSuccessMutate).toHaveBeenCalledTimes(1)
expect(onSuccessMutate).toHaveBeenCalledWith('result2', 'todo', undefined)
expect(onSettledMutate).toHaveBeenCalledTimes(1)
expect(onSettledMutate).toHaveBeenCalledWith(
'result2',
null,
'todo',
undefined,
)
})
it('should go to error state if onSuccess callback errors', async () => {
const error = new Error('error from onSuccess')
const onError = jest.fn()
function Page() {
const mutation = useMutation(
async (_text: string) => {
await sleep(10)
return 'result'
},
{
onSuccess: () => Promise.reject(error),
onError,
},
)
return (
status: {mutation.status}
)
}
const rendered = renderWithClient(queryClient, )
await rendered.findByText('status: idle')
rendered.getByRole('button', { name: /mutate/i }).click()
await rendered.findByText('status: error')
expect(onError).toHaveBeenCalledWith(error, 'todo', undefined)
})
it('should go to error state if onError callback errors', async () => {
const error = new Error('error from onError')
const mutateFnError = new Error('mutateFnError')
function Page() {
const mutation = useMutation(
async (_text: string) => {
await sleep(10)
throw mutateFnError
},
{
onError: () => Promise.reject(error),
},
)
return (
error:{' '}
{mutation.error instanceof Error ? mutation.error.message : 'null'},
status: {mutation.status}
)
}
const rendered = renderWithClient(queryClient, )
await rendered.findByText('error: null, status: idle')
rendered.getByRole('button', { name: /mutate/i }).click()
await rendered.findByText('error: mutateFnError, status: error')
})
it('should go to error state if onSettled callback errors', async () => {
const error = new Error('error from onSettled')
const mutateFnError = new Error('mutateFnError')
const onError = jest.fn()
function Page() {
const mutation = useMutation(
async (_text: string) => {
await sleep(10)
throw mutateFnError
},
{
onSettled: () => Promise.reject(error),
onError,
},
)
return (
error:{' '}
{mutation.error instanceof Error ? mutation.error.message : 'null'},
status: {mutation.status}
)
}
const rendered = renderWithClient(queryClient, )
await rendered.findByText('error: null, status: idle')
rendered.getByRole('button', { name: /mutate/i }).click()
await rendered.findByText('error: mutateFnError, status: error')
expect(onError).toHaveBeenCalledWith(mutateFnError, 'todo', undefined)
})
it('should not call mutate callbacks for mutations started after unmount', async () => {
const onSuccessMutate = jest.fn()
const onSuccessUseMutation = jest.fn()
const onSettledMutate = jest.fn()
const onSettledUseMutation = jest.fn()
function Page() {
const [show, setShow] = React.useState(true)
return (
{show && }
)
}
function Component() {
const mutation = useMutation({
mutationFn: async (text: string) => {
await sleep(10)
return text
},
onSuccess: onSuccessUseMutation,
onSettled: onSettledUseMutation,
})
return (
)
}
const rendered = renderWithClient(queryClient, )
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
fireEvent.click(rendered.getByRole('button', { name: /hide/i }))
await waitFor(() => expect(onSuccessUseMutation).toHaveBeenCalledTimes(1))
await waitFor(() => expect(onSettledUseMutation).toHaveBeenCalledTimes(1))
expect(onSuccessMutate).toHaveBeenCalledTimes(0)
expect(onSettledMutate).toHaveBeenCalledTimes(0)
})
})