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..c87bf2efe
--- /dev/null
+++ b/docs/framework/react/guides/server-errors-and-success.md
@@ -0,0 +1,342 @@
+# 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'
+
+const zodError = {
+ issues: [
+ { path: ['name'], message: 'Name is required' },
+ { path: ['email'], message: 'Invalid email format' },
+ ],
+}
+
+const mapped = mapServerErrors(zodError)
+```
+
+### 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' }],
+}
+```
+
+#### Rails-style Errors
+
+```tsx
+const railsError = {
+ errors: {
+ 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' },
+}
+```
+
+### 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 (
+
+ {(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, {
+ flash: {
+ set: (message) => setFlashMessage(message),
+ message: 'Form saved successfully!',
+ },
+ after: async () => {
+ router.push('/success')
+ },
+ })
+ } catch (error) {}
+ },
+})
+```
+
+### 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}
}
+
+
+ {(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'
+
+ 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)
+ }
+ },
+ })
+}
+```
+
+### 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 }) => {},
+ })
+
+ useEffect(() => {
+ if (actionData?.error) {
+ const mappedErrors = mapServerErrors(actionData.error)
+ applyServerErrors(form, mappedErrors)
+ }
+ }, [actionData])
+}
+```
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..823aa35c1
--- /dev/null
+++ b/packages/form-server/src/index.ts
@@ -0,0 +1,438 @@
+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 ApplyErrorsOptions = {
+ multipleMessages?: 'first' | 'join' | 'array'
+ separator?: string
+}
+
+export type SuccessOptions = {
+ resetStrategy?: 'none' | 'values' | 'all'
+ flash?: { set: (msg: string) => void; message?: string }
+ after?: (result: TResult) => void | Promise
+ storeResult?: boolean
+ onRedirect?: (response: TResult) => 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 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' &&
+ err !== null &&
+ 'message' in err &&
+ typeof (err as Record).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,
+ opts?: {
+ pathMapper?: (serverPath: string) => string
+ fallbackFormMessage?: string
+ },
+): MappedServerErrors {
+ const pathMapper = opts?.pathMapper || defaultPathMapper
+ const fallbackFormMessage = opts?.fallbackFormMessage || 'An error occurred'
+
+ const result: MappedServerErrors = {
+ fields: {},
+ form: undefined
+ }
+
+ if (!err || typeof err !== 'object') {
+ 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)
+ const mappedPath = pathMapper(path)
+ if (mappedPath) {
+ if (!result.fields[mappedPath]) {
+ result.fields[mappedPath] = []
+ }
+ result.fields[mappedPath].push(issue.message)
+ }
+ }
+ return result
+ }
+
+ if (isRailsError(err)) {
+ 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
+ }
+ }
+ }
+ }
+ return result
+ }
+
+ if (isNestJSError(err)) {
+ 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 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)
+ }
+ }
+ }
+ }
+ return result
+ }
+
+ if (isCustomFieldError(err)) {
+ 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)
+ }
+ }
+ }
+
+ if (isCustomFormError(err)) {
+ 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
+ ) {
+ result.form = err.formError.message
+ }
+ } else if (hasStringMessage(err)) {
+ result.form = err.message
+ return result
+ }
+
+ result.form = fallbackFormMessage
+ return result
+}
+
+export function applyServerErrors>(
+ form: TFormApi,
+ mapped: MappedServerErrors,
+ opts?: ApplyErrorsOptions,
+): void {
+ const { multipleMessages = 'first', separator = '; ' } = opts || {}
+
+ for (const [path, messages] of Object.entries(mapped.fields)) {
+ if (Array.isArray(messages) && messages.length > 0) {
+ let errorMessage: string | string[]
+
+ 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
+ }
+ }
+
+ if (errorMessage && 'setFieldMeta' in form && typeof form.setFieldMeta === 'function') {
+ 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') {
+ 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<
+ TFormApi extends {
+ reset?: (options?: { resetValidation?: boolean }) => void
+ setFormMeta?: (updater: (prev: unknown) => unknown) => void
+ },
+ TResult = unknown,
+>(
+ form: TFormApi,
+ result: TResult,
+ opts?: SuccessOptions,
+): Promise {
+ if (typeof form !== 'object') {
+ return
+ }
+
+ const {
+ resetStrategy = 'none',
+ flash,
+ after,
+ storeResult = false,
+ } = opts || {}
+
+ if (storeResult && 'setFormMeta' in form && typeof form.setFormMeta === 'function') {
+ form.setFormMeta((prev: unknown) => {
+ const prevMeta = (prev as Record)
+ return {
+ ...prevMeta,
+ _serverResponse: result,
+ _serverFormError: '',
+ }
+ })
+ }
+
+ if (resetStrategy !== 'none' && 'reset' in form && typeof form.reset === 'function') {
+ if (resetStrategy === 'values') {
+ form.reset({ resetValidation: false })
+ } else {
+ form.reset()
+ }
+ }
+
+ if (flash?.set && flash.message && typeof flash.set === 'function') {
+ flash.set(flash.message)
+ }
+
+ if (after && typeof after === 'function') {
+ await after(result)
+ }
+}
+
+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
+}
+
+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[] = []
+
+ for (const fieldErrors of Object.values(mapped.fields)) {
+ messages.push(...fieldErrors)
+ }
+
+ if (mapped.form) {
+ messages.push(mapped.form)
+ }
+
+ return messages
+}
diff --git a/packages/form-server/tests/applyServerErrors.test.ts b/packages/form-server/tests/applyServerErrors.test.ts
new file mode 100644
index 000000000..bd0e3f9aa
--- /dev/null
+++ b/packages/form-server/tests/applyServerErrors.test.ts
@@ -0,0 +1,257 @@
+import { describe, expect, it, vi } from 'vitest'
+import { applyServerErrors } from '../src/index'
+import type { ApplyErrorsOptions, 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()
+
+ 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))
+
+ 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)
+
+ 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',
+ },
+ })
+
+ 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')
+ })
+
+ 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',
+ ])
+ })
+})
diff --git a/packages/form-server/tests/integration.test.ts b/packages/form-server/tests/integration.test.ts
new file mode 100644
index 000000000..72cf445d4
--- /dev/null
+++ b/packages/form-server/tests/integration.test.ts
@@ -0,0 +1,140 @@
+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', () => {
+ const mockForm = {
+ setFieldMeta: vi.fn(),
+ setFormMeta: vi.fn(),
+ reset: vi.fn(),
+ }
+
+ const serverError = {
+ issues: [
+ { path: ['name'], message: 'Name is required' },
+ { path: ['email'], message: 'Invalid email format' },
+ { path: ['items', 0, 'price'], message: 'Price must be positive' },
+ ],
+ }
+
+ const mappedErrors = mapServerErrors(serverError)
+
+ 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),
+ )
+ })
+
+ 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))
+
+ 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', () => {
+ 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',
+ },
+ }
+
+ 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..582e2573d
--- /dev/null
+++ b/packages/form-server/tests/mapServerErrors.test.ts
@@ -0,0 +1,204 @@
+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..ad264e8bf
--- /dev/null
+++ b/packages/form-server/tests/onServerSuccess.test.ts
@@ -0,0 +1,179 @@
+import { describe, expect, it, vi } from 'vitest'
+import { onServerSuccess } from '../src/index'
+import 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)
+
+ expect(mockForm.reset).toHaveBeenCalled()
+ expect(mockFlashSet).toHaveBeenCalledWith('Success!')
+ expect(mockAfter).toHaveBeenCalled()
+
+ 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',
+ }
+
+ 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..54816c6a0
--- /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' })
+
+ 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/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'])
+ })
+})
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 5ef23b4c2..214c6b78b 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':