Skip to content
Draft
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
131 changes: 130 additions & 1 deletion packages/router-core/src/searchParams.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,107 @@
import { isServer } from '@tanstack/router-core/isServer'
import { decode, encode } from './qss'
import type { AnySchema } from './validators'

// JSON can start with whitespace, ", [, {, -, a digit, or fa/nu/tr.
// False positives safely fall through to JSON.parse.
const jsonStart = /^(?:\s|["[{\d-]|fa|nu|tr)/

// Returns 0 for impossible JSON, 1 for a validated primitive, and 2 when the
// full parser is still needed for a string, array, or object.
function getServerJsonKind(value: string): 0 | 1 | 2 {
const length = value.length
let index = 0
let code = value.charCodeAt(index)

// Strings, arrays, objects, and leading JSON whitespace still need the full
// parser. Returning immediately avoids scanning whitespace-prefixed values
// twice.
if (
code === 34 ||
code === 91 ||
code === 123 ||
code === 32 ||
code === 9 ||
code === 10 ||
code === 13
) {
return 2
}

// Validate the three JSON literal words without allocating a substring.
if (code === 116) {
if (
value.charCodeAt(++index) !== 114 ||
value.charCodeAt(++index) !== 117 ||
value.charCodeAt(++index) !== 101
) {
return 0
}
code = value.charCodeAt(++index)
} else if (code === 102) {
if (
value.charCodeAt(++index) !== 97 ||
value.charCodeAt(++index) !== 108 ||
value.charCodeAt(++index) !== 115 ||
value.charCodeAt(++index) !== 101
) {
return 0
}
code = value.charCodeAt(++index)
} else if (code === 110) {
if (
value.charCodeAt(++index) !== 117 ||
value.charCodeAt(++index) !== 108 ||
value.charCodeAt(++index) !== 108
) {
return 0
}
code = value.charCodeAt(++index)
} else {
// Validate the complete JSON number grammar before bypassing JSON.parse.
if (code === 45) {
code = value.charCodeAt(++index)
}
if (code === 48) {
code = value.charCodeAt(++index)
} else if (code >= 49 && code <= 57) {
do {
code = value.charCodeAt(++index)
} while (code >= 48 && code <= 57)
} else {
return 0
}

if (code === 46) {
code = value.charCodeAt(++index)
if (!(code >= 48 && code <= 57)) {
return 0
}
do {
code = value.charCodeAt(++index)
} while (code >= 48 && code <= 57)
}

if (code === 69 || code === 101) {
code = value.charCodeAt(++index)
if (code === 43 || code === 45) {
code = value.charCodeAt(++index)
}
if (!(code >= 48 && code <= 57)) {
return 0
}
do {
code = value.charCodeAt(++index)
} while (code >= 48 && code <= 57)
}
}

while (code === 32 || code === 9 || code === 10 || code === 13) {
code = value.charCodeAt(++index)
}
return index === length ? 1 : 0
}

/** Default `parseSearch` that strips leading '?' and JSON-parses values. */
export const defaultParseSearch = parseSearchWith(JSON.parse)
/** Default `stringifySearch` using JSON.stringify for complex values. */
Expand Down Expand Up @@ -64,6 +161,30 @@ export function stringifySearchWith(
parser?: (str: string) => any,
) {
const isJsonParser = parser === JSON.parse
function stringifyValueOnServer(val: any) {
if (val && typeof val === 'object') {
try {
return stringify(val)
} catch {
// silent
}
} else if (typeof val === 'string') {
const jsonKind = getServerJsonKind(val)
if (jsonKind === 0) {
return val
}
try {
if (jsonKind === 2) {
parser!(val)
}
return stringify(val)
} catch {
// silent
}
}
return val
}

function stringifyValue(val: any) {
if (val && typeof val === 'object') {
try {
Expand All @@ -72,7 +193,7 @@ export function stringifySearchWith(
// silent
}
} else if (parser && typeof val === 'string') {
// Skip JSON.parse when the value cannot begin valid JSON.
// Keep the client check compact while skipping impossible parses.
if (isJsonParser && !jsonStart.test(val)) {
return val
}
Expand All @@ -89,6 +210,14 @@ export function stringifySearchWith(
}

return (search: Record<string, any>) => {
// Keep this read at invocation time. The server export also loads router
// code, so reading it while this factory initializes can hit a module TDZ.
if (isServer) {
if (isJsonParser) {
const searchStr = encode(search, stringifyValueOnServer)
return searchStr ? `?${searchStr}` : ''
}
}
const searchStr = encode(search, stringifyValue)
return searchStr ? `?${searchStr}` : ''
}
Expand Down
189 changes: 189 additions & 0 deletions packages/router-core/tests/searchParams.server.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { bench, describe, expect, vi } from 'vitest'
import { defaultParseSearch, defaultStringifySearch } from '../src/searchParams'

vi.mock('@tanstack/router-core/isServer', () => ({ isServer: true }))

const iterations = 1_000
const exceptionIterations = 100
const ordinaryStrings = {
tab: 'specs',
filter: 'available',
category: 'hardware',
sort: 'newest',
}
const jsonLiteralPrefixStrings = {
first: 'favorite',
second: 'number',
third: 'travel',
fourth: 'nullish',
}
const jsonLiteralWordStrings = {
truthy: 'true_value',
falsy: 'false_value',
nullable: 'null_value',
}
const jsonStrings = {
number: '123',
boolean: 'true',
object: '{"nested":true}',
array: '[1,2,3]',
}
const jsonPrimitiveStrings = {
integer: '123',
decimal: '-0.5',
exponent: '1e10',
boolean: 'true',
nullable: 'null',
}
const jsonStructuredStrings = {
quoted: '"value"',
object: '{"nested":true}',
array: '[1,2,3]',
nested: '{"items":[1,{"ok":true}]}',
}
const numericLikeStrings = {
date: '2026-08-08',
version: '1.2.3',
leadingZero: '01',
fraction: '1.',
exponent: '1e',
}
const malformedStructuredStrings = {
quoted: '"unterminated',
object: '{"broken":',
array: '[1,',
}
const longValidNumber = { value: '1'.repeat(256) }
const longInvalidNumber = { value: `${'1'.repeat(256)}x` }
const expectedDistribution = {
tab: 'specs',
filter: 'available',
category: 'hardware',
sort: 'newest',
page: '2',
showArchived: 'false',
filters: '["available","featured"]',
}
const whitespace16 = { value: `${' '.repeat(16)}{}` }
const whitespace64 = { value: `${' '.repeat(64)}{}` }
const whitespace256 = { value: `${' '.repeat(256)}{}` }
const whitespace1024 = { value: `${' '.repeat(1_024)}{}` }
const mixedValues = {
tab: 'specs',
page: 2,
filters: ['available', 'featured'],
exactPage: '2',
}
let benchmarkSink = 0

expect(defaultStringifySearch(ordinaryStrings)).toBe(
'?tab=specs&filter=available&category=hardware&sort=newest',
)
expect(defaultStringifySearch(jsonLiteralPrefixStrings)).toBe(
'?first=favorite&second=number&third=travel&fourth=nullish',
)
expect(defaultStringifySearch(jsonLiteralWordStrings)).toBe(
'?truthy=true_value&falsy=false_value&nullable=null_value',
)
expect(defaultStringifySearch(jsonStrings)).toBe(
'?number=%22123%22&boolean=%22true%22&object=%22%7B%5C%22nested%5C%22%3Atrue%7D%22&array=%22%5B1%2C2%2C3%5D%22',
)
expect(defaultStringifySearch(jsonPrimitiveStrings)).toBe(
'?integer=%22123%22&decimal=%22-0.5%22&exponent=%221e10%22&boolean=%22true%22&nullable=%22null%22',
)
const jsonStructuredSearch = defaultStringifySearch(jsonStructuredStrings)
expect(defaultParseSearch(jsonStructuredSearch)).toEqual(jsonStructuredStrings)
expect(defaultStringifySearch(numericLikeStrings)).toBe(
'?date=2026-08-08&version=1.2.3&leadingZero=01&fraction=1.&exponent=1e',
)
for (const input of [
malformedStructuredStrings,
longValidNumber,
longInvalidNumber,
expectedDistribution,
whitespace16,
whitespace64,
whitespace256,
whitespace1024,
]) {
expect(defaultParseSearch(defaultStringifySearch(input))).toEqual(input)
}
expect(defaultStringifySearch(mixedValues)).toBe(
'?tab=specs&page=2&filters=%5B%22available%22%2C%22featured%22%5D&exactPage=%222%22',
)

function stringifyBatch(search: Record<string, unknown>, count = iterations) {
let size = 0
for (let index = 0; index < count; index++) {
size += defaultStringifySearch(search).length
}
benchmarkSink = size
}

describe('server default search serialization', () => {
bench('ordinary string values', () => {
stringifyBatch(ordinaryStrings)
})

bench('application words with JSON-literal prefixes', () => {
stringifyBatch(jsonLiteralPrefixStrings, exceptionIterations)
})

bench('application words with complete JSON-literal prefixes', () => {
stringifyBatch(jsonLiteralWordStrings, exceptionIterations)
})

bench('JSON-compatible string values', () => {
stringifyBatch(jsonStrings)
})

bench('JSON primitive string values', () => {
stringifyBatch(jsonPrimitiveStrings)
})

bench('quoted and structured JSON string values', () => {
stringifyBatch(jsonStructuredStrings)
})

bench('invalid number-like string values', () => {
stringifyBatch(numericLikeStrings, exceptionIterations)
})

bench('malformed structured JSON strings', () => {
stringifyBatch(malformedStructuredStrings, exceptionIterations)
})

bench('long valid JSON number string', () => {
stringifyBatch(longValidNumber)
})

bench('long near-valid JSON number string', () => {
stringifyBatch(longInvalidNumber)
})

bench('expected application distribution', () => {
stringifyBatch(expectedDistribution)
})

bench('structured JSON after 16 whitespace bytes', () => {
stringifyBatch(whitespace16)
})

bench('structured JSON after 64 whitespace bytes', () => {
stringifyBatch(whitespace64)
})

bench('structured JSON after 256 whitespace bytes', () => {
stringifyBatch(whitespace256)
})

bench('structured JSON after 1024 whitespace bytes', () => {
stringifyBatch(whitespace1024)
})

bench('mixed application values', () => {
stringifyBatch(mixedValues)
})
})

void benchmarkSink
Loading
Loading