Skip to content
Open
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: 3 additions & 2 deletions docs/router/api/router/RouteOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Link />` and `navigate()` **instead of** `TSearchSchema`. The difference between `TSearchSchemaInput` and `TSearchSchema` can be useful, for example, to express optional search parameters.

### `search.middlewares` property
Expand Down
18 changes: 18 additions & 0 deletions docs/router/guide/search-params.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>` 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"
Expand Down
1 change: 1 addition & 0 deletions packages/react-router/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export {
createControlledPromise,
retainSearchParams,
stripSearchParams,
validateSearchWithRawInput,
createSerializationAdapter,
} from '@tanstack/router-core'

Expand Down
1 change: 1 addition & 0 deletions packages/router-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ export type {
} from './RouterProvider'

export { retainSearchParams, stripSearchParams } from './searchMiddleware'
export { validateSearchWithRawInput } from './searchValidator'

export {
defaultParseSearch,
Expand Down
11 changes: 7 additions & 4 deletions packages/router-core/src/qss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,22 @@ 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<string, unknown> = Object.create(null)

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)]
}
}

Expand Down
176 changes: 138 additions & 38 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -114,6 +116,57 @@ export type ControllablePromise<T = any> = Promise<T> & {

export type InjectedHtmlEntry = Promise<string>

type ParsedLocationWithRaw<TSearchObj extends AnySchema = AnySchema> =
ParsedLocation<TSearchObj> & {
_rawSearch?: Record<string, unknown>
}

function withRawSearch<TSearchObj extends AnySchema>(
location: ParsedLocation<TSearchObj>,
rawSearch: Record<string, unknown>,
): ParsedLocationWithRaw<TSearchObj> {
return Object.defineProperty(location, '_rawSearch', {
value: rawSearch,
enumerable: false,
configurable: true,
}) as ParsedLocationWithRaw<TSearchObj>
}

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<string, unknown> {
return (location as ParsedLocationWithRaw)._rawSearch ?? location.search
}

function getSearchValidationInput(
validateSearch: AnyValidator,
search: Record<string, unknown>,
rawSearch: Record<string, unknown>,
parentStrictSearch?: Record<string, unknown>,
) {
return validatorUsesRawSearchInput(validateSearch)
? {
...rawSearch,
...parentStrictSearch,
}
: { ...search }
}

export interface Register {
// Lots of things on here like...
// router
Expand Down Expand Up @@ -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
Expand All @@ -1324,26 +1384,30 @@ 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)
url.search = searchStr

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)
Expand All @@ -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
}
Expand Down Expand Up @@ -1413,6 +1481,7 @@ export class RouterCore<
next: ParsedLocation,
opts?: MatchRoutesOpts,
): Array<AnyRouteMatch> {
const rawLocationSearch = getRawSearchFromLocation(next)
const matchedRoutesResult = this.getMatchedRoutes(next.pathname)
const { foundRoute, routeParams, parsedParams } = matchedRoutesResult
let { matchedRoutes } = matchedRoutesResult
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1721,13 +1798,22 @@ export class RouterCore<
// })

// Accumulate search validation through route chain
const rawLocationSearch = getRawSearchFromLocation(location)
const accumulatedSearch = { ...location.search }
const accumulatedStrictSearch: Record<string, unknown> = {}
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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -3127,8 +3221,14 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray<AnyRoute>) {
try {
const validatedSearch = {
...result,
...(validateSearch(route.options.validateSearch, result) ??
undefined),
...(validateSearch(
route.options.validateSearch,
getSearchValidationInput(
route.options.validateSearch,
result,
result,
),
) ?? undefined),
}
return validatedSearch
} catch {
Expand Down
50 changes: 50 additions & 0 deletions packages/router-core/src/searchValidator.ts
Original file line number Diff line number Diff line change
@@ -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<AnyValidator, undefined>,
>(validator: TValidator): TValidator {
if ('~standard' in validator) {
return {
'~standard': validator['~standard'],
[rawSearchInputMarker]: true,
} as unknown as TValidator
}

if ('parse' in validator) {
const wrapped: Record<string, unknown> = {
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<AnyValidator, undefined> & RawSearchInputMarked {
return Boolean(
validator &&
(typeof validator === 'object' || typeof validator === 'function') &&
rawSearchInputMarker in validator,
)
}
Loading