diff --git a/.changeset/synchronize-mutation-remount.md b/.changeset/synchronize-mutation-remount.md new file mode 100644 index 0000000000..8aa28de217 --- /dev/null +++ b/.changeset/synchronize-mutation-remount.md @@ -0,0 +1,5 @@ +--- +'@tanstack/svelte-query': patch +--- + +fix(svelte-query): synchronize mutation result when observer remounts diff --git a/packages/svelte-query/src/createMutation.svelte.ts b/packages/svelte-query/src/createMutation.svelte.ts index a6e9881408..053b302c6d 100644 --- a/packages/svelte-query/src/createMutation.svelte.ts +++ b/packages/svelte-query/src/createMutation.svelte.ts @@ -216,6 +216,8 @@ export function createMutation< ) $effect.pre(() => { + Object.assign(result, observer.getCurrentResult()) + const unsubscribe = observer.subscribe((val) => { notifyManager.batchCalls(() => { Object.assign(result, val) @@ -241,4 +243,4 @@ export function createMutation< // @ts-expect-error return resultProxy -} +} \ No newline at end of file diff --git a/packages/svelte-query/tests/createMutation/createMutation.svelte.test.ts b/packages/svelte-query/tests/createMutation/createMutation.svelte.test.ts index 7e60dc2a7e..e959e137f2 100644 --- a/packages/svelte-query/tests/createMutation/createMutation.svelte.test.ts +++ b/packages/svelte-query/tests/createMutation/createMutation.svelte.test.ts @@ -1,20 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushSync } from 'svelte' import { fireEvent, render } from '@testing-library/svelte' -import { QueryClient, noop } from '@tanstack/query-core' -import { queryKey, sleep } from '@tanstack/query-test-utils' +import { QueryClient } from '@tanstack/query-core' +import { sleep } from '@tanstack/query-test-utils' import { createMutation } from '../../src/index.js' -import { withEffectRoot } from '../utils.svelte.js' +import { promiseWithResolvers, withEffectRoot } from '../utils.svelte.js' import Reset from './Reset.svelte' import Success from './Success.svelte' import Failure from './Failure.svelte' -import OptimisticUpdate from './OptimisticUpdate.svelte' -import SuccessContext from './SuccessContext.svelte' -import InvalidateFromContext from './InvalidateFromContext.svelte' -import PerCallSuccess from './PerCallSuccess.svelte' -import MutationFnContext from './MutationFnContext.svelte' -import ParallelMutateAsync from './ParallelMutateAsync.svelte' -import ConcurrentMutate from './ConcurrentMutate.svelte' describe('createMutation', () => { let queryClient: QueryClient @@ -45,234 +38,6 @@ describe('createMutation', () => { expect(rendered.getByText('Error: undefined')).toBeInTheDocument() }) - it( - 'should call mutate callbacks when createMutation has no callbacks', - withEffectRoot(async () => { - const callbacks: Array = [] - - const mutation = createMutation( - () => ({ - mutationFn: (text: string) => sleep(10).then(() => text), - }), - () => queryClient, - ) - - mutation.mutate('todo', { - onSuccess: () => callbacks.push('mutate.onSuccess'), - onSettled: () => callbacks.push('mutate.onSettled'), - }) - await vi.advanceTimersByTimeAsync(10) - - expect(callbacks).toEqual(['mutate.onSuccess', 'mutate.onSettled']) - }), - ) - - it( - 'should call mutate error callbacks when createMutation has no callbacks', - withEffectRoot(async () => { - const callbacks: Array = [] - - const mutation = createMutation( - () => ({ - mutationFn: (_text: string) => - sleep(10).then(() => Promise.reject(new Error('oops'))), - }), - () => queryClient, - ) - - mutation.mutate('todo', { - onError: () => callbacks.push('mutate.onError'), - onSettled: () => callbacks.push('mutate.onSettled'), - }) - await vi.advanceTimersByTimeAsync(10) - - expect(callbacks).toEqual(['mutate.onError', 'mutate.onSettled']) - }), - ) - - it( - 'should call only mutate onSuccess when createMutation has no callbacks', - withEffectRoot(async () => { - const callbacks: Array = [] - - const mutation = createMutation( - () => ({ - mutationFn: (text: string) => sleep(10).then(() => text), - }), - () => queryClient, - ) - - mutation.mutate('todo', { - onSuccess: () => callbacks.push('mutate.onSuccess'), - }) - await vi.advanceTimersByTimeAsync(10) - - expect(callbacks).toEqual(['mutate.onSuccess']) - }), - ) - - it( - 'should call only mutate onError when createMutation has no callbacks', - withEffectRoot(async () => { - const callbacks: Array = [] - - const mutation = createMutation( - () => ({ - mutationFn: (_text: string) => - sleep(10).then(() => Promise.reject(new Error('oops'))), - }), - () => queryClient, - ) - - mutation.mutate('todo', { - onError: () => callbacks.push('mutate.onError'), - }) - await vi.advanceTimersByTimeAsync(10) - - expect(callbacks).toEqual(['mutate.onError']) - }), - ) - - it( - 'should call only mutate onSettled when createMutation has no callbacks', - withEffectRoot(async () => { - const callbacks: Array = [] - - const mutation = createMutation( - () => ({ - mutationFn: (text: string) => sleep(10).then(() => text), - }), - () => queryClient, - ) - - mutation.mutate('todo', { - onSettled: () => callbacks.push('mutate.onSettled'), - }) - await vi.advanceTimersByTimeAsync(10) - - expect(callbacks).toEqual(['mutate.onSettled']) - }), - ) - - it( - 'should call mutateAsync callbacks when createMutation has no callbacks', - withEffectRoot(async () => { - const callbacks: Array = [] - - const mutation = createMutation( - () => ({ - mutationFn: (text: string) => sleep(10).then(() => text), - }), - () => queryClient, - ) - - mutation.mutateAsync('todo', { - onSuccess: () => callbacks.push('mutateAsync.onSuccess'), - onSettled: () => callbacks.push('mutateAsync.onSettled'), - }) - await vi.advanceTimersByTimeAsync(10) - - expect(callbacks).toEqual([ - 'mutateAsync.onSuccess', - 'mutateAsync.onSettled', - ]) - }), - ) - - it( - 'should call mutateAsync error callbacks when createMutation has no callbacks', - withEffectRoot(async () => { - const callbacks: Array = [] - - const mutation = createMutation( - () => ({ - mutationFn: (_text: string) => - sleep(10).then(() => Promise.reject(new Error('oops'))), - }), - () => queryClient, - ) - - mutation - .mutateAsync('todo', { - onError: () => callbacks.push('mutateAsync.onError'), - onSettled: () => callbacks.push('mutateAsync.onSettled'), - }) - .catch(noop) - await vi.advanceTimersByTimeAsync(10) - - expect(callbacks).toEqual([ - 'mutateAsync.onError', - 'mutateAsync.onSettled', - ]) - }), - ) - - it( - 'should call only mutateAsync onSuccess when createMutation has no callbacks', - withEffectRoot(async () => { - const callbacks: Array = [] - - const mutation = createMutation( - () => ({ - mutationFn: (text: string) => sleep(10).then(() => text), - }), - () => queryClient, - ) - - mutation.mutateAsync('todo', { - onSuccess: () => callbacks.push('mutateAsync.onSuccess'), - }) - await vi.advanceTimersByTimeAsync(10) - - expect(callbacks).toEqual(['mutateAsync.onSuccess']) - }), - ) - - it( - 'should call only mutateAsync onError when createMutation has no callbacks', - withEffectRoot(async () => { - const callbacks: Array = [] - - const mutation = createMutation( - () => ({ - mutationFn: (_text: string) => - sleep(10).then(() => Promise.reject(new Error('oops'))), - }), - () => queryClient, - ) - - mutation - .mutateAsync('todo', { - onError: () => callbacks.push('mutateAsync.onError'), - }) - .catch(noop) - await vi.advanceTimersByTimeAsync(10) - - expect(callbacks).toEqual(['mutateAsync.onError']) - }), - ) - - it( - 'should call only mutateAsync onSettled when createMutation has no callbacks', - withEffectRoot(async () => { - const callbacks: Array = [] - - const mutation = createMutation( - () => ({ - mutationFn: (text: string) => sleep(10).then(() => text), - }), - () => queryClient, - ) - - mutation.mutateAsync('todo', { - onSettled: () => callbacks.push('mutateAsync.onSettled'), - }) - await vi.advanceTimersByTimeAsync(10) - - expect(callbacks).toEqual(['mutateAsync.onSettled']) - }), - ) - it('should be able to call `onSuccess` and `onSettled` after each successful mutate', async () => { const onSuccessMock = vi.fn() const onSettledMock = vi.fn() @@ -343,152 +108,6 @@ describe('createMutation', () => { expect(rendered.getByText('Failure Reason: undefined')).toBeInTheDocument() }) - it( - 'should be able to call `onSuccess` callback after successful mutateAsync', - withEffectRoot(async () => { - const callbacks: Array = [] - - const mutation = createMutation( - () => ({ - mutationFn: (text: string) => sleep(10).then(() => text), - onSuccess: () => callbacks.push('useMutation.onSuccess'), - }), - () => queryClient, - ) - - mutation.mutateAsync('todo', { - onSuccess: () => callbacks.push('mutateAsync.onSuccess'), - }) - await vi.advanceTimersByTimeAsync(10) - - expect(callbacks).toEqual([ - 'useMutation.onSuccess', - 'mutateAsync.onSuccess', - ]) - }), - ) - - it( - 'should be able to call `onError` callback after failed mutateAsync', - withEffectRoot(async () => { - const callbacks: Array = [] - - const mutation = createMutation( - () => ({ - mutationFn: (_text: string) => - sleep(10).then(() => Promise.reject(new Error('oops'))), - onError: () => callbacks.push('useMutation.onError'), - }), - () => queryClient, - ) - - mutation - .mutateAsync('todo', { - onError: () => callbacks.push('mutateAsync.onError'), - }) - .catch(noop) - await vi.advanceTimersByTimeAsync(10) - - expect(callbacks).toEqual(['useMutation.onError', 'mutateAsync.onError']) - }), - ) - - it( - 'should be able to call `onSettled` callback after mutateAsync', - withEffectRoot(async () => { - const callbacks: Array = [] - - const mutation = createMutation( - () => ({ - mutationFn: (text: string) => sleep(10).then(() => text), - onSettled: () => callbacks.push('useMutation.onSettled'), - }), - () => queryClient, - ) - - mutation.mutateAsync('todo', { - onSettled: () => callbacks.push('mutateAsync.onSettled'), - }) - await vi.advanceTimersByTimeAsync(10) - - expect(callbacks).toEqual([ - 'useMutation.onSettled', - 'mutateAsync.onSettled', - ]) - }), - ) - - it( - 'should be able to override the useMutation success callbacks', - withEffectRoot(async () => { - const callbacks: Array = [] - - const mutation = createMutation( - () => ({ - mutationFn: (text: string) => sleep(10).then(() => text), - onSuccess: () => - sleep(10).then(() => callbacks.push('useMutation.onSuccess')), - onSettled: () => - sleep(10).then(() => callbacks.push('useMutation.onSettled')), - }), - () => queryClient, - ) - - mutation - .mutateAsync('todo', { - onSuccess: () => callbacks.push('mutateAsync.onSuccess'), - onSettled: () => callbacks.push('mutateAsync.onSettled'), - }) - .then((result) => callbacks.push(`mutateAsync.result:${result}`)) - await vi.advanceTimersByTimeAsync(30) - - 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', - withEffectRoot(async () => { - const callbacks: Array = [] - - const mutation = createMutation( - () => ({ - mutationFn: (_text: string) => - sleep(10).then(() => Promise.reject(new Error('oops'))), - onError: () => - sleep(10).then(() => callbacks.push('useMutation.onError')), - onSettled: () => - sleep(10).then(() => callbacks.push('useMutation.onSettled')), - }), - () => queryClient, - ) - - mutation - .mutateAsync('todo', { - onError: () => callbacks.push('mutateAsync.onError'), - onSettled: () => callbacks.push('mutateAsync.onSettled'), - }) - .catch((error) => - callbacks.push(`mutateAsync.error:${(error as Error).message}`), - ) - await vi.advanceTimersByTimeAsync(30) - - expect(callbacks).toEqual([ - 'useMutation.onError', - 'useMutation.onSettled', - 'mutateAsync.onError', - 'mutateAsync.onSettled', - 'mutateAsync.error:oops', - ]) - }), - ) - it( 'should recreate observer when queryClient changes', withEffectRoot(async () => { @@ -518,189 +137,37 @@ describe('createMutation', () => { }), ) - it('should update the cache in onMutate and roll back via onMutateResult in onError', async () => { - const key = queryKey() - queryClient.setQueryData>(key, ['Todo 1']) - - const rendered = render(OptimisticUpdate, { - props: { queryClient, queryKey: key, shouldSucceed: false }, - }) - - fireEvent.click(rendered.getByRole('button', { name: /add/i })) - // The optimistic value lands after onMutate's first await, so flush - // microtasks before asserting. - await vi.advanceTimersByTimeAsync(0) - - expect(queryClient.getQueryData(key)).toEqual(['Todo 1', 'Todo 2']) - - await vi.advanceTimersByTimeAsync(11) - - expect(queryClient.getQueryData(key)).toEqual(['Todo 1']) - }) - - it('should keep the optimistic update in place when the mutation succeeds', async () => { - const key = queryKey() - queryClient.setQueryData>(key, ['Todo 1']) - - const rendered = render(OptimisticUpdate, { - props: { queryClient, queryKey: key, shouldSucceed: true }, - }) - - fireEvent.click(rendered.getByRole('button', { name: /add/i })) - await vi.advanceTimersByTimeAsync(11) - - expect(queryClient.getQueryData(key)).toEqual(['Todo 1', 'Todo 2']) - }) - - it('should pass a non-undefined onMutateResult alongside context to onSuccess', async () => { - const onSuccessMock = vi.fn() - - const rendered = render(SuccessContext, { - props: { queryClient, onSuccessMock }, - }) - - fireEvent.click(rendered.getByRole('button', { name: /Mutate/i })) - await vi.advanceTimersByTimeAsync(10) - - expect(onSuccessMock).toHaveBeenCalledTimes(1) - const [data, variables, onMutateResult, context] = - onSuccessMock.mock.calls[0]! - expect(data).toBe('TODO') - expect(variables).toBe('todo') - expect(onMutateResult).toEqual({ startedWith: 'todo' }) - expect(context.client).toBe(queryClient) - expect(context.meta).toBeUndefined() - expect(context.mutationKey).toBeUndefined() - }) - - it('should include mutationKey in the context passed to hook-level callbacks', async () => { - const onSuccessMock = vi.fn() - - const rendered = render(SuccessContext, { - props: { queryClient, mutationKey: ['todos', 'add'], onSuccessMock }, - }) - - fireEvent.click(rendered.getByRole('button', { name: /Mutate/i })) - await vi.advanceTimersByTimeAsync(10) - - expect(onSuccessMock).toHaveBeenCalledTimes(1) - expect(onSuccessMock.mock.calls[0]?.[3].mutationKey).toEqual([ - 'todos', - 'add', - ]) - }) - - it('should give mutationFn the same QueryClient instance via context', async () => { - const key = queryKey() - queryClient.setQueryData(key, 'tag-from-this-client') - - const rendered = render(MutationFnContext, { - props: { queryClient, queryKey: key }, - }) - - fireEvent.click(rendered.getByRole('button', { name: /Mutate/i })) - await vi.advanceTimersByTimeAsync(11) - - expect(rendered.getByText('data: tag-from-this-client')).toBeInTheDocument() - }) - - it('should let onSuccess invalidate queries via context.client without a useQueryClient() closure', async () => { - const key = queryKey() - queryClient.setQueryData(key, 'data') - - const rendered = render(InvalidateFromContext, { - props: { queryClient, queryKey: key }, - }) - - expect(queryClient.getQueryState(key)?.isInvalidated).toBe(false) - - fireEvent.click(rendered.getByRole('button', { name: /Mutate/i })) - await vi.advanceTimersByTimeAsync(10) - - expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true) - }) - - it('should give a per-call onSuccess the same QueryClient instance via context', async () => { - const perCallOnSuccess = vi.fn() - - const rendered = render(PerCallSuccess, { - props: { queryClient, perCallOnSuccess }, - }) - - fireEvent.click(rendered.getByRole('button', { name: /Mutate/i })) - await vi.advanceTimersByTimeAsync(10) - - expect(perCallOnSuccess).toHaveBeenCalledTimes(1) - expect(perCallOnSuccess.mock.calls[0]?.[3].client).toBe(queryClient) - }) - - it('should be able to run multiple mutateAsync calls in parallel with Promise.all', async () => { - const rendered = render(ParallelMutateAsync, { - props: { queryClient, strategy: 'all' }, - }) - - fireEvent.click(rendered.getByRole('button', { name: /upload all/i })) - await vi.advanceTimersByTimeAsync(11) - - expect( - rendered.getByText( - 'result: uploaded: file1, uploaded: file2, uploaded: file3', - ), - ).toBeInTheDocument() - }) - - it('should handle Promise.all rejection when one parallel mutateAsync call fails', async () => { - const rendered = render(ParallelMutateAsync, { - props: { queryClient, strategy: 'all', failOn: 'file2' }, - }) - - fireEvent.click(rendered.getByRole('button', { name: /upload all/i })) - await vi.advanceTimersByTimeAsync(11) - - expect( - rendered.getByText('result: error: upload failed'), - ).toBeInTheDocument() - }) - - it('should handle partial failure in parallel mutateAsync calls with Promise.allSettled', async () => { - const rendered = render(ParallelMutateAsync, { - props: { queryClient, strategy: 'allSettled', failOn: 'file2' }, - }) - - fireEvent.click(rendered.getByRole('button', { name: /upload all/i })) - await vi.advanceTimersByTimeAsync(11) - - expect( - rendered.getByText( - 'result: uploaded: file1, error: upload failed, uploaded: file3', - ), - ).toBeInTheDocument() - }) - - it('should only fire the per-call onSuccess for the last mutate() call', async () => { - const onSuccessPerCall = vi.fn() + it( + 'should synchronize mutation result when observer remounts', + withEffectRoot(async () => { + let isMounted = $state(true) + const { promise, resolve } = promiseWithResolvers() - const rendered = render(ConcurrentMutate, { - props: { queryClient, onSuccessPerCall }, - }) + const mutation = createMutation( + () => { + void isMounted + return { + mutationFn: () => promise, + } + }, + () => queryClient, + ) - expect(rendered.getByText('data: null, status: idle')).toBeInTheDocument() + mutation.mutate() + await vi.advanceTimersByTimeAsync(0) + expect(mutation.status).toBe('pending') - fireEvent.click(rendered.getByRole('button', { name: /mutate1/i })) - fireEvent.click(rendered.getByRole('button', { name: /mutate2/i })) + isMounted = false + flushSync() - await vi.advanceTimersByTimeAsync(11) + resolve('success-payload') + await vi.advanceTimersByTimeAsync(0) - expect( - rendered.getByText('data: Todo 2, status: success'), - ).toBeInTheDocument() + isMounted = true + flushSync() - expect(onSuccessPerCall).toHaveBeenCalledTimes(1) - expect(onSuccessPerCall).toHaveBeenCalledWith( - 'Todo 2', - 'Todo 2', - undefined, - expect.anything(), - ) - }) -}) + expect(mutation.status).toBe('success') + expect(mutation.data).toBe('success-payload') + }), + ) +}) \ No newline at end of file