Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 163 additions & 3 deletions packages/react/src/hooks/action-hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@ import { act, renderHook, waitFor } from '@testing-library/react'
import { baseErrorMap, inputSchema, outputSchema } from '../../../contract/tests/shared'
import { useServerAction } from './action-hooks'

beforeEach(() => {
vi.clearAllMocks()
})

describe('useServerAction', () => {
const handler = vi.fn(async ({ input }) => {
return { output: Number(input?.input ?? 0) }
})

const action = os
.input(inputSchema.optional())
.errors(baseErrorMap)
.output(outputSchema)
.handler(async ({ input }) => {
return { output: Number(input?.input ?? 0) }
})
.handler(handler)
.actionable()

it('on success', async () => {
Expand Down Expand Up @@ -110,6 +116,57 @@ describe('useServerAction', () => {
expect(result.current.error).toBe(null)
})

it('on action calling error', async () => {
const { result } = renderHook(() => useServerAction(() => {
throw new Error('failed to call')
}))

expect(result.current.status).toBe('idle')
expect(result.current.isIdle).toBe(true)
expect(result.current.isPending).toBe(false)
expect(result.current.isSuccess).toBe(false)
expect(result.current.isError).toBe(false)
expect(result.current.input).toBe(undefined)
expect(result.current.data).toBe(undefined)
expect(result.current.error).toBe(null)

act(() => {
result.current.execute({ input: 123 })
})

expect(result.current.status).toBe('pending')
expect(result.current.isIdle).toBe(false)
expect(result.current.isPending).toBe(true)
expect(result.current.isSuccess).toBe(false)
expect(result.current.isError).toBe(false)
expect(result.current.input).toEqual({ input: 123 })
expect(result.current.data).toBe(undefined)
expect(result.current.error).toBe(null)

await waitFor(() => expect(result.current.status).toBe('error'))
expect(result.current.isIdle).toBe(false)
expect(result.current.isPending).toBe(false)
expect(result.current.isSuccess).toBe(false)
expect(result.current.isError).toBe(true)
expect(result.current.input).toEqual({ input: 123 })
expect(result.current.data).toBe(undefined)
expect(result.current.error).toBeInstanceOf(Error)
expect(result.current.error!.message).toBe('failed to call')

act(() => {
result.current.reset()
})

expect(result.current.status).toBe('idle')
expect(result.current.isIdle).toBe(true)
expect(result.current.isPending).toBe(false)
expect(result.current.isSuccess).toBe(false)
expect(result.current.isError).toBe(false)
expect(result.current.input).toBe(undefined)
expect(result.current.data).toBe(undefined)
expect(result.current.error).toBe(null)
})

it('interceptors', async () => {
const interceptor = vi.fn(({ next }) => next())
const executeInterceptor = vi.fn(({ next }) => next())
Expand Down Expand Up @@ -150,4 +207,107 @@ describe('useServerAction', () => {
expect(await executeInterceptor.mock.results[0]!.value).toEqual({ output: '123' })
})
})

it('multiple execute calls', async () => {
const { result } = renderHook(() => useServerAction(action))

expect(result.current.status).toBe('idle')

handler.mockImplementationOnce(async () => {
await new Promise(resolve => setTimeout(resolve, 20))
return { output: 123 }
})

let promise: Promise<any>

act(() => {
promise = result.current.execute({ input: 123 })
})

expect(result.current.status).toBe('pending')
expect(result.current.executedAt).toBeDefined()
expect(result.current.input).toEqual({ input: 123 })
expect(result.current.data).toBeUndefined()
expect(result.current.error).toBeNull()

handler.mockImplementationOnce(async () => {
await new Promise(resolve => setTimeout(resolve, 40))
return { output: 456 }
})

let promise2: Promise<any>

act(() => {
promise2 = result.current.execute({ input: 456 })
})

expect(result.current.status).toBe('pending')
expect(result.current.executedAt).toBeDefined()
expect(result.current.input).toEqual({ input: 456 })
expect(result.current.data).toBeUndefined()
expect(result.current.error).toBeNull()

await act(async () => {
expect((await promise!)[1]).toEqual({ output: '123' })
})

expect(result.current.status).toBe('pending')
expect(result.current.executedAt).toBeDefined()
expect(result.current.input).toEqual({ input: 456 })
expect(result.current.data).toBeUndefined()
expect(result.current.error).toBeNull()

await act(async () => {
expect((await promise2!)[1]).toEqual({ output: '456' })
})

expect(result.current.status).toBe('success')
expect(result.current.executedAt).toBeDefined()
expect(result.current.input).toEqual({ input: 456 })
expect(result.current.data).toEqual({ output: '456' })
expect(result.current.error).toBeNull()
})

it('reset while executing', async () => {
const { result } = renderHook(() => useServerAction(action))

expect(result.current.status).toBe('idle')

handler.mockImplementationOnce(async () => {
await new Promise(resolve => setTimeout(resolve, 20))
return { output: 123 }
})

let promise: Promise<any>

act(() => {
promise = result.current.execute({ input: 123 })
})

expect(result.current.status).toBe('pending')
expect(result.current.executedAt).toBeDefined()
expect(result.current.input).toEqual({ input: 123 })
expect(result.current.data).toBeUndefined()
expect(result.current.error).toBeNull()

act(() => {
result.current.reset()
})

expect(result.current.status).toBe('idle')
expect(result.current.executedAt).toBeUndefined()
expect(result.current.input).toBeUndefined()
expect(result.current.data).toBeUndefined()
expect(result.current.error).toBeNull()

await act(async () => {
expect((await promise!)[1]).toEqual({ output: '123' })
})

expect(result.current.status).toBe('idle')
expect(result.current.executedAt).toBeUndefined()
expect(result.current.input).toBeUndefined()
expect(result.current.data).toBeUndefined()
expect(result.current.error).toBeNull()
})
})
113 changes: 66 additions & 47 deletions packages/react/src/hooks/action-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ActionableClient, UnactionableError } from '@orpc/server'
import type { Interceptor } from '@orpc/shared'
import { createORPCErrorFromJson, safe } from '@orpc/client'
import { intercept, toArray } from '@orpc/shared'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo, useRef, useState, useTransition } from 'react'

export interface UseServerActionOptions<TInput, TOutput, TError> {
interceptors?: Interceptor<{ input: TInput }, TOutput, TError>[]
Expand Down Expand Up @@ -78,10 +78,18 @@ const INITIAL_STATE = {
isSuccess: false,
isError: false,
status: 'idle',
executedAt: undefined,
input: undefined,
} as const

const PENDING_STATE = {
data: undefined,
error: null,
isIdle: false,
isPending: true,
isSuccess: false,
isError: false,
status: 'pending',
}

/**
* Use a Server Action Hook
*
Expand All @@ -97,62 +105,73 @@ export function useServerAction<TInput, TOutput, TError extends ORPCErrorJSON<an
const [state, setState] = useState<Omit<
| UseServerActionIdleResult<TInput, TOutput, UnactionableError<TError>>
| UseServerActionSuccessResult<TInput, TOutput, UnactionableError<TError>>
| UseServerActionErrorResult<TInput, TOutput, UnactionableError<TError>>
| UseServerActionPendingResult<TInput, TOutput, UnactionableError<TError>>,
keyof UseServerActionResultBase<TInput, TOutput, UnactionableError<TError>>
| UseServerActionErrorResult<TInput, TOutput, UnactionableError<TError>>,
keyof UseServerActionResultBase<TInput, TOutput, UnactionableError<TError>> | 'executedAt' | 'input'
>>(INITIAL_STATE)

const executedAtRef = useRef<Date | undefined>(undefined)
const [input, setInput] = useState<TInput | undefined>(undefined)
const [isPending, startTransition] = useTransition()

const reset = useCallback(() => {
setState(INITIAL_STATE)
executedAtRef.current = undefined
setInput(undefined)
setState({ ...INITIAL_STATE })
}, [])

const execute = useCallback(async (input: TInput, executeOptions: UseServerActionExecuteOptions<TInput, TOutput, UnactionableError<TError>> = {}) => {
const executedAt = new Date()

setState({
data: undefined,
error: null,
isIdle: false,
isPending: true,
isSuccess: false,
isError: false,
status: 'pending',
executedAt,
input,
})

const result = await safe(intercept(
[...toArray(options.interceptors), ...toArray(executeOptions.interceptors)],
{ input: input as TInput },
({ input }) => action(input).then(([error, data]) => {
if (error) {
throw createORPCErrorFromJson(error)
executedAtRef.current = executedAt

setInput(input)

return new Promise((resolve) => {
startTransition(async () => {
const result = await safe(intercept(
[...toArray(options.interceptors), ...toArray(executeOptions.interceptors)],
{ input: input as TInput },
({ input }) => action(input).then(([error, data]) => {
if (error) {
throw createORPCErrorFromJson(error)
}

return data as TOutput
}),
))

/**
* If multiple execute calls are made in parallel, only the last one will be effective.
*/
if (executedAtRef.current === executedAt) {
setState({
data: result.data,
error: result.error as any,
isIdle: false,
isPending: false,
isSuccess: !result.error,
isError: !!result.error,
status: !result.error ? 'success' : 'error',
})
}

return data as TOutput
}),
))

setState({
data: result.data,
error: result.error as any,
isIdle: false,
isPending: false,
isSuccess: !result.error,
isError: !!result.error,
status: !result.error ? 'success' : 'error',
executedAt,
input,
resolve(result)
})
})

return result
}, [action, ...toArray(options.interceptors)])

const result = useMemo(() => ({
...state,
reset,
execute,
}), [state, reset, execute])
const result = useMemo(() => {
const currentState = isPending && executedAtRef.current !== undefined
? PENDING_STATE
: state

return {
...currentState,
executedAt: executedAtRef.current,
input,
reset,
execute,
}
}, [isPending, state, input, reset, execute])

return result as any
}