From 1f40a3c5409a5108fa47a5739caf11b6a01394bb Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Mon, 15 Sep 2025 10:05:29 +0900 Subject: [PATCH 01/12] feat(form-server): add server error handling utilities - Add mapServerErrors for normalizing various server error formats - Add applyServerErrors for injecting errors into form instances - Add onServerSuccess for handling successful responses with reset options - Add selector utilities for extracting server data from stores - Support Zod, Rails, NestJS and custom error formats - Include comprehensive test coverage and React integration guide --- docs/config.json | 4 + .../react/guides/server-errors-and-success.md | 346 ++++++++++++++++++ packages/form-server/eslint.config.js | 5 + packages/form-server/package.json | 56 +++ packages/form-server/src/index.ts | 178 +++++++++ .../tests/applyServerErrors.test.ts | 173 +++++++++ .../form-server/tests/integration.test.ts | 134 +++++++ .../form-server/tests/mapServerErrors.test.ts | 201 ++++++++++ .../form-server/tests/onServerSuccess.test.ts | 179 +++++++++ packages/form-server/tests/selectors.test.ts | 113 ++++++ packages/form-server/tsconfig.json | 7 + packages/form-server/vite.config.ts | 22 ++ pnpm-lock.yaml | 6 + 13 files changed, 1424 insertions(+) create mode 100644 docs/framework/react/guides/server-errors-and-success.md create mode 100644 packages/form-server/eslint.config.js create mode 100644 packages/form-server/package.json create mode 100644 packages/form-server/src/index.ts create mode 100644 packages/form-server/tests/applyServerErrors.test.ts create mode 100644 packages/form-server/tests/integration.test.ts create mode 100644 packages/form-server/tests/mapServerErrors.test.ts create mode 100644 packages/form-server/tests/onServerSuccess.test.ts create mode 100644 packages/form-server/tests/selectors.test.ts create mode 100644 packages/form-server/tsconfig.json create mode 100644 packages/form-server/vite.config.ts diff --git a/docs/config.json b/docs/config.json index 09280cf69..1fdfdced1 100644 --- a/docs/config.json +++ b/docs/config.json @@ -154,6 +154,10 @@ "label": "SSR/TanStack Start/Next.js", "to": "framework/react/guides/ssr" }, + { + "label": "Server Errors & Success Flows", + "to": "framework/react/guides/server-errors-and-success" + }, { "label": "Debugging", "to": "framework/react/guides/debugging" diff --git a/docs/framework/react/guides/server-errors-and-success.md b/docs/framework/react/guides/server-errors-and-success.md new file mode 100644 index 000000000..0eb654aae --- /dev/null +++ b/docs/framework/react/guides/server-errors-and-success.md @@ -0,0 +1,346 @@ +# Server Errors & Success Flows + +TanStack Form provides utilities for handling server-side validation errors and success responses through the `@tanstack/form-server` package. + +## Installation + +```bash +npm install @tanstack/form-server +``` + +## Overview + +The form-server package provides three main utilities: + +- **`mapServerErrors`** - Normalizes various server error formats into a consistent structure +- **`applyServerErrors`** - Applies mapped errors to form fields and form-level state +- **`onServerSuccess`** - Handles successful responses with configurable reset and callback options + +## Mapping Server Errors + +The `mapServerErrors` function converts different server error formats into a standardized structure: + +```tsx +import { mapServerErrors } from '@tanstack/form-server' + +// Zod-like errors +const zodError = { + issues: [ + { path: ['name'], message: 'Name is required' }, + { path: ['email'], message: 'Invalid email format' } + ] +} + +const mapped = mapServerErrors(zodError) +// Result: { fields: { name: ['Name is required'], email: ['Invalid email format'] } } +``` + +### Supported Error Formats + +The function automatically detects and handles various server error formats: + +#### Zod-style Validation Errors +```tsx +const zodError = { + issues: [ + { path: ['items', 0, 'price'], message: 'Price must be positive' } + ] +} +// Maps to: { fields: { 'items.0.price': ['Price must be positive'] } } +``` + +#### Rails-style Errors +```tsx +const railsError = { + errors: { + name: 'Name is required', + email: ['Invalid email', 'Email already taken'] + } +} +// Maps to: { fields: { name: ['Name is required'], email: ['Invalid email', 'Email already taken'] } } +``` + +#### Custom Field/Form Errors +```tsx +const customError = { + fieldErrors: [ + { path: 'name', message: 'Name is required' } + ], + formError: { message: 'Form submission failed' } +} +// Maps to: { fields: { name: ['Name is required'] }, form: 'Form submission failed' } +``` + +### Path Mapping + +Use custom path mappers to handle different naming conventions: + +```tsx +const pathMapper = (path: string) => + path.replace(/_attributes/g, '').replace(/\[(\w+)\]/g, '.$1') + +const mapped = mapServerErrors(railsError, { pathMapper }) +``` + +## Applying Errors to Forms + +Use `applyServerErrors` to inject server errors into your form: + +```tsx +import { useForm } from '@tanstack/react-form' +import { mapServerErrors, applyServerErrors } from '@tanstack/form-server' + +function MyForm() { + const form = useForm({ + defaultValues: { name: '', email: '' }, + onSubmit: async ({ value }) => { + try { + await submitForm(value) + } catch (serverError) { + const mappedErrors = mapServerErrors(serverError) + applyServerErrors(form, mappedErrors) + } + } + }) + + return ( +
{ + e.preventDefault() + form.handleSubmit() + }}> + + {(field) => ( +
+ field.handleChange(e.target.value)} + /> + {field.state.meta.errors.map((error) => ( +

{error}

+ ))} +
+ )} +
+
+ ) +} +``` + +## Handling Success Responses + +The `onServerSuccess` function provides configurable success handling: + +```tsx +import { onServerSuccess } from '@tanstack/form-server' + +const form = useForm({ + onSubmit: async ({ value }) => { + try { + const result = await submitForm(value) + + await onServerSuccess(form, result, { + resetStrategy: 'values', // 'none' | 'values' | 'all' + flash: { + set: (message) => setFlashMessage(message), + message: 'Form saved successfully!' + }, + after: async () => { + // Navigate or perform other actions + router.push('/success') + } + }) + } catch (error) { + // Handle errors... + } + } +}) +``` + +### Reset Strategies + +- **`'none'`** - Don't reset the form +- **`'values'`** - Reset form values but keep validation state +- **`'all'`** - Reset everything including validation state + +## Complete Example + +```tsx +import { useForm } from '@tanstack/react-form' +import { mapServerErrors, applyServerErrors, onServerSuccess } from '@tanstack/form-server' +import { useState } from 'react' + +function UserForm() { + const [flashMessage, setFlashMessage] = useState('') + + const form = useForm({ + defaultValues: { + name: '', + email: '', + profile: { bio: '' } + }, + onSubmit: async ({ value }) => { + try { + const result = await fetch('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(value) + }) + + if (!result.ok) { + const error = await result.json() + const mappedErrors = mapServerErrors(error, { + fallbackFormMessage: 'Failed to create user' + }) + applyServerErrors(form, mappedErrors) + return + } + + const userData = await result.json() + await onServerSuccess(form, userData, { + resetStrategy: 'all', + flash: { + set: setFlashMessage, + message: 'User created successfully!' + } + }) + } catch (error) { + const mappedErrors = mapServerErrors(error) + applyServerErrors(form, mappedErrors) + } + } + }) + + return ( +
+ {flashMessage && ( +
{flashMessage}
+ )} + +
{ + e.preventDefault() + form.handleSubmit() + }}> + + {(field) => ( +
+ + field.handleChange(e.target.value)} + /> + {field.state.meta.errors.map((error) => ( +

{error}

+ ))} +
+ )} +
+ + + {(field) => ( +
+ + field.handleChange(e.target.value)} + /> + {field.state.meta.errors.map((error) => ( +

{error}

+ ))} +
+ )} +
+ + +
+
+ ) +} +``` + +## Framework Integration + +### Next.js App Router + +```tsx +import { mapServerErrors, applyServerErrors, onServerSuccess } from '@tanstack/form-server' + +async function createUser(formData: FormData) { + 'use server' + + // Server action implementation + try { + const result = await db.user.create({ + data: Object.fromEntries(formData) + }) + return { success: true, user: result } + } catch (error) { + return { success: false, error } + } +} + +function UserForm() { + const form = useForm({ + onSubmit: async ({ value }) => { + const formData = new FormData() + Object.entries(value).forEach(([key, val]) => { + formData.append(key, val as string) + }) + + const result = await createUser(formData) + + if (result.success) { + await onServerSuccess(form, result.user, { + resetStrategy: 'all', + flash: { set: toast.success, message: 'User created!' } + }) + } else { + const mappedErrors = mapServerErrors(result.error) + applyServerErrors(form, mappedErrors) + } + } + }) + + // Form JSX... +} +``` + +### Remix + +```tsx +import { mapServerErrors, applyServerErrors } from '@tanstack/form-server' +import { useActionData } from '@remix-run/react' + +export async function action({ request }: ActionFunctionArgs) { + const formData = await request.formData() + + try { + const user = await createUser(Object.fromEntries(formData)) + return redirect('/users') + } catch (error) { + return json({ error }, { status: 400 }) + } +} + +function UserForm() { + const actionData = useActionData() + + const form = useForm({ + onSubmit: ({ value }) => { + // Remix handles submission + } + }) + + // Apply server errors if present + useEffect(() => { + if (actionData?.error) { + const mappedErrors = mapServerErrors(actionData.error) + applyServerErrors(form, mappedErrors) + } + }, [actionData]) + + // Form JSX... +} +``` diff --git a/packages/form-server/eslint.config.js b/packages/form-server/eslint.config.js new file mode 100644 index 000000000..8ce6ad05f --- /dev/null +++ b/packages/form-server/eslint.config.js @@ -0,0 +1,5 @@ +// @ts-check + +import rootConfig from '../../eslint.config.js' + +export default [...rootConfig] diff --git a/packages/form-server/package.json b/packages/form-server/package.json new file mode 100644 index 000000000..40738fb0f --- /dev/null +++ b/packages/form-server/package.json @@ -0,0 +1,56 @@ +{ + "name": "@tanstack/form-server", + "version": "1.20.0", + "description": "Server-side utilities for TanStack Form.", + "author": "tannerlinsley", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/form.git", + "directory": "packages/form-server" + }, + "homepage": "https://tanstack.com/form", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "scripts": { + "clean": "premove ./dist ./coverage", + "test:eslint": "eslint ./src ./tests", + "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", + "test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js", + "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js", + "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js", + "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js", + "test:types:ts58": "tsc", + "test:lib": "vitest", + "test:lib:dev": "pnpm run test:lib --watch", + "test:build": "publint --strict", + "build": "vite build" + }, + "type": "module", + "types": "dist/esm/index.d.ts", + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.js", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist", + "src" + ], + "dependencies": { + "@tanstack/form-core": "workspace:*" + } +} diff --git a/packages/form-server/src/index.ts b/packages/form-server/src/index.ts new file mode 100644 index 000000000..7613485b8 --- /dev/null +++ b/packages/form-server/src/index.ts @@ -0,0 +1,178 @@ +export type ServerFieldError = { + path: string + message: string + code?: string +} + +export type ServerFormError = { + message: string + code?: string +} + +export type MappedServerErrors = { + fields: Record + form?: string +} + +export type SuccessOptions = { + resetStrategy?: 'none' | 'values' | 'all' + flash?: { set: (msg: string) => void; message?: string } + after?: () => void | Promise +} + +export function mapServerErrors( + err: unknown, + opts?: { + pathMapper?: (serverPath: string) => string + fallbackFormMessage?: string + } +): MappedServerErrors { + const pathMapper = opts?.pathMapper || defaultPathMapper + const fallbackFormMessage = opts?.fallbackFormMessage || 'An error occurred' + + if (!err || typeof err !== 'object') { + return { fields: {}, form: fallbackFormMessage } + } + + const result: MappedServerErrors = { fields: {} } + + if ('issues' in err && Array.isArray((err as Record).issues)) { + const issues = (err as Record).issues as Array<{ path: (string | number)[]; message: string }> + for (const issue of issues) { + const path = pathMapper(issue.path.join('.')) + if (!result.fields[path]) result.fields[path] = [] + result.fields[path].push(issue.message) + } + return result + } + + if ('errors' in err && typeof (err as Record).errors === 'object') { + const errors = (err as Record).errors as Record + for (const [key, value] of Object.entries(errors)) { + const path = pathMapper(key) + const messages = Array.isArray(value) ? value : [value] + result.fields[path] = messages + } + return result + } + + if ('message' in err && Array.isArray((err as Record).message)) { + const messages = (err as Record).message as Array<{ field: string; message: string }> + for (const item of messages) { + if (typeof item === 'object' && 'field' in item && 'message' in item) { + const path = pathMapper(item.field) + if (!result.fields[path]) result.fields[path] = [] + result.fields[path].push(item.message) + } + } + return result + } + + if ('fieldErrors' in err && Array.isArray((err as Record).fieldErrors)) { + const fieldErrors = (err as Record).fieldErrors as ServerFieldError[] + for (const fieldError of fieldErrors) { + const path = pathMapper(fieldError.path) + if (!result.fields[path]) result.fields[path] = [] + result.fields[path].push(fieldError.message) + } + } + + if ('formError' in err && typeof (err as Record).formError === 'object') { + const formError = (err as Record).formError as ServerFormError + result.form = formError.message + } else if ('message' in err && typeof (err as Record).message === 'string') { + result.form = (err as Record).message as string + } + + if (Object.keys(result.fields).length === 0 && !result.form) { + result.form = fallbackFormMessage + } + + return result +} + +export function applyServerErrors unknown) => void; setFormMeta: (updater: (prev: unknown) => unknown) => void }>( + form: TFormApi, + mapped: MappedServerErrors +): void { + for (const [fieldPath, messages] of Object.entries(mapped.fields)) { + if (messages.length > 0) { + form.setFieldMeta(fieldPath, (prev: unknown) => { + const prevMeta = (prev as Record) || {} + return { + ...prevMeta, + errorMap: { + ...(prevMeta.errorMap as Record), + onServer: messages[0], + }, + errorSourceMap: { + ...(prevMeta.errorSourceMap as Record), + onServer: 'server', + }, + } + }) + } + } + + if (mapped.form) { + form.setFormMeta((prev: unknown) => { + const prevMeta = (prev as Record) || {} + return { + ...prevMeta, + errorMap: { + ...(prevMeta.errorMap as Record), + onServer: mapped.form, + }, + errorSourceMap: { + ...(prevMeta.errorSourceMap as Record), + onServer: 'server', + }, + } + }) + } +} + +export async function onServerSuccess void }>( + form: TFormApi, + _result: unknown, + opts?: SuccessOptions +): Promise { + const { resetStrategy = 'none', flash, after } = opts || {} + + if (resetStrategy !== 'none' && form.reset) { + if (resetStrategy === 'values') { + form.reset({ resetValidation: false }) + } else { + form.reset() + } + } + + if (flash?.set && flash.message) { + flash.set(flash.message) + } + + if (after) { + await after() + } +} + +export const selectServerResponse = (store: unknown): T | undefined => { + if (store && typeof store === 'object' && '_serverResponse' in store) { + return (store as Record)._serverResponse as T + } + return undefined +} + +export const selectServerFormError = (store: unknown): string | undefined => { + if (store && typeof store === 'object' && '_serverFormError' in store) { + return (store as Record)._serverFormError as string + } + return undefined +} + +function defaultPathMapper(serverPath: string): string { + return serverPath + .replace(/\[(\d+)\]/g, '.$1') + .replace(/\[([^\]]+)\]/g, '.$1') + .replace(/^\./, '') +} diff --git a/packages/form-server/tests/applyServerErrors.test.ts b/packages/form-server/tests/applyServerErrors.test.ts new file mode 100644 index 000000000..b38fa3cbc --- /dev/null +++ b/packages/form-server/tests/applyServerErrors.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyServerErrors, type MappedServerErrors } from '../src/index' + +describe('applyServerErrors', () => { + it('should apply field errors to form', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: { + name: ['Name is required'], + email: ['Invalid email format'], + }, + } + + applyServerErrors(mockForm, mappedErrors) + + expect(mockForm.setFieldMeta).toHaveBeenCalledWith('name', expect.any(Function)) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith('email', expect.any(Function)) + expect(mockForm.setFormMeta).not.toHaveBeenCalled() + + // Test the callback function for field meta + const nameCallback = mockForm.setFieldMeta.mock.calls[0]?.[1] + const prevMeta = { errorMap: {}, errorSourceMap: {} } + const newMeta = nameCallback?.(prevMeta) + + expect(newMeta).toEqual({ + errorMap: { onServer: 'Name is required' }, + errorSourceMap: { onServer: 'server' }, + }) + }) + + it('should apply form-level error', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: {}, + form: 'Form submission failed', + } + + applyServerErrors(mockForm, mappedErrors) + + expect(mockForm.setFieldMeta).not.toHaveBeenCalled() + expect(mockForm.setFormMeta).toHaveBeenCalledWith(expect.any(Function)) + + // Test the callback function for form meta + const formCallback = mockForm.setFormMeta.mock.calls[0]?.[0] + const prevMeta = { errorMap: {}, errorSourceMap: {} } + const newMeta = formCallback?.(prevMeta) + + expect(newMeta).toEqual({ + errorMap: { onServer: 'Form submission failed' }, + errorSourceMap: { onServer: 'server' }, + }) + }) + + it('should apply both field and form errors', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: { + name: ['Name is required'], + }, + form: 'Form has errors', + } + + applyServerErrors(mockForm, mappedErrors) + + expect(mockForm.setFieldMeta).toHaveBeenCalledWith('name', expect.any(Function)) + expect(mockForm.setFormMeta).toHaveBeenCalledWith(expect.any(Function)) + }) + + it('should preserve existing error maps', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: { + name: ['Server error'], + }, + form: 'Server form error', + } + + applyServerErrors(mockForm, mappedErrors) + + // Test field meta preservation + const fieldCallback = mockForm.setFieldMeta.mock.calls[0]?.[1] + const prevFieldMeta = { + errorMap: { onChange: 'Client validation error' }, + errorSourceMap: { onChange: 'client' }, + } + const newFieldMeta = fieldCallback?.(prevFieldMeta) + + expect(newFieldMeta).toEqual({ + errorMap: { + onChange: 'Client validation error', + onServer: 'Server error' + }, + errorSourceMap: { + onChange: 'client', + onServer: 'server' + }, + }) + + // Test form meta preservation + const formCallback = mockForm.setFormMeta.mock.calls[0]?.[0] + const prevFormMeta = { + errorMap: { onSubmit: 'Client form error' }, + errorSourceMap: { onSubmit: 'client' }, + } + const newFormMeta = formCallback?.(prevFormMeta) + + expect(newFormMeta).toEqual({ + errorMap: { + onSubmit: 'Client form error', + onServer: 'Server form error' + }, + errorSourceMap: { + onSubmit: 'client', + onServer: 'server' + }, + }) + }) + + it('should handle empty field arrays', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: { + name: [], + email: ['Email error'], + }, + } + + applyServerErrors(mockForm, mappedErrors) + + expect(mockForm.setFieldMeta).toHaveBeenCalledTimes(1) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith('email', expect.any(Function)) + }) + + it('should use first error message when multiple exist', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: { + email: ['First error', 'Second error', 'Third error'], + }, + } + + applyServerErrors(mockForm, mappedErrors) + + const callback = mockForm.setFieldMeta.mock.calls[0]?.[1] + const newMeta = callback?.({ errorMap: {}, errorSourceMap: {} }) + + expect(newMeta.errorMap.onServer).toBe('First error') + }) +}) diff --git a/packages/form-server/tests/integration.test.ts b/packages/form-server/tests/integration.test.ts new file mode 100644 index 000000000..96ec1cea5 --- /dev/null +++ b/packages/form-server/tests/integration.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyServerErrors, mapServerErrors, onServerSuccess } from '../src/index' + +describe('integration tests', () => { + it('should handle complete error mapping and application flow', () => { + // Mock form instance + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + reset: vi.fn(), + } + + // Simulate server error response + const serverError = { + issues: [ + { path: ['name'], message: 'Name is required' }, + { path: ['email'], message: 'Invalid email format' }, + { path: ['items', 0, 'price'], message: 'Price must be positive' }, + ], + } + + // Map server errors + const mappedErrors = mapServerErrors(serverError) + + // Apply to form + applyServerErrors(mockForm, mappedErrors) + + // Verify field errors were applied + expect(mockForm.setFieldMeta).toHaveBeenCalledTimes(3) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith('name', expect.any(Function)) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith('email', expect.any(Function)) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith('items.0.price', expect.any(Function)) + }) + + it('should handle success flow with all options', async () => { + const mockForm = { + reset: vi.fn(), + } + const mockFlashSet = vi.fn() + const mockAfter = vi.fn() + + const serverResponse = { id: 123, message: 'User created successfully' } + + await onServerSuccess(mockForm, serverResponse, { + resetStrategy: 'all', + flash: { + set: mockFlashSet, + message: 'User created successfully!', + }, + after: mockAfter, + }) + + expect(mockForm.reset).toHaveBeenCalledWith() + expect(mockFlashSet).toHaveBeenCalledWith('User created successfully!') + expect(mockAfter).toHaveBeenCalled() + }) + + it('should handle mixed field and form errors', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const serverError = { + fieldErrors: [ + { path: 'username', message: 'Username already taken' }, + ], + formError: { message: 'Account creation failed' }, + } + + const mappedErrors = mapServerErrors(serverError) + applyServerErrors(mockForm, mappedErrors) + + expect(mockForm.setFieldMeta).toHaveBeenCalledWith('username', expect.any(Function)) + expect(mockForm.setFormMeta).toHaveBeenCalledWith(expect.any(Function)) + + // Verify error content + const fieldCallback = mockForm.setFieldMeta.mock.calls[0]?.[1] + const fieldMeta = fieldCallback?.({ errorMap: {}, errorSourceMap: {} }) + expect(fieldMeta?.errorMap.onServer).toBe('Username already taken') + + const formCallback = mockForm.setFormMeta.mock.calls[0]?.[0] + const formMeta = formCallback?.({ errorMap: {}, errorSourceMap: {} }) + expect(formMeta?.errorMap.onServer).toBe('Account creation failed') + }) + + it('should handle path mapping with complex nested structures', () => { + const serverError = { + errors: { + 'user[profile][addresses][0][street]': 'Street is required', + 'items[1][variants][0][price]': 'Price must be positive', + }, + } + + const mappedErrors = mapServerErrors(serverError) + + expect(mappedErrors).toEqual({ + fields: { + 'user.profile.addresses.0.street': ['Street is required'], + 'items.1.variants.0.price': ['Price must be positive'], + }, + }) + }) + + it('should handle custom path mapper with real-world scenario', () => { + // Simulate a Rails-style error with bracket notation + const railsError = { + errors: { + 'user_attributes[profile_attributes][name]': 'Name is required', + 'items_attributes[0][name]': 'Item name is required', + 'items_attributes[0][price]': 'Price must be positive', + }, + } + + // Custom mapper to handle Rails nested attributes + const pathMapper = (path: string) => { + return path + .replace(/_attributes/g, '') + .replace(/\[(\d+)\]/g, '.$1') + .replace(/\[([^\]]+)\]/g, '.$1') + .replace(/^\./, '') + } + + const mappedErrors = mapServerErrors(railsError, { pathMapper }) + + expect(mappedErrors).toEqual({ + fields: { + 'user.profile.name': ['Name is required'], + 'items.0.name': ['Item name is required'], + 'items.0.price': ['Price must be positive'], + }, + }) + }) +}) diff --git a/packages/form-server/tests/mapServerErrors.test.ts b/packages/form-server/tests/mapServerErrors.test.ts new file mode 100644 index 000000000..4f12b4d31 --- /dev/null +++ b/packages/form-server/tests/mapServerErrors.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest' +import { mapServerErrors } from '../src/index' + +describe('mapServerErrors', () => { + describe('Zod-like errors', () => { + it('should map zod validation errors', () => { + const zodError = { + issues: [ + { path: ['name'], message: 'Name is required' }, + { path: ['email'], message: 'Invalid email format' }, + { path: ['items', 0, 'price'], message: 'Price must be positive' }, + ], + } + + const result = mapServerErrors(zodError) + + expect(result).toEqual({ + fields: { + name: ['Name is required'], + email: ['Invalid email format'], + 'items.0.price': ['Price must be positive'], + }, + }) + }) + + it('should handle nested array paths', () => { + const zodError = { + issues: [ + { path: ['users', 1, 'addresses', 0, 'street'], message: 'Street is required' }, + ], + } + + const result = mapServerErrors(zodError) + + expect(result).toEqual({ + fields: { + 'users.1.addresses.0.street': ['Street is required'], + }, + }) + }) + }) + + describe('Rails-like errors', () => { + it('should map rails validation errors', () => { + const railsError = { + errors: { + name: 'Name is required', + email: ['Invalid email', 'Email already taken'], + 'user.profile.bio': 'Bio is too long', + }, + } + + const result = mapServerErrors(railsError) + + expect(result).toEqual({ + fields: { + name: ['Name is required'], + email: ['Invalid email', 'Email already taken'], + 'user.profile.bio': ['Bio is too long'], + }, + }) + }) + }) + + describe('NestJS-like errors', () => { + it('should map nestjs validation errors', () => { + const nestError = { + message: [ + { field: 'name', message: 'Name is required' }, + { field: 'email', message: 'Invalid email' }, + ], + } + + const result = mapServerErrors(nestError) + + expect(result).toEqual({ + fields: { + name: ['Name is required'], + email: ['Invalid email'], + }, + }) + }) + }) + + describe('Custom field/form error format', () => { + it('should map field and form errors', () => { + const customError = { + fieldErrors: [ + { path: 'name', message: 'Name is required' }, + { path: 'email', message: 'Invalid email' }, + ], + formError: { message: 'Form submission failed' }, + } + + const result = mapServerErrors(customError) + + expect(result).toEqual({ + fields: { + name: ['Name is required'], + email: ['Invalid email'], + }, + form: 'Form submission failed', + }) + }) + }) + + describe('Path mapping', () => { + it('should use custom path mapper', () => { + const error = { + issues: [ + { path: ['items[0].price'], message: 'Price is required' }, + { path: ['user[profile][name]'], message: 'Name is required' }, + ], + } + + const pathMapper = (path: string) => path.replace(/\[(\w+)\]/g, '.$1') + const result = mapServerErrors(error, { pathMapper }) + + expect(result).toEqual({ + fields: { + 'items.0.price': ['Price is required'], + 'user.profile.name': ['Name is required'], + }, + }) + }) + + it('should handle bracket notation with default mapper', () => { + const error = { + errors: { + 'items[0].name': 'Name is required', + 'items[1][price]': 'Price is required', + 'user[addresses][0][street]': 'Street is required', + }, + } + + const result = mapServerErrors(error) + + expect(result).toEqual({ + fields: { + 'items.0.name': ['Name is required'], + 'items.1.price': ['Price is required'], + 'user.addresses.0.street': ['Street is required'], + }, + }) + }) + }) + + describe('Fallback handling', () => { + it('should use fallback message for unknown error format', () => { + const unknownError = { someRandomProperty: 'value' } + + const result = mapServerErrors(unknownError) + + expect(result).toEqual({ + fields: {}, + form: 'An error occurred', + }) + }) + + it('should use custom fallback message', () => { + const unknownError = { someRandomProperty: 'value' } + + const result = mapServerErrors(unknownError, { + fallbackFormMessage: 'Custom error message', + }) + + expect(result).toEqual({ + fields: {}, + form: 'Custom error message', + }) + }) + + it('should handle non-object errors', () => { + expect(mapServerErrors(null)).toEqual({ + fields: {}, + form: 'An error occurred', + }) + + expect(mapServerErrors('string error')).toEqual({ + fields: {}, + form: 'An error occurred', + }) + + expect(mapServerErrors(undefined)).toEqual({ + fields: {}, + form: 'An error occurred', + }) + }) + + it('should extract message from generic error object', () => { + const error = { message: 'Something went wrong' } + + const result = mapServerErrors(error) + + expect(result).toEqual({ + fields: {}, + form: 'Something went wrong', + }) + }) + }) +}) diff --git a/packages/form-server/tests/onServerSuccess.test.ts b/packages/form-server/tests/onServerSuccess.test.ts new file mode 100644 index 000000000..536f59244 --- /dev/null +++ b/packages/form-server/tests/onServerSuccess.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi } from 'vitest' +import { onServerSuccess, type SuccessOptions } from '../src/index' + +describe('onServerSuccess', () => { + it('should handle success with no options', async () => { + const mockForm = { + reset: vi.fn(), + } + + await onServerSuccess(mockForm, { success: true }) + + expect(mockForm.reset).not.toHaveBeenCalled() + }) + + it('should reset values only when resetStrategy is "values"', async () => { + const mockForm = { + reset: vi.fn(), + } + + const options: SuccessOptions = { + resetStrategy: 'values', + } + + await onServerSuccess(mockForm, { success: true }, options) + + expect(mockForm.reset).toHaveBeenCalledWith({ resetValidation: false }) + }) + + it('should reset all when resetStrategy is "all"', async () => { + const mockForm = { + reset: vi.fn(), + } + + const options: SuccessOptions = { + resetStrategy: 'all', + } + + await onServerSuccess(mockForm, { success: true }, options) + + expect(mockForm.reset).toHaveBeenCalledWith() + }) + + it('should not reset when resetStrategy is "none"', async () => { + const mockForm = { + reset: vi.fn(), + } + + const options: SuccessOptions = { + resetStrategy: 'none', + } + + await onServerSuccess(mockForm, { success: true }, options) + + expect(mockForm.reset).not.toHaveBeenCalled() + }) + + it('should set flash message when provided', async () => { + const mockForm = {} + const mockFlashSet = vi.fn() + + const options: SuccessOptions = { + flash: { + set: mockFlashSet, + message: 'Success! Data saved.', + }, + } + + await onServerSuccess(mockForm, { success: true }, options) + + expect(mockFlashSet).toHaveBeenCalledWith('Success! Data saved.') + }) + + it('should not set flash message when no message provided', async () => { + const mockForm = {} + const mockFlashSet = vi.fn() + + const options: SuccessOptions = { + flash: { + set: mockFlashSet, + }, + } + + await onServerSuccess(mockForm, { success: true }, options) + + expect(mockFlashSet).not.toHaveBeenCalled() + }) + + it('should execute after callback', async () => { + const mockForm = {} + const mockAfter = vi.fn() + + const options: SuccessOptions = { + after: mockAfter, + } + + await onServerSuccess(mockForm, { success: true }, options) + + expect(mockAfter).toHaveBeenCalled() + }) + + it('should execute async after callback', async () => { + const mockForm = {} + const mockAfter = vi.fn().mockResolvedValue(undefined) + + const options: SuccessOptions = { + after: mockAfter, + } + + await onServerSuccess(mockForm, { success: true }, options) + + expect(mockAfter).toHaveBeenCalled() + }) + + it('should execute operations in correct order', async () => { + const mockForm = { + reset: vi.fn(), + } + const mockFlashSet = vi.fn() + const mockAfter = vi.fn() + + const options: SuccessOptions = { + resetStrategy: 'all', + flash: { + set: mockFlashSet, + message: 'Success!', + }, + after: mockAfter, + } + + await onServerSuccess(mockForm, { success: true }, options) + + // Verify all operations were called + expect(mockForm.reset).toHaveBeenCalled() + expect(mockFlashSet).toHaveBeenCalledWith('Success!') + expect(mockAfter).toHaveBeenCalled() + + // Verify order: reset should be called first + const resetCallOrder = mockForm.reset.mock.invocationCallOrder[0]! + const flashCallOrder = mockFlashSet.mock.invocationCallOrder[0]! + const afterCallOrder = mockAfter.mock.invocationCallOrder[0]! + + expect(resetCallOrder).toBeLessThan(flashCallOrder) + expect(flashCallOrder).toBeLessThan(afterCallOrder) + }) + + it('should handle form without reset method', async () => { + const mockForm = {} + + const options: SuccessOptions = { + resetStrategy: 'all', + } + + // Should not throw error + await expect(onServerSuccess(mockForm, { success: true }, options)).resolves.toBeUndefined() + }) + + it('should handle all options together', async () => { + const mockForm = { + reset: vi.fn(), + } + const mockFlashSet = vi.fn() + const mockAfter = vi.fn() + + const options: SuccessOptions = { + resetStrategy: 'values', + flash: { + set: mockFlashSet, + message: 'Data saved successfully!', + }, + after: mockAfter, + } + + await onServerSuccess(mockForm, { id: 123, name: 'Test' }, options) + + expect(mockForm.reset).toHaveBeenCalledWith({ resetValidation: false }) + expect(mockFlashSet).toHaveBeenCalledWith('Data saved successfully!') + expect(mockAfter).toHaveBeenCalled() + }) +}) diff --git a/packages/form-server/tests/selectors.test.ts b/packages/form-server/tests/selectors.test.ts new file mode 100644 index 000000000..450b59a97 --- /dev/null +++ b/packages/form-server/tests/selectors.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { selectServerFormError, selectServerResponse } from '../src/index' + +describe('selectors', () => { + describe('selectServerResponse', () => { + it('should return server response when present', () => { + const store = { + _serverResponse: { id: 123, name: 'Test' }, + otherData: 'value', + } + + const result = selectServerResponse(store) + + expect(result).toEqual({ id: 123, name: 'Test' }) + }) + + it('should return undefined when server response not present', () => { + const store = { + otherData: 'value', + } + + const result = selectServerResponse(store) + + expect(result).toBeUndefined() + }) + + it('should return undefined for non-object store', () => { + expect(selectServerResponse(null)).toBeUndefined() + expect(selectServerResponse(undefined)).toBeUndefined() + expect(selectServerResponse('string')).toBeUndefined() + expect(selectServerResponse(123)).toBeUndefined() + }) + + it('should return typed response', () => { + interface UserResponse { + id: number + name: string + } + + const store = { + _serverResponse: { id: 123, name: 'Test' }, + } + + const result = selectServerResponse(store) + + expect(result).toEqual({ id: 123, name: 'Test' }) + // TypeScript should infer the correct type + if (result) { + expect(typeof result.id).toBe('number') + expect(typeof result.name).toBe('string') + } + }) + }) + + describe('selectServerFormError', () => { + it('should return server form error when present', () => { + const store = { + _serverFormError: 'Form submission failed', + otherData: 'value', + } + + const result = selectServerFormError(store) + + expect(result).toBe('Form submission failed') + }) + + it('should return undefined when server form error not present', () => { + const store = { + otherData: 'value', + } + + const result = selectServerFormError(store) + + expect(result).toBeUndefined() + }) + + it('should return undefined for non-object store', () => { + expect(selectServerFormError(null)).toBeUndefined() + expect(selectServerFormError(undefined)).toBeUndefined() + expect(selectServerFormError('string')).toBeUndefined() + expect(selectServerFormError(123)).toBeUndefined() + }) + + it('should handle non-string server form error', () => { + const store = { + _serverFormError: 123, + } + + const result = selectServerFormError(store) + + expect(result).toBe(123) + }) + }) + + describe('integration with complex store', () => { + it('should work with complex store structure', () => { + const store = { + form: { + values: { name: 'test' }, + errors: {}, + }, + _serverResponse: { success: true, id: 456 }, + _serverFormError: 'Server validation failed', + ui: { + loading: false, + }, + } + + expect(selectServerResponse(store)).toEqual({ success: true, id: 456 }) + expect(selectServerFormError(store)).toBe('Server validation failed') + }) + }) +}) diff --git a/packages/form-server/tsconfig.json b/packages/form-server/tsconfig.json new file mode 100644 index 000000000..e04bee269 --- /dev/null +++ b/packages/form-server/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "moduleResolution": "Bundler" + }, + "include": ["src", "tests", "eslint.config.js", "vite.config.ts"] +} diff --git a/packages/form-server/vite.config.ts b/packages/form-server/vite.config.ts new file mode 100644 index 000000000..6334a53d9 --- /dev/null +++ b/packages/form-server/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/config/vite' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './tests', + watch: false, + environment: 'jsdom', + coverage: { enabled: true, provider: 'istanbul', include: ['src/**/*'] }, + typecheck: { enabled: true }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: './src/index.ts', + srcDir: './src', + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6eb8daa6d..2526f14cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1103,6 +1103,12 @@ importers: specifier: ^3.25.76 version: 3.25.76 + packages/form-server: + dependencies: + '@tanstack/form-core': + specifier: workspace:* + version: link:../form-core + packages/lit-form: dependencies: '@tanstack/form-core': From 601a343f1b3f649e8f116639d31d57cd1c083153 Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Mon, 15 Sep 2025 10:07:24 +0900 Subject: [PATCH 02/12] refactor: split type imports and remove redundant test comments --- packages/form-server/tests/applyServerErrors.test.ts | 11 ++++++----- packages/form-server/tests/integration.test.ts | 8 -------- packages/form-server/tests/onServerSuccess.test.ts | 6 ++---- packages/form-server/tests/selectors.test.ts | 2 +- 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/packages/form-server/tests/applyServerErrors.test.ts b/packages/form-server/tests/applyServerErrors.test.ts index b38fa3cbc..47f48ed2b 100644 --- a/packages/form-server/tests/applyServerErrors.test.ts +++ b/packages/form-server/tests/applyServerErrors.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { applyServerErrors, type MappedServerErrors } from '../src/index' +import { applyServerErrors } from '../src/index' +import type {MappedServerErrors} from '../src/index'; describe('applyServerErrors', () => { it('should apply field errors to form', () => { @@ -21,7 +22,7 @@ describe('applyServerErrors', () => { expect(mockForm.setFieldMeta).toHaveBeenCalledWith('email', expect.any(Function)) expect(mockForm.setFormMeta).not.toHaveBeenCalled() - // Test the callback function for field meta + const nameCallback = mockForm.setFieldMeta.mock.calls[0]?.[1] const prevMeta = { errorMap: {}, errorSourceMap: {} } const newMeta = nameCallback?.(prevMeta) @@ -48,7 +49,7 @@ describe('applyServerErrors', () => { expect(mockForm.setFieldMeta).not.toHaveBeenCalled() expect(mockForm.setFormMeta).toHaveBeenCalledWith(expect.any(Function)) - // Test the callback function for form meta + const formCallback = mockForm.setFormMeta.mock.calls[0]?.[0] const prevMeta = { errorMap: {}, errorSourceMap: {} } const newMeta = formCallback?.(prevMeta) @@ -93,7 +94,7 @@ describe('applyServerErrors', () => { applyServerErrors(mockForm, mappedErrors) - // Test field meta preservation + const fieldCallback = mockForm.setFieldMeta.mock.calls[0]?.[1] const prevFieldMeta = { errorMap: { onChange: 'Client validation error' }, @@ -112,7 +113,7 @@ describe('applyServerErrors', () => { }, }) - // Test form meta preservation + const formCallback = mockForm.setFormMeta.mock.calls[0]?.[0] const prevFormMeta = { errorMap: { onSubmit: 'Client form error' }, diff --git a/packages/form-server/tests/integration.test.ts b/packages/form-server/tests/integration.test.ts index 96ec1cea5..50dfb323b 100644 --- a/packages/form-server/tests/integration.test.ts +++ b/packages/form-server/tests/integration.test.ts @@ -3,14 +3,12 @@ import { applyServerErrors, mapServerErrors, onServerSuccess } from '../src/inde describe('integration tests', () => { it('should handle complete error mapping and application flow', () => { - // Mock form instance const mockForm = { setFieldMeta: vi.fn(), setFormMeta: vi.fn(), reset: vi.fn(), } - // Simulate server error response const serverError = { issues: [ { path: ['name'], message: 'Name is required' }, @@ -19,13 +17,10 @@ describe('integration tests', () => { ], } - // Map server errors const mappedErrors = mapServerErrors(serverError) - // Apply to form applyServerErrors(mockForm, mappedErrors) - // Verify field errors were applied expect(mockForm.setFieldMeta).toHaveBeenCalledTimes(3) expect(mockForm.setFieldMeta).toHaveBeenCalledWith('name', expect.any(Function)) expect(mockForm.setFieldMeta).toHaveBeenCalledWith('email', expect.any(Function)) @@ -74,7 +69,6 @@ describe('integration tests', () => { expect(mockForm.setFieldMeta).toHaveBeenCalledWith('username', expect.any(Function)) expect(mockForm.setFormMeta).toHaveBeenCalledWith(expect.any(Function)) - // Verify error content const fieldCallback = mockForm.setFieldMeta.mock.calls[0]?.[1] const fieldMeta = fieldCallback?.({ errorMap: {}, errorSourceMap: {} }) expect(fieldMeta?.errorMap.onServer).toBe('Username already taken') @@ -103,7 +97,6 @@ describe('integration tests', () => { }) it('should handle custom path mapper with real-world scenario', () => { - // Simulate a Rails-style error with bracket notation const railsError = { errors: { 'user_attributes[profile_attributes][name]': 'Name is required', @@ -112,7 +105,6 @@ describe('integration tests', () => { }, } - // Custom mapper to handle Rails nested attributes const pathMapper = (path: string) => { return path .replace(/_attributes/g, '') diff --git a/packages/form-server/tests/onServerSuccess.test.ts b/packages/form-server/tests/onServerSuccess.test.ts index 536f59244..7dc7855da 100644 --- a/packages/form-server/tests/onServerSuccess.test.ts +++ b/packages/form-server/tests/onServerSuccess.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { onServerSuccess, type SuccessOptions } from '../src/index' +import { onServerSuccess } from '../src/index' +import type {SuccessOptions} from '../src/index'; describe('onServerSuccess', () => { it('should handle success with no options', async () => { @@ -129,12 +130,10 @@ describe('onServerSuccess', () => { await onServerSuccess(mockForm, { success: true }, options) - // Verify all operations were called expect(mockForm.reset).toHaveBeenCalled() expect(mockFlashSet).toHaveBeenCalledWith('Success!') expect(mockAfter).toHaveBeenCalled() - // Verify order: reset should be called first const resetCallOrder = mockForm.reset.mock.invocationCallOrder[0]! const flashCallOrder = mockFlashSet.mock.invocationCallOrder[0]! const afterCallOrder = mockAfter.mock.invocationCallOrder[0]! @@ -150,7 +149,6 @@ describe('onServerSuccess', () => { resetStrategy: 'all', } - // Should not throw error await expect(onServerSuccess(mockForm, { success: true }, options)).resolves.toBeUndefined() }) diff --git a/packages/form-server/tests/selectors.test.ts b/packages/form-server/tests/selectors.test.ts index 450b59a97..0fcf4e5b1 100644 --- a/packages/form-server/tests/selectors.test.ts +++ b/packages/form-server/tests/selectors.test.ts @@ -44,7 +44,7 @@ describe('selectors', () => { const result = selectServerResponse(store) expect(result).toEqual({ id: 123, name: 'Test' }) - // TypeScript should infer the correct type + if (result) { expect(typeof result.id).toBe('number') expect(typeof result.name).toBe('string') From 37a03a619a4f07056f6e1e94fe29c99e3121e2f4 Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Mon, 15 Sep 2025 10:08:26 +0900 Subject: [PATCH 03/12] docs: remove redundant comments from server errors guide --- .../react/guides/server-errors-and-success.md | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/docs/framework/react/guides/server-errors-and-success.md b/docs/framework/react/guides/server-errors-and-success.md index 0eb654aae..2221a9cad 100644 --- a/docs/framework/react/guides/server-errors-and-success.md +++ b/docs/framework/react/guides/server-errors-and-success.md @@ -23,7 +23,6 @@ The `mapServerErrors` function converts different server error formats into a st ```tsx import { mapServerErrors } from '@tanstack/form-server' -// Zod-like errors const zodError = { issues: [ { path: ['name'], message: 'Name is required' }, @@ -32,7 +31,6 @@ const zodError = { } const mapped = mapServerErrors(zodError) -// Result: { fields: { name: ['Name is required'], email: ['Invalid email format'] } } ``` ### Supported Error Formats @@ -46,7 +44,6 @@ const zodError = { { path: ['items', 0, 'price'], message: 'Price must be positive' } ] } -// Maps to: { fields: { 'items.0.price': ['Price must be positive'] } } ``` #### Rails-style Errors @@ -57,7 +54,6 @@ const railsError = { email: ['Invalid email', 'Email already taken'] } } -// Maps to: { fields: { name: ['Name is required'], email: ['Invalid email', 'Email already taken'] } } ``` #### Custom Field/Form Errors @@ -68,7 +64,6 @@ const customError = { ], formError: { message: 'Form submission failed' } } -// Maps to: { fields: { name: ['Name is required'] }, form: 'Form submission failed' } ``` ### Path Mapping @@ -139,18 +134,15 @@ const form = useForm({ const result = await submitForm(value) await onServerSuccess(form, result, { - resetStrategy: 'values', // 'none' | 'values' | 'all' flash: { set: (message) => setFlashMessage(message), message: 'Form saved successfully!' }, after: async () => { - // Navigate or perform other actions router.push('/success') } }) } catch (error) { - // Handle errors... } } }) @@ -270,7 +262,6 @@ import { mapServerErrors, applyServerErrors, onServerSuccess } from '@tanstack/f async function createUser(formData: FormData) { 'use server' - // Server action implementation try { const result = await db.user.create({ data: Object.fromEntries(formData) @@ -303,7 +294,6 @@ function UserForm() { } }) - // Form JSX... } ``` @@ -329,11 +319,9 @@ function UserForm() { const form = useForm({ onSubmit: ({ value }) => { - // Remix handles submission } }) - // Apply server errors if present useEffect(() => { if (actionData?.error) { const mappedErrors = mapServerErrors(actionData.error) @@ -341,6 +329,5 @@ function UserForm() { } }, [actionData]) - // Form JSX... } ``` From ac6c0516dcd10e788f1caf8d3559786406da9be4 Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Mon, 15 Sep 2025 10:26:39 +0900 Subject: [PATCH 04/12] refactor(form-server): improve type safety with type guard functions - Replace type assertions with proper type guard functions - Add comprehensive null/undefined checks for server error properties - Eliminate 'any' type usage in favor of Record - Improve runtime type validation for all error formats --- packages/form-server/src/index.ts | 104 +++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 25 deletions(-) diff --git a/packages/form-server/src/index.ts b/packages/form-server/src/index.ts index 7613485b8..566c5e100 100644 --- a/packages/form-server/src/index.ts +++ b/packages/form-server/src/index.ts @@ -20,6 +20,61 @@ export type SuccessOptions = { after?: () => void | Promise } +function isZodError(err: unknown): err is { issues: Array<{ path: (string | number)[]; message: string }> } { + return ( + typeof err === 'object' && + err !== null && + 'issues' in err && + Array.isArray((err as Record).issues) + ) +} + +function isRailsError(err: unknown): err is { errors: Record } { + return ( + typeof err === 'object' && + err !== null && + 'errors' in err && + typeof (err as Record).errors === 'object' && + (err as Record).errors !== null + ) +} + +function isNestJSError(err: unknown): err is { message: Array<{ field: string; message: string }> } { + return ( + typeof err === 'object' && + err !== null && + 'message' in err && + Array.isArray((err as Record).message) + ) +} + +function isCustomFieldError(err: unknown): err is { fieldErrors: ServerFieldError[] } { + return ( + typeof err === 'object' && + err !== null && + 'fieldErrors' in err && + Array.isArray((err as Record).fieldErrors) + ) +} + +function isCustomFormError(err: unknown): err is { formError: ServerFormError } { + return ( + typeof err === 'object' && + err !== null && + 'formError' in err && + typeof (err as Record).formError === 'object' + ) +} + +function hasStringMessage(err: unknown): err is { message: string } { + return ( + typeof err === 'object' && + err !== null && + 'message' in err && + typeof (err as Record).message === 'string' + ) +} + export function mapServerErrors( err: unknown, opts?: { @@ -36,30 +91,29 @@ export function mapServerErrors( const result: MappedServerErrors = { fields: {} } - if ('issues' in err && Array.isArray((err as Record).issues)) { - const issues = (err as Record).issues as Array<{ path: (string | number)[]; message: string }> - for (const issue of issues) { - const path = pathMapper(issue.path.join('.')) - if (!result.fields[path]) result.fields[path] = [] - result.fields[path].push(issue.message) + if (isZodError(err)) { + for (const issue of err.issues) { + if (issue.path && issue.message) { + const path = pathMapper(issue.path.join('.')) + if (!result.fields[path]) result.fields[path] = [] + result.fields[path].push(issue.message) + } } return result } - if ('errors' in err && typeof (err as Record).errors === 'object') { - const errors = (err as Record).errors as Record - for (const [key, value] of Object.entries(errors)) { + if (isRailsError(err)) { + for (const [key, value] of Object.entries(err.errors)) { const path = pathMapper(key) const messages = Array.isArray(value) ? value : [value] - result.fields[path] = messages + result.fields[path] = messages.filter(msg => typeof msg === 'string') } return result } - if ('message' in err && Array.isArray((err as Record).message)) { - const messages = (err as Record).message as Array<{ field: string; message: string }> - for (const item of messages) { - if (typeof item === 'object' && 'field' in item && 'message' in item) { + if (isNestJSError(err)) { + for (const item of err.message) { + if (typeof item === 'object' && item && 'field' in item && 'message' in item) { const path = pathMapper(item.field) if (!result.fields[path]) result.fields[path] = [] result.fields[path].push(item.message) @@ -68,20 +122,20 @@ export function mapServerErrors( return result } - if ('fieldErrors' in err && Array.isArray((err as Record).fieldErrors)) { - const fieldErrors = (err as Record).fieldErrors as ServerFieldError[] - for (const fieldError of fieldErrors) { - const path = pathMapper(fieldError.path) - if (!result.fields[path]) result.fields[path] = [] - result.fields[path].push(fieldError.message) + if (isCustomFieldError(err)) { + for (const fieldError of err.fieldErrors) { + if (fieldError.path && fieldError.message) { + const path = pathMapper(fieldError.path) + if (!result.fields[path]) result.fields[path] = [] + result.fields[path].push(fieldError.message) + } } } - if ('formError' in err && typeof (err as Record).formError === 'object') { - const formError = (err as Record).formError as ServerFormError - result.form = formError.message - } else if ('message' in err && typeof (err as Record).message === 'string') { - result.form = (err as Record).message as string + if (isCustomFormError(err)) { + result.form = err.formError.message + } else if (hasStringMessage(err)) { + result.form = err.message } if (Object.keys(result.fields).length === 0 && !result.form) { From 027e4044732262776d3bed3c89147e4fb4a260ad Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Mon, 15 Sep 2025 10:28:46 +0900 Subject: [PATCH 05/12] feat(form-server): add multiple message handling options - Add ApplyErrorsOptions type with multipleMessages strategy - Support 'first', 'join', and 'array' message handling modes - Add customizable separator for join strategy - Include comprehensive tests for all message handling strategies - Maintain backward compatibility with default 'first' behavior --- packages/form-server/src/index.ts | 22 +++++- .../tests/applyServerErrors.test.ts | 68 ++++++++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/packages/form-server/src/index.ts b/packages/form-server/src/index.ts index 566c5e100..c6be74275 100644 --- a/packages/form-server/src/index.ts +++ b/packages/form-server/src/index.ts @@ -14,6 +14,11 @@ export type MappedServerErrors = { form?: string } +export type ApplyErrorsOptions = { + multipleMessages?: 'first' | 'join' | 'array' + separator?: string +} + export type SuccessOptions = { resetStrategy?: 'none' | 'values' | 'all' flash?: { set: (msg: string) => void; message?: string } @@ -147,17 +152,30 @@ export function mapServerErrors( export function applyServerErrors unknown) => void; setFormMeta: (updater: (prev: unknown) => unknown) => void }>( form: TFormApi, - mapped: MappedServerErrors + mapped: MappedServerErrors, + opts?: ApplyErrorsOptions ): void { + const { multipleMessages = 'first', separator = '; ' } = opts || {} + for (const [fieldPath, messages] of Object.entries(mapped.fields)) { if (messages.length > 0) { form.setFieldMeta(fieldPath, (prev: unknown) => { const prevMeta = (prev as Record) || {} + + let errorValue: string | string[] + if (multipleMessages === 'array') { + errorValue = messages + } else if (multipleMessages === 'join') { + errorValue = messages.join(separator) + } else { + errorValue = messages[0] || '' + } + return { ...prevMeta, errorMap: { ...(prevMeta.errorMap as Record), - onServer: messages[0], + onServer: errorValue, }, errorSourceMap: { ...(prevMeta.errorSourceMap as Record), diff --git a/packages/form-server/tests/applyServerErrors.test.ts b/packages/form-server/tests/applyServerErrors.test.ts index 47f48ed2b..e9b370579 100644 --- a/packages/form-server/tests/applyServerErrors.test.ts +++ b/packages/form-server/tests/applyServerErrors.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { applyServerErrors } from '../src/index' -import type {MappedServerErrors} from '../src/index'; +import type {ApplyErrorsOptions, MappedServerErrors} from '../src/index'; describe('applyServerErrors', () => { it('should apply field errors to form', () => { @@ -171,4 +171,70 @@ describe('applyServerErrors', () => { expect(newMeta.errorMap.onServer).toBe('First error') }) + + it('should handle multiple messages with first strategy (default)', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: { + email: ['Invalid email format', 'Email already exists', 'Email too long'] + } + } + + applyServerErrors(mockForm, mappedErrors) + + const callback = mockForm.setFieldMeta.mock.calls[0]?.[1] + const newMeta = callback?.({ errorMap: {}, errorSourceMap: {} }) + expect(newMeta?.errorMap.onServer).toBe('Invalid email format') + }) + + it('should handle multiple messages with join strategy', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: { + email: ['Invalid email format', 'Email already exists'] + } + } + + const options: ApplyErrorsOptions = { + multipleMessages: 'join', + separator: ' | ' + } + + applyServerErrors(mockForm, mappedErrors, options) + + const callback = mockForm.setFieldMeta.mock.calls[0]?.[1] + const newMeta = callback?.({ errorMap: {}, errorSourceMap: {} }) + expect(newMeta?.errorMap.onServer).toBe('Invalid email format | Email already exists') + }) + + it('should handle multiple messages with array strategy', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: { + email: ['Invalid email format', 'Email already exists'] + } + } + + const options: ApplyErrorsOptions = { + multipleMessages: 'array' + } + + applyServerErrors(mockForm, mappedErrors, options) + + const callback = mockForm.setFieldMeta.mock.calls[0]?.[1] + const newMeta = callback?.({ errorMap: {}, errorSourceMap: {} }) + expect(newMeta?.errorMap.onServer).toEqual(['Invalid email format', 'Email already exists']) + }) }) From b4032a37ee9d29e444ef44d7c6e619f652620c4b Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Mon, 15 Sep 2025 10:34:24 +0900 Subject: [PATCH 06/12] feat(form-server): enhance defensive programming and error handling - Add comprehensive null/undefined checks in mapServerErrors - Strengthen error handling with try-catch blocks for all error format parsers - Improve defaultPathMapper with type validation and error recovery - Add defensive checks in applyServerErrors for form API methods - Handle mixed field/form error scenarios properly - Filter out empty/invalid error messages throughout the pipeline - Maintain backward compatibility while improving robustness --- packages/form-server/src/index.ts | 206 +++++++++++++++++++++--------- 1 file changed, 144 insertions(+), 62 deletions(-) diff --git a/packages/form-server/src/index.ts b/packages/form-server/src/index.ts index c6be74275..9a0b8a8da 100644 --- a/packages/form-server/src/index.ts +++ b/packages/form-server/src/index.ts @@ -98,49 +98,96 @@ export function mapServerErrors( if (isZodError(err)) { for (const issue of err.issues) { - if (issue.path && issue.message) { - const path = pathMapper(issue.path.join('.')) - if (!result.fields[path]) result.fields[path] = [] - result.fields[path].push(issue.message) + if (issue.path && issue.message && Array.isArray(issue.path)) { + try { + const path = pathMapper(issue.path.join('.')) + if (path && typeof issue.message === 'string') { + if (!result.fields[path]) result.fields[path] = [] + result.fields[path].push(issue.message) + } + } catch { + // Skip invalid issue + } } } return result } if (isRailsError(err)) { - for (const [key, value] of Object.entries(err.errors)) { - const path = pathMapper(key) - const messages = Array.isArray(value) ? value : [value] - result.fields[path] = messages.filter(msg => typeof msg === 'string') + try { + for (const [key, value] of Object.entries(err.errors)) { + if (typeof key === 'string') { + const path = pathMapper(key) + if (path) { + const messages = Array.isArray(value) ? value : [value] + const validMessages = messages.filter(msg => typeof msg === 'string' && msg.length > 0) + if (validMessages.length > 0) { + result.fields[path] = validMessages + } + } + } + } + } catch { + // Skip invalid Rails error format } return result } if (isNestJSError(err)) { - for (const item of err.message) { - if (typeof item === 'object' && item && 'field' in item && 'message' in item) { - const path = pathMapper(item.field) - if (!result.fields[path]) result.fields[path] = [] - result.fields[path].push(item.message) + try { + for (const item of err.message) { + if (typeof item === 'object' && item && 'field' in item && 'message' in item) { + const field = item.field + const message = item.message + if (typeof field === 'string' && typeof message === 'string' && message.length > 0) { + const path = pathMapper(field) + if (path) { + if (!result.fields[path]) result.fields[path] = [] + result.fields[path].push(message) + } + } + } } + } catch { + // Skip invalid NestJS error format } return result } if (isCustomFieldError(err)) { - for (const fieldError of err.fieldErrors) { - if (fieldError.path && fieldError.message) { - const path = pathMapper(fieldError.path) - if (!result.fields[path]) result.fields[path] = [] - result.fields[path].push(fieldError.message) + try { + for (const fieldError of err.fieldErrors) { + if (fieldError?.path && fieldError?.message && + typeof fieldError.path === 'string' && typeof fieldError.message === 'string' && + fieldError.message.length > 0) { + const path = pathMapper(fieldError.path) + if (path) { + if (!result.fields[path]) result.fields[path] = [] + result.fields[path].push(fieldError.message) + } + } } + } catch { + // Skip invalid custom field error format } + + if (isCustomFormError(err)) { + if (typeof err.formError.message === 'string' && err.formError.message.length > 0) { + result.form = err.formError.message + } + } + + return result } if (isCustomFormError(err)) { - result.form = err.formError.message + if (typeof err.formError.message === 'string' && err.formError.message.length > 0) { + result.form = err.formError.message + } } else if (hasStringMessage(err)) { - result.form = err.message + if (typeof err.message === 'string' && err.message.length > 0) { + result.form = err.message + } } if (Object.keys(result.fields).length === 0 && !result.form) { @@ -150,57 +197,84 @@ export function mapServerErrors( return result } -export function applyServerErrors unknown) => void; setFormMeta: (updater: (prev: unknown) => unknown) => void }>( +export function applyServerErrors( form: TFormApi, mapped: MappedServerErrors, opts?: ApplyErrorsOptions ): void { - const { multipleMessages = 'first', separator = '; ' } = opts || {} + if (!form || !mapped || typeof mapped !== 'object') { + return + } - for (const [fieldPath, messages] of Object.entries(mapped.fields)) { - if (messages.length > 0) { - form.setFieldMeta(fieldPath, (prev: unknown) => { - const prevMeta = (prev as Record) || {} + const multipleMessages = opts?.multipleMessages || 'first' + const separator = opts?.separator || '; ' + + if (mapped.fields && typeof mapped.fields === 'object') { + for (const [path, messages] of Object.entries(mapped.fields)) { + if (messages && Array.isArray(messages) && messages.length > 0 && typeof path === 'string') { + let errorMessage: string | string[] - let errorValue: string | string[] - if (multipleMessages === 'array') { - errorValue = messages - } else if (multipleMessages === 'join') { - errorValue = messages.join(separator) - } else { - errorValue = messages[0] || '' + switch (multipleMessages) { + case 'join': { + errorMessage = messages.filter(msg => typeof msg === 'string' && msg.length > 0).join(separator) + break + } + case 'array': { + errorMessage = messages.filter(msg => typeof msg === 'string' && msg.length > 0) + break + } + default: { + const firstValid = messages.find(msg => typeof msg === 'string' && msg.length > 0) + errorMessage = firstValid || '' + break + } } - return { - ...prevMeta, - errorMap: { - ...(prevMeta.errorMap as Record), - onServer: errorValue, - }, - errorSourceMap: { - ...(prevMeta.errorSourceMap as Record), - onServer: 'server', - }, + if (errorMessage && 'setFieldMeta' in form && typeof form.setFieldMeta === 'function') { + try { + form.setFieldMeta(path, (prev: unknown) => { + const prevMeta = (prev as Record) || {} + return { + ...prevMeta, + errorMap: { + ...(prevMeta.errorMap as Record), + onServer: errorMessage, + }, + errorSourceMap: { + ...(prevMeta.errorSourceMap as Record), + onServer: 'server', + }, + } + }) + } catch { + // Skip if setFieldMeta fails + } } - }) + } } } - if (mapped.form) { - form.setFormMeta((prev: unknown) => { - const prevMeta = (prev as Record) || {} - return { - ...prevMeta, - errorMap: { - ...(prevMeta.errorMap as Record), - onServer: mapped.form, - }, - errorSourceMap: { - ...(prevMeta.errorSourceMap as Record), - onServer: 'server', - }, + if (mapped.form && typeof mapped.form === 'string' && mapped.form.length > 0) { + if ('setFormMeta' in form && typeof form.setFormMeta === 'function') { + try { + form.setFormMeta((prev: unknown) => { + const prevMeta = (prev as Record) || {} + return { + ...prevMeta, + errorMap: { + ...(prevMeta.errorMap as Record), + onServer: mapped.form, + }, + errorSourceMap: { + ...(prevMeta.errorSourceMap as Record), + onServer: 'server', + }, + } + }) + } catch { + // Skip if setFormMeta fails } - }) + } } } @@ -243,8 +317,16 @@ export const selectServerFormError = (store: unknown): string | undefined => { } function defaultPathMapper(serverPath: string): string { - return serverPath - .replace(/\[(\d+)\]/g, '.$1') - .replace(/\[([^\]]+)\]/g, '.$1') - .replace(/^\./, '') + if (typeof serverPath !== 'string') { + return '' + } + + try { + return serverPath + .replace(/\[(\d+)\]/g, '.$1') + .replace(/\[([^\]]+)\]/g, '.$1') + .replace(/^\./, '') + } catch { + return serverPath + } } From 746e624623a3ae1d0ec05ac6d87727ccc85a8874 Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Mon, 15 Sep 2025 11:02:09 +0900 Subject: [PATCH 07/12] feat(form-server): enhance onServerSuccess with result utilization - Add generic type support for server response result - Introduce storeResult option to save server response in form meta - Pass result to after callback for post-success processing - Add comprehensive error handling with try-catch blocks - Improve type safety with proper generic constraints - Maintain backward compatibility while adding new functionality --- packages/form-server/src/index.ts | 67 ++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/packages/form-server/src/index.ts b/packages/form-server/src/index.ts index 9a0b8a8da..72612737a 100644 --- a/packages/form-server/src/index.ts +++ b/packages/form-server/src/index.ts @@ -19,10 +19,11 @@ export type ApplyErrorsOptions = { separator?: string } -export type SuccessOptions = { +export type SuccessOptions = { resetStrategy?: 'none' | 'values' | 'all' flash?: { set: (msg: string) => void; message?: string } - after?: () => void | Promise + after?: (result: TResult) => void | Promise + storeResult?: boolean } function isZodError(err: unknown): err is { issues: Array<{ path: (string | number)[]; message: string }> } { @@ -278,27 +279,63 @@ export function applyServerErrors( } } -export async function onServerSuccess void }>( +export async function onServerSuccess< + TFormApi extends { + reset?: (options?: { resetValidation?: boolean }) => void + setFormMeta?: (updater: (prev: unknown) => unknown) => void + }, + TResult = unknown +>( form: TFormApi, - _result: unknown, - opts?: SuccessOptions + result: TResult, + opts?: SuccessOptions ): Promise { - const { resetStrategy = 'none', flash, after } = opts || {} + if (!form || typeof form !== 'object') { + return + } + + const { resetStrategy = 'none', flash, after, storeResult = false } = opts || {} + + if (storeResult && 'setFormMeta' in form && typeof form.setFormMeta === 'function') { + try { + form.setFormMeta((prev: unknown) => { + const prevMeta = (prev as Record) || {} + return { + ...prevMeta, + _serverResponse: result, + } + }) + } catch { + // Skip if setFormMeta fails + } + } - if (resetStrategy !== 'none' && form.reset) { - if (resetStrategy === 'values') { - form.reset({ resetValidation: false }) - } else { - form.reset() + if (resetStrategy !== 'none' && 'reset' in form && typeof form.reset === 'function') { + try { + if (resetStrategy === 'values') { + form.reset({ resetValidation: false }) + } else { + form.reset() + } + } catch { + // Skip if reset fails } } - if (flash?.set && flash.message) { - flash.set(flash.message) + if (flash?.set && flash.message && typeof flash.set === 'function') { + try { + flash.set(flash.message) + } catch { + // Skip if flash.set fails + } } - if (after) { - await after() + if (after && typeof after === 'function') { + try { + await after(result) + } catch { + // Skip if after callback fails + } } } From c1f78ae62beab5fa5ce1a6b02dcf2314005d2652 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 15 Sep 2025 05:20:39 +0000 Subject: [PATCH 08/12] ci: apply automated fixes and generate docs --- .../react/guides/server-errors-and-success.md | 115 +++++++------- packages/form-server/src/index.ts | 149 +++++++++++++----- .../tests/applyServerErrors.test.ts | 73 +++++---- .../form-server/tests/integration.test.ts | 30 +++- .../form-server/tests/mapServerErrors.test.ts | 5 +- .../form-server/tests/onServerSuccess.test.ts | 8 +- packages/form-server/tests/selectors.test.ts | 2 +- 7 files changed, 246 insertions(+), 136 deletions(-) diff --git a/docs/framework/react/guides/server-errors-and-success.md b/docs/framework/react/guides/server-errors-and-success.md index 2221a9cad..c87bf2efe 100644 --- a/docs/framework/react/guides/server-errors-and-success.md +++ b/docs/framework/react/guides/server-errors-and-success.md @@ -26,8 +26,8 @@ import { mapServerErrors } from '@tanstack/form-server' const zodError = { issues: [ { path: ['name'], message: 'Name is required' }, - { path: ['email'], message: 'Invalid email format' } - ] + { path: ['email'], message: 'Invalid email format' }, + ], } const mapped = mapServerErrors(zodError) @@ -38,31 +38,30 @@ const mapped = mapServerErrors(zodError) The function automatically detects and handles various server error formats: #### Zod-style Validation Errors + ```tsx const zodError = { - issues: [ - { path: ['items', 0, 'price'], message: 'Price must be positive' } - ] + issues: [{ path: ['items', 0, 'price'], message: 'Price must be positive' }], } ``` #### Rails-style Errors + ```tsx const railsError = { errors: { name: 'Name is required', - email: ['Invalid email', 'Email already taken'] - } + email: ['Invalid email', 'Email already taken'], + }, } ``` #### Custom Field/Form Errors + ```tsx const customError = { - fieldErrors: [ - { path: 'name', message: 'Name is required' } - ], - formError: { message: 'Form submission failed' } + fieldErrors: [{ path: 'name', message: 'Name is required' }], + formError: { message: 'Form submission failed' }, } ``` @@ -71,7 +70,7 @@ const customError = { Use custom path mappers to handle different naming conventions: ```tsx -const pathMapper = (path: string) => +const pathMapper = (path: string) => path.replace(/_attributes/g, '').replace(/\[(\w+)\]/g, '.$1') const mapped = mapServerErrors(railsError, { pathMapper }) @@ -95,14 +94,16 @@ function MyForm() { const mappedErrors = mapServerErrors(serverError) applyServerErrors(form, mappedErrors) } - } + }, }) return ( -
{ - e.preventDefault() - form.handleSubmit() - }}> + { + e.preventDefault() + form.handleSubmit() + }} + > {(field) => (
@@ -132,19 +133,18 @@ const form = useForm({ onSubmit: async ({ value }) => { try { const result = await submitForm(value) - + await onServerSuccess(form, result, { flash: { set: (message) => setFlashMessage(message), - message: 'Form saved successfully!' + message: 'Form saved successfully!', }, after: async () => { router.push('/success') - } + }, }) - } catch (error) { - } - } + } catch (error) {} + }, }) ``` @@ -158,7 +158,11 @@ const form = useForm({ ```tsx import { useForm } from '@tanstack/react-form' -import { mapServerErrors, applyServerErrors, onServerSuccess } from '@tanstack/form-server' +import { + mapServerErrors, + applyServerErrors, + onServerSuccess, +} from '@tanstack/form-server' import { useState } from 'react' function UserForm() { @@ -168,20 +172,20 @@ function UserForm() { defaultValues: { name: '', email: '', - profile: { bio: '' } + profile: { bio: '' }, }, onSubmit: async ({ value }) => { try { const result = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(value) + body: JSON.stringify(value), }) if (!result.ok) { const error = await result.json() const mappedErrors = mapServerErrors(error, { - fallbackFormMessage: 'Failed to create user' + fallbackFormMessage: 'Failed to create user', }) applyServerErrors(form, mappedErrors) return @@ -192,26 +196,26 @@ function UserForm() { resetStrategy: 'all', flash: { set: setFlashMessage, - message: 'User created successfully!' - } + message: 'User created successfully!', + }, }) } catch (error) { const mappedErrors = mapServerErrors(error) applyServerErrors(form, mappedErrors) } - } + }, }) return (
- {flashMessage && ( -
{flashMessage}
- )} - - { - e.preventDefault() - form.handleSubmit() - }}> + {flashMessage &&
{flashMessage}
} + + { + e.preventDefault() + form.handleSubmit() + }} + > {(field) => (
@@ -221,7 +225,9 @@ function UserForm() { onChange={(e) => field.handleChange(e.target.value)} /> {field.state.meta.errors.map((error) => ( -

{error}

+

+ {error} +

))}
)} @@ -237,7 +243,9 @@ function UserForm() { onChange={(e) => field.handleChange(e.target.value)} /> {field.state.meta.errors.map((error) => ( -

{error}

+

+ {error} +

))}
)} @@ -257,14 +265,18 @@ function UserForm() { ### Next.js App Router ```tsx -import { mapServerErrors, applyServerErrors, onServerSuccess } from '@tanstack/form-server' +import { + mapServerErrors, + applyServerErrors, + onServerSuccess, +} from '@tanstack/form-server' async function createUser(formData: FormData) { 'use server' - + try { const result = await db.user.create({ - data: Object.fromEntries(formData) + data: Object.fromEntries(formData), }) return { success: true, user: result } } catch (error) { @@ -279,21 +291,20 @@ function UserForm() { Object.entries(value).forEach(([key, val]) => { formData.append(key, val as string) }) - + const result = await createUser(formData) - + if (result.success) { await onServerSuccess(form, result.user, { resetStrategy: 'all', - flash: { set: toast.success, message: 'User created!' } + flash: { set: toast.success, message: 'User created!' }, }) } else { const mappedErrors = mapServerErrors(result.error) applyServerErrors(form, mappedErrors) } - } + }, }) - } ``` @@ -305,7 +316,7 @@ import { useActionData } from '@remix-run/react' export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData() - + try { const user = await createUser(Object.fromEntries(formData)) return redirect('/users') @@ -316,10 +327,9 @@ export async function action({ request }: ActionFunctionArgs) { function UserForm() { const actionData = useActionData() - + const form = useForm({ - onSubmit: ({ value }) => { - } + onSubmit: ({ value }) => {}, }) useEffect(() => { @@ -328,6 +338,5 @@ function UserForm() { applyServerErrors(form, mappedErrors) } }, [actionData]) - } ``` diff --git a/packages/form-server/src/index.ts b/packages/form-server/src/index.ts index 72612737a..10fc6021a 100644 --- a/packages/form-server/src/index.ts +++ b/packages/form-server/src/index.ts @@ -1,17 +1,17 @@ -export type ServerFieldError = { +export type ServerFieldError = { path: string message: string - code?: string + code?: string } -export type ServerFormError = { +export type ServerFormError = { message: string - code?: string + code?: string } -export type MappedServerErrors = { +export type MappedServerErrors = { fields: Record - form?: string + form?: string } export type ApplyErrorsOptions = { @@ -26,7 +26,9 @@ export type SuccessOptions = { storeResult?: boolean } -function isZodError(err: unknown): err is { issues: Array<{ path: (string | number)[]; message: string }> } { +function isZodError( + err: unknown, +): err is { issues: Array<{ path: (string | number)[]; message: string }> } { return ( typeof err === 'object' && err !== null && @@ -35,7 +37,9 @@ function isZodError(err: unknown): err is { issues: Array<{ path: (string | numb ) } -function isRailsError(err: unknown): err is { errors: Record } { +function isRailsError( + err: unknown, +): err is { errors: Record } { return ( typeof err === 'object' && err !== null && @@ -45,7 +49,9 @@ function isRailsError(err: unknown): err is { errors: Record } { +function isNestJSError( + err: unknown, +): err is { message: Array<{ field: string; message: string }> } { return ( typeof err === 'object' && err !== null && @@ -54,7 +60,9 @@ function isNestJSError(err: unknown): err is { message: Array<{ field: string; m ) } -function isCustomFieldError(err: unknown): err is { fieldErrors: ServerFieldError[] } { +function isCustomFieldError( + err: unknown, +): err is { fieldErrors: ServerFieldError[] } { return ( typeof err === 'object' && err !== null && @@ -63,7 +71,9 @@ function isCustomFieldError(err: unknown): err is { fieldErrors: ServerFieldErro ) } -function isCustomFormError(err: unknown): err is { formError: ServerFormError } { +function isCustomFormError( + err: unknown, +): err is { formError: ServerFormError } { return ( typeof err === 'object' && err !== null && @@ -83,14 +93,14 @@ function hasStringMessage(err: unknown): err is { message: string } { export function mapServerErrors( err: unknown, - opts?: { + opts?: { pathMapper?: (serverPath: string) => string - fallbackFormMessage?: string - } + fallbackFormMessage?: string + }, ): MappedServerErrors { const pathMapper = opts?.pathMapper || defaultPathMapper const fallbackFormMessage = opts?.fallbackFormMessage || 'An error occurred' - + if (!err || typeof err !== 'object') { return { fields: {}, form: fallbackFormMessage } } @@ -121,7 +131,9 @@ export function mapServerErrors( const path = pathMapper(key) if (path) { const messages = Array.isArray(value) ? value : [value] - const validMessages = messages.filter(msg => typeof msg === 'string' && msg.length > 0) + const validMessages = messages.filter( + (msg) => typeof msg === 'string' && msg.length > 0, + ) if (validMessages.length > 0) { result.fields[path] = validMessages } @@ -137,10 +149,19 @@ export function mapServerErrors( if (isNestJSError(err)) { try { for (const item of err.message) { - if (typeof item === 'object' && item && 'field' in item && 'message' in item) { + if ( + typeof item === 'object' && + item && + 'field' in item && + 'message' in item + ) { const field = item.field const message = item.message - if (typeof field === 'string' && typeof message === 'string' && message.length > 0) { + if ( + typeof field === 'string' && + typeof message === 'string' && + message.length > 0 + ) { const path = pathMapper(field) if (path) { if (!result.fields[path]) result.fields[path] = [] @@ -158,9 +179,13 @@ export function mapServerErrors( if (isCustomFieldError(err)) { try { for (const fieldError of err.fieldErrors) { - if (fieldError?.path && fieldError?.message && - typeof fieldError.path === 'string' && typeof fieldError.message === 'string' && - fieldError.message.length > 0) { + if ( + fieldError?.path && + fieldError?.message && + typeof fieldError.path === 'string' && + typeof fieldError.message === 'string' && + fieldError.message.length > 0 + ) { const path = pathMapper(fieldError.path) if (path) { if (!result.fields[path]) result.fields[path] = [] @@ -171,18 +196,24 @@ export function mapServerErrors( } catch { // Skip invalid custom field error format } - + if (isCustomFormError(err)) { - if (typeof err.formError.message === 'string' && err.formError.message.length > 0) { + if ( + typeof err.formError.message === 'string' && + err.formError.message.length > 0 + ) { result.form = err.formError.message } } - + return result } if (isCustomFormError(err)) { - if (typeof err.formError.message === 'string' && err.formError.message.length > 0) { + if ( + typeof err.formError.message === 'string' && + err.formError.message.length > 0 + ) { result.form = err.formError.message } } else if (hasStringMessage(err)) { @@ -201,7 +232,7 @@ export function mapServerErrors( export function applyServerErrors( form: TFormApi, mapped: MappedServerErrors, - opts?: ApplyErrorsOptions + opts?: ApplyErrorsOptions, ): void { if (!form || !mapped || typeof mapped !== 'object') { return @@ -212,26 +243,41 @@ export function applyServerErrors( if (mapped.fields && typeof mapped.fields === 'object') { for (const [path, messages] of Object.entries(mapped.fields)) { - if (messages && Array.isArray(messages) && messages.length > 0 && typeof path === 'string') { + if ( + messages && + Array.isArray(messages) && + messages.length > 0 && + typeof path === 'string' + ) { let errorMessage: string | string[] - + switch (multipleMessages) { case 'join': { - errorMessage = messages.filter(msg => typeof msg === 'string' && msg.length > 0).join(separator) + errorMessage = messages + .filter((msg) => typeof msg === 'string' && msg.length > 0) + .join(separator) break } case 'array': { - errorMessage = messages.filter(msg => typeof msg === 'string' && msg.length > 0) + errorMessage = messages.filter( + (msg) => typeof msg === 'string' && msg.length > 0, + ) break } default: { - const firstValid = messages.find(msg => typeof msg === 'string' && msg.length > 0) + const firstValid = messages.find( + (msg) => typeof msg === 'string' && msg.length > 0, + ) errorMessage = firstValid || '' break } } - if (errorMessage && 'setFieldMeta' in form && typeof form.setFieldMeta === 'function') { + if ( + errorMessage && + 'setFieldMeta' in form && + typeof form.setFieldMeta === 'function' + ) { try { form.setFieldMeta(path, (prev: unknown) => { const prevMeta = (prev as Record) || {} @@ -255,7 +301,11 @@ export function applyServerErrors( } } - if (mapped.form && typeof mapped.form === 'string' && mapped.form.length > 0) { + if ( + mapped.form && + typeof mapped.form === 'string' && + mapped.form.length > 0 + ) { if ('setFormMeta' in form && typeof form.setFormMeta === 'function') { try { form.setFormMeta((prev: unknown) => { @@ -280,23 +330,32 @@ export function applyServerErrors( } export async function onServerSuccess< - TFormApi extends { + TFormApi extends { reset?: (options?: { resetValidation?: boolean }) => void setFormMeta?: (updater: (prev: unknown) => unknown) => void }, - TResult = unknown + TResult = unknown, >( form: TFormApi, result: TResult, - opts?: SuccessOptions + opts?: SuccessOptions, ): Promise { if (!form || typeof form !== 'object') { return } - const { resetStrategy = 'none', flash, after, storeResult = false } = opts || {} - - if (storeResult && 'setFormMeta' in form && typeof form.setFormMeta === 'function') { + const { + resetStrategy = 'none', + flash, + after, + storeResult = false, + } = opts || {} + + if ( + storeResult && + 'setFormMeta' in form && + typeof form.setFormMeta === 'function' + ) { try { form.setFormMeta((prev: unknown) => { const prevMeta = (prev as Record) || {} @@ -310,7 +369,11 @@ export async function onServerSuccess< } } - if (resetStrategy !== 'none' && 'reset' in form && typeof form.reset === 'function') { + if ( + resetStrategy !== 'none' && + 'reset' in form && + typeof form.reset === 'function' + ) { try { if (resetStrategy === 'values') { form.reset({ resetValidation: false }) @@ -339,7 +402,9 @@ export async function onServerSuccess< } } -export const selectServerResponse = (store: unknown): T | undefined => { +export const selectServerResponse = ( + store: unknown, +): T | undefined => { if (store && typeof store === 'object' && '_serverResponse' in store) { return (store as Record)._serverResponse as T } @@ -357,7 +422,7 @@ function defaultPathMapper(serverPath: string): string { if (typeof serverPath !== 'string') { return '' } - + try { return serverPath .replace(/\[(\d+)\]/g, '.$1') diff --git a/packages/form-server/tests/applyServerErrors.test.ts b/packages/form-server/tests/applyServerErrors.test.ts index e9b370579..bd0e3f9aa 100644 --- a/packages/form-server/tests/applyServerErrors.test.ts +++ b/packages/form-server/tests/applyServerErrors.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { applyServerErrors } from '../src/index' -import type {ApplyErrorsOptions, MappedServerErrors} from '../src/index'; +import { applyServerErrors } from '../src/index' +import type { ApplyErrorsOptions, MappedServerErrors } from '../src/index' describe('applyServerErrors', () => { it('should apply field errors to form', () => { @@ -18,11 +18,16 @@ describe('applyServerErrors', () => { applyServerErrors(mockForm, mappedErrors) - expect(mockForm.setFieldMeta).toHaveBeenCalledWith('name', expect.any(Function)) - expect(mockForm.setFieldMeta).toHaveBeenCalledWith('email', expect.any(Function)) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith( + 'name', + expect.any(Function), + ) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith( + 'email', + expect.any(Function), + ) expect(mockForm.setFormMeta).not.toHaveBeenCalled() - const nameCallback = mockForm.setFieldMeta.mock.calls[0]?.[1] const prevMeta = { errorMap: {}, errorSourceMap: {} } const newMeta = nameCallback?.(prevMeta) @@ -49,7 +54,6 @@ describe('applyServerErrors', () => { expect(mockForm.setFieldMeta).not.toHaveBeenCalled() expect(mockForm.setFormMeta).toHaveBeenCalledWith(expect.any(Function)) - const formCallback = mockForm.setFormMeta.mock.calls[0]?.[0] const prevMeta = { errorMap: {}, errorSourceMap: {} } const newMeta = formCallback?.(prevMeta) @@ -75,7 +79,10 @@ describe('applyServerErrors', () => { applyServerErrors(mockForm, mappedErrors) - expect(mockForm.setFieldMeta).toHaveBeenCalledWith('name', expect.any(Function)) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith( + 'name', + expect.any(Function), + ) expect(mockForm.setFormMeta).toHaveBeenCalledWith(expect.any(Function)) }) @@ -94,7 +101,6 @@ describe('applyServerErrors', () => { applyServerErrors(mockForm, mappedErrors) - const fieldCallback = mockForm.setFieldMeta.mock.calls[0]?.[1] const prevFieldMeta = { errorMap: { onChange: 'Client validation error' }, @@ -103,17 +109,16 @@ describe('applyServerErrors', () => { const newFieldMeta = fieldCallback?.(prevFieldMeta) expect(newFieldMeta).toEqual({ - errorMap: { + errorMap: { onChange: 'Client validation error', - onServer: 'Server error' + onServer: 'Server error', }, - errorSourceMap: { + errorSourceMap: { onChange: 'client', - onServer: 'server' + onServer: 'server', }, }) - const formCallback = mockForm.setFormMeta.mock.calls[0]?.[0] const prevFormMeta = { errorMap: { onSubmit: 'Client form error' }, @@ -122,13 +127,13 @@ describe('applyServerErrors', () => { const newFormMeta = formCallback?.(prevFormMeta) expect(newFormMeta).toEqual({ - errorMap: { + errorMap: { onSubmit: 'Client form error', - onServer: 'Server form error' + onServer: 'Server form error', }, - errorSourceMap: { + errorSourceMap: { onSubmit: 'client', - onServer: 'server' + onServer: 'server', }, }) }) @@ -149,7 +154,10 @@ describe('applyServerErrors', () => { applyServerErrors(mockForm, mappedErrors) expect(mockForm.setFieldMeta).toHaveBeenCalledTimes(1) - expect(mockForm.setFieldMeta).toHaveBeenCalledWith('email', expect.any(Function)) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith( + 'email', + expect.any(Function), + ) }) it('should use first error message when multiple exist', () => { @@ -180,8 +188,12 @@ describe('applyServerErrors', () => { const mappedErrors: MappedServerErrors = { fields: { - email: ['Invalid email format', 'Email already exists', 'Email too long'] - } + email: [ + 'Invalid email format', + 'Email already exists', + 'Email too long', + ], + }, } applyServerErrors(mockForm, mappedErrors) @@ -199,20 +211,22 @@ describe('applyServerErrors', () => { const mappedErrors: MappedServerErrors = { fields: { - email: ['Invalid email format', 'Email already exists'] - } + email: ['Invalid email format', 'Email already exists'], + }, } const options: ApplyErrorsOptions = { multipleMessages: 'join', - separator: ' | ' + separator: ' | ', } applyServerErrors(mockForm, mappedErrors, options) const callback = mockForm.setFieldMeta.mock.calls[0]?.[1] const newMeta = callback?.({ errorMap: {}, errorSourceMap: {} }) - expect(newMeta?.errorMap.onServer).toBe('Invalid email format | Email already exists') + expect(newMeta?.errorMap.onServer).toBe( + 'Invalid email format | Email already exists', + ) }) it('should handle multiple messages with array strategy', () => { @@ -223,18 +237,21 @@ describe('applyServerErrors', () => { const mappedErrors: MappedServerErrors = { fields: { - email: ['Invalid email format', 'Email already exists'] - } + email: ['Invalid email format', 'Email already exists'], + }, } const options: ApplyErrorsOptions = { - multipleMessages: 'array' + multipleMessages: 'array', } applyServerErrors(mockForm, mappedErrors, options) const callback = mockForm.setFieldMeta.mock.calls[0]?.[1] const newMeta = callback?.({ errorMap: {}, errorSourceMap: {} }) - expect(newMeta?.errorMap.onServer).toEqual(['Invalid email format', 'Email already exists']) + expect(newMeta?.errorMap.onServer).toEqual([ + 'Invalid email format', + 'Email already exists', + ]) }) }) diff --git a/packages/form-server/tests/integration.test.ts b/packages/form-server/tests/integration.test.ts index 50dfb323b..72cf445d4 100644 --- a/packages/form-server/tests/integration.test.ts +++ b/packages/form-server/tests/integration.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import { applyServerErrors, mapServerErrors, onServerSuccess } from '../src/index' +import { + applyServerErrors, + mapServerErrors, + onServerSuccess, +} from '../src/index' describe('integration tests', () => { it('should handle complete error mapping and application flow', () => { @@ -22,9 +26,18 @@ describe('integration tests', () => { applyServerErrors(mockForm, mappedErrors) expect(mockForm.setFieldMeta).toHaveBeenCalledTimes(3) - expect(mockForm.setFieldMeta).toHaveBeenCalledWith('name', expect.any(Function)) - expect(mockForm.setFieldMeta).toHaveBeenCalledWith('email', expect.any(Function)) - expect(mockForm.setFieldMeta).toHaveBeenCalledWith('items.0.price', expect.any(Function)) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith( + 'name', + expect.any(Function), + ) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith( + 'email', + expect.any(Function), + ) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith( + 'items.0.price', + expect.any(Function), + ) }) it('should handle success flow with all options', async () => { @@ -57,16 +70,17 @@ describe('integration tests', () => { } const serverError = { - fieldErrors: [ - { path: 'username', message: 'Username already taken' }, - ], + fieldErrors: [{ path: 'username', message: 'Username already taken' }], formError: { message: 'Account creation failed' }, } const mappedErrors = mapServerErrors(serverError) applyServerErrors(mockForm, mappedErrors) - expect(mockForm.setFieldMeta).toHaveBeenCalledWith('username', expect.any(Function)) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith( + 'username', + expect.any(Function), + ) expect(mockForm.setFormMeta).toHaveBeenCalledWith(expect.any(Function)) const fieldCallback = mockForm.setFieldMeta.mock.calls[0]?.[1] diff --git a/packages/form-server/tests/mapServerErrors.test.ts b/packages/form-server/tests/mapServerErrors.test.ts index 4f12b4d31..582e2573d 100644 --- a/packages/form-server/tests/mapServerErrors.test.ts +++ b/packages/form-server/tests/mapServerErrors.test.ts @@ -26,7 +26,10 @@ describe('mapServerErrors', () => { it('should handle nested array paths', () => { const zodError = { issues: [ - { path: ['users', 1, 'addresses', 0, 'street'], message: 'Street is required' }, + { + path: ['users', 1, 'addresses', 0, 'street'], + message: 'Street is required', + }, ], } diff --git a/packages/form-server/tests/onServerSuccess.test.ts b/packages/form-server/tests/onServerSuccess.test.ts index 7dc7855da..ad264e8bf 100644 --- a/packages/form-server/tests/onServerSuccess.test.ts +++ b/packages/form-server/tests/onServerSuccess.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { onServerSuccess } from '../src/index' -import type {SuccessOptions} from '../src/index'; +import { onServerSuccess } from '../src/index' +import type { SuccessOptions } from '../src/index' describe('onServerSuccess', () => { it('should handle success with no options', async () => { @@ -149,7 +149,9 @@ describe('onServerSuccess', () => { resetStrategy: 'all', } - await expect(onServerSuccess(mockForm, { success: true }, options)).resolves.toBeUndefined() + await expect( + onServerSuccess(mockForm, { success: true }, options), + ).resolves.toBeUndefined() }) it('should handle all options together', async () => { diff --git a/packages/form-server/tests/selectors.test.ts b/packages/form-server/tests/selectors.test.ts index 0fcf4e5b1..54816c6a0 100644 --- a/packages/form-server/tests/selectors.test.ts +++ b/packages/form-server/tests/selectors.test.ts @@ -44,7 +44,7 @@ describe('selectors', () => { const result = selectServerResponse(store) expect(result).toEqual({ id: 123, name: 'Test' }) - + if (result) { expect(typeof result.id).toBe('number') expect(typeof result.name).toBe('string') From a30f52994772b0cbef75ec2947b33fc3a14119cb Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Mon, 15 Sep 2025 18:05:20 +0900 Subject: [PATCH 09/12] refactor: improve error handling and validation in form server error mapping --- packages/form-server/src/index.ts | 135 +++++++++++++----------------- 1 file changed, 60 insertions(+), 75 deletions(-) diff --git a/packages/form-server/src/index.ts b/packages/form-server/src/index.ts index 72612737a..24f0189e7 100644 --- a/packages/form-server/src/index.ts +++ b/packages/form-server/src/index.ts @@ -81,6 +81,22 @@ function hasStringMessage(err: unknown): err is { message: string } { ) } +function defaultPathMapper(serverPath: string): string { + if (typeof serverPath !== 'string') { + return '' + } + + try { + return serverPath + .replace(/\[(\d+)\]/g, '.$1') + .replace(/\[([^\]]+)\]/g, '.$1') + .replace(/^\./, '') + } catch { + return serverPath + } +} + + export function mapServerErrors( err: unknown, opts?: { @@ -91,24 +107,25 @@ export function mapServerErrors( const pathMapper = opts?.pathMapper || defaultPathMapper const fallbackFormMessage = opts?.fallbackFormMessage || 'An error occurred' - if (!err || typeof err !== 'object') { - return { fields: {}, form: fallbackFormMessage } + const result: MappedServerErrors = { + fields: {}, + form: undefined } - const result: MappedServerErrors = { fields: {} } + if (!err) { + result.form = fallbackFormMessage + return result + } if (isZodError(err)) { for (const issue of err.issues) { - if (issue.path && issue.message && Array.isArray(issue.path)) { - try { - const path = pathMapper(issue.path.join('.')) - if (path && typeof issue.message === 'string') { - if (!result.fields[path]) result.fields[path] = [] - result.fields[path].push(issue.message) - } - } catch { - // Skip invalid issue + const path = Array.isArray(issue.path) ? issue.path.join('.') : String(issue.path) + const mappedPath = pathMapper(path) + if (mappedPath) { + if (!result.fields[mappedPath]) { + result.fields[mappedPath] = [] } + result.fields[mappedPath].push(issue.message) } } return result @@ -116,15 +133,13 @@ export function mapServerErrors( if (isRailsError(err)) { try { - for (const [key, value] of Object.entries(err.errors)) { - if (typeof key === 'string') { - const path = pathMapper(key) - if (path) { - const messages = Array.isArray(value) ? value : [value] - const validMessages = messages.filter(msg => typeof msg === 'string' && msg.length > 0) - if (validMessages.length > 0) { - result.fields[path] = validMessages - } + for (const [field, messages] of Object.entries(err.errors)) { + const mappedPath = pathMapper(field) + if (mappedPath) { + if (Array.isArray(messages)) { + result.fields[mappedPath] = messages.filter(msg => typeof msg === 'string' && msg.trim()) + } else if (typeof messages === 'string' && messages.trim()) { + result.fields[mappedPath] = [messages] } } } @@ -158,7 +173,7 @@ export function mapServerErrors( if (isCustomFieldError(err)) { try { for (const fieldError of err.fieldErrors) { - if (fieldError?.path && fieldError?.message && + if (fieldError && fieldError.path && fieldError.message && typeof fieldError.path === 'string' && typeof fieldError.message === 'string' && fieldError.message.length > 0) { const path = pathMapper(fieldError.path) @@ -186,15 +201,11 @@ export function mapServerErrors( result.form = err.formError.message } } else if (hasStringMessage(err)) { - if (typeof err.message === 'string' && err.message.length > 0) { - result.form = err.message - } - } - - if (Object.keys(result.fields).length === 0 && !result.form) { - result.form = fallbackFormMessage + result.form = err.message + return result } + result.form = fallbackFormMessage return result } @@ -203,37 +214,25 @@ export function applyServerErrors( mapped: MappedServerErrors, opts?: ApplyErrorsOptions ): void { - if (!form || !mapped || typeof mapped !== 'object') { - return - } - - const multipleMessages = opts?.multipleMessages || 'first' - const separator = opts?.separator || '; ' - - if (mapped.fields && typeof mapped.fields === 'object') { - for (const [path, messages] of Object.entries(mapped.fields)) { - if (messages && Array.isArray(messages) && messages.length > 0 && typeof path === 'string') { - let errorMessage: string | string[] - - switch (multipleMessages) { - case 'join': { - errorMessage = messages.filter(msg => typeof msg === 'string' && msg.length > 0).join(separator) - break + const { multipleMessages = 'first', separator = '; ' } = opts || {} + + if (form && typeof form === 'object' && 'setFieldMeta' in form && typeof form.setFieldMeta === 'function') { + for (const [fieldPath, messages] of Object.entries(mapped.fields)) { + if (Array.isArray(messages) && messages.length > 0) { + const validMessages = messages.filter(msg => typeof msg === 'string' && msg.trim()) + if (validMessages.length > 0) { + let errorMessage: string | string[] + + if (multipleMessages === 'first') { + errorMessage = validMessages[0] || '' + } else if (multipleMessages === 'join') { + errorMessage = validMessages.join(separator) + } else { + errorMessage = validMessages } - case 'array': { - errorMessage = messages.filter(msg => typeof msg === 'string' && msg.length > 0) - break - } - default: { - const firstValid = messages.find(msg => typeof msg === 'string' && msg.length > 0) - errorMessage = firstValid || '' - break - } - } - if (errorMessage && 'setFieldMeta' in form && typeof form.setFieldMeta === 'function') { try { - form.setFieldMeta(path, (prev: unknown) => { + form.setFieldMeta(fieldPath, (prev: unknown) => { const prevMeta = (prev as Record) || {} return { ...prevMeta, @@ -256,7 +255,7 @@ export function applyServerErrors( } if (mapped.form && typeof mapped.form === 'string' && mapped.form.length > 0) { - if ('setFormMeta' in form && typeof form.setFormMeta === 'function') { + if (form && typeof form === 'object' && 'setFormMeta' in form && typeof form.setFormMeta === 'function') { try { form.setFormMeta((prev: unknown) => { const prevMeta = (prev as Record) || {} @@ -296,13 +295,14 @@ export async function onServerSuccess< const { resetStrategy = 'none', flash, after, storeResult = false } = opts || {} - if (storeResult && 'setFormMeta' in form && typeof form.setFormMeta === 'function') { + if (storeResult && form && typeof form === 'object' && 'setFormMeta' in form && typeof form.setFormMeta === 'function') { try { form.setFormMeta((prev: unknown) => { const prevMeta = (prev as Record) || {} return { ...prevMeta, _serverResponse: result, + _serverFormError: '', } }) } catch { @@ -310,7 +310,7 @@ export async function onServerSuccess< } } - if (resetStrategy !== 'none' && 'reset' in form && typeof form.reset === 'function') { + if (resetStrategy !== 'none' && form && typeof form === 'object' && 'reset' in form && typeof form.reset === 'function') { try { if (resetStrategy === 'values') { form.reset({ resetValidation: false }) @@ -352,18 +352,3 @@ export const selectServerFormError = (store: unknown): string | undefined => { } return undefined } - -function defaultPathMapper(serverPath: string): string { - if (typeof serverPath !== 'string') { - return '' - } - - try { - return serverPath - .replace(/\[(\d+)\]/g, '.$1') - .replace(/\[([^\]]+)\]/g, '.$1') - .replace(/^\./, '') - } catch { - return serverPath - } -} From af896644f59260a802c093e2e04acb48e98ab28d Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Mon, 15 Sep 2025 18:14:55 +0900 Subject: [PATCH 10/12] refactor: simplify error handling and remove try-catch blocks in form server --- packages/form-server/src/index.ts | 217 +++++++++++------------------- 1 file changed, 82 insertions(+), 135 deletions(-) diff --git a/packages/form-server/src/index.ts b/packages/form-server/src/index.ts index 9d1eb9bca..b608fa75c 100644 --- a/packages/form-server/src/index.ts +++ b/packages/form-server/src/index.ts @@ -91,20 +91,6 @@ function hasStringMessage(err: unknown): err is { message: string } { ) } -function defaultPathMapper(serverPath: string): string { - if (typeof serverPath !== 'string') { - return '' - } - - try { - return serverPath - .replace(/\[(\d+)\]/g, '.$1') - .replace(/\[([^\]]+)\]/g, '.$1') - .replace(/^\./, '') - } catch { - return serverPath - } -} export function mapServerErrors( @@ -117,13 +103,13 @@ export function mapServerErrors( const pathMapper = opts?.pathMapper || defaultPathMapper const fallbackFormMessage = opts?.fallbackFormMessage || 'An error occurred' - if (!err || typeof err !== 'object') { - return { fields: {}, form: fallbackFormMessage } + const result: MappedServerErrors = { + fields: {}, + form: undefined } - if (!err) { - result.form = fallbackFormMessage - return result + if (!err || typeof err !== 'object') { + return { fields: {}, form: fallbackFormMessage } } if (isZodError(err)) { @@ -141,70 +127,57 @@ export function mapServerErrors( } if (isRailsError(err)) { - try { - for (const [key, value] of Object.entries(err.errors)) { - if (typeof key === 'string') { - const path = pathMapper(key) - if (path) { - const messages = Array.isArray(value) ? value : [value] - const validMessages = messages.filter(msg => typeof msg === 'string' && msg.length > 0) - if (validMessages.length > 0) { - result.fields[path] = validMessages - } + for (const [key, value] of Object.entries(err.errors)) { + if (typeof key === 'string') { + const path = pathMapper(key) + if (path) { + const messages = Array.isArray(value) ? value : [value] + const validMessages = messages.filter(msg => typeof msg === 'string' && msg.length > 0) + if (validMessages.length > 0) { + result.fields[path] = validMessages } } } - } catch { - // Skip invalid Rails error format } return result } if (isNestJSError(err)) { - try { - for (const item of err.message) { + for (const item of err.message) { + if ( + typeof item === 'object' && + 'field' in item && + 'message' in item + ) { + const field = item.field + const message = item.message if ( - typeof item === 'object' && - item && - 'field' in item && - 'message' in item + typeof field === 'string' && + typeof message === 'string' && + message.length > 0 ) { - const field = item.field - const message = item.message - if ( - typeof field === 'string' && - typeof message === 'string' && - message.length > 0 - ) { - const path = pathMapper(field) - if (path) { - if (!result.fields[path]) result.fields[path] = [] - result.fields[path].push(message) - } + const path = pathMapper(field) + if (path) { + if (!result.fields[path]) result.fields[path] = [] + result.fields[path].push(message) } } } - } catch { - // Skip invalid NestJS error format } return result } if (isCustomFieldError(err)) { - try { - for (const fieldError of err.fieldErrors) { - if (fieldError?.path && fieldError?.message && - typeof fieldError.path === 'string' && typeof fieldError.message === 'string' && - fieldError.message.length > 0) { - const path = pathMapper(fieldError.path) - if (path) { - if (!result.fields[path]) result.fields[path] = [] - result.fields[path].push(fieldError.message) - } + for (const fieldError of err.fieldErrors) { + if (fieldError.path && fieldError.message && + typeof fieldError.path === 'string' && typeof fieldError.message === 'string' && + fieldError.message.length > 0) { + const path = pathMapper(fieldError.path) + if (path) { + if (!result.fields[path]) result.fields[path] = [] + result.fields[path].push(fieldError.message) } } - } catch { - // Skip invalid custom field error format } if (isCustomFormError(err)) { @@ -235,16 +208,15 @@ export function mapServerErrors( return result } -export function applyServerErrors( +export function applyServerErrors>( form: TFormApi, mapped: MappedServerErrors, opts?: ApplyErrorsOptions, ): void { const { multipleMessages = 'first', separator = '; ' } = opts || {} - if (mapped.fields && typeof mapped.fields === 'object') { - for (const [path, messages] of Object.entries(mapped.fields)) { - if (messages && Array.isArray(messages) && messages.length > 0 && typeof path === 'string') { + for (const [path, messages] of Object.entries(mapped.fields)) { + if (Array.isArray(messages) && messages.length > 0) { let errorMessage: string | string[] switch (multipleMessages) { @@ -264,49 +236,40 @@ export function applyServerErrors( } if (errorMessage && 'setFieldMeta' in form && typeof form.setFieldMeta === 'function') { - try { - form.setFieldMeta(fieldPath, (prev: unknown) => { - const prevMeta = (prev as Record) || {} - return { - ...prevMeta, - errorMap: { - ...(prevMeta.errorMap as Record), - onServer: errorMessage, - }, - errorSourceMap: { - ...(prevMeta.errorSourceMap as Record), - onServer: 'server', - }, - } - }) - } catch { - // Skip if setFieldMeta fails - } + form.setFieldMeta(path, (prev: unknown) => { + const prevMeta = (prev as Record) + return { + ...prevMeta, + errorMap: { + ...(prevMeta.errorMap as Record), + onServer: errorMessage, + }, + errorSourceMap: { + ...(prevMeta.errorSourceMap as Record), + onServer: 'server', + }, + } + }) } - } } } if (mapped.form && typeof mapped.form === 'string' && mapped.form.length > 0) { if ('setFormMeta' in form && typeof form.setFormMeta === 'function') { - try { - form.setFormMeta((prev: unknown) => { - const prevMeta = (prev as Record) || {} - return { - ...prevMeta, - errorMap: { - ...(prevMeta.errorMap as Record), - onServer: mapped.form, - }, - errorSourceMap: { - ...(prevMeta.errorSourceMap as Record), - onServer: 'server', - }, - } - }) - } catch { - // Skip if setFormMeta fails - } + form.setFormMeta((prev: unknown) => { + const prevMeta = (prev as Record) + return { + ...prevMeta, + errorMap: { + ...(prevMeta.errorMap as Record), + onServer: mapped.form, + }, + errorSourceMap: { + ...(prevMeta.errorSourceMap as Record), + onServer: 'server', + }, + } + }) } } } @@ -322,7 +285,7 @@ export async function onServerSuccess< result: TResult, opts?: SuccessOptions, ): Promise { - if (!form || typeof form !== 'object') { + if (typeof form !== 'object') { return } @@ -334,46 +297,30 @@ export async function onServerSuccess< } = opts || {} if (storeResult && 'setFormMeta' in form && typeof form.setFormMeta === 'function') { - try { - form.setFormMeta((prev: unknown) => { - const prevMeta = (prev as Record) || {} - return { - ...prevMeta, - _serverResponse: result, - _serverFormError: '', - } - }) - } catch { - // Skip if setFormMeta fails - } + form.setFormMeta((prev: unknown) => { + const prevMeta = (prev as Record) + return { + ...prevMeta, + _serverResponse: result, + _serverFormError: '', + } + }) } if (resetStrategy !== 'none' && 'reset' in form && typeof form.reset === 'function') { - try { - if (resetStrategy === 'values') { - form.reset({ resetValidation: false }) - } else { - form.reset() - } - } catch { - // Skip if reset fails + if (resetStrategy === 'values') { + form.reset({ resetValidation: false }) + } else { + form.reset() } } if (flash?.set && flash.message && typeof flash.set === 'function') { - try { - flash.set(flash.message) - } catch { - // Skip if flash.set fails - } + flash.set(flash.message) } if (after && typeof after === 'function') { - try { - await after(result) - } catch { - // Skip if after callback fails - } + await after(result) } } From 18be3f25532e6440142b219339235ab185221c65 Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Tue, 16 Sep 2025 09:30:29 +0900 Subject: [PATCH 11/12] feat: add Standard Schema type guard & server side error mapping --- packages/form-server/src/index.ts | 104 ++++++++++++++++++++++++++---- 1 file changed, 93 insertions(+), 11 deletions(-) diff --git a/packages/form-server/src/index.ts b/packages/form-server/src/index.ts index b608fa75c..823aa35c1 100644 --- a/packages/form-server/src/index.ts +++ b/packages/form-server/src/index.ts @@ -24,6 +24,7 @@ export type SuccessOptions = { flash?: { set: (msg: string) => void; message?: string } after?: (result: TResult) => void | Promise storeResult?: boolean + onRedirect?: (response: TResult) => void | Promise } function isZodError( @@ -82,6 +83,29 @@ function isCustomFormError( ) } +function isStandardSchemaError( + err: unknown, +): err is { issues: Array<{ path: (string | number)[]; message: string }> } { + if (typeof err !== 'object' || err === null || !('issues' in err)) { + return false + } + + const issues = (err as Record).issues + if (!Array.isArray(issues) || issues.length === 0) { + return false + } + + return issues.every((issue: unknown) => + typeof issue === 'object' && + issue !== null && + 'path' in issue && + 'message' in issue && + Array.isArray((issue as Record).path) && + typeof (issue as Record).message === 'string' && + (issue as Record).message !== '' + ) +} + function hasStringMessage(err: unknown): err is { message: string } { return ( typeof err === 'object' && @@ -91,7 +115,16 @@ function hasStringMessage(err: unknown): err is { message: string } { ) } - +function defaultPathMapper(serverPath: string): string { + if (typeof serverPath !== 'string') { + return '' + } + + return serverPath + .replace(/\[(\d+)\]/g, '.$1') + .replace(/\[([^\]]+)\]/g, '.$1') + .replace(/^\./, '') +} export function mapServerErrors( err: unknown, @@ -112,6 +145,20 @@ export function mapServerErrors( return { fields: {}, form: fallbackFormMessage } } + if (isStandardSchemaError(err)) { + for (const issue of err.issues) { + const path = Array.isArray(issue.path) ? issue.path.join('.') : String(issue.path) + const mappedPath = pathMapper(path) + if (mappedPath) { + if (!result.fields[mappedPath]) { + result.fields[mappedPath] = [] + } + result.fields[mappedPath].push(issue.message) + } + } + return result + } + if (isZodError(err)) { for (const issue of err.issues) { const path = Array.isArray(issue.path) ? issue.path.join('.') : String(issue.path) @@ -340,17 +387,52 @@ export const selectServerFormError = (store: unknown): string | undefined => { return undefined } -function defaultPathMapper(serverPath: string): string { - if (typeof serverPath !== 'string') { - return '' +export function createServerErrorResponse( + error: unknown, + opts?: { + pathMapper?: (serverPath: string) => string + fallbackFormMessage?: string + } +): { + success: false + errors: MappedServerErrors +} { + return { + success: false, + errors: mapServerErrors(error, opts) } +} + +export function createServerSuccessResponse( + data: T +): { + success: true + data: T +} { + return { + success: true, + data + } +} + +export function getFormError(mapped: MappedServerErrors): string | undefined { + return mapped.form +} + +export function hasFieldErrors(mapped: MappedServerErrors): boolean { + return Object.keys(mapped.fields).length > 0 +} + +export function getAllErrorMessages(mapped: MappedServerErrors): string[] { + const messages: string[] = [] - try { - return serverPath - .replace(/\[(\d+)\]/g, '.$1') - .replace(/\[([^\]]+)\]/g, '.$1') - .replace(/^\./, '') - } catch { - return serverPath + for (const fieldErrors of Object.values(mapped.fields)) { + messages.push(...fieldErrors) } + + if (mapped.form) { + messages.push(mapped.form) + } + + return messages } From 2aadbd6597eab1950a83a445ee31bd4f3c3a2cff Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Tue, 16 Sep 2025 09:31:14 +0900 Subject: [PATCH 12/12] test: add helper function --- .../form-server/tests/serverHelpers.test.ts | 150 ++++++++++++++++++ .../form-server/tests/standardSchema.test.ts | 76 +++++++++ 2 files changed, 226 insertions(+) create mode 100644 packages/form-server/tests/serverHelpers.test.ts create mode 100644 packages/form-server/tests/standardSchema.test.ts diff --git a/packages/form-server/tests/serverHelpers.test.ts b/packages/form-server/tests/serverHelpers.test.ts new file mode 100644 index 000000000..530eefe49 --- /dev/null +++ b/packages/form-server/tests/serverHelpers.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest' +import { + createServerErrorResponse, + createServerSuccessResponse, + getAllErrorMessages, + getFormError, + hasFieldErrors +} from '../src/index' + +describe('Server helper functions', () => { + describe('createServerErrorResponse', () => { + it('should create error response with mapped errors', () => { + const zodError = { + issues: [ + { path: ['name'], message: 'Name is required' } + ] + } + + const result = createServerErrorResponse(zodError) + + expect(result.success).toBe(false) + expect(result.errors.fields.name).toEqual(['Name is required']) + }) + + it('should apply path mapper in error response', () => { + const error = { + issues: [ + { path: ['user[0].name'], message: 'Name is required' } + ] + } + + const result = createServerErrorResponse(error, { + pathMapper: (path) => path.replace(/\[(\d+)\]/g, '.$1') + }) + + expect(result.success).toBe(false) + expect(result.errors.fields['user.0.name']).toEqual(['Name is required']) + }) + }) + + describe('createServerSuccessResponse', () => { + it('should create success response with data', () => { + const data = { id: 1, name: 'John' } + const result = createServerSuccessResponse(data) + + expect(result.success).toBe(true) + expect(result.data).toEqual(data) + }) + + it('should handle undefined data', () => { + const result = createServerSuccessResponse(undefined) + + expect(result.success).toBe(true) + expect(result.data).toBeUndefined() + }) + }) + + describe('getFormError', () => { + it('should extract form error', () => { + const mapped = { + fields: { name: ['Name error'] }, + form: 'Form level error' + } + + expect(getFormError(mapped)).toBe('Form level error') + }) + + it('should return undefined when no form error', () => { + const mapped = { + fields: { name: ['Name error'] } + } + + expect(getFormError(mapped)).toBeUndefined() + }) + }) + + describe('hasFieldErrors', () => { + it('should return true when field errors exist', () => { + const mapped = { + fields: { name: ['Name error'] }, + form: 'Form error' + } + + expect(hasFieldErrors(mapped)).toBe(true) + }) + + it('should return false when no field errors', () => { + const mapped = { + fields: {}, + form: 'Form error' + } + + expect(hasFieldErrors(mapped)).toBe(false) + }) + }) + + describe('getAllErrorMessages', () => { + it('should collect all error messages', () => { + const mapped = { + fields: { + name: ['Name is required'], + email: ['Invalid email', 'Email taken'] + }, + form: 'Form level error' + } + + const messages = getAllErrorMessages(mapped) + + expect(messages).toEqual([ + 'Name is required', + 'Invalid email', + 'Email taken', + 'Form level error' + ]) + }) + + it('should handle empty fields', () => { + const mapped = { + fields: {}, + form: 'Form error' + } + + const messages = getAllErrorMessages(mapped) + + expect(messages).toEqual(['Form error']) + }) + + it('should handle no form error', () => { + const mapped = { + fields: { + name: ['Name error'] + } + } + + const messages = getAllErrorMessages(mapped) + + expect(messages).toEqual(['Name error']) + }) + + it('should handle completely empty errors', () => { + const mapped = { + fields: {} + } + + const messages = getAllErrorMessages(mapped) + + expect(messages).toEqual([]) + }) + }) +}) diff --git a/packages/form-server/tests/standardSchema.test.ts b/packages/form-server/tests/standardSchema.test.ts new file mode 100644 index 000000000..eb572016c --- /dev/null +++ b/packages/form-server/tests/standardSchema.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { mapServerErrors } from '../src/index' + +describe('Standard Schema v1 support', () => { + it('should handle Standard Schema errors with highest priority', () => { + const standardSchemaError = { + issues: [ + { + path: ['name'], + message: 'Name is required' + }, + { + path: ['email'], + message: 'Invalid email format' + } + ] + } + + const result = mapServerErrors(standardSchemaError) + + expect(result.fields.name).toEqual(['Name is required']) + expect(result.fields.email).toEqual(['Invalid email format']) + expect(result.form).toBeUndefined() + }) + + it('should handle nested path arrays in Standard Schema', () => { + const standardSchemaError = { + issues: [ + { + path: ['user', 'profile', 'age'], + message: 'Age must be a number' + } + ] + } + + const result = mapServerErrors(standardSchemaError) + + expect(result.fields['user.profile.age']).toEqual(['Age must be a number']) + }) + + it('should prioritize Standard Schema over Zod when both formats are present', () => { + const mixedError = { + issues: [ + { + path: ['name'], + message: 'Standard Schema error' + } + ] + } + + const result = mapServerErrors(mixedError) + + expect(result.fields.name).toEqual(['Standard Schema error']) + }) + + it('should handle invalid Standard Schema format as generic error', () => { + const invalidStandardSchema = { + issues: [ + { + message: 'Invalid format' + }, + { + path: 'invalid-path', + message: 'Another error' + } + ] + } + + const result = mapServerErrors(invalidStandardSchema, { + fallbackFormMessage: 'Fallback error' + }) + + expect(result.fields.undefined).toEqual(['Invalid format']) + expect(result.fields['invalid-path']).toEqual(['Another error']) + }) +})