Skip to content

Commit

Permalink
feat(mutation)!: add mutateAsync
Browse files Browse the repository at this point in the history
BREAKING CHANGE: `mutate` no longer returns a promise and catches errors
to be safely used in templates. The old behavior remains the same with
`mutateAsync`
  • Loading branch information
posva committed Mar 18, 2024
1 parent 3d638a4 commit 5c97b69
Show file tree
Hide file tree
Showing 5 changed files with 149 additions and 73 deletions.
2 changes: 1 addition & 1 deletion .prettierrc.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export default {
semi: false,
trailingComma: 'es5',
trailingComma: 'all',
singleQuote: true,
}
18 changes: 1 addition & 17 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

67 changes: 59 additions & 8 deletions src/use-mutation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,37 @@ describe('useMutation', () => {
expect(wrapper.vm.data).toBe(42)
})

it('invokes the `onMutate` hook', async () => {
it('can be awaited with mutateAsync', async () => {
const { wrapper } = mountSimple()

const p = (wrapper.vm.mutateAsync())
await runTimers()
await expect(p).resolves.toBe(42)
})

it('mutateAsync throws', async () => {
const { wrapper } = mountSimple({
mutation: async () => {
throw new Error('foobar')
},
})

await expect(wrapper.vm.mutateAsync()).rejects.toThrow('foobar')
})

it('mutate catches if mutation throws', async () => {
const { wrapper } = mountSimple({
mutation: async () => {
throw new Error('foobar')
},
})

expect((async () => wrapper.vm.mutate())()).resolves.toBeUndefined()
await runTimers()
expect(wrapper.vm.error).toEqual(new Error('foobar'))
})

it('invokes the "onMutate" hook before mutating', async () => {
const onMutate = vi.fn()
const { wrapper } = mountSimple({
mutation: async ({ a, b }: { a: number, b: number }) => {
Expand All @@ -68,10 +98,14 @@ describe('useMutation', () => {
})
expect(onMutate).not.toHaveBeenCalled()
wrapper.vm.mutate({ a: 24, b: 42 })
expect(onMutate).toHaveBeenCalledWith({ a: 24, b: 42 })
expect(onMutate).toHaveBeenCalledTimes(1)
expect(onMutate).toHaveBeenLastCalledWith({ a: 24, b: 42 })
wrapper.vm.mutateAsync({ a: 0, b: 1 })
expect(onMutate).toHaveBeenCalledTimes(2)
expect(onMutate).toHaveBeenLastCalledWith({ a: 0, b: 1 })
})

it('invokes the `onError` hook', async () => {
it('invokes the "onError" hook if mutation throws', async () => {
const onError = vi.fn()
const { wrapper } = mountSimple({
mutation: async (n: number) => {
Expand All @@ -80,16 +114,33 @@ describe('useMutation', () => {
onError,
})

expect(wrapper.vm.mutate(24)).rejects.toThrow()
expect(onError).not.toHaveBeenCalled()
wrapper.vm.mutate(24)
await runTimers()
expect(onError).toHaveBeenCalledWith(expect.objectContaining({
error: new Error('24'),
vars: 24,
}))
})

it('invokes the `onSuccess` hook', async () => {
it('invokes the "onError" hook if onMutate throws', async () => {
const onError = vi.fn()
const { wrapper } = mountSimple({
onMutate() {
throw new Error('onMutate')
},
onError,
})

wrapper.vm.mutate()
await runTimers(false)
expect(onError).toHaveBeenCalledWith(expect.objectContaining({
error: new Error('onMutate'),
vars: undefined,
}))
})

it('invokes the "onSuccess" hook', async () => {
const onSuccess = vi.fn()
const { wrapper } = mountSimple({
onSuccess,
Expand All @@ -103,7 +154,7 @@ describe('useMutation', () => {
}))
})

describe('invokes the `onSettled` hook', () => {
describe('invokes the "onSettled" hook', () => {
it('on success', async () => {
const onSettled = vi.fn()
const { wrapper } = mountSimple({
Expand All @@ -113,7 +164,7 @@ describe('useMutation', () => {
wrapper.vm.mutate()
await runTimers()
expect(onSettled).toHaveBeenCalledWith(expect.objectContaining({
error: null,
error: undefined,
data: 42,
vars: undefined,
}))
Expand All @@ -128,7 +179,7 @@ describe('useMutation', () => {
onSettled,
})

expect(wrapper.vm.mutate()).rejects.toThrow()
expect(wrapper.vm.mutateAsync()).rejects.toThrow()
await runTimers()
expect(onSettled).toHaveBeenCalledWith(expect.objectContaining({
error: new Error('foobar'),
Expand Down
132 changes: 85 additions & 47 deletions src/use-mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ import type { ComputedRef, ShallowRef } from 'vue'
import { type UseQueryStatus, useQueryCache } from './query-store'
import type { UseQueryKey } from './query-options'
import type { ErrorDefault } from './types-extension'
import type { _Awaitable } from './utils'
import { type _Awaitable, noop } from './utils'

type _MutationKeys<TVars, TResult> =
| 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 _EmptyObject {}

export type _ReduceContext<TContext> = TContext extends void | null | undefined
? _EmptyObject
: TContext

export interface UseMutationOptions<
TResult = unknown,
Expand All @@ -37,18 +40,28 @@ export interface UseMutationOptions<
/**
* Hook to execute a callback in case of error
*/
onError?: (context: { error: TError, vars: TVars } & _ReduceContext<TContext>) => unknown
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, vars: TVars } & _ReduceContext<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, vars: TVars } & _ReduceContext<TContext>) => unknown
onSettled?: (
context: {
data: TResult | undefined
error: TError | undefined
vars: TVars
} & _ReduceContext<TContext>,
) => unknown

// TODO: invalidate options exact, refetch, etc
}
Expand Down Expand Up @@ -80,9 +93,18 @@ export interface UseMutationReturn<TResult, TVars, TError> {
/**
* Calls the mutation and returns a promise with the result.
*
* @param params - parameters to pass to the mutation
* @param args - parameters to pass to the mutation
*/
mutateAsync: unknown | void extends TVars
? () => Promise<TResult>
: (vars: TVars) => Promise<TResult>

/**
* Calls the mutation without returning a promise to avoid unhandled promise rejections.
*
* @param args - parameters to pass to the mutation
*/
mutate: (...args: unknown | void extends TVars ? [] : [TVars]) => Promise<TResult>
mutate: (...args: unknown | void extends TVars ? [] : [vars: TVars]) => void

/**
* Resets the state of the mutation to its initial state.
Expand All @@ -103,56 +125,71 @@ export function useMutation<
): UseMutationReturn<TResult, TVars, TError> {
const store = useQueryCache()

// TODO: there could be a mutation store that stores the state based on an optional key (if passed). This would allow to retrieve the state of a mutation with useMutationState(key)
const status = shallowRef<UseQueryStatus>('pending')
const data = shallowRef<TResult>()
const error = shallowRef<TError | null>(null)

// a pending promise allows us to discard previous ongoing requests
let pendingPromise: Promise<TResult> | null = null
// let pendingPromise: Promise<TResult> | null = null

async function mutate(vars: TVars) {
let pendingCall: symbol | undefined
async function mutateAsync(vars: TVars): Promise<TResult> {
status.value = 'loading'

// 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(vars)
.then(async (newData) => {
await options.onSuccess?.({ data: newData, vars, ...context })

if (pendingPromise === promise) {
data.value = newData
error.value = null
status.value = 'success'
if (options.keys) {
const keys
= typeof options.keys === 'function'
? 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
store.invalidateEntry(key)
}
let currentData: TResult | undefined
let currentError: TError | undefined
let context!: _ReduceContext<TContext>

const currentCall = (pendingCall = Symbol())
try {
// 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>
context = (await options.onMutate?.(vars)) as _ReduceContext<TContext>

const newData = (currentData = await options.mutation(vars))

await options.onSuccess?.({ data: newData, vars, ...context })

if (pendingCall === currentCall) {
data.value = newData
error.value = null
status.value = 'success'

// TODO: move to plugin
if (options.keys) {
const keys
= typeof options.keys === 'function'
? 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
store.invalidateEntry(key)
}
}
return newData
})
.catch(async (newError) => {
if (pendingPromise === promise) {
error.value = newError
status.value = 'error'
}
await options.onError?.({ error: newError, vars, ...context })
throw newError
}
} catch (newError: any) {
currentError = newError
await options.onError?.({ error: newError, vars, ...context })
if (pendingCall === currentCall) {
error.value = newError
status.value = 'error'
}
throw newError
} finally {
await options.onSettled?.({
data: currentData,
error: currentError,
vars,
...context,
})
.finally(async () => {
await options.onSettled?.({ data: data.value, error: error.value, vars, ...context })
}))
}

return currentData
}

return promise
function mutate(vars: TVars) {
mutateAsync(vars).catch(noop)
}

function reset() {
Expand All @@ -166,9 +203,10 @@ 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
// @ts-expect-error: it would be nice to find a type-only refactor that works
mutate,
// @ts-expect-error: it would be nice to find a type-only refactor that works
mutateAsync,
reset,
}
}
3 changes: 3 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ export function stringifyFlatObject(obj: _ObjectFlat | _JSONPrimitive): string {
: String(obj)
}

/**
* @internal
*/
export const noop = () => {}

/**
Expand Down

0 comments on commit 5c97b69

Please sign in to comment.