UNPKG

next-action-forge

Version:

A simple, type-safe toolkit for Next.js server actions with Zod validation

428 lines (421 loc) 14.8 kB
"use client"; // src/hooks/use-server-action.ts import { useCallback, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; // src/next/errors/redirect.ts var RedirectStatusCode = /* @__PURE__ */ ((RedirectStatusCode2) => { RedirectStatusCode2[RedirectStatusCode2["SeeOther"] = 303] = "SeeOther"; RedirectStatusCode2[RedirectStatusCode2["TemporaryRedirect"] = 307] = "TemporaryRedirect"; RedirectStatusCode2[RedirectStatusCode2["PermanentRedirect"] = 308] = "PermanentRedirect"; return RedirectStatusCode2; })(RedirectStatusCode || {}); var REDIRECT_ERROR_CODE = "NEXT_REDIRECT"; function isRedirectError(error) { if (typeof error !== "object" || error === null || !("digest" in error) || typeof error.digest !== "string") { return false; } const digest = error.digest.split(";"); const [errorCode, type] = digest; const destination = digest.slice(2, -2).join(";"); const status = digest.at(-2); const statusCode = Number(status); return errorCode === REDIRECT_ERROR_CODE && (type === "replace" || type === "push") && typeof destination === "string" && !isNaN(statusCode) && statusCode in RedirectStatusCode; } // src/next/errors/http-access-fallback.ts var HTTPAccessErrorStatus = { NOT_FOUND: 404, FORBIDDEN: 403, UNAUTHORIZED: 401 }; var ALLOWED_CODES = new Set(Object.values(HTTPAccessErrorStatus)); var HTTP_ERROR_FALLBACK_ERROR_CODE = "NEXT_HTTP_ERROR_FALLBACK"; function isHTTPAccessFallbackError(error) { if (typeof error !== "object" || error === null || !("digest" in error) || typeof error.digest !== "string") { return false; } const [prefix, httpStatus] = error.digest.split(";"); return prefix === HTTP_ERROR_FALLBACK_ERROR_CODE && ALLOWED_CODES.has(Number(httpStatus)); } // src/next/errors/index.ts function isNextNavigationError(error) { return isRedirectError(error) || isHTTPAccessFallbackError(error); } // src/hooks/use-server-action.ts function useServerAction(action, options = {}) { const router = useRouter(); const [result, setResult] = useState(); const [isExecuting, setIsExecuting] = useState(false); const [isRedirecting, setIsRedirecting] = useState(false); const [, startTransition] = useTransition(); const { onSuccess, onError, successMessage, errorMessage, showSuccessToast = false, showErrorToast = true, redirectOnAuthError = true, preventRedirect = false, redirectDelay = 0 } = options; const hasSucceeded = !!result?.data; const hasErrored = !!result?.serverError || !!result?.validationErrors || !!result?.fetchError; const reset = useCallback(() => { setResult(void 0); setIsRedirecting(false); }, []); const executeInternal = useCallback( async (input) => { try { const response = await (input === void 0 ? action() : action(input)); let actionResult; if (response.success) { actionResult = { data: response.data }; if (process.env.NODE_ENV === "development") { console.log("[useServerAction] Success response:", response); console.log("[useServerAction] Has redirect?", !!response.redirect); } if (showSuccessToast && successMessage) { const message = typeof successMessage === "function" ? successMessage(response.data) : successMessage; toast.success(message); } await onSuccess?.(response.data); if (response.redirect && !preventRedirect) { setIsRedirecting(true); const redirectConfig = typeof response.redirect === "string" ? { url: response.redirect } : response.redirect; const delay = redirectConfig.delay ?? redirectDelay; setTimeout(() => { if (redirectConfig.replace) { router.replace(redirectConfig.url); } else { router.push(redirectConfig.url); } }, delay); } } else if (!response.success && response.error) { actionResult = { serverError: response.error, validationErrors: response.error.fields }; if (process.env.NODE_ENV === "development") { console.log("[useServerAction] Error response:", response.error); console.log("[useServerAction] Should redirect?", response.error.shouldRedirect); console.log("[useServerAction] Redirect to:", response.error.redirectTo); } if (redirectOnAuthError && response.error.shouldRedirect) { if (showErrorToast) { const message = errorMessage ? typeof errorMessage === "function" ? errorMessage(actionResult) : errorMessage : response.error.message || "An error occurred"; if (typeof window !== "undefined") { try { const storageKey = "sonner-toasts"; const existingToasts = JSON.parse( localStorage.getItem(storageKey) || "[]" ); const persistentToast = { id: Date.now(), type: "error", message, persistent: true, createdAt: Date.now() }; existingToasts.push(persistentToast); localStorage.setItem(storageKey, JSON.stringify(existingToasts)); } catch (err) { console.error("Failed to persist toast:", err); } } toast.error(message, { persistent: true }); } setIsRedirecting(true); router.push(response.error.redirectTo || "/login"); return actionResult; } if (showErrorToast) { const message = errorMessage ? typeof errorMessage === "function" ? errorMessage(actionResult) : errorMessage : response.error.message || "An error occurred"; toast.error(message); } await onError?.(actionResult); } else { actionResult = { fetchError: "Unexpected response format" }; } setResult(actionResult); return actionResult; } catch (error) { if (isNextNavigationError(error)) { throw error; } const fetchError = error instanceof Error ? error.message : "Network error"; const actionResult = { fetchError }; setResult(actionResult); if (showErrorToast) { toast.error(fetchError); } await onError?.(actionResult); return actionResult; } }, [action, router, showSuccessToast, successMessage, showErrorToast, errorMessage, redirectOnAuthError, onSuccess, onError] ); const execute = useCallback( (...args) => { setIsExecuting(true); return new Promise((resolve) => { startTransition(async () => { try { const result2 = await executeInternal(args[0]); resolve(result2); } catch (error) { const fetchError = error instanceof Error ? error.message : "Unexpected error"; resolve({ fetchError }); } finally { setIsExecuting(false); } }); }); }, [executeInternal] ); return { execute, result, isExecuting, isRedirecting, hasSucceeded, hasErrored, reset }; } // src/hooks/use-optimistic-action.ts import { useOptimistic, useTransition as useTransition2 } from "react"; function useOptimisticAction(initialState, action, options) { const [optimisticState, addOptimisticUpdate] = useOptimistic( initialState, options.updateFn ); const [, startTransition] = useTransition2(); const { updateFn: _, ...serverActionOptions } = options; const { execute: executeAction, isExecuting, hasSucceeded, hasErrored, result, reset } = useServerAction(action, serverActionOptions); const execute = async (input) => { startTransition(() => { if (input !== void 0) { addOptimisticUpdate(input); } else { addOptimisticUpdate(void 0); } }); return executeAction(input); }; return { optimisticState, execute, isExecuting, hasSucceeded, hasErrored, result, reset }; } // src/hooks/use-form-action.ts import { useActionState, useEffect, useTransition as useTransition3, useRef, useState as useState2 } from "react"; import { useRouter as useRouter2 } from "next/navigation"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { toast as toast2 } from "sonner"; function isSuccessResponse(response) { return response.success === true; } function objectToFormData(obj) { const formData = new FormData(); Object.entries(obj).forEach(([key, value]) => { if (value !== void 0 && value !== null) { if (value instanceof File || value instanceof Blob) { formData.append(key, value); } else if (Array.isArray(value)) { value.forEach((item) => formData.append(`${key}[]`, String(item))); } else if (typeof value === "object") { formData.append(key, JSON.stringify(value)); } else { formData.append(key, String(value)); } } }); return formData; } function useFormAction({ action, schema, defaultValues, mode = "onChange", transformData, onSuccess, onError, resetOnSuccess = false, showSuccessToast = false, showErrorToast = true, preventRedirect = false, redirectDelay = 0 }) { const router = useRouter2(); const [isRedirecting, setIsRedirecting] = useState2(false); const onSuccessRef = useRef(onSuccess); const onErrorRef = useRef(onError); const showSuccessToastRef = useRef(showSuccessToast); const showErrorToastRef = useRef(showErrorToast); useEffect(() => { onSuccessRef.current = onSuccess; onErrorRef.current = onError; showSuccessToastRef.current = showSuccessToast; showErrorToastRef.current = showErrorToast; }); const form = useForm({ resolver: schema ? zodResolver(schema) : void 0, defaultValues, mode }); const initialState = { success: true, data: void 0 }; const [actionState, formAction, isPending] = useActionState(action, initialState); const [isTransitioning, startTransition] = useTransition3(); useEffect(() => { if (actionState.success === true && actionState.data === void 0) { return; } setIsRedirecting(false); let timeoutId; if (!isSuccessResponse(actionState) && actionState.error) { const { error } = actionState; if (error.fields) { Object.entries(error.fields).forEach(([field, messages]) => { if (Array.isArray(messages) && messages.length > 0) { form.setError(field, { type: "server", message: messages[0] }); } }); } if (error.field && error.message && !error.fields) { form.setError(error.field, { type: "server", message: error.message }); } if (error.message && !error.field && !error.fields) { const showToast = showErrorToastRef.current; if (showToast) { const message = typeof showToast === "function" ? showToast(error) : typeof showToast === "string" ? showToast : error.message; toast2.error(message); } form.setError("root", { type: "server", message: error.message }); } onErrorRef.current?.(error); } if (isSuccessResponse(actionState) && actionState.data !== void 0) { const showToast = showSuccessToastRef.current; if (showToast) { const message = typeof showToast === "function" ? showToast(actionState.data) : typeof showToast === "string" ? showToast : "Success!"; toast2.success(message); } if (resetOnSuccess) { form.reset(); } onSuccessRef.current?.(actionState.data); if (actionState.redirect && !preventRedirect) { setIsRedirecting(true); const redirectConfig = typeof actionState.redirect === "string" ? { url: actionState.redirect } : actionState.redirect; const delay = redirectConfig.delay ?? redirectDelay; timeoutId = setTimeout(() => { if (redirectConfig.replace) { router.replace(redirectConfig.url); } else { router.push(redirectConfig.url); } }, delay || 100); } } return () => { if (timeoutId) { clearTimeout(timeoutId); } }; }, [actionState]); const handleSubmit = async (data) => { form.clearErrors(); const formData = transformData ? transformData(data) : objectToFormData(data); startTransition(() => { formAction(formData); }); }; const isSubmitting = isPending || isTransitioning; const onSubmit = (e) => { e?.preventDefault(); void form.handleSubmit(handleSubmit)(e); }; return { form, onSubmit, isSubmitting, isRedirecting, actionState, reset: form.reset, handleSubmit }; } // src/hooks/toast-restorer.tsx import { useEffect as useEffect2 } from "react"; import { toast as toast3 } from "sonner"; function ToastRestorer() { useEffect2(() => { if (typeof window === "undefined") return; const storageKey = "sonner-toasts"; try { const storedToasts = localStorage.getItem(storageKey); if (storedToasts) { const persistentToasts = JSON.parse(storedToasts); if (Array.isArray(persistentToasts) && persistentToasts.length > 0) { localStorage.removeItem(storageKey); const recentToasts = persistentToasts.filter( (t) => Date.now() - (t.createdAt || 0) < 3e4 ); recentToasts.forEach((persistedToast, index) => { if (persistedToast.type === "loading") return; setTimeout(() => { const toastFunction = toast3[persistedToast.type]; if (typeof toastFunction === "function") { if (toastFunction.length >= 2) { toastFunction(persistedToast.message, { persistent: true, id: persistedToast.id }); } else { toastFunction(persistedToast.message); } } }, index * 150); }); } } } catch (error) { console.error("Failed to restore toasts:", error); localStorage.removeItem(storageKey); } }, []); return null; } export { ToastRestorer, useFormAction, useOptimisticAction, useServerAction }; //# sourceMappingURL=index.mjs.map