diff --git a/docs/router/framework/react/api/router/useHistoryStateHook.md b/docs/router/framework/react/api/router/useHistoryStateHook.md new file mode 100644 index 0000000000..e272080085 --- /dev/null +++ b/docs/router/framework/react/api/router/useHistoryStateHook.md @@ -0,0 +1,148 @@ +--- +id: useHistoryStateHook +title: useHistoryState hook +--- + +The `useHistoryState` hook returns the state object that was passed during navigation to the closest match or a specific route match. + +## useHistoryState options + +The `useHistoryState` hook accepts an optional `options` object. + +### `opts.from` option + +- Type: `string` +- Optional +- The route ID to get state from. If not provided, the state from the closest match will be used. + +### `opts.strict` option + +- Type: `boolean` +- Optional - `default: true` +- If `true`, the state object type will be strictly typed based on the route's `validateState`. +- If `false`, the hook returns a loosely typed `Partial>` object. + +### `opts.shouldThrow` option + +- Type: `boolean` +- Optional +- `default: true` +- If `false`, `useHistoryState` will not throw an invariant exception in case a match was not found in the currently rendered matches; in this case, it will return `undefined`. + +### `opts.select` option + +- Optional +- `(state: StateType) => TSelected` +- If supplied, this function will be called with the state object and the return value will be returned from `useHistoryState`. This value will also be used to determine if the hook should re-render its parent component using shallow equality checks. + +### `opts.structuralSharing` option + +- Type: `boolean` +- Optional +- Configures whether structural sharing is enabled for the value returned by `select`. +- See the [Render Optimizations guide](../../guide/render-optimizations.md) for more information. + +## useHistoryState returns + +- The state object passed during navigation to the specified route, or `TSelected` if a `select` function is provided. +- Returns `undefined` if no match is found and `shouldThrow` is `false`. + +## State Validation + +You can validate the state object by defining a `validateState` function on your route: + +```tsx +const route = createRoute({ + // ... + validateState: (input) => + z.object({ + color: z.enum(['white', 'red', 'green']).catch('white'), + key: z.string().catch(''), + }).parse(input), +}) +``` + +This ensures type safety and validation for your route's state. + +## Examples + +```tsx +import { useHistoryState } from '@tanstack/react-router' + +// Get route API for a specific route +const routeApi = getRouteApi('/posts/$postId') + +function Component() { + // Get state from the closest match + const state = useHistoryState() + + // OR + + // Get state from a specific route + const routeState = useHistoryState({ from: '/posts/$postId' }) + + // OR + + // Use the route API + const apiState = routeApi.useHistoryState() + + // OR + + // Select a specific property from the state + const color = useHistoryState({ + from: '/posts/$postId', + select: (state) => state.color, + }) + + // OR + + // Get state without throwing an error if the match is not found + const optionalState = useHistoryState({ shouldThrow: false }) + + // ... +} +``` + +### Complete Example + +```tsx +// Define a route with state validation +const postRoute = createRoute({ + getParentRoute: () => postsLayoutRoute, + path: 'post', + validateState: (input) => + z.object({ + color: z.enum(['white', 'red', 'green']).catch('white'), + key: z.string().catch(''), + }).parse(input), + component: PostComponent, +}) + +// Navigate with state +function PostsLayoutComponent() { + return ( + + View Post + + ) +} + +// Use the state in a component +function PostComponent() { + const post = postRoute.useLoaderData() + const { color } = postRoute.useHistoryState() + + return ( +
+

{post.title}

+

Colored by state

+
+ ) +} +``` diff --git a/examples/react/basic-history-state/.gitignore b/examples/react/basic-history-state/.gitignore new file mode 100644 index 0000000000..8354e4d50d --- /dev/null +++ b/examples/react/basic-history-state/.gitignore @@ -0,0 +1,10 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local + +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ \ No newline at end of file diff --git a/examples/react/basic-history-state/.vscode/settings.json b/examples/react/basic-history-state/.vscode/settings.json new file mode 100644 index 0000000000..00b5278e58 --- /dev/null +++ b/examples/react/basic-history-state/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "files.watcherExclude": { + "**/routeTree.gen.ts": true + }, + "search.exclude": { + "**/routeTree.gen.ts": true + }, + "files.readonlyInclude": { + "**/routeTree.gen.ts": true + } +} diff --git a/examples/react/basic-history-state/README.md b/examples/react/basic-history-state/README.md new file mode 100644 index 0000000000..115199d292 --- /dev/null +++ b/examples/react/basic-history-state/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-history-state/index.html b/examples/react/basic-history-state/index.html new file mode 100644 index 0000000000..9b6335c0ac --- /dev/null +++ b/examples/react/basic-history-state/index.html @@ -0,0 +1,12 @@ + + + + + + Vite App + + +
+ + + diff --git a/examples/react/basic-history-state/package.json b/examples/react/basic-history-state/package.json new file mode 100644 index 0000000000..7f5d824f74 --- /dev/null +++ b/examples/react/basic-history-state/package.json @@ -0,0 +1,29 @@ +{ + "name": "tanstack-router-react-example-basic-history-state", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port=3000", + "build": "vite build && tsc --noEmit", + "serve": "vite preview", + "start": "vite" + }, + "dependencies": { + "@tanstack/react-router": "^1.114.24", + "@tanstack/react-router-devtools": "^1.114.24", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "redaxios": "^0.5.1", + "tailwindcss": "^3.4.17", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.1.0" + } +} diff --git a/examples/react/basic-history-state/postcss.config.mjs b/examples/react/basic-history-state/postcss.config.mjs new file mode 100644 index 0000000000..2e7af2b7f1 --- /dev/null +++ b/examples/react/basic-history-state/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/examples/react/basic-history-state/src/main.tsx b/examples/react/basic-history-state/src/main.tsx new file mode 100644 index 0000000000..6c230afb21 --- /dev/null +++ b/examples/react/basic-history-state/src/main.tsx @@ -0,0 +1,291 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { + ErrorComponent, + Link, + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' +import axios from 'redaxios' +import { z } from 'zod' +import type { + ErrorComponentProps, + SearchSchemaInput, +} from '@tanstack/react-router' +import './styles.css' + +type PostType = { + id: number + title: string + body: string +} + +const fetchPosts = async () => { + console.info('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.info(`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.status === 404) { + throw new NotFoundError(`Post with id "${postId}" not found!`) + } + throw err + }) + .then((r) => r.data) + + return post +} + +const rootRoute = createRootRoute({ + component: RootComponent, + notFoundComponent: () => { + return

This is the notFoundComponent configured on root route

+ }, +}) + +function RootComponent() { + return ( +
+
+ + Home + + + Posts + + + State Examples + +
+ + +
+ ) +} +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, +}) + +function IndexComponent() { + return ( +
+

Welcome Home!

+
+ ) +} + +const postsLayoutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + loader: () => fetchPosts(), + component: PostsLayoutComponent, +}) + +function PostsLayoutComponent() { + const posts = postsLayoutRoute.useLoaderData() + + return ( +
+
+ {posts.map((post, index) => { + return ( +
+ +
{post.title.substring(0, 20)}
+ +
+ ) + })} +
+ +
+ ) +} + +const postsIndexRoute = createRoute({ + getParentRoute: () => postsLayoutRoute, + path: '/', + component: PostsIndexComponent, +}) + +function PostsIndexComponent() { + return
Select a post.
+} + +class NotFoundError extends Error {} + +const postRoute = createRoute({ + getParentRoute: () => postsLayoutRoute, + path: 'post', + validateSearch: ( + input: { + postId: number + } & SearchSchemaInput, + ) => + z + .object({ + postId: z.number().catch(1), + }) + .parse(input), + validateState: ( + input: { + color: 'white' | 'red' | 'green' + }, + ) => + z + .object({ + 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 }: ErrorComponentProps) { + if (error instanceof NotFoundError) { + return
{error.message}
+ } + + return +} + +function PostComponent() { + const post = postRoute.useLoaderData() + const state = postRoute.useHistoryState() + return ( +
+

{post.title}

+

Color: {state.color}

+
+
{post.body}
+
+ ) +} + +// Route to demonstrate various useHistoryState usages +const stateExamplesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'state-examples', + component: StateExamplesComponent, +}) + +const stateDestinationRoute = createRoute({ + getParentRoute: () => stateExamplesRoute, + path: 'destination', + validateState: (input: { + example: string + count: number + options: Array + }) => + z + .object({ + example: z.string(), + count: z.number(), + options: z.array(z.string()), + }) + .parse(input), + component: StateDestinationComponent, +}) + +function StateExamplesComponent() { + return ( +
+

useHistoryState Examples

+
+ + Link with State + +
+ +
+ ) +} + +function StateDestinationComponent() { + const state = stateDestinationRoute.useHistoryState() + return ( +
+

State Data Display

+
+            {JSON.stringify(state, null, 2)}
+          
+
+ ) +} + +const routeTree = rootRoute.addChildren([ + postsLayoutRoute.addChildren([postRoute, postsIndexRoute]), + stateExamplesRoute.addChildren([stateDestinationRoute]), + indexRoute, +]) + +const router = createRouter({ + routeTree, + defaultPreload: 'intent', + defaultStaleTime: 5000, + scrollRestoration: true, +}) + +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-history-state/src/posts.lazy.tsx b/examples/react/basic-history-state/src/posts.lazy.tsx new file mode 100644 index 0000000000..38e0502714 --- /dev/null +++ b/examples/react/basic-history-state/src/posts.lazy.tsx @@ -0,0 +1,36 @@ +import * as React from 'react' +import { Link, Outlet, createLazyRoute } from '@tanstack/react-router' + +export const Route = createLazyRoute('/posts')({ + component: PostsLayoutComponent, +}) + +function PostsLayoutComponent() { + const posts = Route.useLoaderData() + + return ( +
+
    + {[...posts, { id: 'i-do-not-exist', title: 'Non-existent Post' }].map( + (post) => { + return ( +
  • + +
    {post.title.substring(0, 20)}
    + +
  • + ) + }, + )} +
+ +
+ ) +} diff --git a/examples/react/basic-history-state/src/posts.ts b/examples/react/basic-history-state/src/posts.ts new file mode 100644 index 0000000000..54d62e5788 --- /dev/null +++ b/examples/react/basic-history-state/src/posts.ts @@ -0,0 +1,32 @@ +import axios from 'redaxios' + +export class NotFoundError extends Error {} + +type PostType = { + id: string + title: string + body: string +} + +export const fetchPosts = async () => { + console.info('Fetching posts...') + await new Promise((r) => setTimeout(r, 500)) + return axios + .get>('https://jsonplaceholder.typicode.com/posts') + .then((r) => r.data.slice(0, 10)) +} + +export const fetchPost = async (postId: string) => { + console.info(`Fetching post with id ${postId}...`) + await new Promise((r) => setTimeout(r, 500)) + const post = await axios + .get(`https://jsonplaceholder.typicode.com/posts/${postId}`) + .then((r) => r.data) + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!post) { + throw new NotFoundError(`Post with id "${postId}" not found!`) + } + + return post +} diff --git a/examples/react/basic-history-state/src/styles.css b/examples/react/basic-history-state/src/styles.css new file mode 100644 index 0000000000..0b8e317099 --- /dev/null +++ b/examples/react/basic-history-state/src/styles.css @@ -0,0 +1,13 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html { + color-scheme: light dark; +} +* { + @apply border-gray-200 dark:border-gray-800; +} +body { + @apply bg-gray-50 text-gray-950 dark:bg-gray-900 dark:text-gray-200; +} diff --git a/examples/react/basic-history-state/tailwind.config.mjs b/examples/react/basic-history-state/tailwind.config.mjs new file mode 100644 index 0000000000..4986094b9d --- /dev/null +++ b/examples/react/basic-history-state/tailwind.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'], +} diff --git a/examples/react/basic-history-state/tsconfig.dev.json b/examples/react/basic-history-state/tsconfig.dev.json new file mode 100644 index 0000000000..285a09b0dc --- /dev/null +++ b/examples/react/basic-history-state/tsconfig.dev.json @@ -0,0 +1,10 @@ +{ + "composite": true, + "extends": "../../../tsconfig.base.json", + + "files": ["src/main.tsx"], + "include": [ + "src" + // "__tests__/**/*.test.*" + ] +} diff --git a/examples/react/basic-history-state/tsconfig.json b/examples/react/basic-history-state/tsconfig.json new file mode 100644 index 0000000000..ce3a7d2339 --- /dev/null +++ b/examples/react/basic-history-state/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "target": "ESNext", + "moduleResolution": "Bundler", + "module": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "skipLibCheck": true + } +} diff --git a/examples/react/basic-history-state/vite.config.js b/examples/react/basic-history-state/vite.config.js new file mode 100644 index 0000000000..5a33944a9b --- /dev/null +++ b/examples/react/basic-history-state/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 d645cc840d..a33e8d2078 100644 --- a/packages/react-router/src/fileRoute.ts +++ b/packages/react-router/src/fileRoute.ts @@ -6,6 +6,7 @@ import { useLoaderDeps } from './useLoaderDeps' import { useLoaderData } from './useLoaderData' import { useSearch } from './useSearch' import { useParams } from './useParams' +import { useHistoryState } from './useHistoryState' import { useNavigate } from './useNavigate' import { useRouter } from './useRouter' import type { UseParamsRoute } from './useParams' @@ -32,6 +33,7 @@ import type { import type { UseLoaderDepsRoute } from './useLoaderDeps' import type { UseLoaderDataRoute } from './useLoaderData' import type { UseRouteContextRoute } from './useRouteContext' +import type { UseHistoryStateRoute } from './useHistoryState' export function createFileRoute< TFilePath extends keyof FileRoutesByPath, @@ -48,7 +50,7 @@ export function createFileRoute< }).createRoute } -/** +/** @deprecated It's no longer recommended to use the `FileRoute` class directly. Instead, use `createFileRoute('/path/to/file')(options)` to create a file route. */ @@ -71,6 +73,7 @@ export class FileRoute< createRoute = < TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -83,6 +86,7 @@ export class FileRoute< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -96,6 +100,7 @@ export class FileRoute< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, AnyContext, @@ -109,6 +114,7 @@ export class FileRoute< TFilePath, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -128,7 +134,7 @@ export class FileRoute< } } -/** +/** @deprecated It's recommended not to split loaders into separate files. Instead, place the loader function in the the main route file, inside the `createFileRoute('/path/to/file)(options)` options. @@ -197,6 +203,15 @@ export class LazyRoute { } as any) as any } + useHistoryState: UseHistoryStateRoute = (opts) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return useHistoryState({ + select: opts?.select, + structuralSharing: opts?.structuralSharing, + from: this.options.id, + } as any) as any + } + useParams: UseParamsRoute = (opts) => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion return useParams({ diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index b848fc7210..81cd262421 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -314,6 +314,7 @@ export { useNavigate, Navigate } from './useNavigate' export { useParams } from './useParams' export { useSearch } from './useSearch' +export { useHistoryState } from './useHistoryState' export { getRouterContext, // SSR @@ -347,6 +348,7 @@ export type { ValidateToPath, ValidateSearch, ValidateParams, + ValidateHistoryState, InferFrom, InferTo, InferMaskTo, diff --git a/packages/react-router/src/route.ts b/packages/react-router/src/route.ts index f9c8898cfe..3d7c5f63b1 100644 --- a/packages/react-router/src/route.ts +++ b/packages/react-router/src/route.ts @@ -11,6 +11,7 @@ import { useSearch } from './useSearch' import { useNavigate } from './useNavigate' import { useMatch } from './useMatch' import { useRouter } from './useRouter' +import { useHistoryState } from './useHistoryState' import type { AnyContext, AnyRoute, @@ -39,6 +40,7 @@ import type { UseMatchRoute } from './useMatch' import type { UseLoaderDepsRoute } from './useLoaderDeps' import type { UseParamsRoute } from './useParams' import type { UseSearchRoute } from './useSearch' +import type { UseHistoryStateRoute } from './useHistoryState' import type * as React from 'react' import type { UseRouteContextRoute } from './useRouteContext' @@ -60,6 +62,7 @@ declare module '@tanstack/router-core' { useParams: UseParamsRoute useLoaderDeps: UseLoaderDepsRoute useLoaderData: UseLoaderDataRoute + useHistoryState: UseHistoryStateRoute useNavigate: () => UseNavigateResult } } @@ -106,6 +109,15 @@ export class RouteApi< } as any) as any } + useHistoryState: UseHistoryStateRoute = (opts) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return useHistoryState({ + select: opts?.select, + structuralSharing: opts?.structuralSharing, + from: this.id, + } as any) as any + } + useParams: UseParamsRoute = (opts) => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion return useParams({ @@ -149,6 +161,7 @@ export class Route< TPath >, in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TParams = ResolveParams, in out TRouterContext = AnyContext, in out TRouteContextFn = AnyContext, @@ -164,6 +177,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -184,6 +198,7 @@ export class Route< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -221,6 +236,15 @@ export class Route< } as any) as any } + useHistoryState: UseHistoryStateRoute = (opts) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return useHistoryState({ + select: opts?.select, + structuralSharing: opts?.structuralSharing, + from: this.id, + } as any) as any + } + useParams: UseParamsRoute = (opts) => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion return useParams({ @@ -257,6 +281,7 @@ export function createRoute< TPath >, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -271,6 +296,7 @@ export function createRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -285,6 +311,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -300,6 +327,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -317,11 +345,13 @@ export function createRootRouteWithContext() { TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, >( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -331,6 +361,7 @@ export function createRootRouteWithContext() { ) => { return createRootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -347,6 +378,7 @@ export const rootRouteWithContext = createRootRouteWithContext export class RootRoute< in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TRouterContext = {}, in out TRouteContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, @@ -356,6 +388,7 @@ export class RootRoute< in out TFileRouteTypes = unknown, > extends BaseRootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -370,6 +403,7 @@ export class RootRoute< constructor( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -406,6 +440,15 @@ export class RootRoute< } as any) as any } + useHistoryState: UseHistoryStateRoute = (opts) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return useHistoryState({ + select: opts?.select, + structuralSharing: opts?.structuralSharing, + from: this.id, + } as any) as any + } + useParams: UseParamsRoute = (opts) => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion return useParams({ @@ -430,6 +473,7 @@ export class RootRoute< export function createRootRoute< TSearchValidator = undefined, + TStateValidator = undefined, TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -438,6 +482,7 @@ export function createRootRoute< >( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -446,6 +491,7 @@ export function createRootRoute< >, ): RootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -456,6 +502,7 @@ export function createRootRoute< > { return new RootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -498,6 +545,7 @@ export class NotFoundRoute< TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TChildren = unknown, @@ -508,6 +556,7 @@ export class NotFoundRoute< '404', '404', TSearchValidator, + TStateValidator, {}, TRouterContext, TRouteContextFn, @@ -525,6 +574,7 @@ export class NotFoundRoute< string, string, TSearchValidator, + TStateValidator, {}, TLoaderDeps, TLoaderFn, diff --git a/packages/react-router/src/useHistoryState.tsx b/packages/react-router/src/useHistoryState.tsx new file mode 100644 index 0000000000..5c52c586ac --- /dev/null +++ b/packages/react-router/src/useHistoryState.tsx @@ -0,0 +1,112 @@ +import { useMatch } from './useMatch' +import type { + AnyRouter, + Constrain, + Expand, + RegisteredRouter, + RouteById, + RouteIds, + ThrowConstraint, + ThrowOrOptional, + UseHistoryStateResult, +} from '@tanstack/router-core' +import type { + StructuralSharingOption, + ValidateSelected, +} from './structuralSharing' + +type ResolveUseHistoryState< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, +> = TStrict extends false + ? Expand>> + : Expand['types']['stateSchema']> + +export interface UseHistoryStateBaseOptions< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TThrow extends boolean, + TSelected, + TStructuralSharing extends boolean, +> { + select?: ( + state: ResolveUseHistoryState, + ) => ValidateSelected + from?: Constrain> + strict?: TStrict + shouldThrow?: TThrow +} + +export type UseHistoryStateOptions< + TRouter extends AnyRouter, + TFrom extends string | undefined, + TStrict extends boolean, + TThrow extends boolean, + TSelected, + TStructuralSharing extends boolean, +> = UseHistoryStateBaseOptions< + TRouter, + TFrom, + TStrict, + TThrow, + TSelected, + TStructuralSharing +> & + StructuralSharingOption + +export type UseHistoryStateRoute = < + TRouter extends AnyRouter = RegisteredRouter, + TSelected = RouteById['types']['stateSchema'], + TStructuralSharing extends boolean = boolean, +>( + opts?: UseHistoryStateBaseOptions< + TRouter, + TFrom, + /* TStrict */ true, + /* TThrow */ true, + TSelected, + TStructuralSharing + > & + StructuralSharingOption, +) => UseHistoryStateResult + +export function useHistoryState< + TRouter extends AnyRouter = RegisteredRouter, + const TFrom extends string | undefined = undefined, + TStrict extends boolean = true, + TThrow extends boolean = true, + TState = TStrict extends false + ? Expand>> + : Expand['types']['stateSchema']>, + TSelected = TState, + TStructuralSharing extends boolean = boolean, +>( + opts: UseHistoryStateOptions< + TRouter, + TFrom, + TStrict, + ThrowConstraint, + TSelected, + TStructuralSharing + >, +): ThrowOrOptional< + UseHistoryStateResult, + TThrow +> { + return useMatch({ + from: opts.from!, + strict: opts.strict, + shouldThrow: opts.shouldThrow, + structuralSharing: opts.structuralSharing, + select: (match: any) => { + const matchState = match.state; + const filteredState = Object.fromEntries( + Object.entries(matchState).filter(([key]) => !(key.startsWith('__') || key === 'key')) + ); + const typedState = filteredState as unknown as ResolveUseHistoryState; + return opts.select ? opts.select(typedState) : typedState; + }, + } as any) as any; +} diff --git a/packages/react-router/tests/Matches.test-d.tsx b/packages/react-router/tests/Matches.test-d.tsx index 2116e78416..9ad2ae31ce 100644 --- a/packages/react-router/tests/Matches.test-d.tsx +++ b/packages/react-router/tests/Matches.test-d.tsx @@ -21,7 +21,8 @@ type RootMatch = RouteMatch< RootRoute['types']['fullSearchSchema'], RootRoute['types']['loaderData'], RootRoute['types']['allContext'], - RootRoute['types']['loaderDeps'] + RootRoute['types']['loaderDeps'], + RootRoute['types']['fullStateSchema'] > const indexRoute = createRoute({ @@ -38,7 +39,8 @@ type IndexMatch = RouteMatch< IndexRoute['types']['fullSearchSchema'], IndexRoute['types']['loaderData'], IndexRoute['types']['allContext'], - IndexRoute['types']['loaderDeps'] + IndexRoute['types']['loaderDeps'], + IndexRoute['types']['fullStateSchema'] > const invoicesRoute = createRoute({ @@ -54,7 +56,8 @@ type InvoiceMatch = RouteMatch< InvoiceRoute['types']['fullSearchSchema'], InvoiceRoute['types']['loaderData'], InvoiceRoute['types']['allContext'], - InvoiceRoute['types']['loaderDeps'] + InvoiceRoute['types']['loaderDeps'], + InvoiceRoute['types']['fullStateSchema'] > type InvoicesRoute = typeof invoicesRoute @@ -66,7 +69,8 @@ type InvoicesMatch = RouteMatch< InvoicesRoute['types']['fullSearchSchema'], InvoicesRoute['types']['loaderData'], InvoicesRoute['types']['allContext'], - InvoicesRoute['types']['loaderDeps'] + InvoicesRoute['types']['loaderDeps'], + InvoicesRoute['types']['fullStateSchema'] > const invoicesIndexRoute = createRoute({ @@ -83,7 +87,8 @@ type InvoicesIndexMatch = RouteMatch< InvoicesIndexRoute['types']['fullSearchSchema'], InvoicesIndexRoute['types']['loaderData'], InvoicesIndexRoute['types']['allContext'], - InvoicesIndexRoute['types']['loaderDeps'] + InvoicesIndexRoute['types']['loaderDeps'], + InvoicesIndexRoute['types']['fullStateSchema'] > const invoiceRoute = createRoute({ @@ -108,7 +113,8 @@ type LayoutMatch = RouteMatch< LayoutRoute['types']['fullSearchSchema'], LayoutRoute['types']['loaderData'], LayoutRoute['types']['allContext'], - LayoutRoute['types']['loaderDeps'] + LayoutRoute['types']['loaderDeps'], + LayoutRoute['types']['fullStateSchema'] > const commentsRoute = createRoute({ @@ -131,7 +137,8 @@ type CommentsMatch = RouteMatch< CommentsRoute['types']['fullSearchSchema'], CommentsRoute['types']['loaderData'], CommentsRoute['types']['allContext'], - CommentsRoute['types']['loaderDeps'] + CommentsRoute['types']['loaderDeps'], + CommentsRoute['types']['fullStateSchema'] > const routeTree = rootRoute.addChildren([ diff --git a/packages/react-router/tests/useHistoryState.test.tsx b/packages/react-router/tests/useHistoryState.test.tsx new file mode 100644 index 0000000000..14ca50bb41 --- /dev/null +++ b/packages/react-router/tests/useHistoryState.test.tsx @@ -0,0 +1,321 @@ +import { afterEach, describe, expect, test } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { z } from 'zod' +import { + Link, + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + useHistoryState, + useNavigate, +} from '../src' +import type { RouteComponent, RouterHistory } from '../src' + +afterEach(() => { + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +describe('useHistoryState', () => { + function setup({ + RootComponent, + history, + }: { + RootComponent: RouteComponent + history?: RouterHistory + }) { + const rootRoute = createRootRoute({ + component: RootComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> +

IndexTitle

+ Posts + + ), + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + validateState: (input: { testKey?: string; color?: string }) => + z.object({ + testKey: z.string().optional(), + color: z.enum(['red', 'green', 'blue']).optional(), + }).parse(input), + component: () =>

PostsTitle

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history, + }) + + return render() + } + + test('basic state access', async () => { + function RootComponent() { + const match = useHistoryState({ + from: '/posts', + shouldThrow: false, + }) + + return ( +
+
{match?.testKey}
+ +
+ ) + } + + setup({ RootComponent }) + + const postsLink = await screen.findByText('Posts') + fireEvent.click(postsLink) + + await waitFor(() => { + const stateValue = screen.getByTestId('state-value') + expect(stateValue).toHaveTextContent('test-value') + }) + }) + + test('state access with select function', async () => { + function RootComponent() { + const testKey = useHistoryState({ + from: '/posts', + shouldThrow: false, + select: (state) => state.testKey, + }) + + return ( +
+
{testKey}
+ +
+ ) + } + + setup({ RootComponent }) + + const postsLink = await screen.findByText('Posts') + fireEvent.click(postsLink) + + const stateValue = await screen.findByTestId('state-value') + expect(stateValue).toHaveTextContent('test-value') + }) + + test('state validation', async () => { + function RootComponent() { + const navigate = useNavigate() + + return ( +
+ + + +
+ ) + } + + function ValidChecker() { + const state = useHistoryState({ from: '/posts', shouldThrow: false }) + return
{JSON.stringify(state)}
+ } + + const rootRoute = createRootRoute({ + component: RootComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

IndexTitle

, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + validateState: (input: { testKey?: string; color?: string }) => + z.object({ + testKey: z.string(), + color: z.enum(['red', 'green', 'blue']), + }).parse(input), + component: ValidChecker, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render() + + // Valid state transition + const validButton = await screen.findByTestId('valid-state-btn') + fireEvent.click(validButton) + + const validState = await screen.findByTestId('valid-state') + expect(validState).toHaveTextContent('{"testKey":"valid-key","color":"red"}') + + // Invalid state transition + const invalidButton = await screen.findByTestId('invalid-state-btn') + fireEvent.click(invalidButton) + + await waitFor(async () => { + const stateElement = await screen.findByTestId('valid-state') + expect(stateElement).toHaveTextContent('yellow') + }) + }) + + test('throws when match not found and shouldThrow=true', async () => { + function RootComponent() { + try { + useHistoryState({ from: '/non-existent', shouldThrow: true }) + return
No error
+ } catch (e) { + return
Error occurred: {(e as Error).message}
+ } + } + + setup({ RootComponent }) + + const errorMessage = await screen.findByText(/Error occurred:/) + expect(errorMessage).toBeInTheDocument() + expect(errorMessage).toHaveTextContent(/Could not find an active match/) + }) + + test('returns undefined when match not found and shouldThrow=false', async () => { + function RootComponent() { + const state = useHistoryState({ from: '/non-existent', shouldThrow: false }) + return ( +
+
{state === undefined ? 'undefined' : 'defined'}
+ +
+ ) + } + + setup({ RootComponent }) + + const stateResult = await screen.findByTestId('state-result') + expect(stateResult).toHaveTextContent('undefined') + }) + + test('updates when state changes', async () => { + function RootComponent() { + const navigate = useNavigate() + const state = useHistoryState({ from: '/posts', shouldThrow: false }) + + return ( +
+
{state?.count}
+ + + +
+ ) + } + + setup({ RootComponent }) + + // Initial navigation + const navigateBtn = await screen.findByTestId('navigate-btn') + fireEvent.click(navigateBtn) + + // Check initial state + const stateValue = await screen.findByTestId('state-value') + expect(stateValue).toHaveTextContent('1') + + // Update state + const updateBtn = await screen.findByTestId('update-btn') + fireEvent.click(updateBtn) + + // Check updated state + await waitFor(() => { + expect(screen.getByTestId('state-value')).toHaveTextContent('2') + }) + }) + + test('route.useHistoryState hook works properly', async () => { + function PostsComponent() { + const state = postsRoute.useHistoryState() + return
{state.testValue}
+ } + + const rootRoute = createRootRoute({ + component: () => , + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = useNavigate() + return ( + + ) + }, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: PostsComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render() + + const goToPostsBtn = await screen.findByText('Go to Posts') + fireEvent.click(goToPostsBtn) + + const routeState = await screen.findByTestId('route-state') + expect(routeState).toHaveTextContent('route-state-value') + }) +}) diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index 5de34b3c1a..14ba1b799a 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -4,6 +4,7 @@ import type { AllLoaderData, AllParams, FullSearchSchema, + FullStateSchema, ParseRoute, RouteById, RouteIds, @@ -121,6 +122,7 @@ export interface RouteMatch< out TLoaderData, out TAllContext, out TLoaderDeps, + out TFullStateSchema, > extends RouteMatchExtensions { id: string routeId: TRouteId @@ -134,6 +136,7 @@ export interface RouteMatch< error: unknown paramsError: unknown searchError: unknown + stateError: unknown updatedAt: number loadPromise?: ControlledPromise beforeLoadPromise?: ControlledPromise @@ -144,6 +147,8 @@ export interface RouteMatch< context: TAllContext search: TFullSearchSchema _strictSearch: TFullSearchSchema + state: TFullStateSchema + _strictState: TFullStateSchema fetchCount: number abortController: AbortController cause: 'preload' | 'enter' | 'stay' @@ -164,7 +169,8 @@ export type MakeRouteMatchFromRoute = RouteMatch< TRoute['types']['fullSearchSchema'], TRoute['types']['loaderData'], TRoute['types']['allContext'], - TRoute['types']['loaderDeps'] + TRoute['types']['loaderDeps'], + TRoute['types']['stateSchema'] > export type MakeRouteMatch< @@ -186,10 +192,13 @@ export type MakeRouteMatch< TStrict extends false ? AllContext : RouteById['types']['allContext'], - RouteById['types']['loaderDeps'] + RouteById['types']['loaderDeps'], + TStrict extends false + ? FullStateSchema + : RouteById['types']['stateSchema'] > -export type AnyRouteMatch = RouteMatch +export type AnyRouteMatch = RouteMatch export type MakeRouteMatchUnion< TRouter extends AnyRouter = RegisteredRouter, @@ -202,7 +211,8 @@ export type MakeRouteMatchUnion< TRoute['types']['fullSearchSchema'], TRoute['types']['loaderData'], TRoute['types']['allContext'], - TRoute['types']['loaderDeps'] + TRoute['types']['loaderDeps'], + TRoute['types']['stateSchema'] > : never diff --git a/packages/router-core/src/RouterProvider.ts b/packages/router-core/src/RouterProvider.ts index 1c2b42ef21..b471d1d61b 100644 --- a/packages/router-core/src/RouterProvider.ts +++ b/packages/router-core/src/RouterProvider.ts @@ -46,5 +46,6 @@ export type BuildLocationFn = < opts: ToOptions & { leaveParams?: boolean _includeValidateSearch?: boolean + _includeValidateState?: boolean }, ) => ParsedLocation diff --git a/packages/router-core/src/fileRoute.ts b/packages/router-core/src/fileRoute.ts index 8d2e5b33a5..d9696ea212 100644 --- a/packages/router-core/src/fileRoute.ts +++ b/packages/router-core/src/fileRoute.ts @@ -35,6 +35,7 @@ export type LazyRouteOptions = Pick< string, AnyPathParams, AnyValidator, + AnyValidator, {}, AnyContext, AnyContext, diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 4988400855..e8a865e718 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -360,6 +360,11 @@ export type { export type { UseSearchResult, ResolveUseSearch } from './useSearch' +export type { + UseHistoryStateResult, + ResolveUseHistoryState, +} from './useHistoryState' + export type { UseParamsResult, ResolveUseParams } from './useParams' export type { UseNavigateResult } from './useNavigate' @@ -395,6 +400,7 @@ export type { ValidateToPath, ValidateSearch, ValidateParams, + ValidateHistoryState, InferFrom, InferTo, InferMaskTo, @@ -409,4 +415,5 @@ export type { InferSelected, ValidateUseSearchResult, ValidateUseParamsResult, + ValidateUseHistoryStateResult, } from './typePrimitives' diff --git a/packages/router-core/src/link.ts b/packages/router-core/src/link.ts index 5fa5a2a5b3..eeff202b1b 100644 --- a/packages/router-core/src/link.ts +++ b/packages/router-core/src/link.ts @@ -6,6 +6,7 @@ import type { FullSearchSchema, FullSearchSchemaInput, ParentPath, + RouteById, RouteByPath, RouteByToPath, RoutePaths, @@ -344,7 +345,18 @@ export type ToSubOptionsProps< TTo extends string | undefined = '.', > = MakeToRequired & { hash?: true | Updater - state?: true | NonNullableUpdater + state?: TTo extends undefined + ? true | NonNullableUpdater + : true | ResolveRelativePath extends infer TPath + ? TPath extends string + ? TPath extends RoutePaths + ? NonNullableUpdater< + ParsedHistoryState, + RouteById['types']['stateSchema'] + > + : NonNullableUpdater + : NonNullableUpdater + : NonNullableUpdater from?: FromPathOption & {} } diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 4be19d5c24..052a4ec2d4 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -30,6 +30,7 @@ import type { AnyValidatorObj, DefaultValidator, ResolveSearchValidatorInput, + ResolveStateValidatorInput, ResolveValidatorOutput, StandardSchemaValidator, ValidatorAdapter, @@ -99,6 +100,13 @@ export type InferFullSearchSchemaInput = TRoute extends { ? TFullSearchSchemaInput : {} +export type InferFullStateSchemaInput = TRoute extends { + types: { + fullStateSchemaInput: infer TFullStateSchemaInput + } +} + ? TFullStateSchemaInput + : {} export type InferAllParams = TRoute extends { types: { allParams: infer TAllParams @@ -156,6 +164,29 @@ export type ParseSplatParams = TPath & ? never : '_splat' : '_splat' +export type ResolveStateSchemaFn = TStateValidator extends ( + ...args: any +) => infer TStateSchema + ? TStateSchema + : AnySchema + +export type ResolveFullStateSchema< + TParentRoute extends AnyRoute, + TStateValidator, +> = unknown extends TParentRoute + ? ResolveStateSchema + : IntersectAssign< + InferFullStateSchema, + ResolveStateSchema + > + +export type InferFullStateSchema = TRoute extends { + types: { + fullStateSchema: infer TFullStateSchema + } +} + ? TFullStateSchema + : {} export interface SplatParams { _splat?: string @@ -182,12 +213,12 @@ export type ParamsOptions = { stringify?: StringifyParamsFn } - /** + /** @deprecated Use params.parse instead */ parseParams?: ParseParamsFn - /** + /** @deprecated Use params.stringify instead */ stringifyParams?: StringifyParamsFn @@ -324,6 +355,24 @@ export type ResolveFullSearchSchemaInput< InferFullSearchSchemaInput, ResolveSearchValidatorInput > +export type ResolveStateSchema = + unknown extends TStateValidator + ? TStateValidator + : TStateValidator extends AnyStandardSchemaValidator + ? NonNullable['output'] + : TStateValidator extends AnyValidatorAdapter + ? TStateValidator['types']['output'] + : TStateValidator extends AnyValidatorObj + ? ResolveStateSchemaFn + : ResolveStateSchemaFn + +export type ResolveFullStateSchemaInput< + TParentRoute extends AnyRoute, + TStateValidator, +> = IntersectAssign< + InferFullStateSchemaInput, + ResolveStateValidatorInput +> export type ResolveAllParamsFromParent< TParentRoute extends AnyRoute, @@ -395,6 +444,7 @@ export interface RouteTypes< in out TCustomId extends string, in out TId extends string, in out TSearchValidator, + in out TStateValidator, in out TParams, in out TRouterContext, in out TRouteContextFn, @@ -413,11 +463,18 @@ export interface RouteTypes< searchSchema: ResolveValidatorOutput searchSchemaInput: ResolveSearchValidatorInput searchValidator: TSearchValidator + stateSchema: ResolveStateSchema + stateValidator: TStateValidator fullSearchSchema: ResolveFullSearchSchema fullSearchSchemaInput: ResolveFullSearchSchemaInput< TParentRoute, TSearchValidator > + fullStateSchema: ResolveFullStateSchema + fullStateSchemaInput: ResolveFullStateSchemaInput< + TParentRoute, + TStateValidator + > params: TParams allParams: ResolveAllParamsFromParent routerContext: TRouterContext @@ -455,6 +512,7 @@ export type RouteAddChildrenFn< in out TCustomId extends string, in out TId extends string, in out TSearchValidator, + in out TStateValidator, in out TParams, in out TRouterContext, in out TRouteContextFn, @@ -474,6 +532,7 @@ export type RouteAddChildrenFn< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -491,6 +550,7 @@ export type RouteAddFileChildrenFn< in out TCustomId extends string, in out TId extends string, in out TSearchValidator, + in out TStateValidator, in out TParams, in out TRouterContext, in out TRouteContextFn, @@ -507,6 +567,7 @@ export type RouteAddFileChildrenFn< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -524,6 +585,7 @@ export type RouteAddFileTypesFn< TCustomId extends string, TId extends string, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -538,6 +600,7 @@ export type RouteAddFileTypesFn< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -555,6 +618,7 @@ export interface Route< in out TCustomId extends string, in out TId extends string, in out TSearchValidator, + in out TStateValidator, in out TParams, in out TRouterContext, in out TRouteContextFn, @@ -576,6 +640,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -592,6 +657,7 @@ export interface Route< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -613,6 +679,7 @@ export interface Route< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, TRouterContext, @@ -628,6 +695,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -643,6 +711,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -658,6 +727,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -682,6 +752,7 @@ export type AnyRoute = Route< any, any, any, + any, any > @@ -696,6 +767,7 @@ export type RouteOptions< TFullPath extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = AnyPathParams, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -708,6 +780,7 @@ export type RouteOptions< TCustomId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -721,6 +794,7 @@ export type RouteOptions< NoInfer, NoInfer, NoInfer, + NoInfer, NoInfer, NoInfer, NoInfer, @@ -763,6 +837,7 @@ export type FileBaseRouteOptions< TId extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = {}, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -772,6 +847,7 @@ export type FileBaseRouteOptions< TRemountDepsFn = AnyContext, > = ParamsOptions & { validateSearch?: Constrain + validateState?: Constrain shouldReload?: | boolean @@ -854,6 +930,7 @@ export type BaseRouteOptions< TCustomId extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = {}, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -866,6 +943,7 @@ export type BaseRouteOptions< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -922,6 +1000,7 @@ type AssetFnContextOptions< in out TParentRoute extends AnyRoute, in out TParams, in out TSearchValidator, + in out TStateValidator, in out TLoaderFn, in out TRouterContext, in out TRouteContextFn, @@ -934,6 +1013,7 @@ type AssetFnContextOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -949,6 +1029,7 @@ type AssetFnContextOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -978,6 +1059,7 @@ export interface UpdatableRouteOptions< in out TFullPath, in out TParams, in out TSearchValidator, + in out TStateValidator, in out TLoaderFn, in out TLoaderDeps, in out TRouterContext, @@ -1005,13 +1087,13 @@ export interface UpdatableRouteOptions< > > } - /** + /** @deprecated Use search.middlewares instead */ preSearchFilters?: Array< SearchFilter> > - /** + /** @deprecated Use search.middlewares instead */ postSearchFilters?: Array< @@ -1027,6 +1109,7 @@ export interface UpdatableRouteOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1043,6 +1126,7 @@ export interface UpdatableRouteOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1059,6 +1143,7 @@ export interface UpdatableRouteOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1079,6 +1164,7 @@ export interface UpdatableRouteOptions< TParentRoute, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TRouterContext, TRouteContextFn, @@ -1097,6 +1183,7 @@ export interface UpdatableRouteOptions< TParentRoute, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TRouterContext, TRouteContextFn, @@ -1172,6 +1259,7 @@ export interface LoaderFnContext< export type RootRouteOptions< TSearchValidator = undefined, + TStateValidator = undefined, TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -1185,6 +1273,7 @@ export type RootRouteOptions< '', // TFullPath '', // TPath TSearchValidator, + TStateValidator, {}, // TParams TLoaderDeps, TLoaderFn, @@ -1209,6 +1298,8 @@ export type RouteConstraints = { TId: string TSearchSchema: AnySchema TFullSearchSchema: AnySchema + TStateSchema: AnySchema + TFullStateSchema: AnySchema TParams: Record TAllParams: Record TParentContext: AnyContext @@ -1261,6 +1352,7 @@ export class BaseRoute< in out TCustomId extends string = string, in out TId extends string = ResolveId, in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TParams = ResolveParams, in out TRouterContext = AnyContext, in out TRouteContextFn = AnyContext, @@ -1277,6 +1369,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1295,6 +1388,7 @@ export class BaseRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -1347,6 +1441,7 @@ export class BaseRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -1370,6 +1465,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1391,6 +1487,7 @@ export class BaseRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -1455,6 +1552,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1473,6 +1571,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1499,6 +1598,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1532,6 +1632,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1550,6 +1651,7 @@ export class BaseRoute< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, TRouterContext, @@ -1581,6 +1683,7 @@ export class BaseRouteApi { export class BaseRootRoute< in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TRouterContext = {}, in out TRouteContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, @@ -1595,6 +1698,7 @@ export class BaseRootRoute< string, // TCustomId RootRouteId, // TId TSearchValidator, // TSearchValidator + TStateValidator, // TStateValidator {}, // TParams TRouterContext, TRouteContextFn, @@ -1607,6 +1711,7 @@ export class BaseRootRoute< constructor( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, diff --git a/packages/router-core/src/routeInfo.ts b/packages/router-core/src/routeInfo.ts index 6c9249e091..be31fbf59f 100644 --- a/packages/router-core/src/routeInfo.ts +++ b/packages/router-core/src/routeInfo.ts @@ -219,6 +219,16 @@ export type FullSearchSchemaInput = ? PartialMergeAll : never +export type FullStateSchema = + ParseRoute extends infer TRoutes extends AnyRoute + ? PartialMergeAll + : never + +export type FullStateSchemaInput = + ParseRoute extends infer TRoutes extends AnyRoute + ? PartialMergeAll + : never + export type AllParams = ParseRoute extends infer TRoutes extends AnyRoute ? PartialMergeAll diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index f41b47fe2f..2410d4cb83 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1290,6 +1290,44 @@ export class RouterCore< return [parentSearch, {}, searchParamError] } })() + const [preMatchState, strictMatchState, stateError]: [ + Record, + Record, + Error | undefined, + ] = (() => { + const rawState = parentMatch?.state ?? next.state + const parentStrictState = parentMatch?._strictState ?? {} + // Exclude keys starting with __ and key named 'key' + const filteredState = Object.fromEntries( + Object.entries(rawState).filter( + ([key]) => !(key.startsWith('__') || key === 'key'), + ), + ) + + try { + if (route.options.validateState) { + const strictState = + validateState(route.options.validateState, filteredState) || {} + return [ + { + ...filteredState, + ...strictState, + }, + { ...parentStrictState, ...strictState }, + undefined, + ] + } + return [filteredState, {}, undefined] + } catch (err: any) { + const stateValidationError = err + + if (opts?.throwOnError) { + throw stateValidationError + } + + return [filteredState, {}, stateValidationError] + } + })() // This is where we need to call route.options.loaderDeps() to get any additional // deps that the route's loader function might need to run. We need to do this @@ -1345,6 +1383,10 @@ export class RouterCore< ? replaceEqualDeep(previousMatch.search, preMatchSearch) : replaceEqualDeep(existingMatch.search, preMatchSearch), _strictSearch: strictMatchSearch, + state: previousMatch + ? replaceEqualDeep(previousMatch.state, preMatchState) + : replaceEqualDeep(existingMatch.state, preMatchState), + _strictState: strictMatchState, } } else { const status = @@ -1371,6 +1413,11 @@ export class RouterCore< _strictSearch: strictMatchSearch, searchError: undefined, status, + state: previousMatch + ? replaceEqualDeep(previousMatch.state, preMatchState) + : preMatchState, + _strictState: strictMatchState, + stateError: undefined, isFetching: false, error: undefined, paramsError: parseErrors[index], @@ -1402,6 +1449,8 @@ export class RouterCore< // update the searchError if there is one match.searchError = searchError + // update the stateError if there is one + match.stateError = stateError const parentContext = getParentContext(parentMatch) @@ -1765,6 +1814,26 @@ export class RouterCore< nextState = replaceEqualDeep(this.latestLocation.state, nextState) + if (opts._includeValidateState) { + let validatedState = {} + matchedRoutesResult?.matchedRoutes.forEach((route) => { + try { + if (route.options.validateState) { + validatedState = { + ...validatedState, + ...(validateState(route.options.validateState, { + ...validatedState, + ...nextState, + }) ?? {}), + } + } + } catch { + // ignore errors here because they are already handled in matchRoutes + } + }) + nextState = validatedState + } + return { pathname, search, @@ -3161,6 +3230,33 @@ export function getInitialRouterState( statusCode: 200, } } +function validateState(validateState: AnyValidator, input: unknown): unknown { + if (validateState == null) return {} + + if ('~standard' in validateState) { + const result = validateState['~standard'].validate(input) + + if (result instanceof Promise) + throw new Error('Async validation not supported') + + if (result.issues) + throw new Error(JSON.stringify(result.issues, undefined, 2), { + cause: result, + }) + + return result.value + } + + if ('parse' in validateState) { + return validateState.parse(input) + } + + if (typeof validateState === 'function') { + return validateState(input) + } + + return {} +} function validateSearch(validateSearch: AnyValidator, input: unknown): unknown { if (validateSearch == null) return {} diff --git a/packages/router-core/src/typePrimitives.ts b/packages/router-core/src/typePrimitives.ts index f0c5d2ae9f..4201520e40 100644 --- a/packages/router-core/src/typePrimitives.ts +++ b/packages/router-core/src/typePrimitives.ts @@ -10,6 +10,7 @@ import type { RouteIds } from './routeInfo' import type { AnyRouter, RegisteredRouter } from './router' import type { UseParamsResult } from './useParams' import type { UseSearchResult } from './useSearch' +import type { UseHistoryStateResult } from './useHistoryState' import type { Constrain, ConstrainLiteral } from './utils' export type ValidateFromPath< @@ -29,6 +30,16 @@ export type ValidateSearch< TFrom extends string = string, > = SearchParamOptions +export type ValidateHistoryState< + TOptions, + TRouter extends AnyRouter = RegisteredRouter, +> = UseHistoryStateResult< + TRouter, + InferFrom, + InferStrict, + InferSelected +> + export type ValidateParams< TRouter extends AnyRouter = RegisteredRouter, TTo extends string | undefined = undefined, @@ -179,3 +190,12 @@ export type ValidateUseParamsResult< InferSelected > > +export type ValidateUseHistoryStateResult< + TOptions, + TRouter extends AnyRouter = RegisteredRouter, +> = UseHistoryStateResult< + TRouter, + InferFrom, + InferStrict, + InferSelected +> diff --git a/packages/router-core/src/useHistoryState.ts b/packages/router-core/src/useHistoryState.ts new file mode 100644 index 0000000000..a08d6b0038 --- /dev/null +++ b/packages/router-core/src/useHistoryState.ts @@ -0,0 +1,20 @@ +import type { FullStateSchema, RouteById } from './routeInfo' +import type { AnyRouter } from './router' +import type { Expand } from './utils' + +export type UseHistoryStateResult< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TSelected, +> = unknown extends TSelected + ? ResolveUseHistoryState + : TSelected + +export type ResolveUseHistoryState< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, +> = TStrict extends false + ? FullStateSchema + : Expand['types']['fullStateSchema']> diff --git a/packages/router-core/src/validators.ts b/packages/router-core/src/validators.ts index 9173080077..01224c9de5 100644 --- a/packages/router-core/src/validators.ts +++ b/packages/router-core/src/validators.ts @@ -89,6 +89,21 @@ export type ResolveSearchValidatorInput = ? ResolveSearchValidatorInputFn : ResolveSearchValidatorInputFn +export type ResolveStateValidatorInputFn = TValidator extends ( + input: infer TSchemaInput, +) => any + ? TSchemaInput + : AnySchema + +export type ResolveStateValidatorInput = + TValidator extends AnyStandardSchemaValidator + ? NonNullable['input'] + : TValidator extends AnyValidatorAdapter + ? TValidator['types']['input'] + : TValidator extends AnyValidatorObj + ? ResolveStateValidatorInputFn + : ResolveStateValidatorInputFn + export type ResolveValidatorInputFn = TValidator extends ( input: infer TInput, ) => any diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index b05428c926..8bcaa3355c 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -81,6 +81,7 @@ function RouteComp({ string, '__root__', undefined, + undefined, {}, {}, AnyContext, @@ -176,6 +177,25 @@ function RouteComp({ ) } +function filterInternalState(state: Record) { + return Object.fromEntries( + Object.entries(state).filter(([key]) => + !(key.startsWith('__') || key === 'key'), + ), + ) +} + +function getMergedStrictState(routerState: any) { + const matches = [ + ...(routerState.pendingMatches ?? []), + ...routerState.matches, + ] + return Object.assign( + {}, + ...matches.map((m: any) => m._strictState).filter(Boolean), + ) as Record +} + export const BaseTanStackRouterDevtoolsPanel = function BaseTanStackRouterDevtoolsPanel({ ...props @@ -226,6 +246,12 @@ export const BaseTanStackRouterDevtoolsPanel = () => Object.keys(routerState().location.search).length, ) + const validatedState = createMemo(() => + filterInternalState(getMergedStrictState(routerState())), + ) + + const hasState = createMemo(() => Object.keys(validatedState()).length) + const explorerState = createMemo(() => { return { ...router(), @@ -271,6 +297,7 @@ export const BaseTanStackRouterDevtoolsPanel = const activeMatchLoaderData = createMemo(() => activeMatch()?.loaderData) const activeMatchValue = createMemo(() => activeMatch()) const locationSearchValue = createMemo(() => routerState().location.search) + const validatedStateValue = createMemo(() => validatedState()) return (
) : null} + {hasState() ? ( +
+
State Params
+
+ { + obj[next] = {} + return obj + }, {})} + /> +
+
+ ) : null} ) } diff --git a/packages/solid-router/src/fileRoute.ts b/packages/solid-router/src/fileRoute.ts index b20789e938..6b8b667e3e 100644 --- a/packages/solid-router/src/fileRoute.ts +++ b/packages/solid-router/src/fileRoute.ts @@ -8,9 +8,11 @@ import { useSearch } from './useSearch' import { useParams } from './useParams' import { useNavigate } from './useNavigate' import { useRouter } from './useRouter' +import { useHistoryState } from './useHistoryState' import type { UseParamsRoute } from './useParams' import type { UseMatchRoute } from './useMatch' import type { UseSearchRoute } from './useSearch' +import type { UseHistoryStateRoute } from './useHistoryState' import type { AnyContext, AnyRoute, @@ -48,7 +50,7 @@ export function createFileRoute< }).createRoute } -/** +/** @deprecated It's no longer recommended to use the `FileRoute` class directly. Instead, use `createFileRoute('/path/to/file')(options)` to create a file route. */ @@ -71,6 +73,7 @@ export class FileRoute< createRoute = < TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -83,6 +86,7 @@ export class FileRoute< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -96,6 +100,7 @@ export class FileRoute< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, AnyContext, @@ -109,6 +114,7 @@ export class FileRoute< TFilePath, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -128,7 +134,7 @@ export class FileRoute< } } -/** +/** @deprecated It's recommended not to split loaders into separate files. Instead, place the loader function in the the main route file, inside the `createFileRoute('/path/to/file)(options)` options. @@ -193,6 +199,14 @@ export class LazyRoute { } as any) as any } + useHistoryState: UseHistoryStateRoute = (opts) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return useHistoryState({ + select: opts?.select, + from: this.options.id, + } as any) as any + } + useParams: UseParamsRoute = (opts) => { return useParams({ select: opts?.select, diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index fc7c0bfd29..2df618e6fa 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -320,6 +320,7 @@ export { useNavigate, Navigate } from './useNavigate' export { useParams } from './useParams' export { useSearch } from './useSearch' +export { useHistoryState } from './useHistoryState' export { getRouterContext, // SSR @@ -349,6 +350,7 @@ export type { ValidateToPath, ValidateSearch, ValidateParams, + ValidateHistoryState, InferFrom, InferTo, InferMaskTo, diff --git a/packages/solid-router/src/route.ts b/packages/solid-router/src/route.ts index c56dc4ef8f..572777c67e 100644 --- a/packages/solid-router/src/route.ts +++ b/packages/solid-router/src/route.ts @@ -11,6 +11,7 @@ import { useSearch } from './useSearch' import { useNavigate } from './useNavigate' import { useMatch } from './useMatch' import { useRouter } from './useRouter' +import { useHistoryState } from './useHistoryState' import type { AnyContext, AnyRoute, @@ -39,6 +40,7 @@ import type { UseMatchRoute } from './useMatch' import type { UseLoaderDepsRoute } from './useLoaderDeps' import type { UseParamsRoute } from './useParams' import type { UseSearchRoute } from './useSearch' +import type { UseHistoryStateRoute } from './useHistoryState' import type * as Solid from 'solid-js' import type { UseRouteContextRoute } from './useRouteContext' @@ -60,6 +62,7 @@ declare module '@tanstack/router-core' { useParams: UseParamsRoute useLoaderDeps: UseLoaderDepsRoute useLoaderData: UseLoaderDataRoute + useHistoryState: UseHistoryStateRoute useNavigate: () => UseNavigateResult } } @@ -110,6 +113,14 @@ export class RouteApi< } as any) as any } + useHistoryState: UseHistoryStateRoute = (opts?: any) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return useHistoryState({ + select: opts?.select, + from: this.id, + } as any) as any + } + useLoaderDeps: UseLoaderDepsRoute = (opts) => { return useLoaderDeps({ ...opts, from: this.id, strict: false } as any) } @@ -144,6 +155,7 @@ export class Route< TPath >, in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TParams = ResolveParams, in out TRouterContext = AnyContext, in out TRouteContextFn = AnyContext, @@ -159,6 +171,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -179,6 +192,7 @@ export class Route< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -219,6 +233,14 @@ export class Route< } as any) as any } + useHistoryState: UseHistoryStateRoute = (opts?: any) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return useHistoryState({ + select: opts?.select, + from: this.id, + } as any) as any + } + useLoaderDeps: UseLoaderDepsRoute = (opts) => { return useLoaderDeps({ ...opts, from: this.id } as any) } @@ -246,6 +268,7 @@ export function createRoute< TPath >, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -260,6 +283,7 @@ export function createRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -274,6 +298,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -290,6 +315,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -308,11 +334,13 @@ export function createRootRouteWithContext() { TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, >( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -322,6 +350,7 @@ export function createRootRouteWithContext() { ) => { return createRootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -338,6 +367,7 @@ export const rootRouteWithContext = createRootRouteWithContext export class RootRoute< in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TRouterContext = {}, in out TRouteContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, @@ -347,6 +377,7 @@ export class RootRoute< in out TFileRouteTypes = unknown, > extends BaseRootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -361,6 +392,7 @@ export class RootRoute< constructor( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -400,6 +432,14 @@ export class RootRoute< } as any) as any } + useHistoryState: UseHistoryStateRoute = (opts?: any) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return useHistoryState({ + select: opts?.select, + from: this.id, + } as any) as any + } + useLoaderDeps: UseLoaderDepsRoute = (opts) => { return useLoaderDeps({ ...opts, from: this.id } as any) } @@ -445,6 +485,7 @@ export class NotFoundRoute< TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TChildren = unknown, @@ -455,6 +496,7 @@ export class NotFoundRoute< '404', '404', TSearchValidator, + TStateValidator, {}, TRouterContext, TRouteContextFn, @@ -472,6 +514,7 @@ export class NotFoundRoute< string, string, TSearchValidator, + TStateValidator, {}, TLoaderDeps, TLoaderFn, @@ -496,6 +539,7 @@ export class NotFoundRoute< export function createRootRoute< TSearchValidator = undefined, + TStateValidator = undefined, TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -504,6 +548,7 @@ export function createRootRoute< >( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -512,6 +557,7 @@ export function createRootRoute< >, ): RootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -522,6 +568,7 @@ export function createRootRoute< > { return new RootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, diff --git a/packages/solid-router/src/useHistoryState.tsx b/packages/solid-router/src/useHistoryState.tsx new file mode 100644 index 0000000000..301d7d4cf4 --- /dev/null +++ b/packages/solid-router/src/useHistoryState.tsx @@ -0,0 +1,96 @@ +import { useMatch } from './useMatch' +import type { Accessor } from 'solid-js' +import type { + AnyRouter, + Expand, + RegisteredRouter, + RouteById, + StrictOrFrom, + ThrowConstraint, + ThrowOrOptional, + UseHistoryStateResult, +} from '@tanstack/router-core' + +type ResolveUseHistoryState< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, +> = TStrict extends false + ? Expand>> + : Expand['types']['stateSchema']> + +export interface UseHistoryStateBaseOptions< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TThrow extends boolean, + TSelected, +> { + select?: ( + state: ResolveUseHistoryState, + ) => TSelected + shouldThrow?: TThrow +} + +export type UseHistoryStateOptions< + TRouter extends AnyRouter, + TFrom extends string | undefined, + TStrict extends boolean, + TThrow extends boolean, + TSelected, +> = StrictOrFrom & + UseHistoryStateBaseOptions< + TRouter, + TFrom, + TStrict, + TThrow, + TSelected + > + +export type UseHistoryStateRoute = < + TRouter extends AnyRouter = RegisteredRouter, + TSelected = RouteById['types']['stateSchema'], +>( + opts?: UseHistoryStateBaseOptions< + TRouter, + TFrom, + /* TStrict */ true, + /* TThrow */ true, + TSelected + >, +) => Accessor> + +export function useHistoryState< + TRouter extends AnyRouter = RegisteredRouter, + const TFrom extends string | undefined = undefined, + TStrict extends boolean = true, + TThrow extends boolean = true, + TState = TStrict extends false + ? Expand>> + : Expand['types']['stateSchema']>, + TSelected = TState, +>( + opts: UseHistoryStateOptions< + TRouter, + TFrom, + TStrict, + ThrowConstraint, + TSelected + >, +): Accessor< + ThrowOrOptional, TThrow> +> { + return useMatch({ + from: opts.from!, + strict: opts.strict, + shouldThrow: opts.shouldThrow, + select: (match: any) => { + const matchState = match.state; + const filteredState = Object.fromEntries( + Object.entries(matchState).filter(([key]) => !(key.startsWith('__') || key === 'key')) + ); + const typedState = filteredState as unknown as ResolveUseHistoryState; + return opts.select ? opts.select(typedState) : typedState; + }, + }) as any; +} diff --git a/packages/solid-router/tests/Matches.test-d.tsx b/packages/solid-router/tests/Matches.test-d.tsx index b107435db6..9fd14dcf1e 100644 --- a/packages/solid-router/tests/Matches.test-d.tsx +++ b/packages/solid-router/tests/Matches.test-d.tsx @@ -22,7 +22,8 @@ type RootMatch = RouteMatch< RootRoute['types']['fullSearchSchema'], RootRoute['types']['loaderData'], RootRoute['types']['allContext'], - RootRoute['types']['loaderDeps'] + RootRoute['types']['loaderDeps'], + RootRoute['types']['fullStateSchema'] > const indexRoute = createRoute({ @@ -39,7 +40,8 @@ type IndexMatch = RouteMatch< IndexRoute['types']['fullSearchSchema'], IndexRoute['types']['loaderData'], IndexRoute['types']['allContext'], - IndexRoute['types']['loaderDeps'] + IndexRoute['types']['loaderDeps'], + IndexRoute['types']['fullStateSchema'] > const invoicesRoute = createRoute({ @@ -55,7 +57,8 @@ type InvoiceMatch = RouteMatch< InvoiceRoute['types']['fullSearchSchema'], InvoiceRoute['types']['loaderData'], InvoiceRoute['types']['allContext'], - InvoiceRoute['types']['loaderDeps'] + InvoiceRoute['types']['loaderDeps'], + InvoiceRoute['types']['fullStateSchema'] > type InvoicesRoute = typeof invoicesRoute @@ -67,7 +70,8 @@ type InvoicesMatch = RouteMatch< InvoicesRoute['types']['fullSearchSchema'], InvoicesRoute['types']['loaderData'], InvoicesRoute['types']['allContext'], - InvoicesRoute['types']['loaderDeps'] + InvoicesRoute['types']['loaderDeps'], + InvoicesRoute['types']['fullStateSchema'] > const invoicesIndexRoute = createRoute({ @@ -84,7 +88,8 @@ type InvoicesIndexMatch = RouteMatch< InvoicesIndexRoute['types']['fullSearchSchema'], InvoicesIndexRoute['types']['loaderData'], InvoicesIndexRoute['types']['allContext'], - InvoicesIndexRoute['types']['loaderDeps'] + InvoicesIndexRoute['types']['loaderDeps'], + InvoicesIndexRoute['types']['fullStateSchema'] > const invoiceRoute = createRoute({ @@ -109,7 +114,8 @@ type LayoutMatch = RouteMatch< LayoutRoute['types']['fullSearchSchema'], LayoutRoute['types']['loaderData'], LayoutRoute['types']['allContext'], - LayoutRoute['types']['loaderDeps'] + LayoutRoute['types']['loaderDeps'], + LayoutRoute['types']['fullStateSchema'] > const commentsRoute = createRoute({ @@ -132,7 +138,8 @@ type CommentsMatch = RouteMatch< CommentsRoute['types']['fullSearchSchema'], CommentsRoute['types']['loaderData'], CommentsRoute['types']['allContext'], - CommentsRoute['types']['loaderDeps'] + CommentsRoute['types']['loaderDeps'], + CommentsRoute['types']['fullStateSchema'] > const routeTree = rootRoute.addChildren([ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fc81f44a5..6fd2b18c66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2581,6 +2581,52 @@ importers: specifier: 6.1.4 version: 6.1.4(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) + examples/react/basic-history-state: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-router-devtools': + specifier: workspace:^ + version: link:../../../packages/react-router-devtools + autoprefixer: + specifier: ^10.4.20 + version: 10.4.20(postcss@8.5.1) + postcss: + specifier: ^8.5.1 + version: 8.5.1 + react: + specifier: ^19.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) + redaxios: + specifier: ^0.5.1 + version: 0.5.1 + tailwindcss: + specifier: ^3.4.17 + version: 3.4.17 + zod: + specifier: ^3.24.2 + version: 3.24.2 + devDependencies: + '@types/react': + specifier: ^19.0.8 + version: 19.0.8 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.0.3(@types/react@19.0.8) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.3.4(vite@6.1.0(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)) + typescript: + specifier: ^5.7.2 + version: 5.8.2 + vite: + specifier: 6.1.0 + version: 6.1.0(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) + examples/react/basic-non-nested-devtools: dependencies: '@tanstack/react-router':