From 3b4a42ce3f0845f3bbd1b35738aa53536bb837cc Mon Sep 17 00:00:00 2001 From: Ilia Choly Date: Sat, 6 Jun 2026 17:43:22 -0400 Subject: [PATCH] feat(router-core): pass the param key to search parse/stringify callbacks --- .changeset/search-param-key.md | 5 ++++ .../custom-search-param-serialization.md | 20 ++++++++++++---- packages/router-core/src/qss.ts | 4 ++-- packages/router-core/src/searchParams.ts | 22 +++++++++--------- packages/router-core/tests/qss.test.ts | 7 ++++++ .../router-core/tests/searchParams.test.ts | 23 ++++++++++++++++++- 6 files changed, 62 insertions(+), 19 deletions(-) create mode 100644 .changeset/search-param-key.md diff --git a/.changeset/search-param-key.md b/.changeset/search-param-key.md new file mode 100644 index 0000000000..af20cde9cb --- /dev/null +++ b/.changeset/search-param-key.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': minor +--- + +Pass the param's key as a second argument to the `parseSearchWith` and `stringifySearchWith` callbacks, so search params can be parsed and serialized per key. diff --git a/docs/router/guide/custom-search-param-serialization.md b/docs/router/guide/custom-search-param-serialization.md index 7e87191a5a..98f024b009 100644 --- a/docs/router/guide/custom-search-param-serialization.md +++ b/docs/router/guide/custom-search-param-serialization.md @@ -20,7 +20,7 @@ It would be serialized and escaped into the following search string: ?page=1&sort=asc&filters=%7B%22author%22%3A%22tanner%22%2C%22min_words%22%3A800%7D ``` -We can implement the default behavior with the following code: +We can override the behavior for specific parameters using the `key` argument passed to each callback (here keeping `q` as a literal string while everything else uses the default `JSON` behavior): @@ -35,8 +35,13 @@ import { const router = createRouter({ // ... - parseSearch: parseSearchWith(JSON.parse), - stringifySearch: stringifySearchWith(JSON.stringify), + parseSearch: parseSearchWith((value, key) => + key === 'q' ? value : JSON.parse(value), + ), + stringifySearch: stringifySearchWith( + (value, key) => (key === 'q' ? String(value) : JSON.stringify(value)), + (value, key) => JSON.parse(value), + ), }) ``` @@ -51,8 +56,13 @@ import { const router = createRouter({ // ... - parseSearch: parseSearchWith(JSON.parse), - stringifySearch: stringifySearchWith(JSON.stringify), + parseSearch: parseSearchWith((value, key) => + key === 'q' ? value : JSON.parse(value), + ), + stringifySearch: stringifySearchWith( + (value, key) => (key === 'q' ? String(value) : JSON.stringify(value)), + (value, key) => JSON.parse(value), + ), }) ``` diff --git a/packages/router-core/src/qss.ts b/packages/router-core/src/qss.ts index a52fd958a1..65b53ae45c 100644 --- a/packages/router-core/src/qss.ts +++ b/packages/router-core/src/qss.ts @@ -24,14 +24,14 @@ */ export function encode( obj: Record, - stringify: (value: any) => string = String, + stringify: (value: any, key: string) => string = String, ): string { const result = new URLSearchParams() for (const key in obj) { const val = obj[key] if (val !== undefined) { - result.set(key, stringify(val)) + result.set(key, stringify(val, key)) } } diff --git a/packages/router-core/src/searchParams.ts b/packages/router-core/src/searchParams.ts index 740d36441c..83313dc4b8 100644 --- a/packages/router-core/src/searchParams.ts +++ b/packages/router-core/src/searchParams.ts @@ -2,11 +2,11 @@ import { decode, encode } from './qss' import type { AnySchema } from './validators' /** Default `parseSearch` that strips leading '?' and JSON-parses values. */ -export const defaultParseSearch = parseSearchWith(JSON.parse) +export const defaultParseSearch = parseSearchWith((value) => JSON.parse(value)) /** Default `stringifySearch` using JSON.stringify for complex values. */ export const defaultStringifySearch = stringifySearchWith( - JSON.stringify, - JSON.parse, + (value) => JSON.stringify(value), + (value) => JSON.parse(value), ) /** @@ -19,7 +19,7 @@ export const defaultStringifySearch = stringifySearchWith( * @returns A `parseSearch` function compatible with `Router` options. * @link https://tanstack.com/router/latest/docs/framework/react/guide/custom-search-param-serialization */ -export function parseSearchWith(parser: (str: string) => any) { +export function parseSearchWith(parser: (str: string, key: string) => any) { return (searchStr: string): AnySchema => { if (searchStr[0] === '?') { searchStr = searchStr.substring(1) @@ -32,7 +32,7 @@ export function parseSearchWith(parser: (str: string) => any) { const value = query[key] if (typeof value === 'string') { try { - query[key] = parser(value) + query[key] = parser(value, key) } catch (_err) { // silent } @@ -56,14 +56,14 @@ export function parseSearchWith(parser: (str: string) => any) { * @link https://tanstack.com/router/latest/docs/framework/react/guide/custom-search-param-serialization */ export function stringifySearchWith( - stringify: (search: any) => string, - parser?: (str: string) => any, + stringify: (search: any, key: string) => string, + parser?: (str: string, key: string) => any, ) { const hasParser = typeof parser === 'function' - function stringifyValue(val: any) { + function stringifyValue(val: any, key: string) { if (typeof val === 'object' && val !== null) { try { - return stringify(val) + return stringify(val, key) } catch (_err) { // silent } @@ -71,8 +71,8 @@ export function stringifySearchWith( try { // Check if it's a valid parseable string. // If it is, then stringify it again. - parser(val) - return stringify(val) + parser(val, key) + return stringify(val, key) } catch (_err) { // silent } diff --git a/packages/router-core/tests/qss.test.ts b/packages/router-core/tests/qss.test.ts index ce92857ab5..415a98ae0c 100644 --- a/packages/router-core/tests/qss.test.ts +++ b/packages/router-core/tests/qss.test.ts @@ -31,6 +31,13 @@ describe('encode function', () => { const queryString = encode(obj) expect(queryString).toEqual('foo%3Dbar=1') }) + + it('should pass the key to the stringify callback', () => { + const queryString = encode({ a: 'x', b: 'x' }, (value, key) => + key === 'a' ? String(value) : JSON.stringify(value), + ) + expect(queryString).toEqual('a=x&b=%22x%22') + }) }) describe('decode function', () => { diff --git a/packages/router-core/tests/searchParams.test.ts b/packages/router-core/tests/searchParams.test.ts index 006e119be5..a2a8b7256a 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 { + defaultParseSearch, + defaultStringifySearch, + parseSearchWith, + stringifySearchWith, +} from '../src' describe('Search Params serialization and deserialization', () => { /* @@ -98,4 +103,20 @@ describe('Search Params serialization and deserialization', () => { '?foo=%222024-11-18T00%3A00%3A00.000Z%22', ) }) + + test('serializes a chosen key differently from the rest', () => { + // Keep `q` as a literal string while everything else uses JSON. + const parse = parseSearchWith((value, key) => + key === 'q' ? value : JSON.parse(value), + ) + const stringify = stringifySearchWith( + (value, key) => (key === 'q' ? String(value) : JSON.stringify(value)), + (value, key) => JSON.parse(value), + ) + + const search = { q: '[1,2,3]', filters: { a: 1 } } + const str = stringify(search) + expect(str).toEqual('?q=%5B1%2C2%2C3%5D&filters=%7B%22a%22%3A1%7D') + expect(parse(str)).toEqual(search) + }) })