From 0ae9eb088dda6f4852dfdc76897d163d48236c43 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 8 Aug 2026 19:45:28 +0200 Subject: [PATCH] perf(router-core): optimize server search serialization --- packages/router-core/src/searchParams.ts | 131 ++++++++++- .../tests/searchParams.server.bench.ts | 189 ++++++++++++++++ .../tests/searchParams.server.test.ts | 213 ++++++++++++++++++ 3 files changed, 532 insertions(+), 1 deletion(-) create mode 100644 packages/router-core/tests/searchParams.server.bench.ts create mode 100644 packages/router-core/tests/searchParams.server.test.ts diff --git a/packages/router-core/src/searchParams.ts b/packages/router-core/src/searchParams.ts index b93cc23206..66707b3860 100644 --- a/packages/router-core/src/searchParams.ts +++ b/packages/router-core/src/searchParams.ts @@ -1,3 +1,4 @@ +import { isServer } from '@tanstack/router-core/isServer' import { decode, encode } from './qss' import type { AnySchema } from './validators' @@ -5,6 +6,102 @@ import type { AnySchema } from './validators' // 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. */ @@ -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 { @@ -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 } @@ -89,6 +210,14 @@ export function stringifySearchWith( } return (search: Record) => { + // 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}` : '' } diff --git a/packages/router-core/tests/searchParams.server.bench.ts b/packages/router-core/tests/searchParams.server.bench.ts new file mode 100644 index 0000000000..a6c9509a7a --- /dev/null +++ b/packages/router-core/tests/searchParams.server.bench.ts @@ -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, 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 diff --git a/packages/router-core/tests/searchParams.server.test.ts b/packages/router-core/tests/searchParams.server.test.ts new file mode 100644 index 0000000000..26e442284b --- /dev/null +++ b/packages/router-core/tests/searchParams.server.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, test, vi } from 'vitest' +import { defaultParseSearch, stringifySearchWith } from '../src/searchParams' + +vi.mock('@tanstack/router-core/isServer', () => ({ isServer: true })) + +describe('server search serialization', () => { + test('skips JSON.parse for invalid literal-prefix strings', () => { + const parseSpy = vi.spyOn(JSON, 'parse') + const input = { + date: '2026-08-08', + decimal: '1.2.3', + empty: '', + exponent: '1e', + falsePrefix: 'favorite', + falseWord: 'false_value', + fraction: '1.', + leadingZero: '01', + nullPrefix: 'number', + nullWord: 'null.value', + truePrefix: 'travel', + trueWord: 'true-value', + } + let search = '' + try { + const stringify = stringifySearchWith(JSON.stringify, JSON.parse) + + search = stringify(input) + expect(parseSpy).not.toHaveBeenCalled() + } finally { + parseSpy.mockRestore() + } + expect(defaultParseSearch(search)).toEqual(input) + }) + + test.each([ + 'false', + 'true', + 'null', + '-1', + '0', + '123', + '-0', + '0.0', + '10.25', + '0.5', + '-0.5', + '1e10', + '1E+10', + '1E-10', + '1e01', + 'false\t', + ])('serializes validated JSON primitive %j without parsing', (value) => { + const parseSpy = vi.spyOn(JSON, 'parse') + let search = '' + try { + const stringify = stringifySearchWith(JSON.stringify, JSON.parse) + + search = stringify({ value }) + expect(parseSpy).not.toHaveBeenCalled() + } finally { + parseSpy.mockRestore() + } + expect(defaultParseSearch(search)).toEqual({ value }) + }) + + test.each([ + '-', + '+1', + '00', + '01', + '-00', + '-01', + '.1', + '-.1', + '1.', + '1.2.3', + '0x1', + '1_000', + '1 2', + '1e', + '1e+', + '1e-', + '1e1.0', + '--1', + '- 1', + 'NaN', + 'Infinity', + '2026-08-08', + '\ftrue', + '\v0', + '\u00a0null', + '\ufeff1', + '\u2028false', + ])('rejects invalid primitive-like JSON %j without parsing', (value) => { + expect(() => JSON.parse(value)).toThrow() + + const parseSpy = vi.spyOn(JSON, 'parse') + let search = '' + try { + const stringify = stringifySearchWith(JSON.stringify, JSON.parse) + + search = stringify({ value }) + expect(parseSpy).not.toHaveBeenCalled() + } finally { + parseSpy.mockRestore() + } + expect(defaultParseSearch(search)).toEqual({ value }) + }) + + test.each([ + '"quoted"', + '{}', + '[]', + ' true ', + '\nnull\r', + ' 1.5e+2 ', + '\t0e0\n', + '\r-0.0E+01\t', + '\t"quoted"\r', + '\n[1]\r', + ' {"x":1}\t', + ])('still parses JSON value %j that requires the full parser', (value) => { + const parseSpy = vi.spyOn(JSON, 'parse') + let search = '' + try { + const stringify = stringifySearchWith(JSON.stringify, JSON.parse) + + search = stringify({ value }) + expect(parseSpy).toHaveBeenCalledOnce() + expect(parseSpy).toHaveBeenCalledWith(value) + } finally { + parseSpy.mockRestore() + } + expect(defaultParseSearch(search)).toEqual({ value }) + }) + + test.each(['"unterminated', '[1,', '{"x":}', ' ', '\t future'])( + 'falls back to the raw malformed JSON %j', + (value) => { + const parseSpy = vi.spyOn(JSON, 'parse') + let search = '' + try { + const stringify = stringifySearchWith(JSON.stringify, JSON.parse) + + search = stringify({ value }) + expect(parseSpy).toHaveBeenCalledOnce() + expect(parseSpy).toHaveBeenCalledWith(value) + } finally { + parseSpy.mockRestore() + } + expect(defaultParseSearch(search)).toEqual({ value }) + }, + ) + + test('keeps custom parser behavior unchanged', () => { + const parser = vi.fn((value: string) => { + if (value === 'word') { + return value + } + throw new Error('not parseable') + }) + const stringify = stringifySearchWith(JSON.stringify, parser) + + expect(stringify({ value: 'word' })).toEqual('?value=%22word%22') + expect(parser).toHaveBeenCalledWith('word') + }) + + test('still catches serializer errors for validated primitives', () => { + const stringify = stringifySearchWith((value) => { + if (typeof value === 'string') { + throw new Error('not serializable') + } + return JSON.stringify(value) + }, JSON.parse) + + expect(stringify({ value: 'true' })).toEqual('?value=true') + }) + + test('preserves object and non-string serialization behavior', () => { + const input = { + object: { nested: true }, + array: [1, 2, 3], + nullable: null, + omitted: undefined, + } + const search = stringifySearchWith(JSON.stringify, JSON.parse)(input) + + expect(defaultParseSearch(search)).toEqual({ + object: input.object, + array: input.array, + nullable: null, + }) + }) + + test('still catches object serializer errors', () => { + const stringify = stringifySearchWith(() => { + throw new Error('not serializable') + }, JSON.parse) + + expect(stringify({ value: { nested: true } })).toEqual( + '?value=%5Bobject+Object%5D', + ) + }) + + test('preserves cyclic-object fallback behavior', () => { + const value: Record = {} + value.self = value + + expect(stringifySearchWith(JSON.stringify, JSON.parse)({ value })).toEqual( + '?value=%5Bobject+Object%5D', + ) + }) +})