diff --git a/packages/router-core/src/searchParams.ts b/packages/router-core/src/searchParams.ts index 740d36441c0..3b68ec70f0e 100644 --- a/packages/router-core/src/searchParams.ts +++ b/packages/router-core/src/searchParams.ts @@ -9,6 +9,26 @@ export const defaultStringifySearch = stringifySearchWith( JSON.parse, ) +function canStringBeJsonParsed(value: string) { + if (!value) { + return false + } + + const firstCharCode = value.charCodeAt(0) + + return ( + 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' + ) +} + /** * Build a `parseSearch` function using a provided JSON-like parser. * @@ -20,6 +40,8 @@ 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 shouldGuardJsonParse = parser === JSON.parse + return (searchStr: string): AnySchema => { if (searchStr[0] === '?') { searchStr = searchStr.substring(1) @@ -30,7 +52,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') { + if ( + typeof value === 'string' && + (!shouldGuardJsonParse || canStringBeJsonParsed(value)) + ) { try { query[key] = parser(value) } catch (_err) { @@ -60,6 +85,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 { @@ -67,7 +94,11 @@ export function stringifySearchWith( } catch (_err) { // silent } - } else if (hasParser && typeof val === 'string') { + } else if ( + hasParser && + typeof val === 'string' && + (!shouldGuardJsonParse || 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..842cc85c1c0 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,70 @@ describe('Search Params serialization and deserialization', () => { '?foo=%222024-11-18T00%3A00%3A00.000Z%22', ) }) + + 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') + } + + return value.slice(1) + }) + + 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') + + 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 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(parseSpy).toHaveBeenCalled() + } finally { + parseSpy.mockRestore() + } + }) })