From 12d7c50c424cb8f78647c1169a2769efef15ecb1 Mon Sep 17 00:00:00 2001 From: unnoq Date: Wed, 30 Apr 2025 18:51:31 +0700 Subject: [PATCH 1/2] feat(react): use useTransition within useServerAction --- packages/react/src/hooks/action-hooks.ts | 110 +++++++++++++---------- 1 file changed, 61 insertions(+), 49 deletions(-) diff --git a/packages/react/src/hooks/action-hooks.ts b/packages/react/src/hooks/action-hooks.ts index 89d94778e..d1796e957 100644 --- a/packages/react/src/hooks/action-hooks.ts +++ b/packages/react/src/hooks/action-hooks.ts @@ -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 { interceptors?: Interceptor<{ input: TInput }, TOutput, TError>[] @@ -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 * @@ -97,62 +105,66 @@ export function useServerAction> | UseServerActionSuccessResult> - | UseServerActionErrorResult> - | UseServerActionPendingResult>, - keyof UseServerActionResultBase> + | UseServerActionErrorResult>, + keyof UseServerActionResultBase> | 'executedAt' | 'input' >>(INITIAL_STATE) + const executedAtRef = useRef(undefined) + const inputRef = useRef(undefined) + const [isPending, startTransition] = useTransition() + const reset = useCallback(() => { + executedAtRef.current = undefined + inputRef.current = undefined setState(INITIAL_STATE) }, []) const execute = useCallback(async (input: TInput, executeOptions: UseServerActionExecuteOptions> = {}) => { - const executedAt = new Date() - - setState({ - data: undefined, - error: null, - isIdle: false, - isPending: true, - isSuccess: false, - isError: false, - status: 'pending', - executedAt, - input, + inputRef.current = input + executedAtRef.current = new Date() + + 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 + }), + )) + + setState({ + data: result.data, + error: result.error as any, + isIdle: false, + isPending: false, + isSuccess: !result.error, + isError: !!result.error, + status: !result.error ? 'success' : 'error', + }) + + resolve(result) + }) }) - - 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 - }), - )) - - 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, - }) - - return result }, [action, ...toArray(options.interceptors)]) - const result = useMemo(() => ({ - ...state, - reset, - execute, - }), [state, reset, execute]) + const result = useMemo(() => { + const currentState = isPending + ? PENDING_STATE + : state + + return { + ...currentState, + executedAt: executedAtRef.current, + input: inputRef.current, + reset, + execute, + } + }, [isPending, state, reset, execute]) return result as any } From 8b6f088d7321471bd61c20c284a8feeee866adcd Mon Sep 17 00:00:00 2001 From: unnoq Date: Wed, 30 Apr 2025 21:01:53 +0700 Subject: [PATCH 2/2] improve on multiple calling --- .../react/src/hooks/action-hooks.test.tsx | 166 +++++++++++++++++- packages/react/src/hooks/action-hooks.ts | 41 +++-- 2 files changed, 187 insertions(+), 20 deletions(-) diff --git a/packages/react/src/hooks/action-hooks.test.tsx b/packages/react/src/hooks/action-hooks.test.tsx index 15e1d9565..72b48f4f2 100644 --- a/packages/react/src/hooks/action-hooks.test.tsx +++ b/packages/react/src/hooks/action-hooks.test.tsx @@ -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 () => { @@ -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()) @@ -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 + + 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 + + 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 + + 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() + }) }) diff --git a/packages/react/src/hooks/action-hooks.ts b/packages/react/src/hooks/action-hooks.ts index d1796e957..dd8dd77ce 100644 --- a/packages/react/src/hooks/action-hooks.ts +++ b/packages/react/src/hooks/action-hooks.ts @@ -110,18 +110,20 @@ export function useServerAction>(INITIAL_STATE) const executedAtRef = useRef(undefined) - const inputRef = useRef(undefined) + const [input, setInput] = useState(undefined) const [isPending, startTransition] = useTransition() const reset = useCallback(() => { executedAtRef.current = undefined - inputRef.current = undefined - setState(INITIAL_STATE) + setInput(undefined) + setState({ ...INITIAL_STATE }) }, []) const execute = useCallback(async (input: TInput, executeOptions: UseServerActionExecuteOptions> = {}) => { - inputRef.current = input - executedAtRef.current = new Date() + const executedAt = new Date() + executedAtRef.current = executedAt + + setInput(input) return new Promise((resolve) => { startTransition(async () => { @@ -137,15 +139,20 @@ export function useServerAction { - const currentState = isPending + const currentState = isPending && executedAtRef.current !== undefined ? PENDING_STATE : state return { ...currentState, executedAt: executedAtRef.current, - input: inputRef.current, + input, reset, execute, } - }, [isPending, state, reset, execute]) + }, [isPending, state, input, reset, execute]) return result as any }