From d345fc28a5ac0a93dc173edb4fd51425be7f4c1a Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Tue, 8 Sep 2026 09:43:04 +0900 Subject: [PATCH] test({react,preact}-query/useMutation): add tests for 'MutationFunctionContext' passed to mutationFn and callbacks --- .../src/__tests__/useMutation.test-d.tsx | 58 +++++++++- .../src/__tests__/useMutation.test.tsx | 101 ++++++++++++++++++ .../src/__tests__/useMutation.test-d.tsx | 58 +++++++++- .../src/__tests__/useMutation.test.tsx | 101 ++++++++++++++++++ 4 files changed, 316 insertions(+), 2 deletions(-) diff --git a/packages/preact-query/src/__tests__/useMutation.test-d.tsx b/packages/preact-query/src/__tests__/useMutation.test-d.tsx index cba067db9db..2844e617832 100644 --- a/packages/preact-query/src/__tests__/useMutation.test-d.tsx +++ b/packages/preact-query/src/__tests__/useMutation.test-d.tsx @@ -1,7 +1,11 @@ import { describe, expectTypeOf, it } from 'vitest' import { QueryClient } from '@tanstack/query-core' import { useMutation } from '../useMutation' -import type { DefaultError } from '@tanstack/query-core' +import type { + DefaultError, + MutationFunctionContext, + MutationKey, +} from '@tanstack/query-core' import type { UseMutationResult } from '../types' describe('useMutation', () => { @@ -144,4 +148,56 @@ describe('useMutation', () => { expectTypeOf(mutation.data).toEqualTypeOf() }) + + it('should type context as the last argument for mutationFn and every hook-level callback', () => { + useMutation({ + mutationFn: (_vars: string, context) => { + expectTypeOf(context).toEqualTypeOf() + expectTypeOf(context.client).toEqualTypeOf() + return Promise.resolve('data') + }, + onMutate: (_variables, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + onSuccess: (_data, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + onError: (_error, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + onSettled: (_data, _error, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + }) + }) + + it('should type context as the last argument for every per-call mutate option', () => { + const mutation = useMutation({ + mutationFn: () => Promise.resolve('data'), + }) + + mutation.mutate(undefined, { + onSuccess: (_data, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + onError: (_error, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + onSettled: (_data, _error, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + }) + }) + + it('should type context.mutationKey as MutationKey', () => { + useMutation({ + mutationKey: ['todos', 'add'] as const, + mutationFn: () => Promise.resolve('data'), + onSuccess: (_data, _variables, _onMutateResult, context) => { + expectTypeOf(context.mutationKey).toEqualTypeOf< + MutationKey | undefined + >() + }, + }) + }) }) diff --git a/packages/preact-query/src/__tests__/useMutation.test.tsx b/packages/preact-query/src/__tests__/useMutation.test.tsx index 555c8ddf205..71d9f58c849 100644 --- a/packages/preact-query/src/__tests__/useMutation.test.tsx +++ b/packages/preact-query/src/__tests__/useMutation.test.tsx @@ -2476,4 +2476,105 @@ describe('useMutation', () => { ), ).toBeInTheDocument() }) + + it('should pass a non-undefined onMutateResult alongside context to onSuccess', async () => { + const onSuccess = vi.fn() + + function Page() { + const { mutate } = useMutation({ + mutationFn: (text: string) => sleep(10).then(() => text.toUpperCase()), + onMutate: (text: string) => ({ startedWith: text }), + onSuccess, + }) + + return + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(10) + + expect(onSuccess).toHaveBeenCalledTimes(1) + const [data, variables, onMutateResult, context] = onSuccess.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 give mutationFn the same QueryClient instance via context', async () => { + const key = queryKey() + queryClient.setQueryData(key, 'tag-from-this-client') + + function Page() { + const { mutate, data } = useMutation({ + mutationFn: (_text: string, context) => + sleep(10).then(() => context.client.getQueryData(key)), + }) + + return ( +
+
data: {String(data)}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(11) + + expect(rendered.getByText('data: tag-from-this-client')).toBeInTheDocument() + }) + + it('should include mutationKey in the context passed to hook-level callbacks', async () => { + const onSuccess = vi.fn() + + function Page() { + const { mutate } = useMutation({ + mutationKey: ['todos', 'add'], + mutationFn: (text: string) => sleep(10).then(() => text), + onSuccess, + }) + + return + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(10) + + expect(onSuccess).toHaveBeenCalledTimes(1) + expect(onSuccess.mock.calls[0]?.[3].mutationKey).toEqual(['todos', 'add']) + }) + + it('should let onSuccess invalidate queries via context.client without a useQueryClient() closure', async () => { + const key = queryKey() + queryClient.setQueryData(key, 'data') + + function Page() { + const { mutate } = useMutation({ + mutationFn: () => sleep(10).then(() => 'mutated'), + onSuccess: (_data, _variables, _onMutateResult, context) => { + context.client.invalidateQueries({ queryKey: key }) + }, + }) + + return + } + + const rendered = renderWithClient(queryClient, ) + + 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) + }) }) diff --git a/packages/react-query/src/__tests__/useMutation.test-d.tsx b/packages/react-query/src/__tests__/useMutation.test-d.tsx index cba067db9db..2844e617832 100644 --- a/packages/react-query/src/__tests__/useMutation.test-d.tsx +++ b/packages/react-query/src/__tests__/useMutation.test-d.tsx @@ -1,7 +1,11 @@ import { describe, expectTypeOf, it } from 'vitest' import { QueryClient } from '@tanstack/query-core' import { useMutation } from '../useMutation' -import type { DefaultError } from '@tanstack/query-core' +import type { + DefaultError, + MutationFunctionContext, + MutationKey, +} from '@tanstack/query-core' import type { UseMutationResult } from '../types' describe('useMutation', () => { @@ -144,4 +148,56 @@ describe('useMutation', () => { expectTypeOf(mutation.data).toEqualTypeOf() }) + + it('should type context as the last argument for mutationFn and every hook-level callback', () => { + useMutation({ + mutationFn: (_vars: string, context) => { + expectTypeOf(context).toEqualTypeOf() + expectTypeOf(context.client).toEqualTypeOf() + return Promise.resolve('data') + }, + onMutate: (_variables, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + onSuccess: (_data, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + onError: (_error, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + onSettled: (_data, _error, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + }) + }) + + it('should type context as the last argument for every per-call mutate option', () => { + const mutation = useMutation({ + mutationFn: () => Promise.resolve('data'), + }) + + mutation.mutate(undefined, { + onSuccess: (_data, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + onError: (_error, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + onSettled: (_data, _error, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + }) + }) + + it('should type context.mutationKey as MutationKey', () => { + useMutation({ + mutationKey: ['todos', 'add'] as const, + mutationFn: () => Promise.resolve('data'), + onSuccess: (_data, _variables, _onMutateResult, context) => { + expectTypeOf(context.mutationKey).toEqualTypeOf< + MutationKey | undefined + >() + }, + }) + }) }) diff --git a/packages/react-query/src/__tests__/useMutation.test.tsx b/packages/react-query/src/__tests__/useMutation.test.tsx index d7bbe18e7f9..e885b8e244c 100644 --- a/packages/react-query/src/__tests__/useMutation.test.tsx +++ b/packages/react-query/src/__tests__/useMutation.test.tsx @@ -2475,4 +2475,105 @@ describe('useMutation', () => { ), ).toBeInTheDocument() }) + + it('should pass a non-undefined onMutateResult alongside context to onSuccess', async () => { + const onSuccess = vi.fn() + + function Page() { + const { mutate } = useMutation({ + mutationFn: (text: string) => sleep(10).then(() => text.toUpperCase()), + onMutate: (text: string) => ({ startedWith: text }), + onSuccess, + }) + + return + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(10) + + expect(onSuccess).toHaveBeenCalledTimes(1) + const [data, variables, onMutateResult, context] = onSuccess.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 give mutationFn the same QueryClient instance via context', async () => { + const key = queryKey() + queryClient.setQueryData(key, 'tag-from-this-client') + + function Page() { + const { mutate, data } = useMutation({ + mutationFn: (_text: string, context) => + sleep(10).then(() => context.client.getQueryData(key)), + }) + + return ( +
+
data: {String(data)}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(11) + + expect(rendered.getByText('data: tag-from-this-client')).toBeInTheDocument() + }) + + it('should include mutationKey in the context passed to hook-level callbacks', async () => { + const onSuccess = vi.fn() + + function Page() { + const { mutate } = useMutation({ + mutationKey: ['todos', 'add'], + mutationFn: (text: string) => sleep(10).then(() => text), + onSuccess, + }) + + return + } + + const rendered = renderWithClient(queryClient, ) + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(10) + + expect(onSuccess).toHaveBeenCalledTimes(1) + expect(onSuccess.mock.calls[0]?.[3].mutationKey).toEqual(['todos', 'add']) + }) + + it('should let onSuccess invalidate queries via context.client without a useQueryClient() closure', async () => { + const key = queryKey() + queryClient.setQueryData(key, 'data') + + function Page() { + const { mutate } = useMutation({ + mutationFn: () => sleep(10).then(() => 'mutated'), + onSuccess: (_data, _variables, _onMutateResult, context) => { + context.client.invalidateQueries({ queryKey: key }) + }, + }) + + return + } + + const rendered = renderWithClient(queryClient, ) + + 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) + }) })