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..40469e080c --- /dev/null +++ b/examples/react/basic-history-state/src/main.tsx @@ -0,0 +1,146 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { + Link, + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' +import { z } from 'zod' +import './styles.css' + +const rootRoute = createRootRoute({ + component: RootComponent, + notFoundComponent: () => { + return

This is the notFoundComponent configured on root route

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

Welcome Home!

+
+ ) +} + +// 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([ + 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/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/history/src/index.ts b/packages/history/src/index.ts index 3428c41939..1c69f9d36b 100644 --- a/packages/history/src/index.ts +++ b/packages/history/src/index.ts @@ -95,6 +95,20 @@ const stateIndexKey = '__TSR_index' const popStateEvent = 'popstate' const beforeUnloadEvent = 'beforeunload' +/** + * Filters out internal state keys from a state object. + * Internal keys are those that start with '__' or equal 'key'. + */ +export function omitInternalKeys( + state: Record, +): Record { + return Object.fromEntries( + Object.entries(state).filter( + ([key]) => !(key.startsWith('__') || key === 'key'), + ), + ) +} + export function createHistory(opts: { getLocation: () => HistoryLocation getLength: () => number diff --git a/packages/react-router/src/fileRoute.ts b/packages/react-router/src/fileRoute.ts index 0b77e986e7..e15821e324 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' @@ -33,6 +34,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, @@ -54,7 +56,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. */ @@ -77,6 +79,7 @@ export class FileRoute< createRoute = < TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -89,6 +92,7 @@ export class FileRoute< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -102,6 +106,7 @@ export class FileRoute< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, AnyContext, @@ -115,6 +120,7 @@ export class FileRoute< TFilePath, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -134,7 +140,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. @@ -215,6 +221,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 45fca938c3..b0c53a1e17 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -59,6 +59,7 @@ export type { ResolveOptionalParams, ResolveRequiredParams, SearchSchemaInput, + StateSchemaInput, AnyContext, RouteContext, PreloadableObj, @@ -118,6 +119,8 @@ export type { ResolveValidatorOutputFn, ResolveSearchValidatorInput, ResolveSearchValidatorInputFn, + ResolveStateValidatorInput, + ResolveStateValidatorInputFn, Validator, ValidatorAdapter, ValidatorObj, @@ -316,6 +319,7 @@ export { useNavigate, Navigate } from './useNavigate' export { useParams } from './useParams' export { useSearch } from './useSearch' +export { useHistoryState } from './useHistoryState' export { getRouterContext, // SSR @@ -349,6 +353,7 @@ export type { ValidateToPath, ValidateSearch, ValidateParams, + ValidateHistoryState, InferFrom, InferTo, InferMaskTo, diff --git a/packages/react-router/src/route.tsx b/packages/react-router/src/route.tsx index d3730e819e..85bd575e50 100644 --- a/packages/react-router/src/route.tsx +++ b/packages/react-router/src/route.tsx @@ -12,6 +12,7 @@ import { useSearch } from './useSearch' import { useNavigate } from './useNavigate' import { useMatch } from './useMatch' import { useRouter } from './useRouter' +import { useHistoryState } from './useHistoryState' import { Link } from './link' import type { AnyContext, @@ -43,6 +44,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 { UseRouteContextRoute } from './useRouteContext' import type { LinkComponentRoute } from './link' @@ -64,6 +66,7 @@ declare module '@tanstack/router-core' { useParams: UseParamsRoute useLoaderDeps: UseLoaderDepsRoute useLoaderData: UseLoaderDataRoute + useHistoryState: UseHistoryStateRoute useNavigate: () => UseNavigateResult Link: LinkComponentRoute } @@ -111,6 +114,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({ @@ -163,6 +175,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, @@ -179,6 +192,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -196,7 +210,8 @@ export class Route< TCustomId, TId, TSearchValidator, - TParams, + TStateValidator, + TParams, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -217,6 +232,7 @@ export class Route< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -254,6 +270,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({ @@ -296,6 +321,7 @@ export function createRoute< TPath >, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -310,6 +336,7 @@ export function createRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -324,6 +351,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -339,6 +367,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -356,11 +385,13 @@ export function createRootRouteWithContext() { TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, >( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -370,6 +401,7 @@ export function createRootRouteWithContext() { ) => { return createRootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -385,28 +417,30 @@ export function createRootRouteWithContext() { export const rootRouteWithContext = createRootRouteWithContext export class RootRoute< - in out TSearchValidator = undefined, - in out TRouterContext = {}, - in out TRouteContextFn = AnyContext, - in out TBeforeLoadFn = AnyContext, - in out TLoaderDeps extends Record = {}, - in out TLoaderFn = undefined, - in out TChildren = unknown, - in out TFileRouteTypes = unknown, - > - extends BaseRootRoute< - TSearchValidator, - TRouterContext, - TRouteContextFn, - TBeforeLoadFn, - TLoaderDeps, - TLoaderFn, - TChildren, - TFileRouteTypes + in out TSearchValidator = undefined, + in out TStateValidator = undefined, + in out TRouterContext = {}, + in out TRouteContextFn = AnyContext, + in out TBeforeLoadFn = AnyContext, + in out TLoaderDeps extends Record = {}, + in out TLoaderFn = undefined, + in out TChildren = unknown, + in out TFileRouteTypes = unknown, + > extends BaseRootRoute< + TSearchValidator, + TStateValidator, + TRouterContext, + TRouteContextFn, + TBeforeLoadFn, + TLoaderDeps, + TLoaderFn, + TChildren, + TFileRouteTypes > implements RootRouteCore< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -422,6 +456,7 @@ export class RootRoute< constructor( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -458,6 +493,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({ @@ -488,6 +532,7 @@ export class RootRoute< export function createRootRoute< TSearchValidator = undefined, + TStateValidator = undefined, TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -496,6 +541,7 @@ export function createRootRoute< >( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -504,6 +550,7 @@ export function createRootRoute< >, ): RootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -514,6 +561,7 @@ export function createRootRoute< > { return new RootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -556,6 +604,7 @@ export class NotFoundRoute< TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TChildren = unknown, @@ -566,6 +615,7 @@ export class NotFoundRoute< '404', '404', TSearchValidator, + TStateValidator, {}, TRouterContext, TRouteContextFn, @@ -583,6 +633,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..0fee862021 --- /dev/null +++ b/packages/react-router/src/useHistoryState.tsx @@ -0,0 +1,101 @@ +import { omitInternalKeys } from '@tanstack/history' +import { useMatch } from './useMatch' +import type { + AnyRouter, + RegisteredRouter, + ResolveUseHistoryState, + StrictOrFrom, + ThrowConstraint, + ThrowOrOptional, + UseHistoryStateResult, +} from '@tanstack/router-core' +import type { + StructuralSharingOption, + ValidateSelected, +} from './structuralSharing' + +export interface UseHistoryStateBaseOptions< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TThrow extends boolean, + TSelected, + TStructuralSharing, +> { + select?: ( + state: ResolveUseHistoryState, + ) => ValidateSelected + shouldThrow?: TThrow +} + +export type UseHistoryStateOptions< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TThrow extends boolean, + TSelected, + TStructuralSharing, +> = StrictOrFrom & + UseHistoryStateBaseOptions< + TRouter, + TFrom, + TStrict, + TThrow, + TSelected, + TStructuralSharing + > & + StructuralSharingOption + +export type UseHistoryStateRoute = < + TRouter extends AnyRouter = RegisteredRouter, + TSelected = unknown, + 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, + TSelected = unknown, + 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 = omitInternalKeys(matchState) + const typedState = filteredState as unknown as ResolveUseHistoryState< + TRouter, + TFrom, + TStrict + > + 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..38d05a008e 100644 --- a/packages/react-router/tests/Matches.test-d.tsx +++ b/packages/react-router/tests/Matches.test-d.tsx @@ -19,6 +19,7 @@ type RootMatch = RouteMatch< RootRoute['fullPath'], RootRoute['types']['allParams'], RootRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], RootRoute['types']['loaderData'], RootRoute['types']['allContext'], RootRoute['types']['loaderDeps'] @@ -36,6 +37,7 @@ type IndexMatch = RouteMatch< IndexRoute['fullPath'], IndexRoute['types']['allParams'], IndexRoute['types']['fullSearchSchema'], + IndexRoute['types']['fullStateSchema'], IndexRoute['types']['loaderData'], IndexRoute['types']['allContext'], IndexRoute['types']['loaderDeps'] @@ -52,6 +54,7 @@ type InvoiceMatch = RouteMatch< InvoiceRoute['fullPath'], InvoiceRoute['types']['allParams'], InvoiceRoute['types']['fullSearchSchema'], + InvoiceRoute['types']['fullStateSchema'], InvoiceRoute['types']['loaderData'], InvoiceRoute['types']['allContext'], InvoiceRoute['types']['loaderDeps'] @@ -64,6 +67,7 @@ type InvoicesMatch = RouteMatch< InvoicesRoute['fullPath'], InvoicesRoute['types']['allParams'], InvoicesRoute['types']['fullSearchSchema'], + InvoicesRoute['types']['fullStateSchema'], InvoicesRoute['types']['loaderData'], InvoicesRoute['types']['allContext'], InvoicesRoute['types']['loaderDeps'] @@ -81,6 +85,7 @@ type InvoicesIndexMatch = RouteMatch< InvoicesIndexRoute['fullPath'], InvoicesIndexRoute['types']['allParams'], InvoicesIndexRoute['types']['fullSearchSchema'], + InvoicesIndexRoute['types']['fullStateSchema'], InvoicesIndexRoute['types']['loaderData'], InvoicesIndexRoute['types']['allContext'], InvoicesIndexRoute['types']['loaderDeps'] @@ -106,6 +111,7 @@ type LayoutMatch = RouteMatch< LayoutRoute['fullPath'], LayoutRoute['types']['allParams'], LayoutRoute['types']['fullSearchSchema'], + LayoutRoute['types']['fullStateSchema'], LayoutRoute['types']['loaderData'], LayoutRoute['types']['allContext'], LayoutRoute['types']['loaderDeps'] @@ -129,6 +135,7 @@ type CommentsMatch = RouteMatch< CommentsRoute['fullPath'], CommentsRoute['types']['allParams'], CommentsRoute['types']['fullSearchSchema'], + CommentsRoute['types']['fullStateSchema'], CommentsRoute['types']['loaderData'], CommentsRoute['types']['allContext'], CommentsRoute['types']['loaderDeps'] diff --git a/packages/react-router/tests/useHistoryState.test-d.tsx b/packages/react-router/tests/useHistoryState.test-d.tsx new file mode 100644 index 0000000000..0a50ec63fb --- /dev/null +++ b/packages/react-router/tests/useHistoryState.test-d.tsx @@ -0,0 +1,532 @@ +import { describe, expectTypeOf, test } from 'vitest' +import { + createRootRoute, + createRoute, + createRouter, + useHistoryState, +} from '../src' +import type { StateSchemaInput } from '../src' + +describe('useHistoryState', () => { + test('when there are no state params', () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('from') + .toEqualTypeOf< + '/invoices' | '__root__' | '/invoices/$invoiceId' | '/invoices/' | '/' + >() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('strict') + .toEqualTypeOf() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .parameter(0) + .toEqualTypeOf<{}>() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .returns.toEqualTypeOf() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{}>() + + expectTypeOf( + useHistoryState({ + strict: false, + }), + ).toEqualTypeOf<{}>() + }) + + test('when there is one state param', () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateState: () => ({ page: 0 }), + }) + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{}>() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{ + page: number + }>() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((state: { page: number }) => unknown) | undefined>() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{ page?: number }>() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((state: { page?: number }) => unknown) | undefined>() + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + number + >, + ).returns.toEqualTypeOf() + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + { func: () => void } + >, + ) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + ((state: { page?: number }) => { func: () => void }) | undefined + >() + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + { func: () => void } + >, + ) + .parameter(0) + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + { func: () => void }, + true + >, + ) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + | ((state: { page?: number }) => { + func: 'Function is not serializable' + }) + | undefined + >() + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + { func: () => void }, + true + >, + ) + .parameter(0) + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + { hi: any }, + true + >, + ) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + | ((state: { page?: number }) => { + hi: never + }) + | undefined + >() + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + { hi: any }, + true + >, + ) + .parameter(0) + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + // eslint-disable-next-line unused-imports/no-unused-vars + const routerWithStructuralSharing = createRouter({ + routeTree, + defaultStructuralSharing: true, + }) + + expectTypeOf( + useHistoryState< + typeof routerWithStructuralSharing, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + { func: () => void } + >, + ) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + | ((state: { page?: number }) => { + func: 'Function is not serializable' + }) + | undefined + >() + + expectTypeOf( + useHistoryState< + typeof routerWithStructuralSharing, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + { date: () => void }, + true + >, + ) + .parameter(0) + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + }) + + test('when there are multiple state params', () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateState: () => ({ page: 0 }), + }) + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateState: () => ({ detail: 'detail' }), + }) + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{}>() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{ + page: number + }>() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((state: { page: number }) => unknown) | undefined>() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{ page?: number; detail?: string }>() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + ((state: { page?: number; detail?: string }) => unknown) | undefined + >() + }) + + test('when there are overlapping state params', () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateState: () => ({ page: 0 }), + }) + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + validateState: () => ({ detail: 50 }) as const, + }) + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute]), + indexRoute, + ]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices/', + /* strict */ true, + /* shouldThrow */ true, + { page: number; detail: 50 } + >({ + from: '/invoices/', + }), + ).toEqualTypeOf<{ + page: number + detail: 50 + }>() + }) + + test('when the root has no state params but the index route does', () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateState: () => ({ isHome: true }), + }) + const routeTree = rootRoute.addChildren([indexRoute]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{}>() + + expectTypeOf(useHistoryState).returns.toEqualTypeOf<{ + isHome: boolean + }>() + }) + + test('when the root has state params but the index route does not', () => { + const rootRoute = createRootRoute({ + validateState: () => ({ theme: 'dark' }), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const routeTree = rootRoute.addChildren([indexRoute]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{ + theme: string + }>() + + expectTypeOf(useHistoryState).returns.toEqualTypeOf<{ + theme: string + }>() + }) + + test('when the root has state params and the index does too', () => { + const rootRoute = createRootRoute({ + validateState: () => ({ theme: 'dark' }), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateState: () => ({ isHome: true }), + }) + const routeTree = rootRoute.addChildren([indexRoute]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{ + theme: string + }>() + + expectTypeOf(useHistoryState).returns.toEqualTypeOf<{ + theme: string + isHome: boolean + }>() + }) + + test('when route has a union of state params', () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const unionRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/union', + validateState: () => ({ status: 'active' as 'active' | 'inactive' }), + }) + const routeTree = rootRoute.addChildren([indexRoute, unionRoute]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{ + status: 'active' | 'inactive' + }>() + }) + + test('when a route has state params using StateSchemaInput', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateState: (input: { page?: number } & StateSchemaInput) => { + return { page: input.page ?? 0 } + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ routeTree }) + expectTypeOf(useHistoryState).returns.toEqualTypeOf<{ + page: number + }>() + }) + + describe('shouldThrow', () => { + test('when shouldThrow is true', () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const routeTree = rootRoute.addChildren([indexRoute]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState({ + from: '/', + shouldThrow: true, + }), + ).toEqualTypeOf<{}>() + }) + + test('when shouldThrow is false', () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const routeTree = rootRoute.addChildren([indexRoute]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState({ + from: '/', + shouldThrow: false, + }), + ).toEqualTypeOf<{} | undefined>() + }) + }) +}) 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..4b39819b7c 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, @@ -118,6 +119,7 @@ export interface RouteMatch< out TFullPath, out TAllParams, out TFullSearchSchema, + out TFullStateSchema, out TLoaderData, out TAllContext, out TLoaderDeps, @@ -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' @@ -162,6 +167,7 @@ export type MakeRouteMatchFromRoute = RouteMatch< TRoute['types']['fullPath'], TRoute['types']['allParams'], TRoute['types']['fullSearchSchema'], + TRoute['types']['fullStateSchema'], TRoute['types']['loaderData'], TRoute['types']['allContext'], TRoute['types']['loaderDeps'] @@ -180,6 +186,9 @@ export type MakeRouteMatch< TStrict extends false ? FullSearchSchema : RouteById['types']['fullSearchSchema'], + TStrict extends false + ? FullStateSchema + : RouteById['types']['fullStateSchema'], TStrict extends false ? AllLoaderData : RouteById['types']['loaderData'], @@ -189,7 +198,7 @@ export type MakeRouteMatch< RouteById['types']['loaderDeps'] > -export type AnyRouteMatch = RouteMatch +export type AnyRouteMatch = RouteMatch export type MakeRouteMatchUnion< TRouter extends AnyRouter = RegisteredRouter, @@ -200,6 +209,7 @@ export type MakeRouteMatchUnion< TRoute['fullPath'], TRoute['types']['allParams'], TRoute['types']['fullSearchSchema'], + TRoute['types']['fullStateSchema'], TRoute['types']['loaderData'], TRoute['types']['allContext'], TRoute['types']['loaderDeps'] diff --git a/packages/router-core/src/RouterProvider.ts b/packages/router-core/src/RouterProvider.ts index 972596bb12..32872e81f5 100644 --- a/packages/router-core/src/RouterProvider.ts +++ b/packages/router-core/src/RouterProvider.ts @@ -42,5 +42,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 2c689c3929..450240572b 100644 --- a/packages/router-core/src/fileRoute.ts +++ b/packages/router-core/src/fileRoute.ts @@ -39,6 +39,7 @@ export interface FileRouteOptions< TPath extends RouteConstraints['TPath'], TFullPath extends RouteConstraints['TFullPath'], TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -49,6 +50,7 @@ export interface FileRouteOptions< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -62,6 +64,7 @@ export interface FileRouteOptions< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, AnyContext, @@ -77,6 +80,7 @@ export type CreateFileRoute< TFullPath extends RouteConstraints['TFullPath'], > = < TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -90,6 +94,7 @@ export type CreateFileRoute< TPath, TFullPath, TSearchValidator, + TStateValidator, TParams, TRouteContextFn, TBeforeLoadFn, @@ -103,6 +108,7 @@ export type CreateFileRoute< TFilePath, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -120,6 +126,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 5a88e51364..6d91399fe5 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -121,6 +121,7 @@ export { BaseRoute, BaseRouteApi, BaseRootRoute } from './route' export type { AnyPathParams, SearchSchemaInput, + StateSchemaInput, AnyContext, RouteContext, PreloadableObj, @@ -350,6 +351,8 @@ export type { DefaultValidator, ResolveSearchValidatorInputFn, ResolveSearchValidatorInput, + ResolveStateValidatorInputFn, + ResolveStateValidatorInput, ResolveValidatorInputFn, ResolveValidatorInput, ResolveValidatorOutputFn, @@ -364,6 +367,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' @@ -409,6 +417,7 @@ export type { ValidateToPath, ValidateSearch, ValidateParams, + ValidateHistoryState, InferFrom, InferTo, InferMaskTo, @@ -423,4 +432,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 6f488b10cc..1554633a23 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, @@ -422,7 +423,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 & {} unsafeRelative?: 'path' } diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 4d55371437..ddc3f093f4 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -34,6 +34,7 @@ import type { AnyValidatorObj, DefaultValidator, ResolveSearchValidatorInput, + ResolveStateValidatorInput, ResolveValidatorOutput, StandardSchemaValidator, ValidatorAdapter, @@ -47,6 +48,10 @@ export type SearchSchemaInput = { __TSearchSchemaInput__: 'TSearchSchemaInput' } +export type StateSchemaInput = { + __TStateSchemaInput__: 'TStateSchemaInput' +} + export type AnyContext = {} export interface RouteContext {} @@ -103,6 +108,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 @@ -154,6 +166,35 @@ export type ResolveSearchSchema = ? ResolveSearchSchemaFn : ResolveSearchSchemaFn +export type ParseSplatParams = TPath & + `${string}$` extends never + ? TPath & `${string}$/${string}` extends never + ? 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 type ResolveRequiredParams = { [K in ParsePathParams['required']]: T } @@ -183,12 +224,12 @@ export type ParamsOptions = { stringify?: StringifyParamsFn } - /** + /** @deprecated Use params.parse instead */ parseParams?: ParseParamsFn - /** + /** @deprecated Use params.stringify instead */ stringifyParams?: StringifyParamsFn @@ -311,6 +352,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, @@ -382,6 +441,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, @@ -400,11 +460,19 @@ export interface RouteTypes< searchSchema: ResolveValidatorOutput searchSchemaInput: ResolveSearchValidatorInput searchValidator: TSearchValidator + stateSchema: ResolveStateSchema + stateSchemaInput: ResolveStateValidatorInput + stateValidator: TStateValidator fullSearchSchema: ResolveFullSearchSchema fullSearchSchemaInput: ResolveFullSearchSchemaInput< TParentRoute, TSearchValidator > + fullStateSchema: ResolveFullStateSchema + fullStateSchemaInput: ResolveFullStateSchemaInput< + TParentRoute, + TStateValidator + > params: TParams allParams: ResolveAllParamsFromParent routerContext: TRouterContext @@ -445,6 +513,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, @@ -464,6 +533,7 @@ export type RouteAddChildrenFn< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -481,6 +551,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, @@ -497,6 +568,7 @@ export type RouteAddFileChildrenFn< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -514,6 +586,7 @@ export type RouteAddFileTypesFn< TCustomId extends string, TId extends string, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -528,6 +601,7 @@ export type RouteAddFileTypesFn< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -545,6 +619,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, @@ -564,6 +639,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -580,6 +656,7 @@ export interface Route< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -598,6 +675,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -620,6 +698,7 @@ export interface Route< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, TRouterContext, @@ -635,6 +714,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -652,6 +732,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -667,6 +748,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -682,6 +764,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -706,6 +789,7 @@ export type AnyRoute = Route< any, any, any, + any, any > @@ -720,6 +804,7 @@ export type RouteOptions< TFullPath extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = AnyPathParams, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -732,6 +817,7 @@ export type RouteOptions< TCustomId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -745,6 +831,7 @@ export type RouteOptions< NoInfer, NoInfer, NoInfer, + NoInfer, NoInfer, NoInfer, NoInfer, @@ -787,6 +874,7 @@ export type FileBaseRouteOptions< TId extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = {}, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -796,6 +884,7 @@ export type FileBaseRouteOptions< TRemountDepsFn = AnyContext, > = ParamsOptions & { validateSearch?: Constrain + validateState?: Constrain shouldReload?: | boolean @@ -878,6 +967,7 @@ export type BaseRouteOptions< TCustomId extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = {}, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -890,6 +980,7 @@ export type BaseRouteOptions< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -946,6 +1037,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, @@ -958,6 +1050,7 @@ type AssetFnContextOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -973,6 +1066,7 @@ type AssetFnContextOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1002,6 +1096,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, @@ -1029,13 +1124,13 @@ export interface UpdatableRouteOptions< > > } - /** + /** @deprecated Use search.middlewares instead */ preSearchFilters?: Array< SearchFilter> > - /** + /** @deprecated Use search.middlewares instead */ postSearchFilters?: Array< @@ -1051,6 +1146,7 @@ export interface UpdatableRouteOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1067,6 +1163,7 @@ export interface UpdatableRouteOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1083,6 +1180,7 @@ export interface UpdatableRouteOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1100,6 +1198,7 @@ export interface UpdatableRouteOptions< TParentRoute, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TRouterContext, TRouteContextFn, @@ -1114,6 +1213,7 @@ export interface UpdatableRouteOptions< TParentRoute, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TRouterContext, TRouteContextFn, @@ -1132,6 +1232,7 @@ export interface UpdatableRouteOptions< TParentRoute, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TRouterContext, TRouteContextFn, @@ -1207,6 +1308,7 @@ export interface LoaderFnContext< export type RootRouteOptions< TSearchValidator = undefined, + TStateValidator = undefined, TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -1220,6 +1322,7 @@ export type RootRouteOptions< '', // TFullPath '', // TPath TSearchValidator, + TStateValidator, {}, // TParams TLoaderDeps, TLoaderFn, @@ -1244,6 +1347,8 @@ export type RouteConstraints = { TId: string TSearchSchema: AnySchema TFullSearchSchema: AnySchema + TStateSchema: AnySchema + TFullStateSchema: AnySchema TParams: Record TAllParams: Record TParentContext: AnyContext @@ -1296,6 +1401,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, @@ -1313,6 +1419,7 @@ export class BaseRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -1362,6 +1469,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1384,6 +1492,7 @@ export class BaseRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -1407,6 +1516,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1428,6 +1538,7 @@ export class BaseRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -1503,6 +1614,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1521,6 +1633,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1547,6 +1660,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1580,6 +1694,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1598,6 +1713,7 @@ export class BaseRoute< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, TRouterContext, @@ -1617,6 +1733,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1646,6 +1763,7 @@ export class BaseRouteApi { export interface RootRoute< in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TRouterContext = {}, in out TRouteContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, @@ -1660,6 +1778,7 @@ export interface RootRoute< string, // TCustomId RootRouteId, // TId TSearchValidator, // TSearchValidator + TStateValidator, // TStateValidator {}, // TParams TRouterContext, TRouteContextFn, @@ -1672,6 +1791,7 @@ export interface RootRoute< export class BaseRootRoute< in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TRouterContext = {}, in out TRouteContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, @@ -1686,6 +1806,7 @@ export class BaseRootRoute< string, // TCustomId RootRouteId, // TId TSearchValidator, // TSearchValidator + TStateValidator, // TStateValidator {}, // TParams TRouterContext, TRouteContextFn, @@ -1698,6 +1819,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 29c3cf2af5..2401089de7 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2,6 +2,7 @@ import { Store, batch } from '@tanstack/store' import { createBrowserHistory, createMemoryHistory, + omitInternalKeys, parseHref, } from '@tanstack/history' import invariant from 'tiny-invariant' @@ -1201,6 +1202,39 @@ export class RouterCore< return [parentSearch, {}, searchParamError] } })() + const [preMatchState, strictMatchState, stateError]: [ + Record, + Record, + Error | undefined, + ] = (() => { + const rawState = parentMatch?.state ?? next.state + const parentStrictState = parentMatch?._strictState ?? {} + const filteredState = rawState ? omitInternalKeys(rawState) : {} + + 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 @@ -1256,6 +1290,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 = @@ -1282,6 +1320,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], @@ -1313,6 +1356,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) @@ -1537,6 +1582,26 @@ export class RouterCore< // Replace the equal deep nextState = replaceEqualDeep(currentLocation.state, nextState) + if (opts._includeValidateState) { + let validatedState = {} + destRoutes.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 the next location return { pathname: nextPathname, @@ -2944,6 +3009,8 @@ export class RouterCore< export class SearchParamError extends Error {} +export class StateParamError extends Error {} + export class PathParamError extends Error {} // A function that takes an import() argument which is a function and returns a new function that will @@ -2978,34 +3045,46 @@ export function getInitialRouterState( } } -function validateSearch(validateSearch: AnyValidator, input: unknown): unknown { - if (validateSearch == null) return {} +function validateInput( + validator: AnyValidator, + input: unknown, + ErrorClass: new (message?: string, options?: ErrorOptions) => TErrorClass, +): unknown { + if (validator == null) return {} - if ('~standard' in validateSearch) { - const result = validateSearch['~standard'].validate(input) + if ('~standard' in validator) { + const result = validator['~standard'].validate(input) if (result instanceof Promise) - throw new SearchParamError('Async validation not supported') + throw new ErrorClass('Async validation not supported') if (result.issues) - throw new SearchParamError(JSON.stringify(result.issues, undefined, 2), { + throw new ErrorClass(JSON.stringify(result.issues, undefined, 2), { cause: result, }) return result.value } - if ('parse' in validateSearch) { - return validateSearch.parse(input) + if ('parse' in validator) { + return validator.parse(input) } - if (typeof validateSearch === 'function') { - return validateSearch(input) + if (typeof validator === 'function') { + return validator(input) } return {} } +function validateState(validateState: AnyValidator, input: unknown): unknown { + return validateInput(validateState, input, StateParamError) +} + +function validateSearch(validateSearch: AnyValidator, input: unknown): unknown { + return validateInput(validateSearch, input, SearchParamError) +} + export const componentTypes = [ 'component', 'errorComponent', diff --git a/packages/router-core/src/typePrimitives.ts b/packages/router-core/src/typePrimitives.ts index ebc80ed732..75f0c6b3ef 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..33217ce8a0 100644 --- a/packages/router-core/src/validators.ts +++ b/packages/router-core/src/validators.ts @@ -1,4 +1,4 @@ -import type { SearchSchemaInput } from './route' +import type { SearchSchemaInput, StateSchemaInput } from './route' export interface StandardSchemaValidatorProps { readonly types?: StandardSchemaValidatorTypes | undefined @@ -72,22 +72,33 @@ export type AnySchema = {} export type DefaultValidator = Validator, AnySchema> -export type ResolveSearchValidatorInputFn = TValidator extends ( - input: infer TSchemaInput, -) => any - ? TSchemaInput extends SearchSchemaInput - ? Omit - : ResolveValidatorOutputFn - : AnySchema +export type ResolveSchemaValidatorInputFn = + TValidator extends (input: infer TInferredInput) => any + ? TInferredInput extends TSchemaInput + ? Omit + : ResolveValidatorOutputFn + : AnySchema -export type ResolveSearchValidatorInput = +export type ResolveSearchValidatorInputFn = + ResolveSchemaValidatorInputFn + +export type ResolveStateValidatorInputFn = + ResolveSchemaValidatorInputFn + +export type ResolveSchemaValidatorInput = TValidator extends AnyStandardSchemaValidator ? NonNullable['input'] : TValidator extends AnyValidatorAdapter ? TValidator['types']['input'] : TValidator extends AnyValidatorObj - ? ResolveSearchValidatorInputFn - : ResolveSearchValidatorInputFn + ? ResolveSchemaValidatorInputFn + : ResolveSchemaValidatorInputFn + +export type ResolveSearchValidatorInput = + ResolveSchemaValidatorInput + +export type ResolveStateValidatorInput = + ResolveSchemaValidatorInput export type ResolveValidatorInputFn = TValidator extends ( input: infer TInput, diff --git a/packages/router-devtools-core/package.json b/packages/router-devtools-core/package.json index 6e9fe78016..dd69978ce7 100644 --- a/packages/router-devtools-core/package.json +++ b/packages/router-devtools-core/package.json @@ -64,6 +64,7 @@ "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", + "@tanstack/history": "workspace:*", "solid-js": "^1.9.5" }, "devDependencies": { diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index a84f318bc6..7d96318c12 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -2,6 +2,7 @@ import { clsx as cx } from 'clsx' import { default as invariant } from 'tiny-invariant' import { interpolatePath, rootRouteId, trimPath } from '@tanstack/router-core' import { Show, createMemo } from 'solid-js' +import { omitInternalKeys } from '@tanstack/history' import { useDevtoolsOnClose } from './context' import { useStyles } from './useStyles' import useLocalStorage from './useLocalStorage' @@ -104,6 +105,7 @@ function RouteComp({ string, '__root__', undefined, + undefined, {}, {}, AnyContext, @@ -228,6 +230,17 @@ function RouteComp({ ) } +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 @@ -278,6 +291,12 @@ export const BaseTanStackRouterDevtoolsPanel = () => Object.keys(routerState().location.search).length, ) + const validatedState = createMemo(() => + omitInternalKeys(getMergedStrictState(routerState())), + ) + + const hasState = createMemo(() => Object.keys(validatedState()).length) + const explorerState = createMemo(() => { return { ...router(), @@ -323,6 +342,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 427416d193..56604a0dec 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, @@ -54,7 +56,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. */ @@ -77,6 +79,7 @@ export class FileRoute< createRoute = < TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -89,6 +92,7 @@ export class FileRoute< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -102,6 +106,7 @@ export class FileRoute< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, AnyContext, @@ -115,6 +120,7 @@ export class FileRoute< TFilePath, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -134,7 +140,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. @@ -211,6 +217,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 039f99b7e0..c014ca1f1b 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -58,6 +58,7 @@ export type { ResolveOptionalParams, ResolveRequiredParams, SearchSchemaInput, + StateSchemaInput, AnyContext, RouteContext, PreloadableObj, @@ -116,6 +117,8 @@ export type { ResolveValidatorOutputFn, ResolveSearchValidatorInput, ResolveSearchValidatorInputFn, + ResolveStateValidatorInput, + ResolveStateValidatorInputFn, Validator, ValidatorAdapter, ValidatorObj, @@ -322,6 +325,7 @@ export { useNavigate, Navigate } from './useNavigate' export { useParams } from './useParams' export { useSearch } from './useSearch' +export { useHistoryState } from './useHistoryState' export { getRouterContext, // SSR @@ -351,6 +355,7 @@ export type { ValidateToPath, ValidateSearch, ValidateParams, + ValidateHistoryState, InferFrom, InferTo, InferMaskTo, diff --git a/packages/solid-router/src/route.tsx b/packages/solid-router/src/route.tsx index 5e61e3926b..fcb4fc9d0f 100644 --- a/packages/solid-router/src/route.tsx +++ b/packages/solid-router/src/route.tsx @@ -12,6 +12,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, @@ -42,6 +43,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' import type { LinkComponentRoute } from './link' @@ -64,6 +66,7 @@ declare module '@tanstack/router-core' { useParams: UseParamsRoute useLoaderDeps: UseLoaderDepsRoute useLoaderData: UseLoaderDataRoute + useHistoryState: UseHistoryStateRoute useNavigate: () => UseNavigateResult Link: LinkComponentRoute } @@ -115,6 +118,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) } @@ -157,7 +168,8 @@ export class Route< TPath >, in out TSearchValidator = undefined, - in out TParams = ResolveParams, + in out TStateValidator = undefined, + in out TParams = ResolveParams, in out TRouterContext = AnyContext, in out TRouteContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, @@ -173,6 +185,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -190,7 +203,8 @@ export class Route< TCustomId, TId, TSearchValidator, - TParams, + TStateValidator, + TParams, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -211,6 +225,7 @@ export class Route< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -251,6 +266,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) } @@ -282,6 +305,7 @@ export function createRoute< TPath >, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -296,6 +320,7 @@ export function createRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -310,6 +335,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -326,6 +352,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -344,11 +371,13 @@ export function createRootRouteWithContext() { TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, >( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -358,6 +387,7 @@ export function createRootRouteWithContext() { ) => { return createRootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -373,28 +403,30 @@ export function createRootRouteWithContext() { export const rootRouteWithContext = createRootRouteWithContext export class RootRoute< - in out TSearchValidator = undefined, - in out TRouterContext = {}, - in out TRouteContextFn = AnyContext, - in out TBeforeLoadFn = AnyContext, - in out TLoaderDeps extends Record = {}, - in out TLoaderFn = undefined, - in out TChildren = unknown, - in out TFileRouteTypes = unknown, - > - extends BaseRootRoute< - TSearchValidator, - TRouterContext, - TRouteContextFn, - TBeforeLoadFn, - TLoaderDeps, - TLoaderFn, - TChildren, - TFileRouteTypes + in out TSearchValidator = undefined, + in out TStateValidator = undefined, + in out TRouterContext = {}, + in out TRouteContextFn = AnyContext, + in out TBeforeLoadFn = AnyContext, + in out TLoaderDeps extends Record = {}, + in out TLoaderFn = undefined, + in out TChildren = unknown, + in out TFileRouteTypes = unknown, +> extends BaseRootRoute< + TSearchValidator, + TStateValidator, + TRouterContext, + TRouteContextFn, + TBeforeLoadFn, + TLoaderDeps, + TLoaderFn, + TChildren, + TFileRouteTypes > implements RootRouteCore< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -410,6 +442,7 @@ export class RootRoute< constructor( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -449,6 +482,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) } @@ -498,6 +539,7 @@ export class NotFoundRoute< TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TChildren = unknown, @@ -508,6 +550,7 @@ export class NotFoundRoute< '404', '404', TSearchValidator, + TStateValidator, {}, TRouterContext, TRouteContextFn, @@ -525,6 +568,7 @@ export class NotFoundRoute< string, string, TSearchValidator, + TStateValidator, {}, TLoaderDeps, TLoaderFn, @@ -549,6 +593,7 @@ export class NotFoundRoute< export function createRootRoute< TSearchValidator = undefined, + TStateValidator = undefined, TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -557,6 +602,7 @@ export function createRootRoute< >( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -565,6 +611,7 @@ export function createRootRoute< >, ): RootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -575,6 +622,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..c628f0a565 --- /dev/null +++ b/packages/solid-router/src/useHistoryState.tsx @@ -0,0 +1,82 @@ +import { omitInternalKeys } from '@tanstack/history' +import { useMatch } from './useMatch' +import type { Accessor } from 'solid-js' +import type { + AnyRouter, + RegisteredRouter, + ResolveUseHistoryState, + StrictOrFrom, + ThrowConstraint, + ThrowOrOptional, + UseHistoryStateResult, +} from '@tanstack/router-core' + +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, + TStrict extends boolean, + TThrow extends boolean, + TSelected, +> = StrictOrFrom & + UseHistoryStateBaseOptions + +export type UseHistoryStateRoute = < + TRouter extends AnyRouter = RegisteredRouter, + TSelected = unknown, +>( + 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, + TSelected = unknown, +>( + opts: UseHistoryStateOptions< + TRouter, + TFrom, + TStrict, + ThrowConstraint, + TSelected + >, +): Accessor< + ThrowOrOptional< + UseHistoryStateResult, + TThrow + > +> { + return useMatch({ + from: opts.from!, + strict: opts.strict, + shouldThrow: opts.shouldThrow, + select: (match: any) => { + const matchState = match.state + const filteredState = omitInternalKeys(matchState) + const typedState = filteredState as unknown as ResolveUseHistoryState< + TRouter, + TFrom, + TStrict + > + 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..a26ebb0b29 100644 --- a/packages/solid-router/tests/Matches.test-d.tsx +++ b/packages/solid-router/tests/Matches.test-d.tsx @@ -20,6 +20,7 @@ type RootMatch = RouteMatch< RootRoute['fullPath'], RootRoute['types']['allParams'], RootRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], RootRoute['types']['loaderData'], RootRoute['types']['allContext'], RootRoute['types']['loaderDeps'] @@ -37,6 +38,7 @@ type IndexMatch = RouteMatch< IndexRoute['fullPath'], IndexRoute['types']['allParams'], IndexRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], IndexRoute['types']['loaderData'], IndexRoute['types']['allContext'], IndexRoute['types']['loaderDeps'] @@ -53,6 +55,7 @@ type InvoiceMatch = RouteMatch< InvoiceRoute['fullPath'], InvoiceRoute['types']['allParams'], InvoiceRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], InvoiceRoute['types']['loaderData'], InvoiceRoute['types']['allContext'], InvoiceRoute['types']['loaderDeps'] @@ -65,6 +68,7 @@ type InvoicesMatch = RouteMatch< InvoicesRoute['fullPath'], InvoicesRoute['types']['allParams'], InvoicesRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], InvoicesRoute['types']['loaderData'], InvoicesRoute['types']['allContext'], InvoicesRoute['types']['loaderDeps'] @@ -82,6 +86,7 @@ type InvoicesIndexMatch = RouteMatch< InvoicesIndexRoute['fullPath'], InvoicesIndexRoute['types']['allParams'], InvoicesIndexRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], InvoicesIndexRoute['types']['loaderData'], InvoicesIndexRoute['types']['allContext'], InvoicesIndexRoute['types']['loaderDeps'] @@ -107,6 +112,7 @@ type LayoutMatch = RouteMatch< LayoutRoute['fullPath'], LayoutRoute['types']['allParams'], LayoutRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], LayoutRoute['types']['loaderData'], LayoutRoute['types']['allContext'], LayoutRoute['types']['loaderDeps'] @@ -130,6 +136,7 @@ type CommentsMatch = RouteMatch< CommentsRoute['fullPath'], CommentsRoute['types']['allParams'], CommentsRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], CommentsRoute['types']['loaderData'], CommentsRoute['types']['allContext'], CommentsRoute['types']['loaderDeps'] diff --git a/packages/solid-router/tests/useHistoryState.test-d.tsx b/packages/solid-router/tests/useHistoryState.test-d.tsx new file mode 100644 index 0000000000..fdafe2b448 --- /dev/null +++ b/packages/solid-router/tests/useHistoryState.test-d.tsx @@ -0,0 +1,517 @@ +import { describe, expectTypeOf, test } from 'vitest' +import { + createRootRoute, + createRoute, + createRouter, + useHistoryState, +} from '../src' +import type { Accessor } from 'solid-js' + +describe('useHistoryState', () => { + test('when there are no state params', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + const _defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof _defaultRouter + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('from') + .toEqualTypeOf< + '/invoices' | '__root__' | '/invoices/$invoiceId' | '/invoices/' | '/' + >() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('strict') + .toEqualTypeOf() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .parameter(0) + .toEqualTypeOf<{}>() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .returns.toEqualTypeOf() + + expectTypeOf(useHistoryState).returns.toEqualTypeOf< + Accessor<{}> + >() + + expectTypeOf( + useHistoryState({ + strict: false, + }), + ).toEqualTypeOf>() + }) + + test('when there is one state param', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateState: () => ({ page: 0 }), + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + const _defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof _defaultRouter + + expectTypeOf(useHistoryState).returns.toEqualTypeOf< + Accessor<{}> + >() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf< + Accessor<{ + page: number + }> + >() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((state: { page: number }) => unknown) | undefined>() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf>() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((state: { page?: number }) => unknown) | undefined>() + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + number + >, + ).returns.toEqualTypeOf>() + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + { func: () => void } + >, + ) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + ((state: { page?: number }) => { func: () => void }) | undefined + >() + }) + + test('when there are multiple state params', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateState: () => ({ page: 0 }), + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateState: () => ({ detail: 'detail' }), + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + const _defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof _defaultRouter + + expectTypeOf(useHistoryState).returns.toEqualTypeOf< + Accessor<{}> + >() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf< + Accessor<{ + page: number + }> + >() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((state: { page: number }) => unknown) | undefined>() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf< + Accessor<{ + page: number + detail: string + }> + >() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf>() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + ((state: { page?: number; detail?: string }) => unknown) | undefined + >() + }) + + test('when there are overlapping state params', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateState: () => ({ page: 0 }), + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + validateState: () => ({ detail: 50 }) as const, + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateState: () => ({ detail: 'detail' }) as const, + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + const _defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof _defaultRouter + + expectTypeOf(useHistoryState).returns.toEqualTypeOf< + Accessor<{}> + >() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf< + Accessor<{ + page: number + }> + >() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((state: { page: number }) => unknown) | undefined>() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf< + Accessor<{ page?: number; detail?: 'detail' | 50 }> + >() + }) + + test('when the root has no state params but the index route does', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateState: () => ({ indexPage: 0 }), + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + const _defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof _defaultRouter + + expectTypeOf(useHistoryState).returns.toEqualTypeOf< + Accessor<{ + indexPage: number + }> + >() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf>() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf>() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((state: {}) => unknown) | undefined>() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf>() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((state: { indexPage?: number }) => unknown) | undefined>() + }) + + test('when the root has state params but the index route does not', () => { + const rootRoute = createRootRoute({ + validateState: () => ({ rootPage: 0 }), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + const _defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof _defaultRouter + + expectTypeOf(useHistoryState).returns.toEqualTypeOf< + Accessor<{ + rootPage: number + }> + >() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf< + Accessor<{ + rootPage: number + }> + >() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf< + Accessor<{ + rootPage: number + }> + >() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((state: { rootPage: number }) => unknown) | undefined>() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf>() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((state: { rootPage?: number }) => unknown) | undefined>() + }) + + test('when the root has state params and the index does too', () => { + const rootRoute = createRootRoute({ + validateState: () => ({ rootPage: 0 }), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateState: () => ({ indexPage: 0 }), + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + const _defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof _defaultRouter + + expectTypeOf(useHistoryState).returns.toEqualTypeOf< + Accessor<{ + rootPage: number + indexPage: number + }> + >() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf< + Accessor<{ + rootPage: number + }> + >() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf< + Accessor<{ + rootPage: number + }> + >() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((state: { rootPage: number }) => unknown) | undefined>() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf< + Accessor<{ rootPage?: number; indexPage?: number }> + >() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + | ((state: { rootPage?: number; indexPage?: number }) => unknown) + | undefined + >() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1c77ac017..8cb6c24fd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2715,6 +2715,52 @@ importers: specifier: 6.3.5 version: 6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(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.3) + postcss: + specifier: ^8.5.1 + version: 8.5.3 + 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.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)) + typescript: + specifier: ^5.7.2 + version: 5.8.2 + vite: + specifier: 6.3.5 + version: 6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) + examples/react/basic-non-nested-devtools: dependencies: '@tanstack/react-router': @@ -6111,6 +6157,12 @@ importers: specifier: ^1.3.3 version: 1.3.3 devDependencies: + '@tanstack/history': + specifier: workspace:* + version: link:../history + solid-js: + specifier: ^1.9.5 + version: 1.9.5 vite-plugin-solid: specifier: ^2.11.6 version: 2.11.6(@testing-library/jest-dom@6.6.3)(solid-js@1.9.5)(vite@6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0))