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
5 changes: 5 additions & 0 deletions .changeset/fast-search-parsing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-core': patch
---

skip impossible JSON parse attempts when parsing search params
5 changes: 5 additions & 0 deletions packages/router-core/src/searchParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
38 changes: 37 additions & 1 deletion packages/router-core/tests/searchParams.bench.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { bench, describe, expect } from 'vitest'
import { defaultStringifySearch } from '../src'
import { defaultParseSearch, defaultStringifySearch } from '../src'

const iterations = 1_000

Expand Down Expand Up @@ -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<string, unknown>) {
let size = 0
for (let index = 0; index < iterations; index++) {
Expand All @@ -111,6 +121,14 @@ function stringifyBatch(search: Record<string, unknown>) {
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)
Expand Down Expand Up @@ -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
38 changes: 37 additions & 1 deletion packages/router-core/tests/searchParams.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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&notification=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 {
Expand Down
Loading