diff --git a/.changeset/many-symbols-write.md b/.changeset/many-symbols-write.md new file mode 100644 index 00000000000..d54a9e056a9 --- /dev/null +++ b/.changeset/many-symbols-write.md @@ -0,0 +1,5 @@ +--- +'@tanstack/vue-query': minor +--- + +feat(vue-query): add 'mutationOptions' diff --git a/packages/vue-query/src/__tests__/mutationOptions.test-d.ts b/packages/vue-query/src/__tests__/mutationOptions.test-d.ts new file mode 100644 index 00000000000..9275c63e67f --- /dev/null +++ b/packages/vue-query/src/__tests__/mutationOptions.test-d.ts @@ -0,0 +1,220 @@ +import { assertType, describe, expectTypeOf, it } from 'vitest' +import { QueryClient } from '@tanstack/query-core' +import { useMutation } from '../useMutation' +import { useIsMutating, useMutationState } from '../useMutationState' +import { mutationOptions } from '../mutationOptions' +import type { + DefaultError, + MutationFunctionContext, + MutationState, + WithRequired, +} from '@tanstack/query-core' +import type { Ref } from 'vue-demi' +import type { VueMutationOptions } from '../types' + +describe('mutationOptions', () => { + it('should not allow excess properties', () => { + // @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', () => { + mutationOptions({ + mutationFn: () => Promise.resolve(5), + mutationKey: ['key'], + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }) + }) + + it('should infer types for onError callback', () => { + mutationOptions({ + mutationFn: () => { + throw new Error('fail') + }, + mutationKey: ['key'], + onError: (error) => { + expectTypeOf(error).toEqualTypeOf() + }, + }) + }) + + it('should infer types for variables', () => { + mutationOptions({ + mutationFn: (vars) => { + expectTypeOf(vars).toEqualTypeOf<{ id: string }>() + return Promise.resolve(5) + }, + mutationKey: ['with-vars'], + }) + }) + + it('should infer result type correctly', () => { + mutationOptions({ + mutationFn: () => Promise.resolve(5), + mutationKey: ['key'], + onMutate: () => { + return { name: 'onMutateResult' } + }, + onSuccess: (_data, _variables, onMutateResult) => { + expectTypeOf(onMutateResult).toEqualTypeOf<{ name: string }>() + }, + }) + }) + + it('should infer context type correctly', () => { + mutationOptions({ + mutationFn: (_variables, context) => { + expectTypeOf(context).toEqualTypeOf() + return Promise.resolve(5) + }, + mutationKey: ['key'], + 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 error if mutationFn return type mismatches TData', () => { + assertType( + 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', () => { + return mutationOptions({ + mutationFn: () => Promise.resolve(123), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }) + }) + + it('should infer all types when not explicitly provided', () => { + expectTypeOf( + mutationOptions({ + mutationFn: (id: string) => Promise.resolve(id.length), + mutationKey: ['key'], + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ).toEqualTypeOf< + WithRequired< + VueMutationOptions, + 'mutationKey' + > + >() + expectTypeOf( + mutationOptions({ + mutationFn: (id: string) => Promise.resolve(id.length), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ).toEqualTypeOf< + Omit< + VueMutationOptions, + 'mutationKey' + > + >() + }) + + it('should work when used with useMutation', () => { + const mutation = useMutation( + mutationOptions({ + mutationKey: ['key'], + mutationFn: () => Promise.resolve('data'), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ) + expectTypeOf(mutation.data.value).toEqualTypeOf() + + // should allow when used with useMutation without mutationKey + useMutation( + mutationOptions({ + mutationFn: () => Promise.resolve('data'), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ) + }) + + it('should work when used with useIsMutating', () => { + const isMutating = useIsMutating( + mutationOptions({ + mutationKey: ['key'], + mutationFn: () => Promise.resolve(5), + }), + ) + expectTypeOf(isMutating).toEqualTypeOf>() + + useIsMutating( + // @ts-expect-error filters should have mutationKey + mutationOptions({ + mutationFn: () => Promise.resolve(5), + }), + ) + }) + + it('should work when used with queryClient.isMutating', () => { + const queryClient = new QueryClient() + + const isMutating = queryClient.isMutating( + mutationOptions({ + mutationKey: ['key'], + mutationFn: () => Promise.resolve(5), + }), + ) + expectTypeOf(isMutating).toEqualTypeOf() + + queryClient.isMutating( + // @ts-expect-error filters should have mutationKey + mutationOptions({ + mutationFn: () => Promise.resolve(5), + }), + ) + }) + + it('should work when used with useMutationState', () => { + const mutationState = useMutationState({ + filters: mutationOptions({ + mutationKey: ['key'], + mutationFn: () => Promise.resolve(5), + }), + }) + expectTypeOf(mutationState.value).toEqualTypeOf< + Array> + >() + + useMutationState({ + // @ts-expect-error filters should have mutationKey + filters: mutationOptions({ + mutationFn: () => Promise.resolve(5), + }), + }) + }) +}) diff --git a/packages/vue-query/src/__tests__/mutationOptions.test.ts b/packages/vue-query/src/__tests__/mutationOptions.test.ts new file mode 100644 index 00000000000..86e75480342 --- /dev/null +++ b/packages/vue-query/src/__tests__/mutationOptions.test.ts @@ -0,0 +1,405 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { isReactive, ref } from 'vue-demi' +import { sleep } from '@tanstack/query-test-utils' +import { useMutation } from '../useMutation' +import { useIsMutating, useMutationState } from '../useMutationState' +import { useQueryClient } from '../useQueryClient' +import { mutationOptions } from '../mutationOptions' + +vi.mock('../useQueryClient') + +describe('mutationOptions', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + useQueryClient().clear() + }) + + it('should return the object received as a parameter without any modification (with mutationKey in mutationOptions)', () => { + const object = { + mutationKey: ['key'], + mutationFn: () => sleep(10).then(() => 5), + } as const + + expect(mutationOptions(object)).toStrictEqual(object) + }) + + it('should return the object received as a parameter without any modification (without mutationKey in mutationOptions)', () => { + const object = { + mutationFn: () => sleep(10).then(() => 5), + } as const + + expect(mutationOptions(object)).toStrictEqual(object) + }) + + it('should return the number of fetching mutations when used with useIsMutating (with mutationKey in mutationOptions)', async () => { + const isMutatingArray: Array = [] + const mutationOpts = mutationOptions({ + mutationKey: ['key'], + mutationFn: () => sleep(50).then(() => 'data'), + }) + + const { mutate } = useMutation(mutationOpts) + const isMutating = useIsMutating() + + isMutatingArray.push(isMutating.value) + + mutate() + await vi.advanceTimersByTimeAsync(0) + isMutatingArray.push(isMutating.value) + await vi.advanceTimersByTimeAsync(50) + isMutatingArray.push(isMutating.value) + + expect(isMutatingArray).toEqual([0, 1, 0]) + }) + + it('should return the number of fetching mutations when used with useIsMutating (without mutationKey in mutationOptions)', async () => { + const isMutatingArray: Array = [] + const mutationOpts = mutationOptions({ + mutationFn: () => sleep(50).then(() => 'data'), + }) + + const { mutate } = useMutation(mutationOpts) + const isMutating = useIsMutating() + + isMutatingArray.push(isMutating.value) + + mutate() + await vi.advanceTimersByTimeAsync(0) + isMutatingArray.push(isMutating.value) + await vi.advanceTimersByTimeAsync(50) + isMutatingArray.push(isMutating.value) + + expect(isMutatingArray).toEqual([0, 1, 0]) + }) + + it('should return the number of fetching mutations when used with useIsMutating', async () => { + const isMutatingArray: Array = [] + const mutationOpts1 = mutationOptions({ + mutationKey: ['key'], + mutationFn: () => sleep(50).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(50).then(() => 'data2'), + }) + + const { mutate: mutate1 } = useMutation(mutationOpts1) + const { mutate: mutate2 } = useMutation(mutationOpts2) + const isMutating = useIsMutating() + + isMutatingArray.push(isMutating.value) + + mutate1() + mutate2() + await vi.advanceTimersByTimeAsync(0) + isMutatingArray.push(isMutating.value) + await vi.advanceTimersByTimeAsync(50) + isMutatingArray.push(isMutating.value) + + expect(isMutatingArray).toEqual([0, 2, 0]) + }) + + it('should return the number of fetching mutations when used with useIsMutating (filter mutationOpts1.mutationKey)', async () => { + const isMutatingArray: Array = [] + const mutationOpts1 = mutationOptions({ + mutationKey: ['key'], + mutationFn: () => sleep(50).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(50).then(() => 'data2'), + }) + + const { mutate: mutate1 } = useMutation(mutationOpts1) + const { mutate: mutate2 } = useMutation(mutationOpts2) + const isMutating = useIsMutating({ + mutationKey: mutationOpts1.mutationKey, + }) + + isMutatingArray.push(isMutating.value) + + mutate1() + mutate2() + await vi.advanceTimersByTimeAsync(0) + isMutatingArray.push(isMutating.value) + await vi.advanceTimersByTimeAsync(50) + isMutatingArray.push(isMutating.value) + + expect(isMutatingArray).toEqual([0, 1, 0]) + }) + + it('should return the number of fetching mutations when used with queryClient.isMutating (with mutationKey in mutationOptions)', async () => { + const isMutatingArray: Array = [] + const queryClient = useQueryClient() + const mutationOpts = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(500).then(() => 'data'), + }) + + const { mutate } = useMutation(mutationOpts) + + const mutationCache = queryClient.getMutationCache() + const unsubscribe = mutationCache.subscribe(() => { + isMutatingArray.push(queryClient.isMutating(mutationOpts)) + }) + + isMutatingArray.push(queryClient.isMutating(mutationOpts)) + + mutate() + await vi.advanceTimersByTimeAsync(0) + // Use Math.max because subscribe callback count is implementation-dependent + expect(Math.max(...isMutatingArray)).toEqual(1) + await vi.advanceTimersByTimeAsync(500) + expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0) + + unsubscribe() + }) + + it('should return the number of fetching mutations when used with queryClient.isMutating (without mutationKey in mutationOptions)', async () => { + const isMutatingArray: Array = [] + const queryClient = useQueryClient() + const mutationOpts = mutationOptions({ + mutationFn: () => sleep(500).then(() => 'data'), + }) + + const { mutate } = useMutation(mutationOpts) + + const mutationCache = queryClient.getMutationCache() + const unsubscribe = mutationCache.subscribe(() => { + isMutatingArray.push(queryClient.isMutating()) + }) + + isMutatingArray.push(queryClient.isMutating()) + + mutate() + await vi.advanceTimersByTimeAsync(0) + // Use Math.max because subscribe callback count is implementation-dependent + expect(Math.max(...isMutatingArray)).toEqual(1) + await vi.advanceTimersByTimeAsync(500) + expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0) + + unsubscribe() + }) + + it('should return the number of fetching mutations when used with queryClient.isMutating', async () => { + const isMutatingArray: Array = [] + const queryClient = useQueryClient() + const mutationOpts1 = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(500).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(500).then(() => 'data2'), + }) + + const { mutate: mutate1 } = useMutation(mutationOpts1) + const { mutate: mutate2 } = useMutation(mutationOpts2) + + const mutationCache = queryClient.getMutationCache() + const unsubscribe = mutationCache.subscribe(() => { + isMutatingArray.push(queryClient.isMutating()) + }) + + isMutatingArray.push(queryClient.isMutating()) + + mutate1() + mutate2() + await vi.advanceTimersByTimeAsync(0) + // Use Math.max because subscribe callback count is implementation-dependent + expect(Math.max(...isMutatingArray)).toEqual(2) + await vi.advanceTimersByTimeAsync(500) + expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0) + + unsubscribe() + }) + + it('should return the number of fetching mutations when used with queryClient.isMutating (filter mutationOpts1.mutationKey)', async () => { + const isMutatingArray: Array = [] + const queryClient = useQueryClient() + const mutationOpts1 = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(500).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(500).then(() => 'data2'), + }) + + const { mutate: mutate1 } = useMutation(mutationOpts1) + const { mutate: mutate2 } = useMutation(mutationOpts2) + + const mutationCache = queryClient.getMutationCache() + const unsubscribe = mutationCache.subscribe(() => { + isMutatingArray.push( + queryClient.isMutating({ + mutationKey: mutationOpts1.mutationKey, + }), + ) + }) + + isMutatingArray.push( + queryClient.isMutating({ + mutationKey: mutationOpts1.mutationKey, + }), + ) + + mutate1() + mutate2() + await vi.advanceTimersByTimeAsync(0) + // Use Math.max because subscribe callback count is implementation-dependent + expect(Math.max(...isMutatingArray)).toEqual(1) + await vi.advanceTimersByTimeAsync(500) + expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0) + + unsubscribe() + }) + + it('should return mutation states when used with useMutationState (with mutationKey in mutationOptions)', async () => { + const mutationOpts = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(10).then(() => 'data'), + }) + + const { mutate } = useMutation(mutationOpts) + const states = useMutationState({ + filters: { mutationKey: mutationOpts.mutationKey, status: 'success' }, + select: (mutation) => mutation.state.data, + }) + + expect(states.value).toEqual([]) + + mutate() + await vi.advanceTimersByTimeAsync(10) + expect(states.value).toEqual(['data']) + }) + + it('should return mutation states when used with useMutationState (without mutationKey in mutationOptions)', async () => { + const mutationOpts = mutationOptions({ + mutationFn: () => sleep(10).then(() => 'data'), + }) + + const { mutate } = useMutation(mutationOpts) + const states = useMutationState({ + filters: { status: 'success' }, + select: (mutation) => mutation.state.data, + }) + + expect(states.value).toEqual([]) + + mutate() + await vi.advanceTimersByTimeAsync(10) + expect(states.value).toEqual(['data']) + }) + + it('should return mutation states when used with useMutationState', async () => { + const mutationOpts1 = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(10).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(10).then(() => 'data2'), + }) + + const { mutate: mutate1 } = useMutation(mutationOpts1) + const { mutate: mutate2 } = useMutation(mutationOpts2) + const states = useMutationState({ + filters: { status: 'success' }, + select: (mutation) => mutation.state.data, + }) + + expect(states.value).toEqual([]) + + mutate1() + mutate2() + await vi.advanceTimersByTimeAsync(10) + expect(states.value).toEqual(['data1', 'data2']) + }) + + it('should return mutation states when used with useMutationState (filter mutationOpts1.mutationKey)', async () => { + const mutationOpts1 = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(10).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(10).then(() => 'data2'), + }) + + const { mutate: mutate1 } = useMutation(mutationOpts1) + const { mutate: mutate2 } = useMutation(mutationOpts2) + const states = useMutationState({ + filters: { mutationKey: mutationOpts1.mutationKey, status: 'success' }, + select: (mutation) => mutation.state.data, + }) + + expect(states.value).toEqual([]) + + mutate1() + mutate2() + await vi.advanceTimersByTimeAsync(10) + expect(states.value).toEqual(['data1']) + }) + + it('should work with options getter and be reactive when used with useIsMutating', async () => { + const keyRef = ref('isMutatingGetter2') + const mutationOpts = mutationOptions({ + mutationKey: ['isMutatingGetter'], + mutationFn: () => sleep(10).then(() => 'data'), + }) + + const { mutate } = useMutation(mutationOpts) + mutate() + + const isMutating = useIsMutating(() => ({ + mutationKey: [keyRef.value], + })) + + expect(isMutating.value).toEqual(0) + + keyRef.value = 'isMutatingGetter' + + await vi.advanceTimersByTimeAsync(0) + + expect(isMutating.value).toEqual(1) + }) + + it('should work with options getter and be reactive when used with useMutationState', async () => { + const keyRef = ref('useMutationStateGetter2') + const mutationOpts = mutationOptions({ + mutationKey: ['useMutationStateGetter'], + mutationFn: (params: string) => sleep(10).then(() => params), + }) + + const { mutate } = useMutation(mutationOpts) + mutate('foo') + + const states = useMutationState(() => ({ + filters: { mutationKey: [keyRef.value], status: 'pending' }, + select: (mutation) => mutation.state.variables, + })) + + expect(states.value).toEqual([]) + + keyRef.value = 'useMutationStateGetter' + + await vi.advanceTimersByTimeAsync(0) + + expect(states.value).toEqual(['foo']) + }) + + it('should return data in a shallow ref when shallow is true', async () => { + const mutationOpts = mutationOptions({ + mutationKey: ['key'], + mutationFn: () => sleep(10).then(() => ({ nested: { count: 0 } })), + shallow: true, + }) + + const { mutate, data } = useMutation(mutationOpts) + + mutate() + await vi.advanceTimersByTimeAsync(10) + + expect(data.value).toEqual({ nested: { count: 0 } }) + expect(isReactive(data.value?.nested)).toBe(false) + }) +}) diff --git a/packages/vue-query/src/index.ts b/packages/vue-query/src/index.ts index 5ea6e26f836..de61b6c1eab 100644 --- a/packages/vue-query/src/index.ts +++ b/packages/vue-query/src/index.ts @@ -12,6 +12,7 @@ export type { UndefinedInitialDataInfiniteOptions, } from './infiniteQueryOptions' export { MutationCache } from './mutationCache' +export { mutationOptions } from './mutationOptions' export { useQuery } from './useQuery' export { useQueries } from './useQueries' export { useInfiniteQuery } from './useInfiniteQuery' @@ -32,6 +33,7 @@ export type { UseInfiniteQueryReturnType, } from './useInfiniteQuery' export type { UseMutationOptions, UseMutationReturnType } from './useMutation' +export type { VueMutationOptions } from './types' export type { UseQueriesOptions, UseQueriesResults } from './useQueries' export type { MutationFilters, MutationStateOptions } from './useMutationState' export type { QueryFilters } from './useIsFetching' diff --git a/packages/vue-query/src/mutationOptions.ts b/packages/vue-query/src/mutationOptions.ts new file mode 100644 index 00000000000..c51897c6e88 --- /dev/null +++ b/packages/vue-query/src/mutationOptions.ts @@ -0,0 +1,41 @@ +import type { DefaultError, WithRequired } from '@tanstack/query-core' +import type { VueMutationOptions } from './types' + +export function mutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + options: WithRequired< + VueMutationOptions, + 'mutationKey' + >, +): WithRequired< + VueMutationOptions, + 'mutationKey' +> +export function mutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + options: Omit< + VueMutationOptions, + 'mutationKey' + >, +): Omit< + VueMutationOptions, + 'mutationKey' +> +export function mutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + options: VueMutationOptions, +): VueMutationOptions { + return options +} diff --git a/packages/vue-query/src/types.ts b/packages/vue-query/src/types.ts index eb793c811eb..7d5d4cd980d 100644 --- a/packages/vue-query/src/types.ts +++ b/packages/vue-query/src/types.ts @@ -62,6 +62,17 @@ export type ShallowOption = { shallow?: boolean } +export type VueMutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +> = OmitKeyof< + MutationObserverOptions, + '_defaulted' +> & + ShallowOption + export interface DefaultOptions { queries?: OmitKeyof, 'queryKey'> & ShallowOption diff --git a/packages/vue-query/src/useMutation.ts b/packages/vue-query/src/useMutation.ts index 00c4b8fda27..7f14c3f2f11 100644 --- a/packages/vue-query/src/useMutation.ts +++ b/packages/vue-query/src/useMutation.ts @@ -18,11 +18,9 @@ import type { DistributiveOmit, MutateFunction, MutateOptions, - MutationObserverOptions, MutationObserverResult, - OmitKeyof, } from '@tanstack/query-core' -import type { MaybeRefDeep, ShallowOption } from './types' +import type { MaybeRefDeep, VueMutationOptions } from './types' import type { QueryClient } from './queryClient' type MutationResult = @@ -31,24 +29,15 @@ type MutationResult = 'mutate' | 'reset' > -type UseMutationOptionsBase = - OmitKeyof< - MutationObserverOptions, - '_defaulted' - > & - ShallowOption - export type UseMutationOptions< TData = unknown, TError = DefaultError, TVariables = void, TOnMutateResult = unknown, > = - | MaybeRefDeep< - UseMutationOptionsBase - > + | MaybeRefDeep> | (() => MaybeRefDeep< - UseMutationOptionsBase + VueMutationOptions >) type MutateSyncFunction<