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..513b5beff88 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,57 @@ 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 +} + +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 @@ -1300,21 +1353,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, + parseRawSearchValue, + ) 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 +1384,7 @@ export class RouterCore< const url = executeRewriteInput(this.rewrite, fullUrl) const parsedSearch = this.options.parseSearch(url.search) + 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) @@ -1331,19 +1392,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 +1422,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 +1481,7 @@ export class RouterCore< next: ParsedLocation, opts?: MatchRoutesOpts, ): Array { + const rawLocationSearch = getRawSearchFromLocation(next) const matchedRoutesResult = this.getMatchedRoutes(next.pathname) const { foundRoute, routeParams, parsedParams } = matchedRoutesResult let { matchedRoutes } = matchedRoutesResult @@ -1463,11 +1532,19 @@ export class RouterCore< // Validate the search params and stabilize them const parentSearch = parentMatch?.search ?? next.search const parentStrictSearch = parentMatch?._strictSearch ?? undefined + const searchValidationInput = getSearchValidationInput( + route.options.validateSearch, + parentSearch, + rawLocationSearch, + parentStrictSearch, + ) try { const strictSearch = - validateSearch(route.options.validateSearch, { ...parentSearch }) ?? - undefined + validateSearch( + route.options.validateSearch, + searchValidationInput, + ) ?? undefined preMatchSearch = { ...parentSearch, @@ -1721,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 } @@ -1934,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 @@ -3127,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/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..ac9254de240 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 { + Link, + RouterProvider, + 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,113 @@ 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('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()