Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/router-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,8 @@ export type {
ResolveValidatorOutput,
} from './validators'

export { formatValidationError } from './validationError'

export type {
UseRouteContextBaseOptions,
UseRouteContextOptions,
Expand Down
3 changes: 2 additions & 1 deletion packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
})

Expand Down
63 changes: 63 additions & 0 deletions packages/router-core/src/validationError.ts
Original file line number Diff line number Diff line change
@@ -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<IssuePathSegment> | 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<AnyStandardSchemaValidateIssue> | 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')
}
3 changes: 3 additions & 0 deletions packages/router-core/src/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export interface AnyStandardSchemaValidateFailure {

export interface AnyStandardSchemaValidateIssue {
readonly message: string
readonly path?:
| ReadonlyArray<PropertyKey | { readonly key: PropertyKey }>
| undefined
}

export interface AnyStandardSchemaValidateInput {
Expand Down
87 changes: 87 additions & 0 deletions packages/router-core/tests/validationError.test.ts
Original file line number Diff line number Diff line change
@@ -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<AnyStandardSchemaValidateIssue> = [
{ message: 'Required' },
]
expect(formatValidationError(issues)).toBe('(root): Required')
})

test('renders a nested string path', () => {
const issues: Array<AnyStandardSchemaValidateIssue> = [
{ message: 'Expected string', path: ['user', 'name'] },
]
expect(formatValidationError(issues)).toBe(
'["user"]["name"]: Expected string',
)
})

test('renders numeric (array index) path segments', () => {
const issues: Array<AnyStandardSchemaValidateIssue> = [
{ 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<AnyStandardSchemaValidateIssue> = [
{ 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<AnyStandardSchemaValidateIssue> = [
{ message: 'x', path: ['a.b'] },
]
const nested: Array<AnyStandardSchemaValidateIssue> = [
{ 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<AnyStandardSchemaValidateIssue> = [
{ 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<AnyStandardSchemaValidateIssue> = [
{ 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<AnyStandardSchemaValidateIssue> = [
{ 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')
})
})
9 changes: 6 additions & 3 deletions packages/start-client-core/src/createServerFn.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add curly braces to the if statement.

Per the project's coding guidelines for **/*.{ts,tsx,js,jsx}: "Always use curly braces for if, else, loops, and similar control statements. Never write one-line bodies like if (foo) x = 1."

♻️ Proposed fix
-    if (result.issues) throw new Error(formatValidationError(result.issues))
+    if (result.issues) {
+      throw new Error(formatValidationError(result.issues))
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (result.issues) throw new Error(formatValidationError(result.issues))
if (result.issues) {
throw new Error(formatValidationError(result.issues))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/start-client-core/src/createServerFn.ts` at line 904, Update the if
statement handling result.issues to use curly braces around the throw statement,
following the project’s control-flow style guidelines.

Source: Coding guidelines


return result.value
}
Expand Down