From bfb52332cf21efde668a1037b4d25eaf6a107ca0 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 8 Aug 2026 20:25:52 +0200 Subject: [PATCH] perf(router-core): skip impossible JSON parses during search parsing --- .changeset/fast-search-parsing.md | 5 +++ packages/router-core/src/searchParams.ts | 5 +++ .../router-core/tests/searchParams.bench.ts | 38 ++++++++++++++++++- .../router-core/tests/searchParams.test.ts | 38 ++++++++++++++++++- 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 .changeset/fast-search-parsing.md diff --git a/.changeset/fast-search-parsing.md b/.changeset/fast-search-parsing.md new file mode 100644 index 0000000000..0e9e7ed0ce --- /dev/null +++ b/.changeset/fast-search-parsing.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +skip impossible JSON parse attempts when parsing search params diff --git a/packages/router-core/src/searchParams.ts b/packages/router-core/src/searchParams.ts index b93cc23206..926e4e53dd 100644 --- a/packages/router-core/src/searchParams.ts +++ b/packages/router-core/src/searchParams.ts @@ -24,6 +24,7 @@ export const defaultStringifySearch = stringifySearchWith( * @link https://tanstack.com/router/latest/docs/framework/react/guide/custom-search-param-serialization */ export function parseSearchWith(parser: (str: string) => any) { + const isJsonParser = parser === JSON.parse return (searchStr: string): AnySchema => { if (searchStr[0] === '?') { searchStr = searchStr.substring(1) @@ -35,6 +36,10 @@ export function parseSearchWith(parser: (str: string) => any) { for (const key in query) { const value = query[key] if (typeof value === 'string') { + // Skip JSON.parse when the value cannot begin valid JSON. + if (isJsonParser && !jsonStart.test(value)) { + continue + } try { query[key] = parser(value) } catch (_err) { diff --git a/packages/router-core/tests/searchParams.bench.ts b/packages/router-core/tests/searchParams.bench.ts index e2c2acb263..562809aa23 100644 --- a/packages/router-core/tests/searchParams.bench.ts +++ b/packages/router-core/tests/searchParams.bench.ts @@ -1,5 +1,5 @@ import { bench, describe, expect } from 'vitest' -import { defaultStringifySearch } from '../src' +import { defaultParseSearch, defaultStringifySearch } from '../src' const iterations = 1_000 @@ -103,6 +103,16 @@ expect(defaultStringifySearch(mixedValues)).toBe( '?tab=specs&page=2&filters=%5B%22available%22%2C%22featured%22%5D&exactPage=%222%22', ) +const ordinarySearch = defaultStringifySearch(ordinaryStrings) +const jsonInitialSearch = defaultStringifySearch(jsonInitialStrings) +const jsonSearch = defaultStringifySearch(jsonStrings) +const mixedSearch = defaultStringifySearch(mixedValues) + +expect(defaultParseSearch(ordinarySearch)).toEqual(ordinaryStrings) +expect(defaultParseSearch(jsonInitialSearch)).toEqual(jsonInitialStrings) +expect(defaultParseSearch(jsonSearch)).toEqual(jsonStrings) +expect(defaultParseSearch(mixedSearch)).toEqual(mixedValues) + function stringifyBatch(search: Record) { let size = 0 for (let index = 0; index < iterations; index++) { @@ -111,6 +121,14 @@ function stringifyBatch(search: Record) { benchmarkSink = size } +function parseBatch(search: string) { + let size = 0 + for (let index = 0; index < iterations; index++) { + size += Object.keys(defaultParseSearch(search)).length + } + benchmarkSink = size +} + describe('default search serialization', () => { bench('ordinary string values', () => { stringifyBatch(ordinaryStrings) @@ -157,4 +175,22 @@ describe('default search serialization', () => { }) }) +describe('default search parsing', () => { + bench('ordinary string values', () => { + parseBatch(ordinarySearch) + }) + + bench('ordinary strings with JSON-literal initials', () => { + parseBatch(jsonInitialSearch) + }) + + bench('JSON-compatible string values', () => { + parseBatch(jsonSearch) + }) + + bench('mixed application values', () => { + parseBatch(mixedSearch) + }) +}) + void benchmarkSink diff --git a/packages/router-core/tests/searchParams.test.ts b/packages/router-core/tests/searchParams.test.ts index e56f8b7131..6fe87bb9d6 100644 --- a/packages/router-core/tests/searchParams.test.ts +++ b/packages/router-core/tests/searchParams.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, test, vi } from 'vitest' +import { describe, expect, onTestFinished, test, vi } from 'vitest' import { defaultParseSearch, defaultStringifySearch, + parseSearchWith, stringifySearchWith, } from '../src' @@ -81,6 +82,41 @@ describe('Search Params serialization and deserialization', () => { expect(stringify({ value: 'word' })).toEqual('?value=%22word%22') }) + test('uses custom parsers for ordinary strings during parsing', () => { + const parser = vi.fn((value: string) => value.toUpperCase()) + const parse = parseSearchWith(parser) + + expect(parse('?value=word')).toEqual({ value: 'WORD' }) + expect(parser).toHaveBeenCalledWith('word') + }) + + test('skips JSON.parse during parsing for strings that cannot be JSON', () => { + const parseSpy = vi.spyOn(JSON, 'parse') + onTestFinished(() => parseSpy.mockRestore()) + const parse = parseSearchWith(JSON.parse) + + expect( + parse( + '?empty=&filter=foo&future=future&name=name¬ification=new&tab=tabular&topic=topic&unicode=%E9%9B%AA', + ), + ).toEqual({ + empty: '', + filter: 'foo', + future: 'future', + name: 'name', + notification: 'new', + tab: 'tabular', + topic: 'topic', + unicode: '雪', + }) + expect(parse('?file=.env&path=%2Fproducts&positive=%2B1')).toEqual({ + file: '.env', + path: '/products', + positive: '+1', + }) + expect(parseSpy).not.toHaveBeenCalled() + }) + test('skips JSON.parse for strings that cannot be JSON', () => { const parseSpy = vi.spyOn(JSON, 'parse') try {