diff --git a/packages/core/package.json b/packages/core/package.json index 73dbebd..227cb78 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "hookform-action-core", - "version": "4.0.2", + "version": "4.0.3", "description": "Seamless integration between React Hook Form and Server Actions with Zod validation, automatic type inference, optimistic UI, and multi-step form persistence.", "keywords": [ "next.js", diff --git a/packages/core/src/use-action-form-core.ts b/packages/core/src/use-action-form-core.ts index c24e87c..b95c5c5 100644 --- a/packages/core/src/use-action-form-core.ts +++ b/packages/core/src/use-action-form-core.ts @@ -1,8 +1,8 @@ -'use client' +"use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { type DefaultValues, type FieldPath, type FieldValues, useForm } from 'react-hook-form' -import type { ZodError, ZodSchema } from 'zod' +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type DefaultValues, type FieldPath, type FieldValues, useForm } from "react-hook-form"; +import type { ZodError, ZodSchema } from "zod"; import type { ActionFormState, @@ -14,37 +14,30 @@ import type { SubmitFunction, UseActionFormCoreOptions, UseActionFormCoreReturn, -} from './core-types' -import { defaultErrorMapper } from './core-types' -import { clearPersistedValues, debounce, loadPersistedValues, savePersistedValues } from './persist' -import { - hasUseOptimistic, - useOptimistic as useOptimisticReact19, - useTransition, -} from './react-shim' +} from "./core-types"; +import { defaultErrorMapper } from "./core-types"; +import { clearPersistedValues, debounce, loadPersistedValues, savePersistedValues } from "./persist"; +import { hasUseOptimistic, useOptimistic as useOptimisticReact19, useTransition } from "./react-shim"; // --------------------------------------------------------------------------- // Internal: client-side Zod validation helper // --------------------------------------------------------------------------- -function validateWithSchema( - schema: ZodSchema, - values: Record, -): FieldErrorRecord | null { - const result = schema.safeParse(values) - if (result.success) return null +function validateWithSchema(schema: ZodSchema, values: Record): FieldErrorRecord | null { + const result = schema.safeParse(values); + if (result.success) return null; - const zodError = result.error as ZodError - const flat = zodError.flatten() - const errors: FieldErrorRecord = {} + const zodError = result.error as ZodError; + const flat = zodError.flatten(); + const errors: FieldErrorRecord = {}; for (const [field, messages] of Object.entries(flat.fieldErrors)) { if (messages && messages.length > 0) { - errors[field] = messages as string[] + errors[field] = messages as string[]; } } - return Object.keys(errors).length > 0 ? errors : null + return Object.keys(errors).length > 0 ? errors : null; } // --------------------------------------------------------------------------- @@ -52,8 +45,8 @@ function validateWithSchema( // --------------------------------------------------------------------------- function useOptimisticFallback(initial: T): [T, (value: T) => void] { - const [state, setState] = useState(initial) - return [state, setState] + const [state, setState] = useState(initial); + return [state, setState]; } // --------------------------------------------------------------------------- @@ -86,50 +79,50 @@ export function useActionFormCore< ): UseActionFormCoreReturn { const { defaultValues: optionDefaults, - mode = 'onSubmit', + mode = "onSubmit", persistKey, errorMapper = defaultErrorMapper as ErrorMapper, onSuccess, onError, persistDebounce = 300, schema: optionsSchema, - validationMode = 'onSubmit', + validationMode = "onSubmit", optimisticKey, optimisticData, optimisticInitial, plugins = [], - } = options + } = options; // ----- Resolve Zod schema ----------------------------------------------- - const resolvedSchema = useMemo(() => optionsSchema ?? undefined, [optionsSchema]) + const resolvedSchema = useMemo(() => optionsSchema ?? undefined, [optionsSchema]); // ----- Resolve initial values (persisted > options) ---------------------- // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally run once on mount const resolvedDefaults = useMemo | undefined>(() => { if (persistKey) { - const persisted = loadPersistedValues(persistKey) + const persisted = loadPersistedValues(persistKey); if (persisted) { return { ...(optionDefaults as Record | undefined), ...persisted, - } as DefaultValues + } as DefaultValues; } } - return optionDefaults - }, []) + return optionDefaults; + }, []); // ----- React Hook Form --------------------------------------------------- const form = useForm({ defaultValues: resolvedDefaults, mode, - }) + }); // ----- useTransition (React 18 & 19) ------------------------------------ - const [isTransitioning, startTransition] = useTransition() + const [isTransitioning, startTransition] = useTransition(); // ----- Action state ------------------------------------------------------ @@ -139,111 +132,110 @@ export function useActionFormCore< submitErrors: null, actionResult: null, isPending: false, - }) + }); // ----- Submission history (for DevTools) ---------------------------------- - const submissionHistoryRef = useRef[]>([]) + const submissionHistoryRef = useRef[]>([]); // ----- Optimistic UI (React 19 only) ------------------------------------- - const hasOptimistic = optimisticKey != null && optimisticData != null + const hasOptimistic = optimisticKey != null && optimisticData != null; - const useOptimisticHook = - hasUseOptimistic && useOptimisticReact19 ? useOptimisticReact19 : useOptimisticFallback + const useOptimisticHook = hasUseOptimistic && useOptimisticReact19 ? useOptimisticReact19 : useOptimisticFallback; - const [optimisticState, setOptimistic] = useOptimisticHook(optimisticInitial as TOptimistic) + const [optimisticState, setOptimistic] = useOptimisticHook(optimisticInitial as TOptimistic); - const confirmedOptimisticRef = useRef(optimisticInitial as TOptimistic) + const confirmedOptimisticRef = useRef(optimisticInitial as TOptimistic); const rollbackOptimistic = useCallback(() => { - setOptimistic(confirmedOptimisticRef.current) - }, [setOptimistic]) + setOptimistic(confirmedOptimisticRef.current); + }, [setOptimistic]); // ----- Persistence ------------------------------------------------------- const debouncedSave = useMemo(() => { - if (!persistKey) return null + if (!persistKey) return null; return debounce((values: TFieldValues) => { - savePersistedValues(persistKey, values) - }, persistDebounce) - }, [persistKey, persistDebounce]) + savePersistedValues(persistKey, values); + }, persistDebounce); + }, [persistKey, persistDebounce]); useEffect(() => { - if (!persistKey || !debouncedSave) return + if (!persistKey || !debouncedSave) return; const subscription = form.watch((values) => { - debouncedSave(values as TFieldValues) - }) + debouncedSave(values as TFieldValues); + }); - return () => subscription.unsubscribe() - }, [persistKey, debouncedSave, form]) + return () => subscription.unsubscribe(); + }, [persistKey, debouncedSave, form]); // ----- Client-side Zod validation (onChange / onBlur) --------------------- useEffect(() => { - if (!resolvedSchema || validationMode === 'onSubmit') return + if (!resolvedSchema || validationMode === "onSubmit") return; const subscription = form.watch((values, { name, type }) => { - if (!name) return + if (!name) return; - if (validationMode === 'onChange' || type === 'blur') { - const fieldResult = resolvedSchema.safeParse(values) + if (validationMode === "onChange" || type === "blur") { + const fieldResult = resolvedSchema.safeParse(values); if (fieldResult.success) { - form.clearErrors(name as FieldPath) + form.clearErrors(name as FieldPath); } else { - const zodError = fieldResult.error as ZodError - const flat = zodError.flatten() - const fieldErrors = flat.fieldErrors[name] + const zodError = fieldResult.error as ZodError; + const flat = zodError.flatten(); + const fieldErrors = flat.fieldErrors[name]; if (fieldErrors && fieldErrors.length > 0) { form.setError(name as FieldPath, { - type: 'validation', + type: "validation", message: fieldErrors[0], - }) + }); } else { - form.clearErrors(name as FieldPath) + form.clearErrors(name as FieldPath); } } } - }) + }); - return () => subscription.unsubscribe() - }, [resolvedSchema, validationMode, form]) + return () => subscription.unsubscribe(); + }, [resolvedSchema, validationMode, form]); // ----- Plugin lifecycle: onMount ----------------------------------------- // biome-ignore lint/correctness/useExhaustiveDependencies: plugins identity changes every render; run once on mount useEffect(() => { - const cleanups = plugins.map((p) => p.onMount?.()).filter(Boolean) as (() => void)[] + const cleanups = plugins.map((p) => p.onMount?.()).filter(Boolean) as (() => void)[]; return () => { for (const cleanup of cleanups) { - cleanup() + cleanup(); } - } - }, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount // ----- Manual persist / clear -------------------------------------------- const persist = useCallback(() => { - if (!persistKey) return - savePersistedValues(persistKey, form.getValues()) - }, [persistKey, form]) + if (!persistKey) return; + savePersistedValues(persistKey, form.getValues()); + }, [persistKey, form]); const clearPersisted = useCallback(() => { - if (!persistKey) return - clearPersistedValues(persistKey) - }, [persistKey]) + if (!persistKey) return; + clearPersistedValues(persistKey); + }, [persistKey]); // ----- Set a server error on a field ------------------------------------- const setSubmitError = useCallback( (field: keyof TFieldValues & string, message: string) => { - form.setError(field as never, { type: 'server', message }) + form.setError(field as never, { type: "server", message }); }, [form], - ) + ); // ----- Map server errors to RHF ------------------------------------------ @@ -252,40 +244,40 @@ export function useActionFormCore< for (const [field, messages] of Object.entries(errors)) { if (messages && messages.length > 0) { form.setError(field as never, { - type: 'server', + type: "server", message: messages[0], - }) + }); } } }, [form], - ) + ); // ----- Core submit logic ------------------------------------------------- const executeSubmit = useCallback( async (data: TFieldValues) => { // Client-side schema validation (for onSubmit mode) - if (resolvedSchema && validationMode === 'onSubmit') { - const clientErrors = validateWithSchema(resolvedSchema, data as Record) + if (resolvedSchema && validationMode === "onSubmit") { + const clientErrors = validateWithSchema(resolvedSchema, data as Record); if (clientErrors) { - applyServerErrors(clientErrors) + applyServerErrors(clientErrors); setActionState({ isSubmitting: false, isSubmitSuccessful: false, submitErrors: clientErrors, actionResult: null, isPending: false, - }) - return + }); + return; } } // Plugin: onBeforeSubmit for (const plugin of plugins) { if (plugin.onBeforeSubmit) { - const shouldContinue = await plugin.onBeforeSubmit(data) - if (shouldContinue === false) return + const shouldContinue = await plugin.onBeforeSubmit(data); + if (shouldContinue === false) return; } } @@ -294,20 +286,20 @@ export function useActionFormCore< isSubmitting: true, isPending: true, submitErrors: null, - })) + })); // Apply optimistic update before the action runs if (hasOptimistic && optimisticData) { - const optimisticResult = optimisticData(confirmedOptimisticRef.current, data) - setOptimistic(optimisticResult) + const optimisticResult = optimisticData(confirmedOptimisticRef.current, data); + setOptimistic(optimisticResult); } - const startTime = Date.now() + const startTime = Date.now(); try { - const result = await submit(data) + const result = await submit(data); - const fieldErrors = errorMapper(result) + const fieldErrors = errorMapper(result); // Record submission for DevTools const record: SubmissionRecord = { @@ -318,14 +310,14 @@ export function useActionFormCore< error: null, duration: Date.now() - startTime, success: !fieldErrors || Object.keys(fieldErrors).length === 0, - } - submissionHistoryRef.current = [...submissionHistoryRef.current.slice(-49), record] + }; + submissionHistoryRef.current = [...submissionHistoryRef.current.slice(-49), record]; if (fieldErrors && Object.keys(fieldErrors).length > 0) { - applyServerErrors(fieldErrors) + applyServerErrors(fieldErrors); if (hasOptimistic) { - rollbackOptimistic() + rollbackOptimistic(); } setActionState({ @@ -334,22 +326,22 @@ export function useActionFormCore< submitErrors: fieldErrors, actionResult: result, isPending: false, - }) + }); // Plugin: onError for (const plugin of plugins) { - plugin.onError?.(result, data) + plugin.onError?.(result, data); } - onError?.(result) + onError?.(result); } else { // Success – update confirmed optimistic state if (hasOptimistic && optimisticData) { - confirmedOptimisticRef.current = optimisticData(confirmedOptimisticRef.current, data) + confirmedOptimisticRef.current = optimisticData(confirmedOptimisticRef.current, data); } // Clear persisted data - if (persistKey) clearPersistedValues(persistKey) + if (persistKey) clearPersistedValues(persistKey); setActionState({ isSubmitting: false, @@ -357,14 +349,14 @@ export function useActionFormCore< submitErrors: null, actionResult: result, isPending: false, - }) + }); // Plugin: onSuccess for (const plugin of plugins) { - plugin.onSuccess?.(result, data) + plugin.onSuccess?.(result, data); } - onSuccess?.(result) + onSuccess?.(result); } } catch (error) { // Record failed submission for DevTools @@ -376,11 +368,11 @@ export function useActionFormCore< error: error instanceof Error ? error : new Error(String(error)), duration: Date.now() - startTime, success: false, - } - submissionHistoryRef.current = [...submissionHistoryRef.current.slice(-49), record] + }; + submissionHistoryRef.current = [...submissionHistoryRef.current.slice(-49), record]; if (hasOptimistic) { - rollbackOptimistic() + rollbackOptimistic(); } setActionState((prev) => ({ @@ -388,16 +380,16 @@ export function useActionFormCore< isSubmitting: false, isSubmitSuccessful: false, isPending: false, - })) + })); - const wrappedError = error instanceof Error ? error : new Error(String(error)) + const wrappedError = error instanceof Error ? error : new Error(String(error)); // Plugin: onError for (const plugin of plugins) { - plugin.onError?.(wrappedError as TResult & Error, data) + plugin.onError?.(wrappedError as TResult & Error, data); } - onError?.(wrappedError) + onError?.(wrappedError); } }, [ @@ -415,58 +407,67 @@ export function useActionFormCore< rollbackOptimistic, plugins, ], - ) + ); // ----- handleSubmit wrapper ---------------------------------------------- const handleSubmit = useCallback( (onValid?: (data: TFieldValues) => void | Promise) => { return form.handleSubmit(async (data) => { - if (onValid) await onValid(data) + if (onValid) await onValid(data); // @ts-ignore – React 19 supports async transitions; React 18 ignores the promise startTransition(async () => { - await executeSubmit(data) - }) - }) + await executeSubmit(data); + }); + }); }, [form, executeSubmit, startTransition], - ) + ); // ----- Compose return value ---------------------------------------------- const composedFormState = useMemo( () => ({ ...form.formState, + // Explicitly read proxy-tracked properties from RHF's formState + // so they survive the spread (RHF uses a Proxy internally). + errors: form.formState.errors, + isDirty: form.formState.isDirty, + isValid: form.formState.isValid, + touchedFields: form.formState.touchedFields, + dirtyFields: form.formState.dirtyFields, + isLoading: form.formState.isLoading, + isValidating: form.formState.isValidating, ...actionState, isSubmitting: form.formState.isSubmitting || actionState.isSubmitting, isPending: isTransitioning || actionState.isPending, }), [form.formState, actionState, isTransitioning], - ) + ); // ----- Compose optimistic return ----------------------------------------- const optimisticReturn = useMemo(() => { - if (!hasOptimistic) return undefined + if (!hasOptimistic) return undefined; return { data: optimisticState, isPending: isTransitioning || actionState.isPending, rollback: rollbackOptimistic, - } as OptimisticState - }, [hasOptimistic, optimisticState, isTransitioning, actionState.isPending, rollbackOptimistic]) + } as OptimisticState; + }, [hasOptimistic, optimisticState, isTransitioning, actionState.isPending, rollbackOptimistic]); // ----- Compose control with DevTools metadata ---------------------------- const enhancedControl = useMemo(() => { const ctrl = form.control as typeof form.control & { - _submissionHistory: SubmissionRecord[] - _actionFormState: ActionFormState - } - ctrl._submissionHistory = submissionHistoryRef.current - ctrl._actionFormState = actionState - return ctrl - }, [form.control, actionState]) + _submissionHistory: SubmissionRecord[]; + _actionFormState: ActionFormState; + }; + ctrl._submissionHistory = submissionHistoryRef.current; + ctrl._actionFormState = actionState; + return ctrl; + }, [form.control, actionState]); return { ...form, @@ -477,5 +478,5 @@ export function useActionFormCore< persist, clearPersistedData: clearPersisted, optimistic: optimisticReturn, - } as UseActionFormCoreReturn + } as UseActionFormCoreReturn; } diff --git a/packages/core/src/use-action-form.ts b/packages/core/src/use-action-form.ts index 93aae2a..6e9c619 100644 --- a/packages/core/src/use-action-form.ts +++ b/packages/core/src/use-action-form.ts @@ -486,6 +486,15 @@ export function useActionForm< const composedFormState = useMemo( () => ({ ...form.formState, + // Explicitly read proxy-tracked properties from RHF's formState + // so they survive the spread (RHF uses a Proxy internally). + errors: form.formState.errors, + isDirty: form.formState.isDirty, + isValid: form.formState.isValid, + touchedFields: form.formState.touchedFields, + dirtyFields: form.formState.dirtyFields, + isLoading: form.formState.isLoading, + isValidating: form.formState.isValidating, ...actionState, // RHF's own isSubmitting OR our action isSubmitting isSubmitting: form.formState.isSubmitting || actionState.isSubmitting, diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 864c7ec..1dbb7d5 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -1,6 +1,6 @@ { "name": "hookform-action-devtools", - "version": "4.0.2", + "version": "4.0.3", "description": "DevTools panel for hookform-action – inspect form state, submission history, and debug optimistic UI.", "keywords": [ "react-hook-form", diff --git a/packages/next/package.json b/packages/next/package.json index fbc5183..128e588 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "hookform-action", - "version": "4.0.2", + "version": "4.0.3", "description": "Next.js adapter for hookform-action – bridges React Hook Form with Server Actions.", "keywords": [ "next.js", diff --git a/packages/standalone/package.json b/packages/standalone/package.json index d4e25fe..e370f21 100644 --- a/packages/standalone/package.json +++ b/packages/standalone/package.json @@ -1,6 +1,6 @@ { "name": "hookform-action-standalone", - "version": "4.0.2", + "version": "4.0.3", "description": "Standalone React adapter for hookform-action – use the same API without Next.js (Vite, Remix, Astro, SPAs).", "keywords": [ "react",