diff --git a/.changeset/icy-cities-speak.md b/.changeset/icy-cities-speak.md new file mode 100644 index 00000000000..b02c932197d --- /dev/null +++ b/.changeset/icy-cities-speak.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-query': minor +--- + +feat(react-query): backport mutationOptions in v4 diff --git a/packages/react-query/src/__tests__/mutationOptions.test.tsx b/packages/react-query/src/__tests__/mutationOptions.test.tsx new file mode 100644 index 00000000000..1a1af7c97f7 --- /dev/null +++ b/packages/react-query/src/__tests__/mutationOptions.test.tsx @@ -0,0 +1,140 @@ +import * as React from 'react' +import { QueryClient } from '@tanstack/query-core' +import { fireEvent, waitFor } from '@testing-library/react' +import { mutationOptions } from '../mutationOptions' +import { useIsMutating, useMutation } from '..' +import { renderWithClient, sleep } from './utils' +import type { UseMutationOptions } from '../types' + +describe('mutationOptions', () => { + it('should return the object received as a parameter without any modification (with mutationKey)', () => { + const object: UseMutationOptions = { + mutationKey: ['key'], + mutationFn: () => Promise.resolve(5), + } as const + + expect(mutationOptions(object)).toBe(object) + }) + + it('should return the object received as a parameter without any modification (without mutationKey)', () => { + const object: UseMutationOptions = { + mutationFn: () => Promise.resolve(5), + } as const + + expect(mutationOptions(object)).toBe(object) + }) + + it('should work with useMutation (with mutationKey)', async () => { + const queryClient = new QueryClient() + const mutationOpts = mutationOptions({ + mutationKey: ['key'], + mutationFn: () => sleep(10).then(() => 'data'), + }) + + function Page() { + const mutation = useMutation(mutationOpts) + + return ( +
+ + {mutation.data ?? 'empty'} +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('empty')).toBeTruthy() + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await waitFor(() => rendered.getByText('data')) + }) + + it('should work with useMutation (without mutationKey)', async () => { + const queryClient = new QueryClient() + const mutationOpts = mutationOptions({ + mutationFn: () => sleep(10).then(() => 'data'), + }) + + function Page() { + const mutation = useMutation(mutationOpts) + + return ( +
+ + {mutation.data ?? 'empty'} +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('empty')).toBeTruthy() + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await waitFor(() => rendered.getByText('data')) + }) + + it('should work with useIsMutating filtering by mutationKey', async () => { + const queryClient = new QueryClient() + const mutationOpts1 = mutationOptions({ + mutationKey: ['key1'], + mutationFn: () => sleep(50).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationKey: ['key2'], + mutationFn: () => sleep(50).then(() => 'data2'), + }) + + function Page() { + const isMutating = useIsMutating({ + mutationKey: mutationOpts1.mutationKey, + }) + const { mutate: mutate1 } = useMutation(mutationOpts1) + const { mutate: mutate2 } = useMutation(mutationOpts2) + + return ( +
+ isMutating: {isMutating} + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + rendered.getByText('isMutating: 0') + fireEvent.click(rendered.getByRole('button', { name: /mutate1/i })) + fireEvent.click(rendered.getByRole('button', { name: /mutate2/i })) + await waitFor(() => rendered.getByText('isMutating: 1')) + await waitFor(() => rendered.getByText('isMutating: 0')) + }) + + it('should work with queryClient.isMutating', async () => { + const queryClient = new QueryClient() + const mutationOpts = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(10).then(() => 'data'), + }) + + function Page() { + const isMutating = queryClient.isMutating({ + mutationKey: mutationOpts.mutationKey, + }) + const { mutate } = useMutation(mutationOpts) + + return ( +
+ isMutating: {isMutating} + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + rendered.getByText('isMutating: 0') + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await waitFor(() => rendered.getByText('isMutating: 1')) + await waitFor(() => rendered.getByText('isMutating: 0')) + }) +}) diff --git a/packages/react-query/src/__tests__/mutationOptions.types.test.tsx b/packages/react-query/src/__tests__/mutationOptions.types.test.tsx new file mode 100644 index 00000000000..0cf877cbb8d --- /dev/null +++ b/packages/react-query/src/__tests__/mutationOptions.types.test.tsx @@ -0,0 +1,438 @@ +import { expectTypeOf } from 'expect-type' +import { QueryClient } from '@tanstack/query-core' +import { + type UseMutationOptions, + type UseMutationResult, + mutationOptions, + useIsMutating, + useMutation, + useQueryClient, +} from '..' +import { doNotExecute } from './utils' +import type { MutationKey, OmitKeyof, WithRequired } from '@tanstack/query-core' + +const mutationKey = ['key'] as const +const mutationFn = (_input: { id: string }) => + Promise.resolve({ field: 'success' }) + +describe('mutationOptions', () => { + it('should not allow excess properties', () => { + doNotExecute(() => { + // @ts-expect-error this is a good error, because onMutates does not exist! + mutationOptions({ + mutationFn: () => Promise.resolve(5), + mutationKey: ['key'], + onMutates: 1000, + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }) + }) + }) + + it('should infer types for callbacks', () => { + doNotExecute(() => { + mutationOptions({ + mutationFn: () => Promise.resolve(5), + mutationKey: ['key'], + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }) + }) + }) + + it('should infer types for onError callback', () => { + doNotExecute(() => { + mutationOptions({ + mutationFn: () => { + throw new Error('fail') + }, + mutationKey: ['key'], + onError: (error) => { + expectTypeOf(error).toEqualTypeOf() + }, + }) + }) + }) + + it('should infer types for variables', () => { + doNotExecute(() => { + mutationOptions({ + mutationFn: (vars) => { + expectTypeOf(vars).toEqualTypeOf<{ id: string }>() + return Promise.resolve(5) + }, + mutationKey: ['with-vars'], + }) + }) + }) + + it('should infer context type correctly', () => { + doNotExecute(() => { + mutationOptions({ + mutationFn: () => Promise.resolve(5), + mutationKey: ['key'], + onMutate: () => { + return { name: 'context' } + }, + onSuccess: (_data, _variables, context) => { + expectTypeOf(context).toEqualTypeOf<{ name: string } | undefined>() + }, + }) + }) + }) + + it('should error if mutationFn return type mismatches TData', () => { + doNotExecute(() => { + mutationOptions({ + // @ts-expect-error this is a good error, because return type is string, not number + mutationFn: async () => Promise.resolve('wrong return'), + }) + }) + }) + + it('should allow mutationKey to be omitted', () => { + doNotExecute(() => { + mutationOptions({ + mutationFn: () => Promise.resolve(123), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }) + }) + }) + + it('should infer all types when not explicitly provided', () => { + doNotExecute(() => { + expectTypeOf( + mutationOptions({ + mutationFn: (id: string) => Promise.resolve(id.length), + mutationKey: ['key'], + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ).toEqualTypeOf< + WithRequired< + UseMutationOptions, + 'mutationKey' + > + >() + expectTypeOf( + mutationOptions({ + mutationFn: (id: string) => Promise.resolve(id.length), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ).toEqualTypeOf< + OmitKeyof< + UseMutationOptions, + 'mutationKey' + > + >() + }) + }) + + it('should infer types when used with useMutation', () => { + doNotExecute(() => { + const mutation = useMutation( + mutationOptions({ + mutationKey: ['key'], + mutationFn: () => Promise.resolve('data'), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ) + expectTypeOf(mutation).toEqualTypeOf< + UseMutationResult + >() + + // should allow when used with useMutation without mutationKey + useMutation( + mutationOptions({ + mutationFn: () => Promise.resolve('data'), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ) + }) + }) + + it('should be used with useMutation and spread with additional options', () => { + doNotExecute(() => { + const result = useMutation({ + ...mutationOptions({ + mutationKey, + mutationFn, + }), + retry: 3, + }) + + expectTypeOf(result).toEqualTypeOf< + UseMutationResult<{ field: string }, unknown, { id: string }, unknown> + >() + }) + }) + + it('should preserve mutationKey for use with useIsMutating/queryClient', () => { + doNotExecute(() => { + const options = mutationOptions({ + mutationKey: ['todos', 'create'] as const, + mutationFn: (input: { title: string }) => + Promise.resolve({ id: 1, title: input.title }), + }) + + // mutationKey is MutationKey, usable with filters + expectTypeOf(options.mutationKey).toMatchTypeOf() + }) + }) + + it('should work with void variables (no arguments to mutationFn)', () => { + doNotExecute(() => { + const options = mutationOptions({ + mutationKey, + mutationFn: () => Promise.resolve('done'), + }) + + const result = useMutation(options) + + // mutate should be callable without arguments + result.mutate() + }) + }) + + it('should infer TContext from onMutate when explicitly typed', () => { + doNotExecute(() => { + mutationOptions< + { success: boolean }, + unknown, + string, + { previousData: string } + >({ + mutationKey, + mutationFn: (_id: string) => Promise.resolve({ success: true }), + onMutate: (variables) => { + expectTypeOf(variables).toEqualTypeOf() + return { previousData: 'backup' } + }, + onError: (_error, variables, context) => { + expectTypeOf(variables).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf< + { previousData: string } | undefined + >() + }, + onSuccess: (data, variables, context) => { + expectTypeOf(data).toEqualTypeOf<{ success: boolean }>() + expectTypeOf(variables).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf< + { previousData: string } | undefined + >() + }, + onSettled: (data, _error, variables, context) => { + expectTypeOf(data).toEqualTypeOf<{ success: boolean } | undefined>() + expectTypeOf(variables).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf< + { previousData: string } | undefined + >() + }, + }) + }) + }) + + it('should work with complex generic types', () => { + doNotExecute(() => { + interface CreateUserInput { + name: string + email: string + roles: Array<'admin' | 'user'> + } + + interface User { + id: number + name: string + email: string + roles: Array<'admin' | 'user'> + createdAt: Date + } + + interface OptimisticContext { + previousUsers: Array + tempId: number + } + + const options = mutationOptions< + User, + unknown, + CreateUserInput, + OptimisticContext + >({ + mutationKey: ['users', 'create'] as const, + mutationFn: (input: CreateUserInput) => + Promise.resolve({ + id: 1, + ...input, + createdAt: new Date(), + } as User), + onMutate: (variables) => { + expectTypeOf(variables).toEqualTypeOf() + return { previousUsers: [], tempId: Date.now() } + }, + onError: (_error, _variables, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + onSuccess: (data, variables, context) => { + expectTypeOf(data).toEqualTypeOf() + expectTypeOf(variables).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf() + }, + }) + + const result = useMutation(options) + expectTypeOf(result.data).toEqualTypeOf() + }) + }) + + it('should be usable in a factory pattern', () => { + doNotExecute(() => { + const mutations = { + create: () => + mutationOptions({ + mutationKey: ['items', 'create'] as const, + mutationFn: (input: { name: string }) => + Promise.resolve({ id: 1, name: input.name }), + }), + delete: () => + mutationOptions({ + mutationKey: ['items', 'delete'] as const, + mutationFn: (_id: number) => Promise.resolve(undefined), + }), + } + + const createResult = useMutation(mutations.create()) + expectTypeOf(createResult.data).toEqualTypeOf< + { id: number; name: string } | undefined + >() + + const deleteResult = useMutation(mutations.delete()) + expectTypeOf(deleteResult.data).toEqualTypeOf() + }) + }) + + it('should work with queryClient mutation cache filters', () => { + doNotExecute(async () => { + const queryClient = useQueryClient() + const options = mutationOptions({ + mutationKey: ['key'] as const, + mutationFn: () => Promise.resolve('data'), + }) + + queryClient.getMutationCache().findAll({ + mutationKey: options.mutationKey, + }) + }) + }) + + it('should infer types when used with queryClient.isMutating', () => { + doNotExecute(() => { + const queryClient = new QueryClient() + + const isMutating = queryClient.isMutating({ + mutationKey: mutationOptions({ + mutationKey: ['key'], + mutationFn: () => Promise.resolve(5), + }).mutationKey, + }) + expectTypeOf(isMutating).toEqualTypeOf() + }) + }) + + it('should handle union type variables', () => { + doNotExecute(() => { + type Action = + | { type: 'create'; payload: { name: string } } + | { type: 'delete'; payload: { id: number } } + + const options = mutationOptions({ + mutationKey, + mutationFn: (_action: Action) => Promise.resolve('done'), + }) + + const result = useMutation(options) + result.mutate({ type: 'create', payload: { name: 'test' } }) + result.mutate({ type: 'delete', payload: { id: 1 } }) + }) + }) + + it('should properly narrow mutationKey presence based on overload', () => { + doNotExecute(() => { + // With mutationKey: mutationKey is required in the return type + const withKey = mutationOptions({ + mutationKey: ['key'] as const, + mutationFn: () => Promise.resolve(1), + }) + expectTypeOf(withKey.mutationKey).toMatchTypeOf() + + // Without mutationKey: mutationKey should not be accessible + const withoutKey = mutationOptions({ + mutationFn: () => Promise.resolve(1), + }) + // @ts-expect-error mutationKey should not exist + withoutKey.mutationKey + }) + }) + + it('should allow mutationKey to be used as MutationKey', () => { + doNotExecute(() => { + const options = mutationOptions({ + mutationKey: ['todos', { status: 'active' }] as const, + mutationFn: () => Promise.resolve(true), + }) + + const key: MutationKey = options.mutationKey + expectTypeOf(key).toMatchTypeOf() + }) + }) + + it('should infer types when used with useIsMutating via mutationKey filter', () => { + doNotExecute(() => { + const options = mutationOptions({ + mutationKey: ['key'] as const, + mutationFn: () => Promise.resolve(5), + }) + + // mutationKey from mutationOptions can be used in MutationFilters + const isMutating = useIsMutating({ + mutationKey: options.mutationKey, + }) + expectTypeOf(isMutating).toEqualTypeOf() + }) + }) + + it('should infer types when used with useIsMutating passing mutationKey directly', () => { + doNotExecute(() => { + const options = mutationOptions({ + mutationKey: ['key'] as const, + mutationFn: () => Promise.resolve(5), + }) + + // v4 useIsMutating accepts MutationKey as first arg + const isMutating = useIsMutating(options.mutationKey) + expectTypeOf(isMutating).toEqualTypeOf() + }) + }) + + it('should not allow passing mutationOptions without mutationKey to useIsMutating filter', () => { + doNotExecute(() => { + const options = mutationOptions({ + mutationFn: () => Promise.resolve(5), + }) + + // @ts-expect-error mutationKey does not exist on options without mutationKey + useIsMutating({ mutationKey: options.mutationKey }) + }) + }) +}) diff --git a/packages/react-query/src/index.ts b/packages/react-query/src/index.ts index d04d91e70f3..37d93f63088 100644 --- a/packages/react-query/src/index.ts +++ b/packages/react-query/src/index.ts @@ -43,6 +43,7 @@ export { } from './QueryErrorResetBoundary' export { useIsFetching } from './useIsFetching' export { useIsMutating } from './useIsMutating' +export { mutationOptions } from './mutationOptions' export { useMutation } from './useMutation' export { useInfiniteQuery } from './useInfiniteQuery' export { useIsRestoring, IsRestoringProvider } from './isRestoring' diff --git a/packages/react-query/src/mutationOptions.ts b/packages/react-query/src/mutationOptions.ts new file mode 100644 index 00000000000..67a4e2b4f0f --- /dev/null +++ b/packages/react-query/src/mutationOptions.ts @@ -0,0 +1,34 @@ +import type { OmitKeyof, WithRequired } from '@tanstack/query-core' +import type { UseMutationOptions } from './types' + +export function mutationOptions< + TData = unknown, + TError = unknown, + TVariables = void, + TContext = unknown, +>( + options: WithRequired< + UseMutationOptions, + 'mutationKey' + >, +): WithRequired< + UseMutationOptions, + 'mutationKey' +> +export function mutationOptions< + TData = unknown, + TError = unknown, + TVariables = void, + TContext = unknown, +>( + options: OmitKeyof< + UseMutationOptions, + 'mutationKey' + >, +): OmitKeyof< + UseMutationOptions, + 'mutationKey' +> +export function mutationOptions(options: unknown) { + return options +}