From 1dd05cbf74073528d4a7e05f441b4f63bee286a6 Mon Sep 17 00:00:00 2001 From: Sandy Chen Date: Tue, 17 Mar 2026 12:56:39 +0900 Subject: [PATCH 1/4] feat: add validateSearchWithRawInput for raw URL search validation and update related documentation --- docs/router/api/router/RouteOptionsType.md | 5 +- docs/router/guide/search-params.md | 18 ++++ packages/react-router/src/index.tsx | 1 + packages/router-core/src/index.ts | 1 + packages/router-core/src/qss.ts | 11 ++- packages/router-core/src/router.ts | 104 ++++++++++++++------ packages/router-core/src/searchValidator.ts | 50 ++++++++++ packages/solid-router/src/index.tsx | 1 + packages/vue-router/src/index.tsx | 1 + packages/zod-adapter/tests/index.test.tsx | 76 ++++++++++++++ 10 files changed, 233 insertions(+), 35 deletions(-) create mode 100644 packages/router-core/src/searchValidator.ts diff --git a/docs/router/api/router/RouteOptionsType.md b/docs/router/api/router/RouteOptionsType.md index 3e575832de9..257d72fc3d3 100644 --- a/docs/router/api/router/RouteOptionsType.md +++ b/docs/router/api/router/RouteOptionsType.md @@ -53,9 +53,10 @@ The `RouteOptions` type accepts an object with the following properties: ### `validateSearch` method -- Type: `(rawSearchParams: unknown) => TSearchSchema` +- Type: `(searchParams: unknown) => TSearchSchema` - Optional -- A function that will be called when this route is matched and passed the raw search params from the current location and return valid parsed search params. If this function throws, the route will be put into an error state and the error will be thrown during render. If this function does not throw, its return value will be used as the route's search params and the return type will be inferred into the rest of the router. +- A function that will be called when this route is matched and passed the parsed-but-not-yet-validated search params from the current location and return valid parsed search params. If this function throws, the route will be put into an error state and the error will be thrown during render. If this function does not throw, its return value will be used as the route's search params and the return type will be inferred into the rest of the router. +- By default, search params have already gone through the router's search parser before `validateSearch` runs. If you need the raw URL string values for validation, wrap your validator with `validateSearchWithRawInput(...)`. - Optionally, the parameter type can be tagged with the `SearchSchemaInput` type like this: `(searchParams: TSearchSchemaInput & SearchSchemaInput) => TSearchSchema`. If this tag is present, `TSearchSchemaInput` will be used to type the `search` property of `` and `navigate()` **instead of** `TSearchSchema`. The difference between `TSearchSchemaInput` and `TSearchSchema` can be useful, for example, to express optional search parameters. ### `search.middlewares` property diff --git a/docs/router/guide/search-params.md b/docs/router/guide/search-params.md index 53e9cd1c8a1..69e1b148903 100644 --- a/docs/router/guide/search-params.md +++ b/docs/router/guide/search-params.md @@ -117,6 +117,24 @@ In the above example, we're validating the search params of the `Route` and retu The `validateSearch` option is a function that is provided the JSON parsed (but non-validated) search params as a `Record` and returns a typed object of your choice. It's usually best to provide sensible fallbacks for malformed or unexpected search params so your users' experience stays non-interrupted. +If you need to validate against the raw URL string values instead of the default parsed search object, wrap the validator with `validateSearchWithRawInput(...)`: + +```tsx +import { + createFileRoute, + validateSearchWithRawInput, +} from '@tanstack/react-router' +import { z } from 'zod' + +export const Route = createFileRoute('/files')({ + validateSearch: validateSearchWithRawInput( + z.object({ + folder: z.string(), + }), + ), +}) +``` + Here's an example: ```tsx title="src/routes/shop/products.tsx" diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index 09b93ad1938..84bfaca1983 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -21,6 +21,7 @@ export { createControlledPromise, retainSearchParams, stripSearchParams, + validateSearchWithRawInput, createSerializationAdapter, } from '@tanstack/router-core' diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index e428852be4c..656df0c4d4b 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -251,6 +251,7 @@ export type { } from './RouterProvider' export { retainSearchParams, stripSearchParams } from './searchMiddleware' +export { validateSearchWithRawInput } from './searchValidator' export { defaultParseSearch, diff --git a/packages/router-core/src/qss.ts b/packages/router-core/src/qss.ts index a52fd958a19..48048d8eb65 100644 --- a/packages/router-core/src/qss.ts +++ b/packages/router-core/src/qss.ts @@ -61,7 +61,10 @@ function toValue(str: unknown) { * // Example input: decode("token=foo&key=value") * // Expected output: { "token": "foo", "key": "value" } */ -export function decode(str: any): any { +export function decode( + str: any, + parser: (value: string) => unknown = toValue, +): any { const searchParams = new URLSearchParams(str) const result: Record = Object.create(null) @@ -69,11 +72,11 @@ export function decode(str: any): any { for (const [key, value] of searchParams.entries()) { const previousValue = result[key] if (previousValue == null) { - result[key] = toValue(value) + result[key] = parser(value) } else if (Array.isArray(previousValue)) { - previousValue.push(toValue(value)) + previousValue.push(parser(value)) } else { - result[key] = [previousValue, toValue(value)] + result[key] = [previousValue, parser(value)] } } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 117a591ca7e..6b64181d00a 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -32,8 +32,10 @@ import { } from './path' import { createLRUCache } from './lru-cache' import { isNotFound } from './not-found' +import { decode } from './qss' import { setupScrollRestoration } from './scroll-restoration' import { defaultParseSearch, defaultStringifySearch } from './searchParams' +import { validatorUsesRawSearchInput } from './searchValidator' import { rootRouteId } from './root' import { isRedirect, redirect } from './redirect' import { loadMatches, loadRouteChunk, routeNeedsPreload } from './load-matches' @@ -114,6 +116,22 @@ export type ControllablePromise = Promise & { export type InjectedHtmlEntry = Promise +type ParsedLocationWithRaw = + ParsedLocation & { + _rawSearch?: Record + } + +function withRawSearch( + location: ParsedLocation, + rawSearch: Record, +): ParsedLocationWithRaw { + return Object.defineProperty(location, '_rawSearch', { + value: rawSearch, + enumerable: false, + configurable: true, + }) as ParsedLocationWithRaw +} + export interface Register { // Lots of things on here like... // router @@ -1300,21 +1318,28 @@ export class RouterCore< // eslint-disable-next-line no-control-regex if (!this.rewrite && !/[ \x00-\x1f\x7f\u0080-\uffff]/.test(pathname)) { const parsedSearch = this.options.parseSearch(search) + const rawSearch = decode( + search[0] === '?' ? search.substring(1) : search, + (value) => value, + ) const searchStr = this.options.stringifySearch(parsedSearch) - return { - href: pathname + searchStr + hash, - publicHref: href, - pathname: decodePath(pathname).path, - external: false, - searchStr, - search: nullReplaceEqualDeep( - previousLocation?.search, - parsedSearch, - ) as any, - hash: decodePath(hash.slice(1)).path, - state: replaceEqualDeep(previousLocation?.state, state), - } + return withRawSearch( + { + href: pathname + searchStr + hash, + publicHref: href, + pathname: decodePath(pathname).path, + external: false, + searchStr, + search: nullReplaceEqualDeep( + previousLocation?.search, + parsedSearch, + ) as any, + hash: decodePath(hash.slice(1)).path, + state: replaceEqualDeep(previousLocation?.state, state), + }, + rawSearch, + ) } // Before we do any processing, we need to allow rewrites to modify the URL @@ -1324,6 +1349,7 @@ export class RouterCore< const url = executeRewriteInput(this.rewrite, fullUrl) const parsedSearch = this.options.parseSearch(url.search) + const rawSearch = decode(url.search, (value) => value) const searchStr = this.options.stringifySearch(parsedSearch) // Make sure our final url uses the re-stringified pathname, search, and has for consistency // (We were already doing this, so just keeping it for now) @@ -1331,19 +1357,22 @@ export class RouterCore< const fullPath = url.href.replace(url.origin, '') - return { - href: fullPath, - publicHref: href, - pathname: decodePath(url.pathname).path, - external: !!this.rewrite && url.origin !== this.origin, - searchStr, - search: nullReplaceEqualDeep( - previousLocation?.search, - parsedSearch, - ) as any, - hash: decodePath(url.hash.slice(1)).path, - state: replaceEqualDeep(previousLocation?.state, state), - } + return withRawSearch( + { + href: fullPath, + publicHref: href, + pathname: decodePath(url.pathname).path, + external: !!this.rewrite && url.origin !== this.origin, + searchStr, + search: nullReplaceEqualDeep( + previousLocation?.search, + parsedSearch, + ) as any, + hash: decodePath(url.hash.slice(1)).path, + state: replaceEqualDeep(previousLocation?.state, state), + }, + rawSearch, + ) } const location = parse(locationToParse) @@ -1358,10 +1387,14 @@ export class RouterCore< delete parsedTempLocation.state.__tempLocation - return { + // Re-attach _rawSearch after spread (non-enumerable props are lost + // by the spread operator) + const merged = { ...parsedTempLocation, maskedLocation: location, } + const tempRaw = (parsedTempLocation as ParsedLocationWithRaw)._rawSearch + return tempRaw ? withRawSearch(merged, tempRaw) : merged } return location } @@ -1413,6 +1446,9 @@ export class RouterCore< next: ParsedLocation, opts?: MatchRoutesOpts, ): Array { + const rawLocationSearch = + (next as ParsedLocation & { _rawSearch?: Record }) + ._rawSearch ?? next.search const matchedRoutesResult = this.getMatchedRoutes(next.pathname) const { foundRoute, routeParams, parsedParams } = matchedRoutesResult let { matchedRoutes } = matchedRoutesResult @@ -1463,11 +1499,21 @@ export class RouterCore< // Validate the search params and stabilize them const parentSearch = parentMatch?.search ?? next.search const parentStrictSearch = parentMatch?._strictSearch ?? undefined + const searchValidationInput = validatorUsesRawSearchInput( + route.options.validateSearch, + ) + ? { + ...rawLocationSearch, + ...parentStrictSearch, + } + : { ...parentSearch } try { const strictSearch = - validateSearch(route.options.validateSearch, { ...parentSearch }) ?? - undefined + validateSearch( + route.options.validateSearch, + searchValidationInput, + ) ?? undefined preMatchSearch = { ...parentSearch, diff --git a/packages/router-core/src/searchValidator.ts b/packages/router-core/src/searchValidator.ts new file mode 100644 index 00000000000..650f67ae8a5 --- /dev/null +++ b/packages/router-core/src/searchValidator.ts @@ -0,0 +1,50 @@ +import type { AnyValidator } from './validators' + +const rawSearchInputMarker = Symbol('tanstack.router.rawSearchInput') + +type RawSearchInputMarked = { + [rawSearchInputMarker]: true +} + +/** + * Marks a search validator so route matching passes raw URL search values + * instead of the default parsed/coerced search object. + */ +export function validateSearchWithRawInput< + TValidator extends Exclude, +>(validator: TValidator): TValidator { + if ('~standard' in validator) { + return { + '~standard': validator['~standard'], + [rawSearchInputMarker]: true, + } as unknown as TValidator + } + + if ('parse' in validator) { + const wrapped: Record = { + parse: (input: unknown) => validator.parse(input), + [rawSearchInputMarker]: true, + } + + if ('types' in validator) { + wrapped.types = validator.types + } + + return wrapped as unknown as TValidator + } + + const wrapped = ((input: unknown) => + validator(input as never)) as TValidator & RawSearchInputMarked + wrapped[rawSearchInputMarker] = true + return wrapped +} + +export function validatorUsesRawSearchInput( + validator: AnyValidator, +): validator is Exclude & RawSearchInputMarked { + return Boolean( + validator && + (typeof validator === 'object' || typeof validator === 'function') && + rawSearchInputMarker in validator, + ) +} diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index 31be74086fd..ebaae49ce31 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -21,6 +21,7 @@ export { createControlledPromise, retainSearchParams, stripSearchParams, + validateSearchWithRawInput, createSerializationAdapter, } from '@tanstack/router-core' diff --git a/packages/vue-router/src/index.tsx b/packages/vue-router/src/index.tsx index 2e14e349f06..b50dd6bcd86 100644 --- a/packages/vue-router/src/index.tsx +++ b/packages/vue-router/src/index.tsx @@ -21,6 +21,7 @@ export { createControlledPromise, retainSearchParams, stripSearchParams, + validateSearchWithRawInput, createSerializationAdapter, } from '@tanstack/router-core' diff --git a/packages/zod-adapter/tests/index.test.tsx b/packages/zod-adapter/tests/index.test.tsx index d59ec207fc8..3bd69a2a95f 100644 --- a/packages/zod-adapter/tests/index.test.tsx +++ b/packages/zod-adapter/tests/index.test.tsx @@ -2,11 +2,13 @@ import { afterEach, expect, test, vi } from 'vitest' import { zodValidator } from '../src' import { z } from 'zod' import { + createMemoryHistory, createRootRoute, createRoute, createRouter, Link, RouterProvider, + validateSearchWithRawInput, } from '@tanstack/react-router' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import '@testing-library/jest-dom/vitest' @@ -78,6 +80,80 @@ test('when navigating to a route with zodValidator', async () => { expect(await screen.findByText('Page: 0')).toBeInTheDocument() }) +test('raw URL number search params still use the default parsed input', async () => { + const rootRoute = createRootRoute() + + const Invoices = () => { + const search = invoicesRoute.useSearch() + + return ( + <> +

Invoices

+ Page: {search.page} + + ) + } + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateSearch: z.object({ + page: z.number(), + }), + component: Invoices, + }) + + const routeTree = rootRoute.addChildren([invoicesRoute]) + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/invoices?page=0'] }), + }) + + render() + + expect(await screen.findByText('Page: 0')).toBeInTheDocument() +}) + +test('validateSearchWithRawInput preserves numeric-looking strings from the URL', async () => { + const rootRoute = createRootRoute() + + const Files = () => { + const search = filesRoute.useSearch() + + return ( + <> +

Files

+ Folder: {search.folder} + + ) + } + + const filesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'files', + validateSearch: validateSearchWithRawInput( + z.object({ + folder: z.string(), + }), + ), + component: Files, + }) + + const routeTree = rootRoute.addChildren([filesRoute]) + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/files?folder=34324324235325352523'], + }), + }) + + render() + + expect( + await screen.findByText('Folder: 34324324235325352523'), + ).toBeInTheDocument() +}) + test('when navigating to a route with zodValidator input set to output', async () => { const rootRoute = createRootRoute() From e0f00005dabe8dd885f52898d5eddd9fd7c0572f Mon Sep 17 00:00:00 2001 From: Sandy Chen Date: Tue, 17 Mar 2026 14:18:10 +0900 Subject: [PATCH 2/4] feat: add tests for buildLocation to preserve raw string search values with search=true --- packages/router-core/src/router.ts | 94 ++++++++++++++++++----- packages/zod-adapter/tests/index.test.tsx | 33 ++++++++ 2 files changed, 107 insertions(+), 20 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 6b64181d00a..513b5beff88 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -132,6 +132,41 @@ function withRawSearch( }) as ParsedLocationWithRaw } +function parseRawSearchValue(value: string) { + if (value[0] === '"' && value[value.length - 1] === '"') { + try { + const parsed = JSON.parse(value) + if (typeof parsed === 'string') { + return parsed + } + } catch { + // fall through to the raw decoded value + } + } + + return value +} + +function getRawSearchFromLocation( + location: ParsedLocation, +): Record { + return (location as ParsedLocationWithRaw)._rawSearch ?? location.search +} + +function getSearchValidationInput( + validateSearch: AnyValidator, + search: Record, + rawSearch: Record, + parentStrictSearch?: Record, +) { + return validatorUsesRawSearchInput(validateSearch) + ? { + ...rawSearch, + ...parentStrictSearch, + } + : { ...search } +} + export interface Register { // Lots of things on here like... // router @@ -1320,7 +1355,7 @@ export class RouterCore< const parsedSearch = this.options.parseSearch(search) const rawSearch = decode( search[0] === '?' ? search.substring(1) : search, - (value) => value, + parseRawSearchValue, ) const searchStr = this.options.stringifySearch(parsedSearch) @@ -1349,7 +1384,7 @@ export class RouterCore< const url = executeRewriteInput(this.rewrite, fullUrl) const parsedSearch = this.options.parseSearch(url.search) - const rawSearch = decode(url.search, (value) => value) + const rawSearch = decode(url.search, parseRawSearchValue) const searchStr = this.options.stringifySearch(parsedSearch) // Make sure our final url uses the re-stringified pathname, search, and has for consistency // (We were already doing this, so just keeping it for now) @@ -1446,9 +1481,7 @@ export class RouterCore< next: ParsedLocation, opts?: MatchRoutesOpts, ): Array { - const rawLocationSearch = - (next as ParsedLocation & { _rawSearch?: Record }) - ._rawSearch ?? next.search + const rawLocationSearch = getRawSearchFromLocation(next) const matchedRoutesResult = this.getMatchedRoutes(next.pathname) const { foundRoute, routeParams, parsedParams } = matchedRoutesResult let { matchedRoutes } = matchedRoutesResult @@ -1499,14 +1532,12 @@ export class RouterCore< // Validate the search params and stabilize them const parentSearch = parentMatch?.search ?? next.search const parentStrictSearch = parentMatch?._strictSearch ?? undefined - const searchValidationInput = validatorUsesRawSearchInput( + const searchValidationInput = getSearchValidationInput( route.options.validateSearch, + parentSearch, + rawLocationSearch, + parentStrictSearch, ) - ? { - ...rawLocationSearch, - ...parentStrictSearch, - } - : { ...parentSearch } try { const strictSearch = @@ -1767,13 +1798,22 @@ export class RouterCore< // }) // Accumulate search validation through route chain + const rawLocationSearch = getRawSearchFromLocation(location) const accumulatedSearch = { ...location.search } + const accumulatedStrictSearch: Record = {} for (const route of matchedRoutes) { try { - Object.assign( - accumulatedSearch, - validateSearch(route.options.validateSearch, accumulatedSearch), + const strictSearch = validateSearch( + route.options.validateSearch, + getSearchValidationInput( + route.options.validateSearch, + accumulatedSearch, + rawLocationSearch, + accumulatedStrictSearch, + ), ) + Object.assign(accumulatedSearch, strictSearch) + Object.assign(accumulatedStrictSearch, strictSearch) } catch { // Ignore errors, we're not actually routing } @@ -1980,10 +2020,18 @@ export class RouterCore< try { Object.assign( validatedSearch, - validateSearch(route.options.validateSearch, { - ...validatedSearch, - ...nextSearch, - }), + validateSearch( + route.options.validateSearch, + getSearchValidationInput( + route.options.validateSearch, + { + ...validatedSearch, + ...nextSearch, + }, + nextSearch, + validatedSearch, + ), + ), ) } catch { // ignore errors here because they are already handled in matchRoutes @@ -3173,8 +3221,14 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray) { try { const validatedSearch = { ...result, - ...(validateSearch(route.options.validateSearch, result) ?? - undefined), + ...(validateSearch( + route.options.validateSearch, + getSearchValidationInput( + route.options.validateSearch, + result, + result, + ), + ) ?? undefined), } return validatedSearch } catch { diff --git a/packages/zod-adapter/tests/index.test.tsx b/packages/zod-adapter/tests/index.test.tsx index 3bd69a2a95f..8a7e26b8584 100644 --- a/packages/zod-adapter/tests/index.test.tsx +++ b/packages/zod-adapter/tests/index.test.tsx @@ -154,6 +154,39 @@ test('validateSearchWithRawInput preserves numeric-looking strings from the URL' ).toBeInTheDocument() }) +test('buildLocation with search=true preserves raw string search values', async () => { + const rootRoute = createRootRoute() + + const filesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'files', + validateSearch: validateSearchWithRawInput( + z.object({ + folder: z.string(), + }), + ), + }) + + const routeTree = rootRoute.addChildren([filesRoute]) + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/files?folder=34324324235325352523'], + }), + }) + + await router.load() + + const nextLocation = router.buildLocation({ + to: '.', + search: true, + }) + + expect(nextLocation.search).toEqual({ + folder: '34324324235325352523', + }) +}) + test('when navigating to a route with zodValidator input set to output', async () => { const rootRoute = createRootRoute() From 5f4157477fd90c7381aafd599d34de68f96604c0 Mon Sep 17 00:00:00 2001 From: Sandy Chen Date: Tue, 17 Mar 2026 14:29:31 +0900 Subject: [PATCH 3/4] fix: reorder imports for consistency in index.test.tsx --- packages/zod-adapter/tests/index.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/zod-adapter/tests/index.test.tsx b/packages/zod-adapter/tests/index.test.tsx index 8a7e26b8584..ac9254de240 100644 --- a/packages/zod-adapter/tests/index.test.tsx +++ b/packages/zod-adapter/tests/index.test.tsx @@ -2,12 +2,12 @@ import { afterEach, expect, test, vi } from 'vitest' import { zodValidator } from '../src' import { z } from 'zod' import { + Link, + RouterProvider, createMemoryHistory, createRootRoute, createRoute, createRouter, - Link, - RouterProvider, validateSearchWithRawInput, } from '@tanstack/react-router' import { cleanup, fireEvent, render, screen } from '@testing-library/react' From a176e42ac38fffcb1c8b88d05f815ce542b26d55 Mon Sep 17 00:00:00 2001 From: Sandy Chen Date: Fri, 20 Mar 2026 00:58:57 +0900 Subject: [PATCH 4/4] feat: add tests for defaultRawSearchInput to validate numeric-looking strings handling --- packages/router-core/src/router.ts | 27 ++++++++- packages/zod-adapter/tests/index.test.tsx | 73 +++++++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 513b5beff88..339b322c8af 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -158,8 +158,9 @@ function getSearchValidationInput( search: Record, rawSearch: Record, parentStrictSearch?: Record, + defaultRawSearchInput?: boolean, ) { - return validatorUsesRawSearchInput(validateSearch) + return validatorUsesRawSearchInput(validateSearch) || defaultRawSearchInput ? { ...rawSearch, ...parentStrictSearch, @@ -240,6 +241,14 @@ export interface RouterOptions< * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/custom-search-param-serialization) */ parseSearch?: SearchParser + /** + * When `true`, all route validators receive raw URL search strings instead of + * the default parsed/coerced values. Individual routes can still override this + * via `validateSearchWithRawInput`. + * + * @default false + */ + defaultRawSearchInput?: boolean /** * If `false`, routes will not be preloaded by default in any way. * @@ -1537,6 +1546,7 @@ export class RouterCore< parentSearch, rawLocationSearch, parentStrictSearch, + this.options.defaultRawSearchInput, ) try { @@ -1810,6 +1820,7 @@ export class RouterCore< accumulatedSearch, rawLocationSearch, accumulatedStrictSearch, + this.options.defaultRawSearchInput, ), ) Object.assign(accumulatedSearch, strictSearch) @@ -2030,6 +2041,7 @@ export class RouterCore< }, nextSearch, validatedSearch, + this.options.defaultRawSearchInput, ), ), ) @@ -2046,6 +2058,7 @@ export class RouterCore< dest, destRoutes, _includeValidateSearch: opts._includeValidateSearch, + defaultRawSearchInput: this.options.defaultRawSearchInput, }) // Replace the equal deep @@ -3156,20 +3169,26 @@ function applySearchMiddleware({ dest, destRoutes, _includeValidateSearch, + defaultRawSearchInput, }: { search: any dest: { search?: unknown } destRoutes: ReadonlyArray _includeValidateSearch: boolean | undefined + defaultRawSearchInput?: boolean }) { - const middleware = buildMiddlewareChain(destRoutes) + const middleware = buildMiddlewareChain(destRoutes, defaultRawSearchInput) return middleware(search, dest, _includeValidateSearch ?? false) } -function buildMiddlewareChain(destRoutes: ReadonlyArray) { +function buildMiddlewareChain( + destRoutes: ReadonlyArray, + defaultRawSearchInput?: boolean, +) { const context = { dest: null as unknown as BuildNextOptions, _includeValidateSearch: false, + defaultRawSearchInput, middlewares: [] as Array>, } @@ -3227,6 +3246,8 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray) { route.options.validateSearch, result, result, + undefined, + context.defaultRawSearchInput, ), ) ?? undefined), } diff --git a/packages/zod-adapter/tests/index.test.tsx b/packages/zod-adapter/tests/index.test.tsx index ac9254de240..86a8cf84155 100644 --- a/packages/zod-adapter/tests/index.test.tsx +++ b/packages/zod-adapter/tests/index.test.tsx @@ -187,6 +187,79 @@ test('buildLocation with search=true preserves raw string search values', async }) }) +test('defaultRawSearchInput on router preserves numeric-looking strings globally', async () => { + const rootRoute = createRootRoute() + + const Files = () => { + const search = filesRoute.useSearch() + + return ( + <> +

Files

+ Folder: {search.folder} + + ) + } + + const filesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'files', + validateSearch: z.object({ + folder: z.string(), + }), + component: Files, + }) + + const routeTree = rootRoute.addChildren([filesRoute]) + const router = createRouter({ + routeTree, + defaultRawSearchInput: true, + history: createMemoryHistory({ + initialEntries: ['/files?folder=34324324235325352523'], + }), + }) + + render() + + expect( + await screen.findByText('Folder: 34324324235325352523'), + ).toBeInTheDocument() +}) + +test('defaultRawSearchInput does not affect routes using validateSearchWithRawInput', async () => { + const rootRoute = createRootRoute() + + const filesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'files', + validateSearch: validateSearchWithRawInput( + z.object({ + folder: z.string(), + }), + ), + }) + + const routeTree = rootRoute.addChildren([filesRoute]) + const router = createRouter({ + routeTree, + defaultRawSearchInput: true, + history: createMemoryHistory({ + initialEntries: ['/files?folder=34324324235325352523'], + }), + }) + + await router.load() + + const nextLocation = router.buildLocation({ + to: '.', + search: true, + }) + + expect(nextLocation.search).toEqual({ + folder: '34324324235325352523', + }) +}) + test('when navigating to a route with zodValidator input set to output', async () => { const rootRoute = createRootRoute()