diff --git a/packages/preact-query/src/__tests__/useMutation.test.tsx b/packages/preact-query/src/__tests__/useMutation.test.tsx index 285c47cff4..48ed686fb6 100644 --- a/packages/preact-query/src/__tests__/useMutation.test.tsx +++ b/packages/preact-query/src/__tests__/useMutation.test.tsx @@ -2088,4 +2088,123 @@ describe('useMutation', () => { expect(rendered.getByText('message: result: success')).toBeInTheDocument() }) + + it('should support optimistic update on success', async () => { + function Page() { + const [items, setItems] = useState>([ + 'item1', + 'item2', + 'item3', + ]) + + const [successMessage, setSuccessMessage] = useState('') + + const deleteMutation = useMutation({ + mutationFn: (item: string) => sleep(10).then(() => item), + onMutate: (item) => { + const previousItems = [...items] + setItems((prev) => prev.filter((i) => i !== item)) + return { previousItems } + }, + onSuccess: (deletedItem) => { + setSuccessMessage(`deleted: ${deletedItem}`) + }, + onError: (_error, _item, context) => { + if (context?.previousItems) { + setItems(context.previousItems) + } + }, + }) + + return ( +
+ {items.map((item) => ( + + ))} +
items: {items.join(', ')}
+
success: {successMessage || 'none'}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('items: item1, item2, item3')).toBeInTheDocument() + expect(rendered.getByText('success: none')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /delete item2/i })) + + // optimistic update: item2 removed immediately + expect(rendered.getByText('items: item1, item3')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(11) + + // success: item2 stays removed and onSuccess called + expect(rendered.getByText('items: item1, item3')).toBeInTheDocument() + expect(rendered.getByText('success: deleted: item2')).toBeInTheDocument() + }) + + it('should support optimistic update and rollback on error', async () => { + function Page() { + const [items, setItems] = useState>([ + 'item1', + 'item2', + 'item3', + ]) + + const [message, setMessage] = useState('') + + const deleteMutation = useMutation({ + mutationFn: (item: string) => + sleep(10).then(() => { + throw new Error(`Failed to delete ${item}`) + }), + onMutate: (item) => { + const previousItems = [...items] + setItems((prev) => prev.filter((i) => i !== item)) + return { previousItems } + }, + onSuccess: (deletedItem) => { + setMessage(`deleted: ${deletedItem}`) + }, + onError: (_error, _item, context) => { + setMessage('rollback') + if (context?.previousItems) { + setItems(context.previousItems) + } + }, + retry: false, + }) + + return ( +
+ {items.map((item) => ( + + ))} +
items: {items.join(', ')}
+
message: {message || 'none'}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('items: item1, item2, item3')).toBeInTheDocument() + expect(rendered.getByText('message: none')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /delete item2/i })) + + // optimistic update: item2 removed immediately + expect(rendered.getByText('items: item1, item3')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(11) + + // rollback: item2 restored after error, onSuccess not called + expect(rendered.getByText('items: item1, item2, item3')).toBeInTheDocument() + expect(rendered.getByText('message: rollback')).toBeInTheDocument() + }) }) diff --git a/packages/react-query/src/__tests__/useMutation.test.tsx b/packages/react-query/src/__tests__/useMutation.test.tsx index cf24c29ebd..8fbf481e98 100644 --- a/packages/react-query/src/__tests__/useMutation.test.tsx +++ b/packages/react-query/src/__tests__/useMutation.test.tsx @@ -2087,4 +2087,123 @@ describe('useMutation', () => { expect(rendered.getByText('message: result: success')).toBeInTheDocument() }) + + it('should support optimistic update on success', async () => { + function Page() { + const [items, setItems] = React.useState>([ + 'item1', + 'item2', + 'item3', + ]) + + const [successMessage, setSuccessMessage] = React.useState('') + + const deleteMutation = useMutation({ + mutationFn: (item: string) => sleep(10).then(() => item), + onMutate: (item) => { + const previousItems = [...items] + setItems((prev) => prev.filter((i) => i !== item)) + return { previousItems } + }, + onSuccess: (deletedItem) => { + setSuccessMessage(`deleted: ${deletedItem}`) + }, + onError: (_error, _item, context) => { + if (context?.previousItems) { + setItems(context.previousItems) + } + }, + }) + + return ( +
+ {items.map((item) => ( + + ))} +
items: {items.join(', ')}
+
success: {successMessage || 'none'}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('items: item1, item2, item3')).toBeInTheDocument() + expect(rendered.getByText('success: none')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /delete item2/i })) + + // optimistic update: item2 removed immediately + expect(rendered.getByText('items: item1, item3')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(11) + + // success: item2 stays removed and onSuccess called + expect(rendered.getByText('items: item1, item3')).toBeInTheDocument() + expect(rendered.getByText('success: deleted: item2')).toBeInTheDocument() + }) + + it('should support optimistic update and rollback on error', async () => { + function Page() { + const [items, setItems] = React.useState>([ + 'item1', + 'item2', + 'item3', + ]) + + const [message, setMessage] = React.useState('') + + const deleteMutation = useMutation({ + mutationFn: (item: string) => + sleep(10).then(() => { + throw new Error(`Failed to delete ${item}`) + }), + onMutate: (item) => { + const previousItems = [...items] + setItems((prev) => prev.filter((i) => i !== item)) + return { previousItems } + }, + onSuccess: (deletedItem) => { + setMessage(`deleted: ${deletedItem}`) + }, + onError: (_error, _item, context) => { + setMessage('rollback') + if (context?.previousItems) { + setItems(context.previousItems) + } + }, + retry: false, + }) + + return ( +
+ {items.map((item) => ( + + ))} +
items: {items.join(', ')}
+
message: {message || 'none'}
+
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('items: item1, item2, item3')).toBeInTheDocument() + expect(rendered.getByText('message: none')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /delete item2/i })) + + // optimistic update: item2 removed immediately + expect(rendered.getByText('items: item1, item3')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(11) + + // rollback: item2 restored after error, onSuccess not called + expect(rendered.getByText('items: item1, item2, item3')).toBeInTheDocument() + expect(rendered.getByText('message: rollback')).toBeInTheDocument() + }) })