From 6244391a188593f24c18e8d62825354c1c4997f1 Mon Sep 17 00:00:00 2001 From: VedAnt-1004 Date: Thu, 27 Aug 2026 23:52:06 +0530 Subject: [PATCH 1/2] fix(svelte-query): updated mutation state synchronously upon observer resolution --- .../svelte-query/src/createMutation.svelte.ts | 4 +- .../createMutation.svelte.test.ts | 167 ++++++++---------- 2 files changed, 73 insertions(+), 98 deletions(-) diff --git a/packages/svelte-query/src/createMutation.svelte.ts b/packages/svelte-query/src/createMutation.svelte.ts index 9bff1c5614a..8d37ab65864 100644 --- a/packages/svelte-query/src/createMutation.svelte.ts +++ b/packages/svelte-query/src/createMutation.svelte.ts @@ -62,6 +62,8 @@ export function createMutation< ) $effect.pre(() => { + Object.assign(result, observer.getCurrentResult()) + const unsubscribe = observer.subscribe((val) => { notifyManager.batchCalls(() => { Object.assign(result, val) @@ -87,4 +89,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 05ec2ddbf75..ac1b3dff477 100644 --- a/packages/svelte-query/tests/createMutation/createMutation.svelte.test.ts +++ b/packages/svelte-query/tests/createMutation/createMutation.svelte.test.ts @@ -1,48 +1,20 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushSync } from 'svelte' -import { fireEvent, render } from '@testing-library/svelte' -import { QueryClient } from '@tanstack/query-core' +import { describe, expect, test, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte' import { sleep } from '@tanstack/query-test-utils' -import { createMutation } from '../../src/index.js' -import { withEffectRoot } from '../utils.svelte.js' -import Reset from './Reset.svelte' +import { QueryClient } from '@tanstack/query-core' +import { createMutation } from '../../src/createMutation.svelte.js' +import { promiseWithResolvers, withEffectRoot } from '../utils.svelte.js' import Success from './Success.svelte' import Failure from './Failure.svelte' +import Reset from './Reset.svelte' describe('createMutation', () => { - let queryClient: QueryClient - - beforeEach(() => { - vi.useFakeTimers() - queryClient = new QueryClient() - }) - - afterEach(() => { - queryClient.clear() - vi.useRealTimers() - }) - - it('should be able to reset `error`', async () => { - const rendered = render(Reset, { - props: { queryClient }, - }) - - expect(rendered.queryByText('Error: undefined')).toBeInTheDocument() - - fireEvent.click(rendered.getByRole('button', { name: /Mutate/i })) - await vi.advanceTimersByTimeAsync(11) - expect(rendered.getByText('Error: Expected mock error')).toBeInTheDocument() - - fireEvent.click(rendered.getByRole('button', { name: /Reset/i })) - await vi.advanceTimersByTimeAsync(11) - expect(rendered.getByText('Error: undefined')).toBeInTheDocument() - }) - - it('should be able to call `onSuccess` and `onSettled` after each successful mutate', async () => { + test('Success', async () => { + const queryClient = new QueryClient() const onSuccessMock = vi.fn() const onSettledMock = vi.fn() - const rendered = render(Success, { + render(Success, { props: { queryClient, onSuccessMock, @@ -50,90 +22,91 @@ describe('createMutation', () => { }, }) - expect(rendered.queryByText('Count: 0')).toBeInTheDocument() + expect(screen.getByText('Count: 0')).toBeInTheDocument() - fireEvent.click(rendered.getByRole('button', { name: /Mutate/i })) - fireEvent.click(rendered.getByRole('button', { name: /Mutate/i })) - fireEvent.click(rendered.getByRole('button', { name: /Mutate/i })) - await vi.advanceTimersByTimeAsync(11) - expect(rendered.queryByText('Count: 3')).toBeInTheDocument() + await fireEvent.click(screen.getByRole('button', { name: /Mutate/i })) - expect(onSuccessMock).toHaveBeenCalledTimes(3) - expect(onSuccessMock).toHaveBeenNthCalledWith(1, 1) - expect(onSuccessMock).toHaveBeenNthCalledWith(2, 2) - expect(onSuccessMock).toHaveBeenNthCalledWith(3, 3) + expect(screen.getByText('Count: 1')).toBeInTheDocument() - expect(onSettledMock).toHaveBeenCalledTimes(3) - expect(onSettledMock).toHaveBeenNthCalledWith(1, 1) - expect(onSettledMock).toHaveBeenNthCalledWith(2, 2) - expect(onSettledMock).toHaveBeenNthCalledWith(3, 3) + await waitFor(() => { + expect(onSuccessMock).toHaveBeenCalledTimes(1) + expect(onSuccessMock).toHaveBeenCalledWith(1) + expect(onSettledMock).toHaveBeenCalledTimes(1) + expect(onSettledMock).toHaveBeenCalledWith(1) + }) }) - it('should set correct values for `failureReason` and `failureCount` on multiple mutate calls', async () => { - type Value = { count: number } + test('Failure', async () => { + const queryClient = new QueryClient() + const mutationFn = vi.fn().mockImplementation(() => + sleep(10).then(() => { + throw new Error('Mutation failed') + }), + ) + + render(Failure, { + props: { + queryClient, + mutationFn, + }, + }) + + expect(screen.getByText('Status: idle')).toBeInTheDocument() - const mutationFn = vi.fn<(value: Value) => Promise>() + await fireEvent.click(screen.getByRole('button', { name: /Mutate/i })) - mutationFn.mockImplementationOnce(() => - sleep(20).then(() => Promise.reject(`Expected mock error`)), - ) + await waitFor(() => { + expect(screen.getByText('Status: error')).toBeInTheDocument() + expect(screen.getByText('Failure Count: 1')).toBeInTheDocument() + }) + }) - mutationFn.mockImplementation((value) => sleep(10).then(() => value)) + test('Reset', async () => { + const queryClient = new QueryClient() - const rendered = render(Failure, { + render(Reset, { props: { queryClient, - mutationFn, }, }) - expect(rendered.queryByText('Data: undefined')).toBeInTheDocument() - - fireEvent.click(rendered.getByRole('button', { name: /Mutate/i })) - expect(rendered.getByText('Data: undefined')).toBeInTheDocument() - await vi.advanceTimersByTimeAsync(21) - expect(rendered.getByText('Status: error')).toBeInTheDocument() - expect(rendered.getByText('Failure Count: 1')).toBeInTheDocument() - expect( - rendered.getByText('Failure Reason: Expected mock error'), - ).toBeInTheDocument() - - fireEvent.click(rendered.getByRole('button', { name: /Mutate/i })) - await vi.advanceTimersByTimeAsync(0) - expect(rendered.getByText('Status: pending')).toBeInTheDocument() - await vi.advanceTimersByTimeAsync(11) - expect(rendered.getByText('Status: success')).toBeInTheDocument() - expect(rendered.getByText('Data: 2')).toBeInTheDocument() - expect(rendered.getByText('Failure Count: 0')).toBeInTheDocument() - expect(rendered.getByText('Failure Reason: undefined')).toBeInTheDocument() + expect(screen.getByText('Error: undefined')).toBeInTheDocument() + + await fireEvent.click(screen.getByRole('button', { name: /Mutate/i })) + + await waitFor(() => { + expect(screen.getByText('Error: Expected mock error')).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /Reset/i })) + + await waitFor(() => { + expect(screen.getByText('Error: undefined')).toBeInTheDocument() + }) }) - it( - 'should recreate observer when queryClient changes', + test( + 'should synchronize status when background mutation resolves', withEffectRoot(async () => { - const queryClient1 = new QueryClient() - const queryClient2 = new QueryClient() - - let activeClient = $state(queryClient1) + const queryClient = new QueryClient() + const { promise, resolve } = promiseWithResolvers() const mutation = createMutation( () => ({ - mutationFn: (params: string) => sleep(10).then(() => params), + mutationFn: () => promise, }), - () => activeClient, + () => queryClient, ) - mutation.mutate('first') - await vi.advanceTimersByTimeAsync(11) - - expect(mutation.status).toBe('success') - expect(mutation.data).toBe('first') + mutation.mutate() + await sleep(1) + expect(mutation.status).toBe('pending') - activeClient = queryClient2 - flushSync() + resolve('success-payload') + await sleep(10) - expect(mutation.status).toBe('idle') - expect(mutation.data).toBeUndefined() + expect(mutation.status).toBe('success') + expect(mutation.data).toBe('success-payload') }), ) -}) +}) \ No newline at end of file From 0380d80b85e1cb2e8d102ab3dc688b24ce283548 Mon Sep 17 00:00:00 2001 From: VedAnt-1004 Date: Thu, 10 Sep 2026 04:18:29 +0530 Subject: [PATCH 2/2] test(svelte-query): verify mutation result sync upon observer remount and add changeset --- .changeset/synchronize-mutation-remount.md | 5 + .../createMutation.svelte.test.ts | 180 ++++++++++++------ 2 files changed, 128 insertions(+), 57 deletions(-) create mode 100644 .changeset/synchronize-mutation-remount.md diff --git a/.changeset/synchronize-mutation-remount.md b/.changeset/synchronize-mutation-remount.md new file mode 100644 index 00000000000..8aa28de217d --- /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/tests/createMutation/createMutation.svelte.test.ts b/packages/svelte-query/tests/createMutation/createMutation.svelte.test.ts index ac1b3dff477..aae0df5747a 100644 --- a/packages/svelte-query/tests/createMutation/createMutation.svelte.test.ts +++ b/packages/svelte-query/tests/createMutation/createMutation.svelte.test.ts @@ -1,20 +1,48 @@ -import { describe, expect, test, vi } from 'vitest' -import { fireEvent, render, screen, waitFor } from '@testing-library/svelte' -import { sleep } from '@tanstack/query-test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushSync } from 'svelte' +import { fireEvent, render } from '@testing-library/svelte' import { QueryClient } from '@tanstack/query-core' -import { createMutation } from '../../src/createMutation.svelte.js' +import { sleep } from '@tanstack/query-test-utils' +import { createMutation } from '../../src/index.js' import { promiseWithResolvers, withEffectRoot } from '../utils.svelte.js' +import Reset from './Reset.svelte' import Success from './Success.svelte' import Failure from './Failure.svelte' -import Reset from './Reset.svelte' describe('createMutation', () => { - test('Success', async () => { - const queryClient = new QueryClient() + let queryClient: QueryClient + + beforeEach(() => { + vi.useFakeTimers() + queryClient = new QueryClient() + }) + + afterEach(() => { + queryClient.clear() + vi.useRealTimers() + }) + + it('should be able to reset `error`', async () => { + const rendered = render(Reset, { + props: { queryClient }, + }) + + expect(rendered.queryByText('Error: undefined')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /Mutate/i })) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('Error: Expected mock error')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /Reset/i })) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('Error: undefined')).toBeInTheDocument() + }) + + it('should be able to call `onSuccess` and `onSettled` after each successful mutate', async () => { const onSuccessMock = vi.fn() const onSettledMock = vi.fn() - render(Success, { + const rendered = render(Success, { props: { queryClient, onSuccessMock, @@ -22,89 +50,127 @@ describe('createMutation', () => { }, }) - expect(screen.getByText('Count: 0')).toBeInTheDocument() + expect(rendered.queryByText('Count: 0')).toBeInTheDocument() - await fireEvent.click(screen.getByRole('button', { name: /Mutate/i })) + fireEvent.click(rendered.getByRole('button', { name: /Mutate/i })) + fireEvent.click(rendered.getByRole('button', { name: /Mutate/i })) + fireEvent.click(rendered.getByRole('button', { name: /Mutate/i })) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.queryByText('Count: 3')).toBeInTheDocument() - expect(screen.getByText('Count: 1')).toBeInTheDocument() + expect(onSuccessMock).toHaveBeenCalledTimes(3) + expect(onSuccessMock).toHaveBeenNthCalledWith(1, 1) + expect(onSuccessMock).toHaveBeenNthCalledWith(2, 2) + expect(onSuccessMock).toHaveBeenNthCalledWith(3, 3) - await waitFor(() => { - expect(onSuccessMock).toHaveBeenCalledTimes(1) - expect(onSuccessMock).toHaveBeenCalledWith(1) - expect(onSettledMock).toHaveBeenCalledTimes(1) - expect(onSettledMock).toHaveBeenCalledWith(1) - }) + expect(onSettledMock).toHaveBeenCalledTimes(3) + expect(onSettledMock).toHaveBeenNthCalledWith(1, 1) + expect(onSettledMock).toHaveBeenNthCalledWith(2, 2) + expect(onSettledMock).toHaveBeenNthCalledWith(3, 3) }) - test('Failure', async () => { - const queryClient = new QueryClient() - const mutationFn = vi.fn().mockImplementation(() => - sleep(10).then(() => { - throw new Error('Mutation failed') - }), + it('should set correct values for `failureReason` and `failureCount` on multiple mutate calls', async () => { + type Value = { count: number } + + const mutationFn = vi.fn<(value: Value) => Promise>() + + mutationFn.mockImplementationOnce(() => + sleep(20).then(() => Promise.reject(`Expected mock error`)), ) - render(Failure, { + mutationFn.mockImplementation((value) => sleep(10).then(() => value)) + + const rendered = render(Failure, { props: { queryClient, mutationFn, }, }) - expect(screen.getByText('Status: idle')).toBeInTheDocument() - - await fireEvent.click(screen.getByRole('button', { name: /Mutate/i })) - - await waitFor(() => { - expect(screen.getByText('Status: error')).toBeInTheDocument() - expect(screen.getByText('Failure Count: 1')).toBeInTheDocument() - }) + expect(rendered.queryByText('Data: undefined')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /Mutate/i })) + expect(rendered.getByText('Data: undefined')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(21) + expect(rendered.getByText('Status: error')).toBeInTheDocument() + expect(rendered.getByText('Failure Count: 1')).toBeInTheDocument() + expect( + rendered.getByText('Failure Reason: Expected mock error'), + ).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /Mutate/i })) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('Status: pending')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('Status: success')).toBeInTheDocument() + expect(rendered.getByText('Data: 2')).toBeInTheDocument() + expect(rendered.getByText('Failure Count: 0')).toBeInTheDocument() + expect(rendered.getByText('Failure Reason: undefined')).toBeInTheDocument() }) - test('Reset', async () => { - const queryClient = new QueryClient() + it( + 'should recreate observer when queryClient changes', + withEffectRoot(async () => { + const queryClient1 = new QueryClient() + const queryClient2 = new QueryClient() - render(Reset, { - props: { - queryClient, - }, - }) + let activeClient = $state(queryClient1) - expect(screen.getByText('Error: undefined')).toBeInTheDocument() + const mutation = createMutation( + () => ({ + mutationFn: (params: string) => sleep(10).then(() => params), + }), + () => activeClient, + ) - await fireEvent.click(screen.getByRole('button', { name: /Mutate/i })) + mutation.mutate('first') + await vi.advanceTimersByTimeAsync(11) - await waitFor(() => { - expect(screen.getByText('Error: Expected mock error')).toBeInTheDocument() - }) + expect(mutation.status).toBe('success') + expect(mutation.data).toBe('first') - await fireEvent.click(screen.getByRole('button', { name: /Reset/i })) + activeClient = queryClient2 + flushSync() - await waitFor(() => { - expect(screen.getByText('Error: undefined')).toBeInTheDocument() - }) - }) + expect(mutation.status).toBe('idle') + expect(mutation.data).toBeUndefined() + }), + ) - test( - 'should synchronize status when background mutation resolves', + it( + 'should synchronize mutation result when observer remounts', withEffectRoot(async () => { - const queryClient = new QueryClient() + let isMounted = $state(true) const { promise, resolve } = promiseWithResolvers() const mutation = createMutation( - () => ({ - mutationFn: () => promise, - }), + () => { + // Access reactive state so this effect dependency triggers teardown/remount + void isMounted + return { + mutationFn: () => promise, + } + }, () => queryClient, ) mutation.mutate() - await sleep(1) + await vi.advanceTimersByTimeAsync(0) expect(mutation.status).toBe('pending') + // Trigger effect re-run / observer cleanup (simulates unmount/detach) + isMounted = false + flushSync() + + // Mutation settles while observer is detached resolve('success-payload') - await sleep(10) + await vi.advanceTimersByTimeAsync(0) + + // Remount the observer + isMounted = true + flushSync() + // Immediately synchronous verification of Object.assign(result, observer.getCurrentResult()) expect(mutation.status).toBe('success') expect(mutation.data).toBe('success-payload') }),