Skip to content

Commit

Permalink
Merge pull request #12 from posva/3-optimistic-updates
Browse files Browse the repository at this point in the history
feat(useMutation): add hooks
  • Loading branch information
posva authored Mar 16, 2024
2 parents 38b6cfb + bbcbf24 commit 6e6b872
Show file tree
Hide file tree
Showing 7 changed files with 247 additions and 19 deletions.
4 changes: 2 additions & 2 deletions playground/db.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,15 @@
"color": "Sienna",
"imagesrc": "https://tailwindui.com/img/ecommerce-images/shopping-cart-page-01-product-01.jpg",
"price": 24,
"availability": -2
"availability": 1
},
{
"id": 4,
"name": "Basic G",
"color": "Grey",
"imagesrc": "https://tailwindui.com/img/ecommerce-images/product-page-01-related-product-03.jpg",
"price": 24,
"availability": 11
"availability": 0
},
{
"id": 5,
Expand Down
1 change: 1 addition & 0 deletions playground/src/api/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
31 changes: 28 additions & 3 deletions playground/src/pages/ecom/item/[id].vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { StarIcon } from '@heroicons/vue/20/solid'
import { HeartIcon } from '@heroicons/vue/24/outline'
import { useMutation, useQuery } from '@pinia/colada'
import { useRoute } from 'vue-router/auto'
import { type ProductListItem, changeProductAvailability, getProductById } from '@/api/products'
import { delay } from '@/api/utils'
const route = useRoute('/ecom/item/[id]')
Expand All @@ -19,9 +21,32 @@ const { data: item, isPending } = useQuery({
staleTime: 15000,
})
const itemAvailability = ref()
watch(() => item.value?.availability, value => itemAvailability.value = value)
const { mutate: bookProduct } = useMutation({
keys: product => [['items', product.id]],
mutation: (product: ProductListItem) => changeProductAvailability(product),
keys: product => [['items'], ['items', product.id]],
mutation: async (product: ProductListItem) => {
await delay(Math.random() * 1000 + 200)
return changeProductAvailability(product, undefined)
},
// NOTE: the optimistic update only works if there are no parallele updates
onMutate: (product) => {
const context = { previousAvailability: product.availability }
itemAvailability.value = product.availability - 1
return context
},
onError() {
itemAvailability.value = item.value?.availability
},
onSuccess(data) {
// TODO: find a better usecase
console.log('Success hook called', data)

Check warning on line 44 in playground/src/pages/ecom/item/[id].vue

View workflow job for this annotation

GitHub Actions / build

Unexpected console statement
},
onSettled() {
// TODO: find a better usecase
console.log('Settled hook called')

Check warning on line 48 in playground/src/pages/ecom/item/[id].vue

View workflow job for this annotation

GitHub Actions / build

Unexpected console statement
},
// onMutate: async () => {
// // Cancel any outgoing refetches
// // (so they don't overwrite our optimistic update)
Expand Down Expand Up @@ -107,7 +132,7 @@ const { mutate: bookProduct } = useMutation({
/>
</div>

<div>Availability: {{ item?.availability }}</div>
<div>Availability: {{ itemAvailability }}</div>

<div class="flex mt-10">
<button
Expand Down
83 changes: 82 additions & 1 deletion src/use-mutation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('useMutation', () => {
render: () => null,
setup() {
return {
...useMutation<TResult>({
...useMutation<TResult, TParams>({
...options,
// @ts-expect-error: generic unmatched but types work
mutation,
Expand All @@ -48,6 +48,7 @@ describe('useMutation', () => {
)
return Object.assign([wrapper, mutation] as const, { wrapper, mutation })
}

it('invokes the mutation', async () => {
const { wrapper } = mountSimple()

Expand All @@ -56,4 +57,84 @@ describe('useMutation', () => {

expect(wrapper.vm.data).toBe(42)
})

it('invokes the `onMutate` hook', async () => {
const onMutate = vi.fn()
const { wrapper } = mountSimple({
mutation: async (arg1: number, arg2: number) => {
return arg1 + arg2
},
onMutate,
})
expect(onMutate).not.toHaveBeenCalled()
wrapper.vm.mutate(24, 42)
expect(onMutate).toHaveBeenCalledWith(24, 42)
})

it('invokes the `onError` hook', async () => {
const onError = vi.fn()
const { wrapper } = mountSimple({
mutation: async (n: number) => {
throw new Error(String(n))
},
onError,
})

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

it('invokes the `onSuccess` hook', async () => {
const onSuccess = vi.fn()
const { wrapper } = mountSimple({
onSuccess,
})

wrapper.vm.mutate()
await runTimers()
expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({
data: 42,
args: [],
}))
})

describe('invokes the `onSettled` hook', () => {
it('on success', async () => {
const onSettled = vi.fn()
const { wrapper } = mountSimple({
onSettled,
})

wrapper.vm.mutate()
await runTimers()
expect(onSettled).toHaveBeenCalledWith(expect.objectContaining({
error: null,
data: 42,
args: [],
}))
})

it('on error', async () => {
const onSettled = vi.fn()
const { wrapper } = mountSimple({
mutation: async () => {
throw new Error('foobar')
},
onSettled,
})

expect(wrapper.vm.mutate()).rejects.toThrow()
await runTimers()
expect(onSettled).toHaveBeenCalledWith(expect.objectContaining({
error: new Error('foobar'),
data: undefined,
args: [],
}))
})
})
})
82 changes: 82 additions & 0 deletions src/use-mutation.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,85 @@ 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]>()
},
onError({ args }) {
expectTypeOf(args).toEqualTypeOf<[string, number]>()
},
onSettled({ args }) {
expectTypeOf(args).toEqualTypeOf<[string, number]>()
},
})
})

it('can infer the data from the mutation', () => {
useMutation({
mutation: () => Promise.resolve(42),
onSuccess({ data }) {
expectTypeOf(data).toEqualTypeOf<number>()
},
onSettled({ data }) {
expectTypeOf(data).toEqualTypeOf<number | undefined>()
},
})
})

it('can infer the context from sync onMutate', () => {
useMutation({
mutation: () => Promise.resolve(42),
onMutate() {
return { foo: 'bar' }
},
onSuccess({ context }) {
expectTypeOf(context).toEqualTypeOf<{ foo: string }>()
},
onError({ context }) {
expectTypeOf(context).toEqualTypeOf<{ foo: string }>()
},
onSettled({ context }) {
expectTypeOf(context).toEqualTypeOf<{ foo: string }>()
},
})
})

it('can infer the context from async onMutate', () => {
useMutation({
mutation: () => Promise.resolve(42),
async onMutate() {
return { foo: 'bar' }
},
onSuccess({ context }) {
expectTypeOf(context).toEqualTypeOf<{ foo: string }>()
},
onError({ context }) {
expectTypeOf(context).toEqualTypeOf<{ foo: string }>()
},
onSettled({ context }) {
expectTypeOf(context).toEqualTypeOf<{ foo: string }>()
},
})
})

it('can infer a context of void', () => {
useMutation({
mutation: () => Promise.resolve(42),
onMutate() {
// no return
},

onSuccess({ context }) {
expectTypeOf(context).toEqualTypeOf<void>()
},
onError({ context }) {
expectTypeOf(context).toEqualTypeOf<void>()
},
onSettled({ context }) {
expectTypeOf(context).toEqualTypeOf<void>()
},
})
})
59 changes: 46 additions & 13 deletions src/use-mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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'

type _MutationKeys<TParams extends readonly any[], TResult> =
| UseQueryKey[]
Expand All @@ -11,6 +12,8 @@ type _MutationKeys<TParams extends readonly any[], TResult> =
export interface UseMutationOptions<
TResult = unknown,
TParams extends readonly unknown[] = readonly [],
TError = ErrorDefault,
TContext = unknown,
> {
/**
* The key of the mutation. If the mutation is successful, it will invalidate the query with the same key and refetch it
Expand All @@ -23,6 +26,27 @@ export interface UseMutationOptions<
*/
keys?: _MutationKeys<TParams, TResult>

/**
* Hook to execute a callback when the mutation is triggered
*/
onMutate?: (...args: TParams) => _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

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

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

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

Expand Down Expand Up @@ -71,8 +95,9 @@ export function useMutation<
TResult,
TParams extends readonly unknown[] = readonly [],
TError = ErrorDefault,
TContext = void,
>(
options: UseMutationOptions<TResult, TParams>,
options: UseMutationOptions<TResult, TParams, TError, TContext>,
): UseMutationReturn<TResult, TParams, TError> {
const store = useQueryCache()

Expand All @@ -82,36 +107,46 @@ export function useMutation<

// a pending promise allows us to discard previous ongoing requests
let pendingPromise: Promise<TResult> | null = null
function mutate(...args: TParams) {

async function mutate(...args: TParams) {
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: AbortSignal that is aborted when the mutation is called again so we can throw in pending
const promise = (pendingPromise = options
.mutation(...args)
.then((_data) => {
.then(async (newData) => {
await options.onSuccess?.({ data: newData, args, context })

if (pendingPromise === promise) {
data.value = _data
data.value = newData
error.value = null
status.value = 'success'
if (options.keys) {
const keys
= typeof options.keys === 'function'
? options.keys(_data, ...args)
? options.keys(newData, ...args)
: 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 _data
return newData
})
.catch((_error) => {
.catch(async (newError) => {
if (pendingPromise === promise) {
error.value = _error
error.value = newError
status.value = 'error'
}
throw _error
await options.onError?.({ error: newError, args, context })
throw newError
})
.finally(async () => {
await options.onSettled?.({ data: data.value, error: error.value, args, context })
}))

return promise
Expand All @@ -123,14 +158,12 @@ export function useMutation<
status.value = 'pending'
}

const mutationReturn = {
return {
data,
isLoading: computed(() => status.value === 'loading'),
status,
error,
mutate,
reset,
} satisfies UseMutationReturn<TResult, TParams, TError>

return mutationReturn
}
}
Loading

0 comments on commit 6e6b872

Please sign in to comment.