diff --git a/docs/api/router/RouteOptionsType.md b/docs/api/router/RouteOptionsType.md index 2154c2bae5c..7dd13cd7cdc 100644 --- a/docs/api/router/RouteOptionsType.md +++ b/docs/api/router/RouteOptionsType.md @@ -31,6 +31,7 @@ The `RouteOptions` type is used to describe the options that can be used when cr - Type: `(rawSearchParams: 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. +- 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. #### `parseParams` diff --git a/docs/api/router/SearchParamOptionsType.md b/docs/api/router/SearchParamOptionsType.md index 8bcc7a5cb8c..cefe40b3f0a 100644 --- a/docs/api/router/SearchParamOptionsType.md +++ b/docs/api/router/SearchParamOptionsType.md @@ -10,7 +10,7 @@ The `SearchParamOptions` type is used to describe how search params can be provi type SearchParamOptions = { search?: | true - | Record + | TToSearch | ((prev: TFromSearch) => TToSearch) } ``` diff --git a/docs/api/router/SearchSchemaInputType.md b/docs/api/router/SearchSchemaInputType.md new file mode 100644 index 00000000000..1cc1b01a09c --- /dev/null +++ b/docs/api/router/SearchSchemaInputType.md @@ -0,0 +1,7 @@ +--- +id: SearchSchemaInputType +title: `SearchSchemaInput` type +--- + + +The `SearchSchemaInput` type is used to tag the input type of a `validateSearch` method to signalize to TanStack router that its parameter type `TSearchSchemaInput` shall be used to type the search param of `` and `navigate()`. \ No newline at end of file diff --git a/examples/react/basic-default-search-params/.gitignore b/examples/react/basic-default-search-params/.gitignore new file mode 100644 index 00000000000..d451ff16c10 --- /dev/null +++ b/examples/react/basic-default-search-params/.gitignore @@ -0,0 +1,5 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local diff --git a/examples/react/basic-default-search-params/README.md b/examples/react/basic-default-search-params/README.md new file mode 100644 index 00000000000..115199d292c --- /dev/null +++ b/examples/react/basic-default-search-params/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` or `yarn` +- `npm start` or `yarn start` diff --git a/examples/react/basic-default-search-params/index.html b/examples/react/basic-default-search-params/index.html new file mode 100644 index 00000000000..2e8ce205fc4 --- /dev/null +++ b/examples/react/basic-default-search-params/index.html @@ -0,0 +1,13 @@ + + + + + + Vite App + + + +
+ + + diff --git a/examples/react/basic-default-search-params/package.json b/examples/react/basic-default-search-params/package.json new file mode 100644 index 00000000000..6ad42014237 --- /dev/null +++ b/examples/react/basic-default-search-params/package.json @@ -0,0 +1,25 @@ +{ + "name": "tanstack-router-react-example-basic-default-search-params", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "vite --port=3001", + "build": "vite build", + "serve": "vite preview", + "start": "vite" + }, + "dependencies": { + "@tanstack/react-query": "^5.4.3", + "@tanstack/react-router": "1.0.8", + "@tanstack/router-devtools": "1.0.8", + "@vitejs/plugin-react": "^1.1.3", + "axios": "^1.1.3", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "vite": "^2.8.6" + }, + "devDependencies": { + "@types/react": "^18.2.41", + "@types/react-dom": "^18.2.17" + } +} diff --git a/examples/react/basic-default-search-params/src/main.tsx b/examples/react/basic-default-search-params/src/main.tsx new file mode 100644 index 00000000000..b79a6398301 --- /dev/null +++ b/examples/react/basic-default-search-params/src/main.tsx @@ -0,0 +1,224 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { + Outlet, + RouterProvider, + Link, + Route, + ErrorComponent, + Router, + RootRoute, + ErrorRouteProps, + NotFoundRoute, + SearchSchemaInput, +} from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/router-devtools' +import axios from 'axios' +import { z } from 'zod' + +type PostType = { + id: number + title: string + body: string +} + +const fetchPosts = async () => { + console.log('Fetching posts...') + await new Promise((r) => setTimeout(r, 300)) + return axios + .get('https://jsonplaceholder.typicode.com/posts') + .then((r) => r.data.slice(0, 10)) +} + +const fetchPost = async (postId: number) => { + console.log(`Fetching post with id ${postId}...`) + await new Promise((r) => setTimeout(r, 300)) + const post = await axios + .get(`https://jsonplaceholder.typicode.com/posts/${postId}`) + .catch((err) => { + if (err.response.status === 404) { + throw new NotFoundError(`Post with id "${postId}" not found!`) + } + throw err + }) + .then((r) => r.data) + + return post +} + +const rootRoute = new RootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return ( +
+
+ + Home + {' '} + + Posts + +
+ + +
+ ) +} +const indexRoute = new Route({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, +}) + +function IndexComponent() { + return ( +
+

Welcome Home!

+
+ ) +} + +const postsRoute = new Route({ + getParentRoute: () => rootRoute, + path: 'posts', + loader: () => fetchPosts(), + component: PostsComponent, +}) + +function PostsComponent() { + const posts = postsRoute.useLoaderData() + + return ( +
+
+ {posts.map((post, index) => { + return ( +
+ +
{post.title.substring(0, 20)}
+ +
+ ) + })} +
+ +
+ ) +} + +const postsIndexRoute = new Route({ + getParentRoute: () => postsRoute, + path: '/', + component: PostsIndexComponent, +}) + +function PostsIndexComponent() { + return
Select a post.
+} + +class NotFoundError extends Error {} + +const postRoute = new Route({ + getParentRoute: () => postsRoute, + path: 'post', + validateSearch: ( + input: { + postId: number + color?: 'white' | 'red' | 'green' + } & SearchSchemaInput, + ) => + z + .object({ + postId: z.number().catch(1), + color: z.enum(['white', 'red', 'green']).catch('white'), + }) + .parse(input), + loaderDeps: ({ search: { postId } }) => ({ + postId, + }), + errorComponent: PostErrorComponent, + loader: ({ deps: { postId } }) => fetchPost(postId), + component: PostComponent, +}) + +function PostErrorComponent({ error }: ErrorRouteProps) { + if (error instanceof NotFoundError) { + return
{error.message}
+ } + + return +} + +function PostComponent() { + const post = postRoute.useLoaderData() + const { color } = postRoute.useSearch() + return ( +
+

{post.title}

+
+
{post.body}
+
+ ) +} + +const notFoundRoute = new NotFoundRoute({ + getParentRoute: () => rootRoute, + component: NotFound, +}) + +function NotFound() { + return ( +
+

404 - Not Found

+
+ ) +} + +const routeTree = rootRoute.addChildren([ + postsRoute.addChildren([postRoute, postsIndexRoute]), + indexRoute, +]) + +// Set up a Router instance +const router = new Router({ + routeTree, + notFoundRoute, + defaultPreload: 'intent', + defaultStaleTime: 5000, +}) + +// Register things for typesafety +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +const rootElement = document.getElementById('app')! + +if (!rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement) + + root.render() +} diff --git a/examples/react/basic-default-search-params/tsconfig.dev.json b/examples/react/basic-default-search-params/tsconfig.dev.json new file mode 100644 index 00000000000..c09bc865f06 --- /dev/null +++ b/examples/react/basic-default-search-params/tsconfig.dev.json @@ -0,0 +1,12 @@ +{ + "composite": true, + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./build/types" + }, + "files": ["src/main.tsx"], + "include": [ + "src" + // "__tests__/**/*.test.*" + ] +} diff --git a/examples/react/basic-default-search-params/tsconfig.json b/examples/react/basic-default-search-params/tsconfig.json new file mode 100644 index 00000000000..0453a66fb9d --- /dev/null +++ b/examples/react/basic-default-search-params/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react" + } +} diff --git a/examples/react/basic-default-search-params/vite.config.js b/examples/react/basic-default-search-params/vite.config.js new file mode 100644 index 00000000000..5a33944a9b4 --- /dev/null +++ b/examples/react/basic-default-search-params/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/react-router/src/fileRoute.ts b/packages/react-router/src/fileRoute.ts index a1cec00287e..c7008c0cb68 100644 --- a/packages/react-router/src/fileRoute.ts +++ b/packages/react-router/src/fileRoute.ts @@ -13,6 +13,8 @@ import { RootRouteId, TrimPathLeft, RouteConstraints, + ResolveFullSearchSchemaInput, + SearchSchemaInput, } from './route' import { Assign, Expand, IsAny } from './utils' @@ -85,7 +87,19 @@ export class FileRoute< constructor(public path: TFilePath) {} createRoute = < + TSearchSchemaInput extends RouteConstraints['TSearchSchema'] = {}, TSearchSchema extends RouteConstraints['TSearchSchema'] = {}, + TSearchSchemaUsed extends Record< + string, + any + > = TSearchSchemaInput extends SearchSchemaInput + ? TSearchSchemaInput + : TSearchSchema, + TFullSearchSchemaInput extends + RouteConstraints['TFullSearchSchema'] = ResolveFullSearchSchemaInput< + TParentRoute, + TSearchSchemaUsed + >, TFullSearchSchema extends RouteConstraints['TFullSearchSchema'] = ResolveFullSearchSchema< TParentRoute, @@ -115,7 +129,10 @@ export class FileRoute< TParentRoute, string, TPath, + TSearchSchemaInput, TSearchSchema, + TSearchSchemaUsed, + TFullSearchSchemaInput, TFullSearchSchema, TParams, TAllParams, @@ -133,7 +150,10 @@ export class FileRoute< TFullPath, TFilePath, TId, + TSearchSchemaInput, TSearchSchema, + TSearchSchemaUsed, + TFullSearchSchemaInput, TFullSearchSchema, TParams, TAllParams, diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 7afb724f1f1..61551682ea9 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -167,18 +167,21 @@ export type ToSubOptions< type ParamsReducer = TTo | ((current: TFrom) => TTo) +type ParamVariant = 'PATH' | 'SEARCH'; export type ParamOptions< TRouteTree extends AnyRoute, TFrom, TTo, TResolved, - TParamVariant extends 'allParams' | 'fullSearchSchema', - TFromParams = Expand['types'][TParamVariant]>, + TParamVariant extends ParamVariant, + TFromRouteType extends 'allParams' | 'fullSearchSchema' = TParamVariant extends 'PATH' ? 'allParams' : 'fullSearchSchema', + TToRouteType extends 'allParams' | 'fullSearchSchemaInput' = TParamVariant extends 'PATH' ? 'allParams' : 'fullSearchSchemaInput', + TFromParams = Expand['types'][TFromRouteType]>, TToParams = TTo extends '' ? TFromParams : never extends TResolved - ? Expand['types'][TParamVariant]> - : Expand['types'][TParamVariant]>, + ? Expand['types'][TToRouteType]> + : Expand['types'][TToRouteType]>, TReducer = ParamsReducer, > = Expand>> extends never ? Partial> @@ -187,9 +190,9 @@ export type ParamOptions< : MakeParamOption type MakeParamOption< - TParamVariant extends 'allParams' | 'fullSearchSchema', + TParamVariant extends ParamVariant, T, -> = TParamVariant extends 'allParams' +> = TParamVariant extends 'PATH' ? MakePathParamOptions : MakeSearchParamOptions type MakeSearchParamOptions = { search: T } @@ -200,14 +203,14 @@ export type SearchParamOptions< TFrom, TTo, TResolved, -> = ParamOptions +> = ParamOptions export type PathParamOptions< TRouteTree extends AnyRoute, TFrom, TTo, TResolved, -> = ParamOptions +> = ParamOptions export type ToPathOption< TRouteTree extends AnyRoute = AnyRoute, diff --git a/packages/react-router/src/route.ts b/packages/react-router/src/route.ts index 4413a8f383e..8b28a7cb051 100644 --- a/packages/react-router/src/route.ts +++ b/packages/react-router/src/route.ts @@ -23,6 +23,10 @@ export const rootRouteId = '__root__' as const export type RootRouteId = typeof rootRouteId export type AnyPathParams = {} +export type SearchSchemaInput = { + __TSearchSchemaInput__: 'TSearchSchemaInput' +} + export type AnySearchSchema = {} export type AnyContext = {} @@ -56,7 +60,10 @@ export type RouteOptions< TParentRoute extends AnyRoute = AnyRoute, TCustomId extends string = string, TPath extends string = string, + TSearchSchemaInput extends Record = {}, TSearchSchema extends Record = {}, + TSearchSchemaUsed extends Record = {}, + TFullSearchSchemaInput extends Record = TSearchSchemaUsed, TFullSearchSchema extends Record = TSearchSchema, TParams extends AnyPathParams = AnyPathParams, TAllParams extends AnyPathParams = TParams, @@ -68,7 +75,10 @@ export type RouteOptions< TParentRoute, TCustomId, TPath, + TSearchSchemaInput, TSearchSchema, + TSearchSchemaUsed, + TFullSearchSchemaInput, TFullSearchSchema, TParams, TAllParams, @@ -88,7 +98,10 @@ export type BaseRouteOptions< TParentRoute extends AnyRoute = AnyRoute, TCustomId extends string = string, TPath extends string = string, + TSearchSchemaInput extends Record = {}, TSearchSchema extends Record = {}, + TSearchSchemaUsed extends Record = {}, + TFullSearchSchemaInput extends Record = TSearchSchemaUsed, TFullSearchSchema extends Record = TSearchSchema, TParams extends AnyPathParams = {}, TAllParams = ParamsFallback, @@ -98,7 +111,7 @@ export type BaseRouteOptions< TLoaderData extends any = unknown, > = RoutePathOptions & { getParentRoute: () => TParentRoute - validateSearch?: SearchSchemaValidator + validateSearch?: SearchSchemaValidator shouldReload?: | boolean | (( @@ -221,16 +234,16 @@ export type ParseParamsObj = { } // The parse type here allows a zod schema to be passed directly to the validator -export type SearchSchemaValidator = - | SearchSchemaValidatorObj - | SearchSchemaValidatorFn +export type SearchSchemaValidator = + | SearchSchemaValidatorObj + | SearchSchemaValidatorFn -export type SearchSchemaValidatorObj = { - parse?: SearchSchemaValidatorFn +export type SearchSchemaValidatorObj = { + parse?: SearchSchemaValidatorFn } -export type SearchSchemaValidatorFn = ( - searchObj: Record, +export type SearchSchemaValidatorFn = ( + searchObj: TInput, ) => TReturn export type DefinedPathParamWarning = @@ -287,10 +300,21 @@ export type InferFullSearchSchema = TRoute extends { ? TFullSearchSchema : {} +export type InferFullSearchSchemaInput = TRoute extends { + types: { + fullSearchSchemaInput: infer TFullSearchSchemaInput + } +} + ? TFullSearchSchemaInput + : {} + export type ResolveFullSearchSchema = Expand< Assign, TSearchSchema> > +export type ResolveFullSearchSchemaInput = + Expand, TSearchSchemaUsed>> + export interface AnyRoute extends Route< any, @@ -308,6 +332,9 @@ export interface AnyRoute any, any, any, + any, + any, + any, any > {} @@ -412,7 +439,18 @@ export class Route< TCustomId, TPath >, + TSearchSchemaInput extends RouteConstraints['TSearchSchema'] = {}, TSearchSchema extends RouteConstraints['TSearchSchema'] = {}, + TSearchSchemaUsed extends Record< + string, + any + > = TSearchSchemaInput extends SearchSchemaInput + ? Omit + : TSearchSchema, + TFullSearchSchemaInput extends Record< + string, + any + > = ResolveFullSearchSchemaInput, TFullSearchSchema extends RouteConstraints['TFullSearchSchema'] = ResolveFullSearchSchema< TParentRoute, @@ -442,7 +480,10 @@ export class Route< TParentRoute, TCustomId, TPath, + TSearchSchemaInput, TSearchSchema, + TSearchSchemaUsed, + TFullSearchSchemaInput, TFullSearchSchema, TParams, TAllParams, @@ -475,7 +516,10 @@ export class Route< TParentRoute, TCustomId, TPath, + TSearchSchemaInput, TSearchSchema, + TSearchSchemaUsed, + TFullSearchSchemaInput, TFullSearchSchema, TParams, TAllParams, @@ -502,7 +546,10 @@ export class Route< customId: TCustomId id: TId searchSchema: TSearchSchema + searchSchemaInput: TSearchSchemaInput + searchSchemaUsed: TSearchSchemaUsed fullSearchSchema: TFullSearchSchema + fullSearchSchemaInput: TFullSearchSchemaInput params: TParams allParams: TAllParams routeContext: TRouteContext @@ -521,7 +568,10 @@ export class Route< TParentRoute, TCustomId, TPath, + TSearchSchemaInput, TSearchSchema, + TSearchSchemaUsed, + TFullSearchSchemaInput, TFullSearchSchema, TParams, TAllParams, @@ -590,7 +640,10 @@ export class Route< TFullPath, TCustomId, TId, + TSearchSchemaInput, TSearchSchema, + TSearchSchemaUsed, + TFullSearchSchemaInput, TFullSearchSchema, TParams, TAllParams, @@ -652,11 +705,13 @@ export class Route< } } -export type AnyRootRoute = RootRoute +export type AnyRootRoute = RootRoute export function rootRouteWithContext() { return < + TSearchSchemaInput extends Record = {}, TSearchSchema extends Record = {}, + TSearchSchemaUsed extends Record = {}, TRouteContext extends RouteContext = RouteContext, TLoaderDeps extends Record = {}, TLoaderData extends any = unknown, @@ -666,7 +721,10 @@ export function rootRouteWithContext() { AnyRoute, // TParentRoute RootRouteId, // TCustomId '', // TPath + TSearchSchemaInput, // TSearchSchemaInput TSearchSchema, // TSearchSchema + TSearchSchemaUsed, + TSearchSchemaUsed, //TFullSearchSchemaInput TSearchSchema, // TFullSearchSchema {}, // TParams {}, // TAllParams @@ -682,13 +740,21 @@ export function rootRouteWithContext() { | 'parseParams' | 'stringifyParams' >, - ): RootRoute => { + ): RootRoute< + TSearchSchemaInput, + TSearchSchema, + TSearchSchemaUsed, + TRouteContext, + TRouterContext + > => { return new RootRoute(options) as any } } export class RootRoute< + TSearchSchemaInput extends Record = {}, TSearchSchema extends Record = {}, + TSearchSchemaUsed extends Record = {}, TRouteContext extends RouteContext = RouteContext, TRouterContext extends {} = {}, TLoaderDeps extends Record = {}, @@ -699,7 +765,10 @@ export class RootRoute< '/', // TFullPath string, // TCustomId RootRouteId, // TId + TSearchSchemaInput, // TSearchSchemaInput TSearchSchema, // TSearchSchema + TSearchSchemaUsed, + TSearchSchemaUsed, // TFullSearchSchemaInput TSearchSchema, // TFullSearchSchema {}, // TParams {}, // TAllParams @@ -717,7 +786,10 @@ export class RootRoute< AnyRoute, // TParentRoute RootRouteId, // TCustomId '', // TPath + TSearchSchemaInput, // TSearchSchemaInput TSearchSchema, // TSearchSchema + TSearchSchemaUsed, + TSearchSchemaUsed, // TFullSearchSchemaInput TSearchSchema, // TFullSearchSchema {}, // TParams {}, // TAllParams @@ -821,7 +893,14 @@ export type ErrorRouteComponent = RouteComponent export class NotFoundRoute< TParentRoute extends AnyRootRoute, + TSearchSchemaInput extends Record = {}, TSearchSchema extends RouteConstraints['TSearchSchema'] = {}, + TSearchSchemaUsed extends RouteConstraints['TSearchSchema'] = {}, + TFullSearchSchemaInput extends + RouteConstraints['TFullSearchSchema'] = ResolveFullSearchSchemaInput< + TParentRoute, + TSearchSchemaUsed + >, TFullSearchSchema extends RouteConstraints['TFullSearchSchema'] = ResolveFullSearchSchema< TParentRoute, @@ -844,7 +923,10 @@ export class NotFoundRoute< '/404', '404', '404', + TSearchSchemaInput, TSearchSchema, + TSearchSchemaUsed, + TFullSearchSchemaInput, TFullSearchSchema, {}, {}, @@ -862,7 +944,10 @@ export class NotFoundRoute< TParentRoute, string, string, + TSearchSchemaInput, TSearchSchema, + TSearchSchemaUsed, + TFullSearchSchemaInput, TFullSearchSchema, {}, {}, diff --git a/packages/react-router/src/routeInfo.ts b/packages/react-router/src/routeInfo.ts index 575946a06d5..025e17ddb5e 100644 --- a/packages/react-router/src/routeInfo.ts +++ b/packages/react-router/src/routeInfo.ts @@ -21,6 +21,9 @@ export type ParseRouteChildren = any, any, any, + any, + any, + any, infer TChildren, any >