diff --git a/.changeset/lucky-numbers-change.md b/.changeset/lucky-numbers-change.md new file mode 100644 index 0000000000..0933654041 --- /dev/null +++ b/.changeset/lucky-numbers-change.md @@ -0,0 +1,5 @@ +--- +'@tanstack/vue-query': minor +--- + +Add mutationOptions. diff --git a/docs/framework/vue/reference/mutationOptions.md b/docs/framework/vue/reference/mutationOptions.md new file mode 100644 index 0000000000..0fa145a890 --- /dev/null +++ b/docs/framework/vue/reference/mutationOptions.md @@ -0,0 +1,15 @@ +--- +id: mutationOptions +title: mutationOptions +--- + +```tsx +mutationOptions({ + mutationFn, + ...options, +}) +``` + +**Options** + +You can generally pass everything to `mutationOptions` that you can also pass to [`useMutation`](./useMutation.md). 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 0000000000..ed2c06e9c0 --- /dev/null +++ b/packages/vue-query/src/__tests__/mutationOptions.test-d.ts @@ -0,0 +1,164 @@ +import { assertType, describe, expectTypeOf, it } from 'vitest' +import { reactive, ref } from 'vue-demi' +import { useIsMutating, useMutationState } from '../useMutationState' +import { useMutation } from '../useMutation' +import { mutationOptions } from '../mutationOptions' +import type { + DefaultError, + MutationFunctionContext, + MutationState, +} from '@tanstack/query-core' + +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 types when used with useMutation', () => { + const mutation = reactive( + useMutation( + mutationOptions({ + mutationKey: ['key'], + mutationFn: () => Promise.resolve('data'), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ), + ) + expectTypeOf(mutation.data).toEqualTypeOf() + + reactive( + useMutation( + // should allow when used with useMutation without mutationKey + mutationOptions({ + mutationFn: () => Promise.resolve('data'), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ), + ) + }) + + it('should infer types when used with useIsMutating', () => { + const isMutating = useIsMutating({ + mutationKey: ['key'], + }) + expectTypeOf(isMutating.value).toEqualTypeOf() + }) + + it('should infer types when used with useMutationState', () => { + const mutationState = useMutationState({ + filters: { + mutationKey: ['key'], + }, + }) + expectTypeOf(mutationState.value).toEqualTypeOf< + Array> + >() + }) + + it('should allow to be passed to useMutation while containing ref in mutationKey', () => { + const options = mutationOptions({ + mutationKey: ['key', ref(1), { nested: ref(2) }], + mutationFn: () => Promise.resolve(5), + }) + + const mutation = reactive(useMutation(options)) + expectTypeOf(mutation.data).toEqualTypeOf() + }) +}) 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 0000000000..74ce8c90c5 --- /dev/null +++ b/packages/vue-query/src/__tests__/mutationOptions.test.ts @@ -0,0 +1,402 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { onScopeDispose } from 'vue-demi' +import { sleep } from '@tanstack/query-test-utils' +import { mutationOptions } from '../mutationOptions' +import { useMutation } from '../useMutation' +import { useIsMutating, useMutationState } from '../useMutationState' +import { useQueryClient } from '../useQueryClient' +import type { MockedFunction } from 'vitest' +import type { MutationState } from '@tanstack/query-core' + +vi.mock('../useQueryClient') + +describe('mutationOptions', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + 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(51) + isMutatingArray.push(isMutating.value) + + expect(isMutatingArray[0]).toEqual(0) + expect(isMutatingArray[1]).toEqual(1) + expect(isMutatingArray[2]).toEqual(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(51) + isMutatingArray.push(isMutating.value) + + expect(isMutatingArray[0]).toEqual(0) + expect(isMutatingArray[1]).toEqual(1) + expect(isMutatingArray[2]).toEqual(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(51) + isMutatingArray.push(isMutating.value) + + expect(isMutatingArray[0]).toEqual(0) + expect(isMutatingArray[1]).toEqual(2) + expect(isMutatingArray[2]).toEqual(0) + }) + + it('should return the number of fetching mutations when used with useIsMutating (filter mutationOpts1.mutationKey)', async () => { + const isMutatingArray: Array = [] + const mutationKey1 = ['key'] as const + const mutationOpts1 = mutationOptions({ + mutationKey: mutationKey1, + 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: mutationKey1, + }) + + isMutatingArray.push(isMutating.value) + + mutate1() + mutate2() + await vi.advanceTimersByTimeAsync(0) + isMutatingArray.push(isMutating.value) + + await vi.advanceTimersByTimeAsync(51) + isMutatingArray.push(isMutating.value) + + expect(isMutatingArray[0]).toEqual(0) + expect(isMutatingArray[1]).toEqual(1) + expect(isMutatingArray[2]).toEqual(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 mutationKey = ['mutation'] as const + const mutationOpts = mutationOptions({ + mutationKey, + mutationFn: () => sleep(500).then(() => 'data'), + }) + + const { mutate } = useMutation(mutationOpts) + isMutatingArray.push(queryClient.isMutating({ mutationKey })) + + mutate() + await vi.advanceTimersByTimeAsync(0) + isMutatingArray.push(queryClient.isMutating({ mutationKey })) + + await vi.advanceTimersByTimeAsync(501) + isMutatingArray.push(queryClient.isMutating({ mutationKey })) + + expect(isMutatingArray[0]).toEqual(0) + expect(isMutatingArray[1]).toEqual(1) + expect(isMutatingArray[2]).toEqual(0) + }) + + 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) + isMutatingArray.push(queryClient.isMutating()) + + mutate() + await vi.advanceTimersByTimeAsync(0) + isMutatingArray.push(queryClient.isMutating()) + + await vi.advanceTimersByTimeAsync(501) + isMutatingArray.push(queryClient.isMutating()) + + expect(isMutatingArray[0]).toEqual(0) + expect(isMutatingArray[1]).toEqual(1) + expect(isMutatingArray[2]).toEqual(0) + }) + + 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) + isMutatingArray.push(queryClient.isMutating()) + + mutate1() + mutate2() + await vi.advanceTimersByTimeAsync(0) + isMutatingArray.push(queryClient.isMutating()) + + await vi.advanceTimersByTimeAsync(501) + isMutatingArray.push(queryClient.isMutating()) + + expect(isMutatingArray[0]).toEqual(0) + expect(isMutatingArray[1]).toEqual(2) + expect(isMutatingArray[2]).toEqual(0) + }) + + it('should return the number of fetching mutations when used with queryClient.isMutating (filter mutationOpt1.mutationKey)', async () => { + const isMutatingArray: Array = [] + const queryClient = useQueryClient() + const mutationKey1 = ['mutation'] as const + const mutationOpts1 = mutationOptions({ + mutationKey: mutationKey1, + mutationFn: () => sleep(500).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(500).then(() => 'data2'), + }) + + const { mutate: mutate1 } = useMutation(mutationOpts1) + const { mutate: mutate2 } = useMutation(mutationOpts2) + isMutatingArray.push(queryClient.isMutating({ + mutationKey: mutationKey1, + })) + + mutate1() + mutate2() + await vi.advanceTimersByTimeAsync(0) + isMutatingArray.push(queryClient.isMutating({ + mutationKey: mutationKey1, + })) + + await vi.advanceTimersByTimeAsync(501) + isMutatingArray.push(queryClient.isMutating({ + mutationKey: mutationKey1, + })) + + expect(isMutatingArray[0]).toEqual(0) + expect(isMutatingArray[1]).toEqual(1) + expect(isMutatingArray[2]).toEqual(0) + }) + + it('should return the number of fetching mutations when used with useMutationState (with mutationKey in mutationOptions)', async () => { + const queryClient = useQueryClient() + queryClient.clear() + const mutationStateArray: Array< + MutationState + > = [] + const mutationKey = ['mutation'] as const + const mutationOpts = mutationOptions({ + mutationKey, + mutationFn: () => sleep(10).then(() => 'data'), + }) + + const { mutate } = useMutation(mutationOpts) + const mutationState = useMutationState({ + filters: { mutationKey, status: 'success' }, + }) + + expect(mutationState.value.length).toEqual(0) + + mutate() + await vi.advanceTimersByTimeAsync(11) + + mutationStateArray.push(...mutationState.value) + expect(mutationStateArray.length).toEqual(1) + expect(mutationStateArray[0]?.data).toEqual('data') + }) + + it('should return the number of fetching mutations when used with useMutationState (without mutationKey in mutationOptions)', async () => { + const queryClient = useQueryClient() + queryClient.clear() + const mutationStateArray: Array< + MutationState + > = [] + const mutationOpts = mutationOptions({ + mutationFn: () => sleep(10).then(() => 'data'), + }) + + const { mutate } = useMutation(mutationOpts) + const mutationState = useMutationState({ + filters: { status: 'success' }, + }) + + expect(mutationState.value.length).toEqual(0) + + mutate() + await vi.advanceTimersByTimeAsync(11) + + mutationStateArray.push(...mutationState.value) + expect(mutationStateArray.length).toEqual(1) + expect(mutationStateArray[0]?.data).toEqual('data') + }) + + it('should return the number of fetching mutations when used with useMutationState', async () => { + const queryClient = useQueryClient() + queryClient.clear() + const mutationStateArray: Array< + MutationState + > = [] + 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 mutationState = useMutationState({ + filters: { status: 'success' }, + }) + + expect(mutationState.value.length).toEqual(0) + + mutate1() + mutate2() + await vi.advanceTimersByTimeAsync(11) + + mutationStateArray.push(...mutationState.value) + expect(mutationStateArray.length).toEqual(2) + expect(mutationStateArray[0]?.data).toEqual('data1') + expect(mutationStateArray[1]?.data).toEqual('data2') + }) + + it('should return the number of fetching mutations when used with useMutationState (filter mutationOpt1.mutationKey)', async () => { + const queryClient = useQueryClient() + queryClient.clear() + const mutationStateArray: Array< + MutationState + > = [] + const mutationKey1 = ['mutation'] as const + const mutationOpts1 = mutationOptions({ + mutationKey: mutationKey1, + mutationFn: () => sleep(10).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(10).then(() => 'data2'), + }) + + const { mutate: mutate1 } = useMutation(mutationOpts1) + const { mutate: mutate2 } = useMutation(mutationOpts2) + const mutationState = useMutationState({ + filters: { mutationKey: mutationKey1, status: 'success' }, + }) + + expect(mutationState.value.length).toEqual(0) + + mutate1() + mutate2() + await vi.advanceTimersByTimeAsync(11) + + mutationStateArray.push(...mutationState.value) + expect(mutationStateArray.length).toEqual(1) + expect(mutationStateArray[0]?.data).toEqual('data1') + expect(mutationStateArray[1]).toBeFalsy() + }) + + it('should stop listening to changes on onScopeDispose', async () => { + const onScopeDisposeMock = onScopeDispose as MockedFunction< + typeof onScopeDispose + > + onScopeDisposeMock.mockImplementation((fn) => fn()) + + const mutation = useMutation({ + mutationFn: (params: string) => sleep(0).then(() => params), + }) + const mutation2 = useMutation({ + mutationFn: (params: string) => sleep(0).then(() => params), + }) + const isMutating = useIsMutating() + + expect(isMutating.value).toStrictEqual(0) + + mutation.mutateAsync('a') + mutation2.mutateAsync('b') + + await vi.advanceTimersByTimeAsync(0) + + expect(isMutating.value).toStrictEqual(0) + + await vi.advanceTimersByTimeAsync(0) + + expect(isMutating.value).toStrictEqual(0) + + onScopeDisposeMock.mockReset() + }) +}) diff --git a/packages/vue-query/src/index.ts b/packages/vue-query/src/index.ts index 5ea6e26f83..5ccee15f15 100644 --- a/packages/vue-query/src/index.ts +++ b/packages/vue-query/src/index.ts @@ -16,6 +16,7 @@ export { useQuery } from './useQuery' export { useQueries } from './useQueries' export { useInfiniteQuery } from './useInfiniteQuery' export { useMutation } from './useMutation' +export { mutationOptions } from './mutationOptions' export { useIsFetching } from './useIsFetching' export { useIsMutating, useMutationState } from './useMutationState' export { VUE_QUERY_CLIENT } from './utils' diff --git a/packages/vue-query/src/mutationOptions.ts b/packages/vue-query/src/mutationOptions.ts new file mode 100644 index 0000000000..98dd12c01c --- /dev/null +++ b/packages/vue-query/src/mutationOptions.ts @@ -0,0 +1,50 @@ +import type { UseMutationOptions } from './useMutation'; +import type { DefaultError, WithRequired } from '@tanstack/query-core' +import type { DeepUnwrapRefOrGetter, MaybeRefDeepOrGetter } from './types' + +export function mutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + options: MaybeRefDeepOrGetter< + WithRequired< + DeepUnwrapRefOrGetter>, + 'mutationKey' + > + >, +): MaybeRefDeepOrGetter< + WithRequired< + DeepUnwrapRefOrGetter>, + 'mutationKey' + > +> +export function mutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + options: MaybeRefDeepOrGetter< + Omit< + DeepUnwrapRefOrGetter>, + 'mutationKey' + > + >, +): MaybeRefDeepOrGetter< + Omit< + DeepUnwrapRefOrGetter>, + 'mutationKey' + > +> +export function mutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + options: UseMutationOptions, +): UseMutationOptions { + return options +} diff --git a/packages/vue-query/src/types.ts b/packages/vue-query/src/types.ts index eb793c811e..46ff03df2a 100644 --- a/packages/vue-query/src/types.ts +++ b/packages/vue-query/src/types.ts @@ -36,6 +36,8 @@ export type MaybeRefDeep = MaybeRef< : T > +export type MaybeRefDeepOrGetter = MaybeRefDeep | (() => T) + export type NoUnknown = Equal extends true ? never : T export type Equal = @@ -55,6 +57,10 @@ export type DeepUnwrapRef = T extends UnwrapLeaf } : UnwrapRef +export type DeepUnwrapRefOrGetter = T extends (...args: any) => MaybeRefDeep + ? DeepUnwrapRef> + : DeepUnwrapRef + export type ShallowOption = { /** * Return data in a shallow ref object (it is `false` by default). It can be set to `true` to return data in a shallow ref object, which can improve performance if your data does not need to be deeply reactive. diff --git a/packages/vue-query/src/useMutation.ts b/packages/vue-query/src/useMutation.ts index 2bb852acbf..39ccae9c8e 100644 --- a/packages/vue-query/src/useMutation.ts +++ b/packages/vue-query/src/useMutation.ts @@ -21,7 +21,7 @@ import type { MutationObserverOptions, MutationObserverResult, } from '@tanstack/query-core' -import type { MaybeRefDeep, ShallowOption } from './types' +import type { MaybeRefDeepOrGetter, ShallowOption } from './types' import type { QueryClient } from './queryClient' type MutationResult = @@ -39,13 +39,7 @@ export type UseMutationOptions< TError = DefaultError, TVariables = void, TOnMutateResult = unknown, -> = - | MaybeRefDeep< - UseMutationOptionsBase - > - | (() => MaybeRefDeep< - UseMutationOptionsBase - >) +> = MaybeRefDeepOrGetter> type MutateSyncFunction< TData = unknown,