diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index fd673ca410..555e69bb55 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -388,6 +388,8 @@ export type { ResolveValidatorOutput, } from './validators' +export { formatValidationError } from './validationError' + export type { UseRouteContextBaseOptions, UseRouteContextOptions, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2197dab737..a251d70226 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -104,6 +104,7 @@ import type { ManifestRouteAssets, RouterManagedTag, } from './manifest' +import { formatValidationError } from './validationError' import type { AnySchema, AnyValidator } from './validators' import type { NavigateOptions, ResolveRelativePath, ToOptions } from './link' import type { NotFoundError } from './not-found' @@ -3078,7 +3079,7 @@ function validateSearch(validateSearch: AnyValidator, input: unknown): unknown { throw new SearchParamError('Async validation not supported') if (result.issues) - throw new SearchParamError(JSON.stringify(result.issues, undefined, 2), { + throw new SearchParamError(formatValidationError(result.issues), { cause: result, }) diff --git a/packages/router-core/src/validationError.ts b/packages/router-core/src/validationError.ts new file mode 100644 index 0000000000..c5132ce18f --- /dev/null +++ b/packages/router-core/src/validationError.ts @@ -0,0 +1,63 @@ +import type { AnyStandardSchemaValidateIssue } from './validators' + +type IssuePathSegment = PropertyKey | { readonly key: PropertyKey } + +function formatIssuePathSegment(segment: IssuePathSegment): string { + const key = + typeof segment === 'object' && segment !== null && 'key' in segment + ? segment.key + : segment + + if (typeof key === 'number') { + return `[${key}]` + } + if (typeof key === 'symbol') { + return `[${key.toString()}]` + } + // String keys are always bracketed and quoted. This keeps the path + // unambiguous when a key itself contains "." or "[" (so a literal key + // "a.b" can't be confused with the nested path a -> b), and it means a + // key such as "__proto__" is only ever rendered as text, never used to + // index into an object. + return `[${JSON.stringify(key)}]` +} + +function formatIssuePath( + path: ReadonlyArray | undefined, +): string { + if (!path || path.length === 0) { + return '(root)' + } + return path.map(formatIssuePathSegment).join('') +} + +/** + * Format Standard Schema validation issues into a readable string. + * + * Issue objects can hold values that `JSON.stringify` cannot serialize + * (symbols in paths, circular references), in which case stringifying them + * throws and hides the original validation failure. This walks the issues + * defensively and only ever reads the message and path, so the real + * validation errors survive. + * + * Intended for internal use across router-core and start-client-core; it is + * not part of the documented public API. + */ +export function formatValidationError( + issues: ReadonlyArray | undefined, +): string { + if (!issues || issues.length === 0) { + return 'Validation failed' + } + + return issues + .map((issue) => { + const path = formatIssuePath(issue.path) + const message = + typeof issue.message === 'string' + ? issue.message + : String(issue.message) + return `${path}: ${message}` + }) + .join('\n') +} diff --git a/packages/router-core/src/validators.ts b/packages/router-core/src/validators.ts index 9173080077..cde6227668 100644 --- a/packages/router-core/src/validators.ts +++ b/packages/router-core/src/validators.ts @@ -27,6 +27,9 @@ export interface AnyStandardSchemaValidateFailure { export interface AnyStandardSchemaValidateIssue { readonly message: string + readonly path?: + | ReadonlyArray + | undefined } export interface AnyStandardSchemaValidateInput { diff --git a/packages/router-core/tests/validationError.test.ts b/packages/router-core/tests/validationError.test.ts new file mode 100644 index 0000000000..6b2922a38e --- /dev/null +++ b/packages/router-core/tests/validationError.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'vitest' +import { formatValidationError } from '../src/validationError' +import type { AnyStandardSchemaValidateIssue } from '../src/validators' + +describe('formatValidationError', () => { + test('falls back when there are no issues', () => { + expect(formatValidationError([])).toBe('Validation failed') + expect(formatValidationError(undefined)).toBe('Validation failed') + }) + + test('renders a root issue without a path', () => { + const issues: Array = [ + { message: 'Required' }, + ] + expect(formatValidationError(issues)).toBe('(root): Required') + }) + + test('renders a nested string path', () => { + const issues: Array = [ + { message: 'Expected string', path: ['user', 'name'] }, + ] + expect(formatValidationError(issues)).toBe( + '["user"]["name"]: Expected string', + ) + }) + + test('renders numeric (array index) path segments', () => { + const issues: Array = [ + { message: 'Expected number', path: ['scores', 0] }, + ] + expect(formatValidationError(issues)).toBe('["scores"][0]: Expected number') + }) + + test('reads path segments given as { key } objects', () => { + const issues: Array = [ + { message: 'Invalid', path: [{ key: 'a' }, { key: 1 }] }, + ] + expect(formatValidationError(issues)).toBe('["a"][1]: Invalid') + }) + + test('does not confuse a literal dotted key with a nested path', () => { + const flat: Array = [ + { message: 'x', path: ['a.b'] }, + ] + const nested: Array = [ + { message: 'x', path: ['a', 'b'] }, + ] + expect(formatValidationError(flat)).not.toBe(formatValidationError(nested)) + expect(formatValidationError(flat)).toBe('["a.b"]: x') + }) + + test('renders a prototype-named key as text without indexing into objects', () => { + const issues: Array = [ + { message: 'nope', path: ['__proto__', 'polluted'] }, + ] + expect(formatValidationError(issues)).toBe( + '["__proto__"]["polluted"]: nope', + ) + }) + + test('renders symbol path segments instead of throwing', () => { + const sym = Symbol('id') + const issues: Array = [ + { message: 'bad symbol key', path: [sym] }, + ] + expect(formatValidationError(issues)).toBe( + `[${sym.toString()}]: bad symbol key`, + ) + }) + + test('joins multiple issues on separate lines', () => { + const issues: Array = [ + { message: 'Required', path: ['a'] }, + { message: 'Too short', path: ['b', 2] }, + ] + expect(formatValidationError(issues)).toBe( + '["a"]: Required\n["b"][2]: Too short', + ) + }) + + test('does not throw on a circular issue object', () => { + const circular: any = { message: 'circular' } + circular.self = circular + expect(() => formatValidationError([circular])).not.toThrow() + expect(formatValidationError([circular])).toBe('(root): circular') + }) +}) diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index bfa48039cf..e16339bb42 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -1,6 +1,10 @@ import { mergeHeaders } from '@tanstack/router-core/ssr/client' -import { isRedirect, parseRedirect } from '@tanstack/router-core' +import { + formatValidationError, + isRedirect, + parseRedirect, +} from '@tanstack/router-core' import { TSS_SERVER_FUNCTION_FACTORY } from './constants' import { getStartOptions } from './getStartOptions' import { getStartContextServerOnly } from './getStartContextServerOnly' @@ -897,8 +901,7 @@ export async function execValidator( if ('~standard' in validator) { const result = await validator['~standard'].validate(input) - if (result.issues) - throw new Error(JSON.stringify(result.issues, undefined, 2)) + if (result.issues) throw new Error(formatValidationError(result.issues)) return result.value }