Skip to content

Commit

Permalink
feat(mutation)!: require one argument only for useMutation
Browse files Browse the repository at this point in the history
BREAKING CHANGE: the `mutation` option in `useMutation()` now only
accepts one argument for the variables. This allows to add extra
parameters in the future like a signal, an extra context, etc
  • Loading branch information
posva committed Mar 17, 2024
1 parent c431284 commit 86b5996
Show file tree
Hide file tree
Showing 3 changed files with 104 additions and 67 deletions.
18 changes: 9 additions & 9 deletions src/use-mutation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe('useMutation', () => {
vi.restoreAllMocks()
})

function mountSimple<TResult = number, TParams extends any[] = []>(
function mountSimple<TResult = number, TParams = void>(
options: Partial<UseMutationOptions<TResult, TParams>> = {},
mountOptions?: GlobalMountOptions,
) {
Expand Down Expand Up @@ -61,14 +61,14 @@ describe('useMutation', () => {
it('invokes the `onMutate` hook', async () => {
const onMutate = vi.fn()
const { wrapper } = mountSimple({
mutation: async (arg1: number, arg2: number) => {
return arg1 + arg2
mutation: async ({ a, b }: { a: number, b: number }) => {
return a + b
},
onMutate,
})
expect(onMutate).not.toHaveBeenCalled()
wrapper.vm.mutate(24, 42)
expect(onMutate).toHaveBeenCalledWith(24, 42)
wrapper.vm.mutate({ a: 24, b: 42 })
expect(onMutate).toHaveBeenCalledWith({ a: 24, b: 42 })
})

it('invokes the `onError` hook', async () => {
Expand All @@ -85,7 +85,7 @@ describe('useMutation', () => {
await runTimers()
expect(onError).toHaveBeenCalledWith(expect.objectContaining({
error: new Error('24'),
args: [24],
vars: 24,
}))
})

Expand All @@ -99,7 +99,7 @@ describe('useMutation', () => {
await runTimers()
expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({
data: 42,
args: [],
vars: undefined,
}))
})

Expand All @@ -115,7 +115,7 @@ describe('useMutation', () => {
expect(onSettled).toHaveBeenCalledWith(expect.objectContaining({
error: null,
data: 42,
args: [],
vars: undefined,
}))
})

Expand All @@ -133,7 +133,7 @@ describe('useMutation', () => {
expect(onSettled).toHaveBeenCalledWith(expect.objectContaining({
error: new Error('foobar'),
data: undefined,
args: [],
vars: undefined,
}))
})
})
Expand Down
94 changes: 63 additions & 31 deletions src/use-mutation.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,32 @@ import { expectTypeOf, it } from 'vitest'
import { useMutation } from './use-mutation'

it('types the parameters for the key', () => {
useMutation({
mutation: (_one: string, _two: number) => Promise.resolve({ name: 'foo' }),
keys(result, one, two) {
const { mutate } = useMutation({
mutation: (_one: string) => Promise.resolve({ name: 'foo' }),
keys(result, one) {
expectTypeOf(one).toBeString()
expectTypeOf(two).toBeNumber()
expectTypeOf(result).toEqualTypeOf<{ name: string }>()
return [['foo']]
},
})

mutate('one')
// @ts-expect-error: missing arg
mutate()
})

it('allows no arguments to mutation', () => {
const { mutate } = useMutation({
mutation: () => Promise.resolve({ name: 'foo' }),
keys(result) {
expectTypeOf(result).toEqualTypeOf<{ name: string }>()
return [['foo']]
},
})

mutate()
// @ts-expect-error: no extra arg
mutate(25)
})

it('can return an array of keys', () => {
Expand All @@ -24,15 +41,15 @@ it('can return an array of keys', () => {

it('can infer the arguments from the mutation', () => {
useMutation({
mutation: (_one: string, _two: number) => Promise.resolve({ name: 'foo' }),
onSuccess({ args }) {
expectTypeOf(args).toEqualTypeOf<[string, number]>()
mutation: (_one: string) => Promise.resolve({ name: 'foo' }),
onSuccess({ vars }) {
expectTypeOf(vars).toEqualTypeOf<string>()
},
onError({ args }) {
expectTypeOf(args).toEqualTypeOf<[string, number]>()
onError({ vars }) {
expectTypeOf(vars).toEqualTypeOf<string>()
},
onSettled({ args }) {
expectTypeOf(args).toEqualTypeOf<[string, number]>()
onSettled({ vars }) {
expectTypeOf(vars).toEqualTypeOf<string>()
},
})
})
Expand All @@ -55,14 +72,14 @@ it('can infer the context from sync onMutate', () => {
onMutate() {
return { foo: 'bar' }
},
onSuccess({ context }) {
expectTypeOf(context).toEqualTypeOf<{ foo: string }>()
onSuccess(context) {
expectTypeOf(context).toMatchTypeOf<{ foo: string }>()
},
onError({ context }) {
expectTypeOf(context).toEqualTypeOf<{ foo: string }>()
onError(context) {
expectTypeOf(context).toMatchTypeOf<{ foo: string }>()
},
onSettled({ context }) {
expectTypeOf(context).toEqualTypeOf<{ foo: string }>()
onSettled(context) {
expectTypeOf(context).toMatchTypeOf<{ foo: string }>()
},
})
})
Expand All @@ -83,6 +100,11 @@ it('can return undefined in onMutate', () => {
onMutate() {
return undefined
},
onSuccess(context) {
expectTypeOf(context).toMatchTypeOf<{
data: number
}>()
},
})
})

Expand All @@ -101,14 +123,14 @@ it('can infer the context from async onMutate', () => {
async onMutate() {
return { foo: 'bar' }
},
onSuccess({ context }) {
expectTypeOf(context).toEqualTypeOf<{ foo: string }>()
onSuccess(context) {
expectTypeOf(context).toMatchTypeOf<{ foo: string }>()
},
onError({ context }) {
expectTypeOf(context).toEqualTypeOf<{ foo: string }>()
onError(context) {
expectTypeOf(context).toMatchTypeOf<{ foo: string }>()
},
onSettled({ context }) {
expectTypeOf(context).toEqualTypeOf<{ foo: string }>()
onSettled(context) {
expectTypeOf(context).toMatchTypeOf<{ foo: string }>()
},
})
})
Expand All @@ -120,14 +142,24 @@ it('can infer a context of void', () => {
// no return
},

onSuccess({ context }) {
expectTypeOf(context).toEqualTypeOf<void>()
},
onError({ context }) {
expectTypeOf(context).toEqualTypeOf<void>()
},
onSettled({ context }) {
expectTypeOf(context).toEqualTypeOf<void>()
onSuccess(context) {
expectTypeOf(context).toMatchTypeOf<{
data: number
vars: undefined | void
}>()
},
onError(context) {
expectTypeOf(context).toMatchTypeOf<{
error: unknown
vars: undefined | void
}>()
},
onSettled(context) {
expectTypeOf(context).toMatchTypeOf<{
data: number | undefined
error: unknown | null
vars: undefined | void
}>()
},
})
})
59 changes: 32 additions & 27 deletions src/use-mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,58 +5,57 @@ import type { UseQueryKey } from './query-options'
import type { ErrorDefault } from './types-extension'
import type { _Awaitable } from './utils'

type _MutationKeys<TParams extends readonly any[], TResult> =
type _MutationKeys<TVars, TResult> =
| UseQueryKey[]
| ((result: TResult, ...args: TParams) => UseQueryKey[])
| ((data: TResult, vars: TVars) => UseQueryKey[])

// eslint-disable-next-line ts/ban-types
export type _ReduceContext<TContext> = TContext extends void | null | undefined ? {} : TContext

export interface UseMutationOptions<
TResult = unknown,
TParams extends readonly unknown[] = readonly [],
TVars = void,
TError = ErrorDefault,
TContext extends Record<any, any> | void | null = void,
> {
/**
* The key of the mutation. If the mutation is successful, it will invalidate the query with the same key and refetch it
*/
mutation: (...args: TParams) => Promise<TResult>
mutation: (vars: TVars) => Promise<TResult>

// TODO: move this to a plugin that calls invalidateEntry()
/**
* Keys to invalidate if the mutation succeeds so that `useQuery()` refetch if used.
*/
keys?: _MutationKeys<TParams, TResult>
keys?: _MutationKeys<TVars, TResult>

/**
* Hook to execute a callback when the mutation is triggered
*/
onMutate?: (...args: TParams) => _Awaitable<TContext>
onMutate?: (vars: TVars) => _Awaitable<TContext>

/**
* Hook to execute a callback in case of error
*/
onError?: (context: { error: TError, args: TParams, context: TContext }) => unknown
// onError?: (context: { error: TError, args: TParams } & TContext) => Promise<TContext | void> | TContext | void
onError?: (context: { error: TError, vars: TVars } & _ReduceContext<TContext>) => unknown
// onError?: (context: { error: TError, vars: TParams } & TContext) => Promise<TContext | void> | TContext | void

/**
* Hook to execute a callback in case of error
*/
onSuccess?: (context: { data: TResult, args: TParams, context: TContext }) => unknown
onSuccess?: (context: { data: TResult, vars: TVars } & _ReduceContext<TContext>) => unknown

/**
* Hook to execute a callback in case of error
*/
onSettled?: (context: { data: TResult | undefined, error: TError | null, args: TParams, context: TContext }) => unknown
onSettled?: (context: { data: TResult | undefined, error: TError | null, vars: TVars } & _ReduceContext<TContext>) => unknown

// TODO: invalidate options exact, refetch, etc
}

// export const USE_MUTATIONS_DEFAULTS = {} satisfies Partial<UseMutationsOptions>

export interface UseMutationReturn<
TResult = unknown,
TParams extends readonly unknown[] = readonly [],
TError = ErrorDefault,
> {
export interface UseMutationReturn<TResult, TVars, TError> {
/**
* The result of the mutation. `undefined` if the mutation has not been called yet.
*/
Expand All @@ -83,22 +82,25 @@ export interface UseMutationReturn<
*
* @param params - parameters to pass to the mutation
*/
mutate: (...params: TParams) => Promise<TResult>
mutate: (...args: unknown | void extends TVars ? [] : [TVars]) => Promise<TResult>

/**
* Resets the state of the mutation to its initial state.
*/
reset: () => void
}

// TODO: it might be worth having multiple UseMutationReturnState:
// type UseMutationReturn<TResult, TVars, TError> = UseMutationReturnSuccess | UseMutationReturnError | UseMutationReturnLoading

export function useMutation<
TResult,
TParams extends readonly unknown[] = readonly [],
TVars = void,
TError = ErrorDefault,
TContext extends Record<any, any> | void | null = void,
>(
options: UseMutationOptions<TResult, TParams, TError, TContext>,
): UseMutationReturn<TResult, TParams, TError> {
options: UseMutationOptions<TResult, TVars, TError, TContext>,
): UseMutationReturn<TResult, TVars, TError> {
const store = useQueryCache()

const status = shallowRef<UseQueryStatus>('pending')
Expand All @@ -108,17 +110,18 @@ export function useMutation<
// a pending promise allows us to discard previous ongoing requests
let pendingPromise: Promise<TResult> | null = null

async function mutate(...args: TParams) {
async function mutate(vars: TVars) {
status.value = 'loading'

// TODO: should this context be passed to mutation() and ...args transformed into one object?
const context = (await options.onMutate?.(...args)) as TContext
// TODO: should this context be passed to mutation() and vars transformed into one object?
// NOTE: the cast makes it easier to write without extra code. It's safe because { ...null, ...undefined } works and TContext must be a Record<any, any>
const context = (await options.onMutate?.(vars)) as _ReduceContext<TContext>

// TODO: AbortSignal that is aborted when the mutation is called again so we can throw in pending
const promise = (pendingPromise = options
.mutation(...args)
.mutation(vars)
.then(async (newData) => {
await options.onSuccess?.({ data: newData, args, context })
await options.onSuccess?.({ data: newData, vars, ...context })

if (pendingPromise === promise) {
data.value = newData
Expand All @@ -127,7 +130,7 @@ export function useMutation<
if (options.keys) {
const keys
= typeof options.keys === 'function'
? options.keys(newData, ...args)
? options.keys(newData, vars)
: options.keys
for (const key of keys) {
// TODO: find a way to pass a source of the invalidation, could be a symbol associated with the mutation, the parameters
Expand All @@ -142,11 +145,11 @@ export function useMutation<
error.value = newError
status.value = 'error'
}
await options.onError?.({ error: newError, args, context })
await options.onError?.({ error: newError, vars, ...context })
throw newError
})
.finally(async () => {
await options.onSettled?.({ data: data.value, error: error.value, args, context })
await options.onSettled?.({ data: data.value, error: error.value, vars, ...context })
}))

return promise
Expand All @@ -163,6 +166,8 @@ export function useMutation<
isLoading: computed(() => status.value === 'loading'),
status,
error,
// @ts-expect-error: the actual type has a ternary that makes this difficult to type
// without writing extra code
mutate,
reset,
}
Expand Down

0 comments on commit 86b5996

Please sign in to comment.