From 1ad29de71520e7830a6a083b87bbc4c654f6e11a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 23 Mar 2026 15:33:38 +0100 Subject: [PATCH 1/4] perf(router-core): avoid parsing plain search strings as json --- packages/router-core/src/searchParams.ts | 31 ++++++++++++- .../router-core/tests/searchParams.test.ts | 44 ++++++++++++++++++- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/packages/router-core/src/searchParams.ts b/packages/router-core/src/searchParams.ts index 740d36441c0..159a186df15 100644 --- a/packages/router-core/src/searchParams.ts +++ b/packages/router-core/src/searchParams.ts @@ -9,6 +9,29 @@ export const defaultStringifySearch = stringifySearchWith( JSON.parse, ) +function canStringBeJsonParsed(value: string) { + if (!value) { + return false + } + + const firstCharCode = value.charCodeAt(0) + + if (firstCharCode <= 32) { + return true + } + + return ( + firstCharCode === 34 || + firstCharCode === 45 || + firstCharCode === 91 || + firstCharCode === 123 || + (firstCharCode >= 48 && firstCharCode <= 57) || + value === 'true' || + value === 'false' || + value === 'null' + ) +} + /** * Build a `parseSearch` function using a provided JSON-like parser. * @@ -30,7 +53,7 @@ export function parseSearchWith(parser: (str: string) => any) { // Try to parse any query params that might be json for (const key in query) { const value = query[key] - if (typeof value === 'string') { + if (typeof value === 'string' && canStringBeJsonParsed(value)) { try { query[key] = parser(value) } catch (_err) { @@ -67,7 +90,11 @@ export function stringifySearchWith( } catch (_err) { // silent } - } else if (hasParser && typeof val === 'string') { + } else if ( + hasParser && + typeof val === 'string' && + canStringBeJsonParsed(val) + ) { try { // Check if it's a valid parseable string. // If it is, then stringify it again. diff --git a/packages/router-core/tests/searchParams.test.ts b/packages/router-core/tests/searchParams.test.ts index 006e119be53..d79e390fcec 100644 --- a/packages/router-core/tests/searchParams.test.ts +++ b/packages/router-core/tests/searchParams.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, test } from 'vitest' -import { defaultParseSearch, defaultStringifySearch } from '../src' +import { describe, expect, test, vi } from 'vitest' +import { + defaultParseSearch, + defaultStringifySearch, + parseSearchWith, + stringifySearchWith, +} from '../src' describe('Search Params serialization and deserialization', () => { /* @@ -98,4 +103,39 @@ describe('Search Params serialization and deserialization', () => { '?foo=%222024-11-18T00%3A00%3A00.000Z%22', ) }) + + test('skips parser work for obviously non-json strings', () => { + const parser = vi.fn(JSON.parse) + const parseSearch = parseSearchWith(parser) + const stringifySearch = stringifySearchWith(JSON.stringify, parser) + + expect(parseSearch('?plain=value&other=abc123')).toEqual({ + plain: 'value', + other: 'abc123', + }) + expect(stringifySearch({ plain: 'value', other: 'abc123' })).toEqual( + '?plain=value&other=abc123', + ) + + expect(parser).not.toHaveBeenCalled() + }) + + test('still parses json-like strings', () => { + const parser = vi.fn(JSON.parse) + const parseSearch = parseSearchWith(parser) + const stringifySearch = stringifySearchWith(JSON.stringify, parser) + + expect( + parseSearch('?quoted=%22value%22&object=%7B%22ok%22%3Atrue%7D&num=123'), + ).toEqual({ + quoted: 'value', + object: { ok: true }, + num: 123, + }) + expect( + stringifySearch({ quoted: '123', object: { ok: true }, num: '42' }), + ).toEqual('?quoted=%22123%22&object=%7B%22ok%22%3Atrue%7D&num=%2242%22') + + expect(parser).toHaveBeenCalled() + }) }) From 7a5b555cc31856437cbf5f70bc92afe28ef68346 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 23 Mar 2026 18:47:58 +0100 Subject: [PATCH 2/4] fix(router-core): preserve custom search parser behavior Only apply the JSON fast path to JSON.parse so documented custom serializers still round-trip, and add regression coverage for non-JSON parsers. --- packages/router-core/src/searchParams.ts | 11 ++- .../router-core/tests/searchParams.test.ts | 83 +++++++++++++------ 2 files changed, 66 insertions(+), 28 deletions(-) diff --git a/packages/router-core/src/searchParams.ts b/packages/router-core/src/searchParams.ts index 159a186df15..0564bbb5d4a 100644 --- a/packages/router-core/src/searchParams.ts +++ b/packages/router-core/src/searchParams.ts @@ -43,6 +43,8 @@ function canStringBeJsonParsed(value: string) { * @link https://tanstack.com/router/latest/docs/framework/react/guide/custom-search-param-serialization */ export function parseSearchWith(parser: (str: string) => any) { + const shouldGuardJsonParse = parser === JSON.parse + return (searchStr: string): AnySchema => { if (searchStr[0] === '?') { searchStr = searchStr.substring(1) @@ -53,7 +55,10 @@ export function parseSearchWith(parser: (str: string) => any) { // Try to parse any query params that might be json for (const key in query) { const value = query[key] - if (typeof value === 'string' && canStringBeJsonParsed(value)) { + if ( + typeof value === 'string' && + (!shouldGuardJsonParse || canStringBeJsonParsed(value)) + ) { try { query[key] = parser(value) } catch (_err) { @@ -83,6 +88,8 @@ export function stringifySearchWith( parser?: (str: string) => any, ) { const hasParser = typeof parser === 'function' + const shouldGuardJsonParse = parser === JSON.parse + function stringifyValue(val: any) { if (typeof val === 'object' && val !== null) { try { @@ -93,7 +100,7 @@ export function stringifySearchWith( } else if ( hasParser && typeof val === 'string' && - canStringBeJsonParsed(val) + (!shouldGuardJsonParse || canStringBeJsonParsed(val)) ) { try { // Check if it's a valid parseable string. diff --git a/packages/router-core/tests/searchParams.test.ts b/packages/router-core/tests/searchParams.test.ts index d79e390fcec..842cc85c1c0 100644 --- a/packages/router-core/tests/searchParams.test.ts +++ b/packages/router-core/tests/searchParams.test.ts @@ -104,38 +104,69 @@ describe('Search Params serialization and deserialization', () => { ) }) - test('skips parser work for obviously non-json strings', () => { - const parser = vi.fn(JSON.parse) - const parseSearch = parseSearchWith(parser) - const stringifySearch = stringifySearchWith(JSON.stringify, parser) + test('custom parsers still run for non-json-looking strings', () => { + const parser = vi.fn((value: string) => { + if (!value.startsWith('~')) { + throw new Error('not custom-encoded') + } - expect(parseSearch('?plain=value&other=abc123')).toEqual({ - plain: 'value', - other: 'abc123', + return value.slice(1) }) - expect(stringifySearch({ plain: 'value', other: 'abc123' })).toEqual( - '?plain=value&other=abc123', + + const parseSearch = parseSearchWith(parser) + const stringifySearch = stringifySearchWith((value) => `~${value}`, parser) + + expect(parseSearch('?filter=%7Eauthor')).toEqual({ filter: 'author' }) + expect(stringifySearch({ filter: '~author' })).toEqual( + '?filter=%7E%7Eauthor', ) + expect(parseSearch(stringifySearch({ filter: '~author' }))).toEqual({ + filter: '~author', + }) + }) + + test('skips JSON.parse work for obviously non-json strings', () => { + const parseSpy = vi.spyOn(JSON, 'parse') - expect(parser).not.toHaveBeenCalled() + try { + const parseSearch = parseSearchWith(JSON.parse) + const stringifySearch = stringifySearchWith(JSON.stringify, JSON.parse) + + expect(parseSearch('?plain=value&other=abc123')).toEqual({ + plain: 'value', + other: 'abc123', + }) + expect(stringifySearch({ plain: 'value', other: 'abc123' })).toEqual( + '?plain=value&other=abc123', + ) + + expect(parseSpy).not.toHaveBeenCalled() + } finally { + parseSpy.mockRestore() + } }) - test('still parses json-like strings', () => { - const parser = vi.fn(JSON.parse) - const parseSearch = parseSearchWith(parser) - const stringifySearch = stringifySearchWith(JSON.stringify, parser) - - expect( - parseSearch('?quoted=%22value%22&object=%7B%22ok%22%3Atrue%7D&num=123'), - ).toEqual({ - quoted: 'value', - object: { ok: true }, - num: 123, - }) - expect( - stringifySearch({ quoted: '123', object: { ok: true }, num: '42' }), - ).toEqual('?quoted=%22123%22&object=%7B%22ok%22%3Atrue%7D&num=%2242%22') + test('still parses json-like strings with JSON.parse', () => { + const parseSpy = vi.spyOn(JSON, 'parse') + + try { + const parseSearch = parseSearchWith(JSON.parse) + const stringifySearch = stringifySearchWith(JSON.stringify, JSON.parse) + + expect( + parseSearch('?quoted=%22value%22&object=%7B%22ok%22%3Atrue%7D&num=123'), + ).toEqual({ + quoted: 'value', + object: { ok: true }, + num: 123, + }) + expect( + stringifySearch({ quoted: '123', object: { ok: true }, num: '42' }), + ).toEqual('?quoted=%22123%22&object=%7B%22ok%22%3Atrue%7D&num=%2242%22') - expect(parser).toHaveBeenCalled() + expect(parseSpy).toHaveBeenCalled() + } finally { + parseSpy.mockRestore() + } }) }) From afd373a93d4005e07846a8f74d158e64600d7fd2 Mon Sep 17 00:00:00 2001 From: Flo Date: Mon, 23 Mar 2026 22:26:40 +0100 Subject: [PATCH 3/4] Apply suggestion from @Sheraff --- packages/router-core/src/searchParams.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/router-core/src/searchParams.ts b/packages/router-core/src/searchParams.ts index 0564bbb5d4a..b9e01ff1fe9 100644 --- a/packages/router-core/src/searchParams.ts +++ b/packages/router-core/src/searchParams.ts @@ -16,11 +16,8 @@ function canStringBeJsonParsed(value: string) { const firstCharCode = value.charCodeAt(0) - if (firstCharCode <= 32) { - return true - } - return ( + firstCharCode <= 32 || firstCharCode === 34 || firstCharCode === 45 || firstCharCode === 91 || From 35e98c5760089ecf023ce7c83db1a9779b0f801c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 23 Mar 2026 22:46:49 +0100 Subject: [PATCH 4/4] docs(router-core): clarify JSON parse guard char codes --- packages/router-core/src/searchParams.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/router-core/src/searchParams.ts b/packages/router-core/src/searchParams.ts index b9e01ff1fe9..3b68ec70f0e 100644 --- a/packages/router-core/src/searchParams.ts +++ b/packages/router-core/src/searchParams.ts @@ -17,12 +17,12 @@ function canStringBeJsonParsed(value: string) { const firstCharCode = value.charCodeAt(0) return ( - firstCharCode <= 32 || - firstCharCode === 34 || - firstCharCode === 45 || - firstCharCode === 91 || - firstCharCode === 123 || - (firstCharCode >= 48 && firstCharCode <= 57) || + firstCharCode <= 32 || // ASCII control chars through space (' ') + firstCharCode === 34 || // double quote ('"') + firstCharCode === 45 || // minus sign ('-') + firstCharCode === 91 || // opening bracket ('[') + firstCharCode === 123 || // opening brace ('{') + (firstCharCode >= 48 && firstCharCode <= 57) || // digits ('0' - '9') value === 'true' || value === 'false' || value === 'null'