diff --git a/docs/router/api/router.md b/docs/router/api/router.md index 2c5ede186c0..67b52db0962 100644 --- a/docs/router/api/router.md +++ b/docs/router/api/router.md @@ -39,6 +39,7 @@ title: Router API - [`useBlocker`](./router/useBlockerHook.md) - [`useCanGoBack`](./router/useCanGoBack.md) - [`useChildMatches`](./router/useChildMatchesHook.md) + - [`useHistoryState`](./router/useHistoryStateHook.md) - [`useLinkProps`](./router/useLinkPropsHook.md) - [`useLoaderData`](./router/useLoaderDataHook.md) - [`useLoaderDeps`](./router/useLoaderDepsHook.md) diff --git a/docs/router/api/router/useHistoryStateHook.md b/docs/router/api/router/useHistoryStateHook.md new file mode 100644 index 00000000000..dcc88cba0da --- /dev/null +++ b/docs/router/api/router/useHistoryStateHook.md @@ -0,0 +1,190 @@ +--- +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`. + +> [!NOTE] +> `useHistoryState` is currently available for React and Solid. Vue support is planned as a follow-up; `validateState` itself already runs for Vue, but there is no Vue hook to read the result yet. + +## 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), +}) +``` + +When a route (or any of its parents) declares `validateState`, `useHistoryState` returns **only** what those validators produced. Keys present in the raw history state that no validator claimed are not exposed, so the value you receive always matches the validated type. If no route in the chain declares `validateState`, the route's state type is unconstrained and the raw state is passed through unchanged. + +Validation also runs when a navigation is committed, so defaults and transforms your validator applies are written into the actual history entry rather than being recomputed on read. + +### Direct loads, refreshes, and SSR + +History state does not survive a fresh document load. Opening a URL directly, refreshing the page, or rendering on the server all start from an empty state object, so `validateState` receives `{}` in those cases. + +This means a schema with **required** fields will fail on a direct load. That failure is surfaced the same way a `validateSearch` failure is: the route renders its `errorComponent` with a `StateParamError`, and on the server the response status becomes `500`. This is deliberate — it prevents a component from ever receiving a value that does not match its validated type. + +For any route that can be reached by a direct URL, give the schema defaults or make the fields optional: + +```tsx +const route = createRoute({ + // ... + // `count` is always present, falling back to 0 on a direct load + validateState: (input) => + z.object({ count: z.number().default(0) }).parse(input), +}) +``` + +If the route only makes sense when state is present, you can redirect instead of erroring — `redirect()` and `notFound()` thrown from `validateState` are passed through rather than wrapped: + +```tsx +const route = createRoute({ + // ... + validateState: (input: { token?: string }) => { + if (typeof input.token !== 'string') { + throw redirect({ to: '/login' }) + } + return { token: input.token } + }, +}) +``` + +Setting `strict: false` on the hook widens the return type so every field is optional, which is useful when you want to read state without committing to a schema. + +## 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 00000000000..8354e4d50d5 --- /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 00000000000..00b5278e580 --- /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 00000000000..115199d292c --- /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 00000000000..9b6335c0ac1 --- /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 00000000000..f07702e96e1 --- /dev/null +++ b/examples/react/basic-history-state/package.json @@ -0,0 +1,28 @@ +{ + "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": { + "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-router": "^1.158.4", + "@tanstack/react-router-devtools": "^1.158.4", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "redaxios": "^0.5.1", + "tailwindcss": "^4.1.18", + "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": "^7.3.1" + } +} 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 00000000000..40469e080cf --- /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 00000000000..ce24a390c75 --- /dev/null +++ b/examples/react/basic-history-state/src/styles.css @@ -0,0 +1,21 @@ +@import 'tailwindcss' source('../'); + +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } +} + +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/tsconfig.dev.json b/examples/react/basic-history-state/tsconfig.dev.json new file mode 100644 index 00000000000..285a09b0dcf --- /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 00000000000..ce3a7d23397 --- /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 00000000000..2764078e8ba --- /dev/null +++ b/examples/react/basic-history-state/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [tailwindcss(), react()], +}) diff --git a/packages/history/src/index.ts b/packages/history/src/index.ts index 0f3be8242e7..e81246a3104 100644 --- a/packages/history/src/index.ts +++ b/packages/history/src/index.ts @@ -96,6 +96,33 @@ const stateIndexKey = '__TSR_index' const popStateEvent = 'popstate' const beforeUnloadEvent = 'beforeunload' +/** + * Internal state keys are those that start with '__' (e.g. `__TSR_index`, + * `__TSR_key`, `__tempLocation`) or equal the legacy 'key'. + */ +const isInternalKey = (key: string) => key.startsWith('__') || key === 'key' + +/** + * Filters out internal state keys from a state object, leaving only the + * user-supplied state. + */ +export function omitInternalKeys(state: object): Record { + return Object.fromEntries( + Object.entries(state).filter(([key]) => !isInternalKey(key)), + ) +} + +/** + * Keeps only the internal state keys from a state object. This is the + * complement of `omitInternalKeys` and is used when user state has to be + * rebuilt without dropping the router's own bookkeeping. + */ +export function pickInternalKeys(state: object): Record { + return Object.fromEntries( + Object.entries(state).filter(([key]) => isInternalKey(key)), + ) +} + export function createHistory(opts: { getLocation: () => HistoryLocation getLength: () => number diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx index bf945571a6b..e13c29649b8 100644 --- a/packages/react-router/src/Transitioner.tsx +++ b/packages/react-router/src/Transitioner.tsx @@ -43,6 +43,7 @@ export function Transitioner() { hash: true, state: true, _includeValidateSearch: true, + _includeValidateState: true, }) // Check if the current URL matches the canonical form. diff --git a/packages/react-router/src/fileRoute.ts b/packages/react-router/src/fileRoute.ts index 46b96240a47..7203e0ab9ff 100644 --- a/packages/react-router/src/fileRoute.ts +++ b/packages/react-router/src/fileRoute.ts @@ -5,6 +5,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 { useRouteContext } from './useRouteContext' @@ -34,6 +35,7 @@ import type { import type { UseLoaderDepsRoute } from './useLoaderDeps' import type { UseLoaderDataRoute } from './useLoaderData' import type { UseRouteContextRoute } from './useRouteContext' +import type { UseHistoryStateRoute } from './useHistoryState' /** * Creates a file-based Route factory for a given path. @@ -61,7 +63,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. */ @@ -85,6 +87,7 @@ export class FileRoute< createRoute = < TRegister = Register, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -101,6 +104,7 @@ export class FileRoute< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -118,6 +122,7 @@ export class FileRoute< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, AnyContext, @@ -132,6 +137,7 @@ export class FileRoute< TFilePath, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -235,6 +241,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 6637c7542bc..6ba411da0fe 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -41,6 +41,7 @@ export type { ResolveOptionalParams, ResolveRequiredParams, SearchSchemaInput, + StateSchemaInput, AnyContext, RouteContext, PreloadableObj, @@ -97,6 +98,8 @@ export type { ResolveValidatorOutputFn, ResolveSearchValidatorInput, ResolveSearchValidatorInputFn, + ResolveStateValidatorInput, + ResolveStateValidatorInputFn, Validator, ValidatorAdapter, ValidatorObj, @@ -275,7 +278,7 @@ export type { export { createRouter, Router } from './router' -export { lazyFn, SearchParamError } from '@tanstack/router-core' +export { lazyFn, SearchParamError, StateParamError } from '@tanstack/router-core' export { RouterProvider, RouterContextProvider } from './RouterProvider' export type { RouterProps } from './RouterProvider' @@ -292,6 +295,7 @@ export { useNavigate, Navigate } from './useNavigate' export { useParams } from './useParams' export { useSearch } from './useSearch' +export { useHistoryState } from './useHistoryState' export { useRouteContext } from './useRouteContext' export { useRouter } from './useRouter' diff --git a/packages/react-router/src/route.tsx b/packages/react-router/src/route.tsx index 3021e3fb7d2..3bc485f3c6b 100644 --- a/packages/react-router/src/route.tsx +++ b/packages/react-router/src/route.tsx @@ -13,6 +13,7 @@ import { useNavigate } from './useNavigate' import { useMatch } from './useMatch' import { useRouteContext } from './useRouteContext' import { useRouter } from './useRouter' +import { useHistoryState } from './useHistoryState' import { Link } from './link' import type { AnyContext, @@ -45,6 +46,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' @@ -74,6 +76,7 @@ declare module '@tanstack/router-core' { useParams: UseParamsRoute useLoaderDeps: UseLoaderDepsRoute useLoaderData: UseLoaderDataRoute + useHistoryState: UseHistoryStateRoute useNavigate: () => UseNavigateResult Link: LinkComponentRoute } @@ -127,6 +130,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({ @@ -180,6 +192,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, @@ -200,6 +213,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -221,6 +235,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -246,6 +261,7 @@ export class Route< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -281,6 +297,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({ @@ -335,6 +360,7 @@ export function createRoute< TPath >, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -352,6 +378,7 @@ export function createRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -369,6 +396,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -387,6 +415,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -431,6 +460,7 @@ export function createRootRouteWithContext() { TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TSSR = unknown, @@ -439,6 +469,7 @@ export function createRootRouteWithContext() { options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -451,6 +482,7 @@ export function createRootRouteWithContext() { return createRootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -470,6 +502,7 @@ export const rootRouteWithContext = createRootRouteWithContext export class RootRoute< in out TRegister = unknown, in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TRouterContext = {}, in out TRouteContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, @@ -484,6 +517,7 @@ export class RootRoute< extends BaseRootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -499,6 +533,7 @@ export class RootRoute< RootRouteCore< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -518,6 +553,7 @@ export class RootRoute< options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -552,6 +588,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({ @@ -593,6 +638,7 @@ export class RootRoute< export function createRootRoute< TRegister = Register, TSearchValidator = undefined, + TStateValidator = undefined, TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -605,6 +651,7 @@ export function createRootRoute< options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -617,6 +664,7 @@ export function createRootRoute< ): RootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -631,6 +679,7 @@ export function createRootRoute< return new RootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -680,6 +729,7 @@ export class NotFoundRoute< TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TChildren = unknown, @@ -693,6 +743,7 @@ export class NotFoundRoute< '404', '404', TSearchValidator, + TStateValidator, {}, TRouterContext, TRouteContextFn, @@ -713,6 +764,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 00000000000..ea267528061 --- /dev/null +++ b/packages/react-router/src/useHistoryState.tsx @@ -0,0 +1,102 @@ +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) => { + // `_strictState` only ever holds the output of the `validateState` + // validators along the route chain, so it is guaranteed to match the + // validated type. `match.state` is the loose merge of the raw history + // state and would leak unvalidated keys. + const typedState = match._strictState 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 89e1088454b..cc96047d282 100644 --- a/packages/react-router/tests/Matches.test-d.tsx +++ b/packages/react-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'], + IndexRoute['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'], + InvoiceRoute['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'], + InvoicesRoute['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'], + InvoicesIndexRoute['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'], + LayoutRoute['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'], + CommentsRoute['types']['fullStateSchema'], CommentsRoute['types']['loaderData'], CommentsRoute['types']['allContext'], CommentsRoute['types']['loaderDeps'] diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 43c4d474239..704c35c6695 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -18,11 +18,14 @@ import { Outlet, RouterProvider, SearchParamError, + StateParamError, createBrowserHistory, createMemoryHistory, createRootRoute, createRoute, createRouter, + redirect, + useHistoryState, useNavigate, } from '../src' import type { StandardSchemaValidator } from '@tanstack/router-core' @@ -1896,6 +1899,162 @@ describe('search params in URL', () => { }) }) }) + + describe('validates history state', () => { + class TestValidationError extends Error { + issues: Array<{ message: string }> + constructor(issues: Array<{ message: string }>) { + super('validation failed') + this.name = 'TestValidationError' + this.issues = issues + } + } + + const testCases: [ + StandardSchemaValidator, { count: number }>, + ValidatorFn, { count: number }>, + ValidatorObj, { count: number }>, + ] = [ + { + ['~standard']: { + validate: (input) => { + const result = z.object({ count: z.number() }).safeParse(input) + if (result.success) { + return { value: result.data } + } + return new TestValidationError(result.error.issues) + }, + }, + }, + ({ count }) => { + if (typeof count !== 'number') { + throw new TestValidationError([{ message: 'count must be a number' }]) + } + return { count } + }, + { + parse: ({ count }) => { + if (typeof count !== 'number') { + throw new TestValidationError([ + { message: 'count must be a number' }, + ]) + } + return { count } + }, + }, + ] + + describe.each(testCases)('state validation', (validateState) => { + // History state never survives a fresh document load, so a direct load + // always hands the validator an empty object. A schema with required + // fields therefore has to fail, which is what keeps `useHistoryState` + // from ever handing a component a value that is not of the validated + // type. + it('errors on a direct load when required state is absent', async () => { + let errorSpy: Error | undefined + const rootRoute = createRootRoute({ + validateState, + errorComponent: ({ error }) => { + errorSpy = error + return null + }, + }) + + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createRouter({ routeTree: rootRoute, history }) + render() + await act(() => router.load()) + + expect(errorSpy).toBeInstanceOf(StateParamError) + expect(errorSpy?.cause).toBeInstanceOf(TestValidationError) + }) + + it('does not error when navigating with valid state', async () => { + let errorSpy: Error | undefined + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
index
, + }) + const targetRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/target', + validateState, + component: () =>
target
, + errorComponent: ({ error }) => { + errorSpy = error + return null + }, + }) + + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history, + }) + render() + await act(() => router.load()) + await act(() => + router.navigate({ to: '/target', state: { count: 1 } as any }), + ) + + expect(errorSpy).toBeUndefined() + }) + }) + + it('applies validator defaults on a direct load', async () => { + let observedState: unknown + + function StateReader() { + observedState = useHistoryState({ from: '__root__' }) + return null + } + + const rootRoute = createRootRoute({ + validateState: (input: { count?: number }) => + z.object({ count: z.number().default(0) }).parse(input), + component: StateReader, + }) + + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createRouter({ routeTree: rootRoute, history }) + render() + await act(() => router.load()) + + expect(observedState).toEqual({ count: 0 }) + }) + + it('lets a validator redirect instead of erroring', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
index
, + }) + const guardedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/guarded', + validateState: (input: { token?: string }) => { + if (typeof input.token !== 'string') { + throw redirect({ to: '/' }) + } + return { token: input.token } + }, + component: () =>
guarded
, + }) + + const history = createMemoryHistory({ initialEntries: ['/guarded'] }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, guardedRoute]), + history, + }) + render() + await act(() => router.load()) + + expect(router.state.location.pathname).toBe('/') + }) + }) }) describe('route ids should be consistent after rebuilding the route tree', () => { 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 00000000000..42e4f5b5e7e --- /dev/null +++ b/packages/react-router/tests/useHistoryState.test-d.tsx @@ -0,0 +1,581 @@ +import { describe, expectTypeOf, test } from 'vitest' +import { + createRootRoute, + createRoute, + createRouter, + useHistoryState, +} from '../src' +import type { LinkOptions, 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>() + }) + }) +}) + +describe('Link state', () => { + const rootRoute = createRootRoute({ + validateState: (input: { theme?: 'light' | 'dark' }) => ({ + theme: input.theme ?? ('light' as const), + }), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateState: (input: { page?: number }) => ({ + page: input.page ?? 0, + }), + }) + const routeTree = rootRoute.addChildren([invoicesRoute, indexRoute]) + const defaultRouter = createRouter({ routeTree }) + type DefaultRouter = typeof defaultRouter + + type InvoicesState = LinkOptions['state'] + + test('narrows to the destination route full state schema', () => { + // The target type is the *full* state schema, so the key inherited from + // the root route is accepted alongside the route's own. + const asValue: InvoicesState = { page: 1, theme: 'dark' } + const asUpdater: InvoicesState = () => ({ page: 1, theme: 'dark' }) + + expectTypeOf(asValue).not.toBeNever() + expectTypeOf(asUpdater).not.toBeNever() + }) + + test('rejects values that do not match the state schema', () => { + // @ts-expect-error `page` is a number + const wrongType: InvoicesState = { page: 'nope', theme: 'dark' } + // @ts-expect-error `theme` is inherited from the root route validator + const wrongEnum: InvoicesState = { page: 1, theme: 'purple' } + + void wrongType + void wrongEnum + }) + + test('still accepts `true` to carry the current state forward', () => { + const carried: InvoicesState = true + expectTypeOf(carried).not.toBeNever() + }) +}) diff --git a/packages/react-router/tests/useHistoryState.test.tsx b/packages/react-router/tests/useHistoryState.test.tsx new file mode 100644 index 00000000000..5291c8c5639 --- /dev/null +++ b/packages/react-router/tests/useHistoryState.test.tsx @@ -0,0 +1,363 @@ +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, + StateParamError, + 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

, + }) + + let stateError: Error | undefined + + 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, + errorComponent: ({ error }) => { + stateError = error + return
{error.message}
+ }, + }) + + 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: the validator rejects `color: 'yellow'`, so + // the route renders its error boundary instead of handing the component + // a value that does not match the validated type. + const invalidButton = await screen.findByTestId('invalid-state-btn') + fireEvent.click(invalidButton) + + await waitFor(() => { + expect(screen.getByTestId('state-error')).toBeInTheDocument() + }) + + expect(screen.queryByTestId('valid-state')).not.toBeInTheDocument() + expect(stateError).toBeInstanceOf(StateParamError) + }) + + 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 IndexComponent = () => { + const navigate = useNavigate() + return ( + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + 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 852d186b67d..1cf9fadd3d3 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, @@ -120,6 +121,7 @@ export interface RouteMatch< out TFullPath, out TAllParams, out TFullSearchSchema, + out TFullStateSchema, out TLoaderData, out TAllContext, out TLoaderDeps, @@ -136,6 +138,7 @@ export interface RouteMatch< error: unknown paramsError: unknown searchError: unknown + stateError: unknown updatedAt: number _nonReactive: { /** @internal */ @@ -159,6 +162,8 @@ export interface RouteMatch< context: TAllContext search: TFullSearchSchema _strictSearch: TFullSearchSchema + state: TFullStateSchema + _strictState: TFullStateSchema fetchCount: number abortController: AbortController cause: 'preload' | 'enter' | 'stay' @@ -179,6 +184,7 @@ export interface PreValidationErrorHandlingRouteMatch< TFullPath, TAllParams, TFullSearchSchema, + TFullStateSchema, > { id: string routeId: TRouteId @@ -191,6 +197,9 @@ export interface PreValidationErrorHandlingRouteMatch< params: | { status: 'success'; value: TAllParams } | { status: 'error'; error: unknown } + state: + | { status: 'success'; value: TFullStateSchema } + | { status: 'error'; error: unknown } staticData: StaticDataRouteOption ssr?: boolean | 'data-only' } @@ -203,7 +212,8 @@ export type MakePreValidationErrorHandlingRouteMatchUnion< TRoute['id'], TRoute['fullPath'], TRoute['types']['allParams'], - TRoute['types']['fullSearchSchema'] + TRoute['types']['fullSearchSchema'], + TRoute['types']['fullStateSchema'] > : never @@ -212,6 +222,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'] @@ -230,6 +241,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'], @@ -239,7 +253,7 @@ export type MakeRouteMatch< RouteById['types']['loaderDeps'] > -export type AnyRouteMatch = RouteMatch +export type AnyRouteMatch = RouteMatch export type MakeRouteMatchUnion< TRouter extends AnyRouter = RegisteredRouter, @@ -250,6 +264,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 52aeaffabb3..00876467f6b 100644 --- a/packages/router-core/src/RouterProvider.ts +++ b/packages/router-core/src/RouterProvider.ts @@ -42,6 +42,7 @@ export type BuildLocationFn = < opts: ToOptions & { leaveParams?: boolean _includeValidateSearch?: boolean + _includeValidateState?: boolean _isNavigate?: boolean }, ) => ParsedLocation diff --git a/packages/router-core/src/fileRoute.ts b/packages/router-core/src/fileRoute.ts index 90b47ab6437..dfe0bf4d415 100644 --- a/packages/router-core/src/fileRoute.ts +++ b/packages/router-core/src/fileRoute.ts @@ -41,6 +41,7 @@ export interface FileRouteOptions< TPath extends RouteConstraints['TPath'], TFullPath extends RouteConstraints['TFullPath'], TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -57,6 +58,7 @@ export interface FileRouteOptions< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -74,6 +76,7 @@ export interface FileRouteOptions< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, AnyContext, @@ -90,6 +93,7 @@ export type CreateFileRoute< > = < TRegister = Register, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -107,6 +111,7 @@ export type CreateFileRoute< TPath, TFullPath, TSearchValidator, + TStateValidator, TParams, TRouteContextFn, TBeforeLoadFn, @@ -124,6 +129,7 @@ export type CreateFileRoute< TFilePath, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -144,6 +150,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 fd673ca4103..45b2f57a251 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -141,6 +141,7 @@ export { BaseRoute, BaseRouteApi, BaseRootRoute } from './route' export type { AnyPathParams, SearchSchemaInput, + StateSchemaInput, AnyContext, RouteContext, PreloadableObj, @@ -240,6 +241,7 @@ export { RouterCore, lazyFn, SearchParamError, + StateParamError, PathParamError, getInitialRouterState, getMatchedRoutes, @@ -382,6 +384,8 @@ export type { DefaultValidator, ResolveSearchValidatorInputFn, ResolveSearchValidatorInput, + ResolveStateValidatorInputFn, + ResolveStateValidatorInput, ResolveValidatorInputFn, ResolveValidatorInput, ResolveValidatorOutputFn, @@ -396,6 +400,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' @@ -454,6 +463,7 @@ export type { InferSelected, ValidateUseSearchResult, ValidateUseParamsResult, + ValidateUseHistoryStateResult, } from './typePrimitives' export type { diff --git a/packages/router-core/src/link.ts b/packages/router-core/src/link.ts index 55d5a79ce84..ba85fe71ce2 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, @@ -436,7 +437,26 @@ export type ToSubOptionsProps< TTo extends string | undefined = '.', > = MakeToRequired & { hash?: true | Updater - state?: true | NonNullableUpdater + state?: TTo extends undefined + ? true | NonNullableUpdater + : + | true + // The parentheses matter: without them `true | X extends infer TPath` + // parses as `(true | X) extends infer TPath`, which makes `TPath` + // include `true` and so `TPath extends string` never matches. + | (ResolveRelativePath extends infer TPath + ? TPath extends string + ? TPath extends RoutePaths + ? NonNullableUpdater< + ParsedHistoryState, + RouteById< + TRouter['routeTree'], + TPath + >['types']['fullStateSchema'] + > + : NonNullableUpdater + : NonNullableUpdater + : never) from?: FromPathOption & {} unsafeRelative?: 'path' } diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index f901a0c97dd..1da2f87cf76 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -289,11 +289,12 @@ const isBeforeLoadSsr = ( existingMatch.ssr = parentOverride(route.options.ssr) return } - const { search, params } = existingMatch + const { search, params, state } = existingMatch - const ssrFnContext: SsrContextOptions = { + const ssrFnContext: SsrContextOptions = { search: makeMaybe(search, existingMatch.searchError), params: makeMaybe(params, existingMatch.paramsError), + state: makeMaybe(state, existingMatch.stateError), location: inner.location, matches: inner.matches.map((match) => ({ index: match.index, @@ -304,6 +305,7 @@ const isBeforeLoadSsr = ( routeId: match.routeId, search: makeMaybe(match.search, match.searchError), params: makeMaybe(match.params, match.paramsError), + state: makeMaybe(match.state, match.stateError), ssr: match.ssr, })), } @@ -400,7 +402,7 @@ const executeBeforeLoad = ( prevLoadPromise = undefined }) - const { paramsError, searchError } = match + const { paramsError, searchError, stateError } = match if (paramsError) { handleSerialError(inner, index, paramsError) @@ -410,6 +412,10 @@ const executeBeforeLoad = ( handleSerialError(inner, index, searchError) } + if (stateError) { + handleSerialError(inner, index, stateError) + } + setupPendingTimeout(inner, matchId, route, match) const abortController = new AbortController() diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 2de7dc57f05..e2b315ba07c 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -37,6 +37,7 @@ import type { AnyValidatorObj, DefaultValidator, ResolveSearchValidatorInput, + ResolveStateValidatorInput, ResolveValidatorOutput, StandardSchemaValidator, ValidatorAdapter, @@ -51,6 +52,10 @@ export type SearchSchemaInput = { __TSearchSchemaInput__: 'TSearchSchemaInput' } +export type StateSchemaInput = { + __TStateSchemaInput__: 'TStateSchemaInput' +} + export type AnyContext = {} export interface RouteContext {} @@ -115,6 +120,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 @@ -166,6 +178,30 @@ export type ResolveSearchSchema = ? ResolveSearchSchemaFn : ResolveSearchSchemaFn + +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 } @@ -206,13 +242,13 @@ export type ParamsOptions = { stringify?: StringifyParamsFn } - /** + /** @deprecated Use params.parse instead */ parseParams?: ParseParamsFn & ValidateParsedParams - /** + /** @deprecated Use params.stringify instead */ stringifyParams?: StringifyParamsFn @@ -368,6 +404,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, @@ -440,6 +494,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, @@ -461,11 +516,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 @@ -522,6 +585,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, @@ -545,6 +609,7 @@ export type RouteAddChildrenFn< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -566,6 +631,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, @@ -586,6 +652,7 @@ export type RouteAddFileChildrenFn< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -607,6 +674,7 @@ export type RouteAddFileTypesFn< TCustomId extends string, TId extends string, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -625,6 +693,7 @@ export type RouteAddFileTypesFn< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -646,6 +715,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, @@ -669,6 +739,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -689,6 +760,7 @@ export interface Route< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -714,6 +786,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -742,6 +815,7 @@ export interface Route< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, TRouterContext, @@ -758,6 +832,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -779,6 +854,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -798,6 +874,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -817,6 +894,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -856,6 +934,7 @@ export type AnyRoute = Route< any, any, any, + any, any > @@ -871,6 +950,7 @@ export type RouteOptions< TFullPath extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = AnyPathParams, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -887,6 +967,7 @@ export type RouteOptions< TCustomId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -903,6 +984,7 @@ export type RouteOptions< NoInfer, NoInfer, NoInfer, + NoInfer, NoInfer, NoInfer, NoInfer, @@ -932,6 +1014,7 @@ export type FileBaseRouteOptions< TId extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = {}, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -949,6 +1032,7 @@ export type FileBaseRouteOptions< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -967,6 +1051,7 @@ export interface FilebaseRouteOptionsInterface< TId extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = {}, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -979,6 +1064,7 @@ export interface FilebaseRouteOptionsInterface< THandlers = undefined, > { validateSearch?: Constrain + validateState?: Constrain shouldReload?: | boolean @@ -1015,7 +1101,12 @@ export interface FilebaseRouteOptionsInterface< | undefined | SSROption | (( - ctx: SsrContextOptions, + ctx: SsrContextOptions< + TParentRoute, + TSearchValidator, + TStateValidator, + TParams + >, ) => Awaitable) > @@ -1097,6 +1188,7 @@ export type BaseRouteOptions< TCustomId extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = {}, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -1113,6 +1205,7 @@ export type BaseRouteOptions< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -1160,6 +1253,7 @@ export interface RouteContextOptions< export interface SsrContextOptions< in out TParentRoute extends AnyRoute, in out TSearchValidator, + in out TStateValidator, in out TParams, > { params: @@ -1174,6 +1268,12 @@ export interface SsrContextOptions< value: Expand> } | { status: 'error'; error: unknown } + state: + | { + status: 'success' + value: Expand> + } + | { status: 'error'; error: unknown } location: ParsedLocation matches: Array } @@ -1203,6 +1303,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, @@ -1218,6 +1319,7 @@ type AssetFnContextOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1233,6 +1335,7 @@ type AssetFnContextOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1261,6 +1364,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, @@ -1292,13 +1396,13 @@ export interface UpdatableRouteOptions< SearchMiddleware> > } - /** + /** @deprecated Use search.middlewares instead */ preSearchFilters?: Array< SearchFilter> > - /** + /** @deprecated Use search.middlewares instead */ postSearchFilters?: Array< @@ -1314,6 +1418,7 @@ export interface UpdatableRouteOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1330,6 +1435,7 @@ export interface UpdatableRouteOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1346,6 +1452,7 @@ export interface UpdatableRouteOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1363,6 +1470,7 @@ export interface UpdatableRouteOptions< TParentRoute, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TRouterContext, TRouteContextFn, @@ -1377,6 +1485,7 @@ export interface UpdatableRouteOptions< TParentRoute, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TRouterContext, TRouteContextFn, @@ -1396,6 +1505,7 @@ export interface UpdatableRouteOptions< TParentRoute, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TRouterContext, TRouteContextFn, @@ -1525,6 +1635,7 @@ export interface RootRouteOptionsExtensions extends DefaultRootRouteOptionsExten export interface RootRouteOptions< TRegister = unknown, TSearchValidator = undefined, + TStateValidator = undefined, TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -1544,6 +1655,7 @@ export interface RootRouteOptions< '', // TFullPath '', // TPath TSearchValidator, + TStateValidator, {}, // TParams TLoaderDeps, TLoaderFn, @@ -1572,6 +1684,8 @@ export type RouteConstraints = { TId: string TSearchSchema: AnySchema TFullSearchSchema: AnySchema + TStateSchema: AnySchema + TFullStateSchema: AnySchema TParams: Record TAllParams: Record TParentContext: AnyContext @@ -1627,6 +1741,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, @@ -1648,6 +1763,7 @@ export class BaseRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -1696,6 +1812,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1724,6 +1841,7 @@ export class BaseRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -1751,6 +1869,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1776,6 +1895,7 @@ export class BaseRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -1846,6 +1966,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1868,6 +1989,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1898,6 +2020,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1936,6 +2059,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1957,6 +2081,7 @@ export class BaseRoute< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, TRouterContext, @@ -1977,6 +2102,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -2031,6 +2157,7 @@ export class BaseRouteApi { export interface RootRoute< in out TRegister, in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TRouterContext = {}, in out TRouteContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, @@ -2049,6 +2176,7 @@ export interface RootRoute< string, // TCustomId RootRouteId, // TId TSearchValidator, // TSearchValidator + TStateValidator, // TStateValidator {}, // TParams TRouterContext, TRouteContextFn, @@ -2065,6 +2193,7 @@ export interface RootRoute< export class BaseRootRoute< in out TRegister = Register, in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TRouterContext = {}, in out TRouteContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, @@ -2083,6 +2212,7 @@ export class BaseRootRoute< string, // TCustomId RootRouteId, // TId TSearchValidator, // TSearchValidator + TStateValidator, // TStateValidator {}, // TParams TRouterContext, TRouteContextFn, @@ -2099,6 +2229,7 @@ export class BaseRootRoute< options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, diff --git a/packages/router-core/src/routeInfo.ts b/packages/router-core/src/routeInfo.ts index 6c9249e091a..be31fbf59f6 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 df9251a739d..5e1df0b4e6f 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1,4 +1,9 @@ -import { createBrowserHistory, parseHref } from '@tanstack/history' +import { + createBrowserHistory, + omitInternalKeys, + parseHref, + pickInternalKeys, +} from '@tanstack/history' import { isServer } from '@tanstack/router-core/isServer' import { DEFAULT_PROTOCOL_ALLOWLIST, @@ -1468,6 +1473,11 @@ export class RouterCore< : undefined const matches = new Array(matchedRoutes.length) + // Tracks whether any route from the root down to the current one declared + // a `validateState`. When none did, there is no schema to project the + // history state onto, so `_strictState` carries the raw user state + // through instead of an empty object. + let hasStateValidator = false // Snapshot of active match state keyed by routeId, used to stabilise // params/search across navigations. const previousActiveMatchesByRouteId = new Map() @@ -1524,6 +1534,56 @@ export class RouterCore< searchError = searchParamError } } + const [preMatchState, strictMatchState, stateError]: [ + Record, + Record, + // `unknown` because a validator may throw a `redirect()`/`notFound()`, + // which are control-flow objects rather than `Error` instances. + unknown, + ] = (() => { + const rawState = parentMatch?.state ?? next.state + const parentStrictState = parentMatch?._strictState ?? {} + const filteredState = rawState ? omitInternalKeys(rawState) : {} + + try { + if (route.options.validateState) { + hasStateValidator = true + const strictState = + validateState(route.options.validateState, filteredState) || {} + return [ + { + ...filteredState, + ...strictState, + }, + { ...parentStrictState, ...strictState }, + undefined, + ] + } + // No validator on this route: inherit whatever the ancestors + // validated. If nothing in the chain declared one, the route's + // state type is unconstrained, so the raw user state is passed + // through unchanged. + return [ + filteredState, + hasStateValidator ? { ...parentStrictState } : filteredState, + undefined, + ] + } catch (err: any) { + // Redirects and not-founds thrown from a validator are intentional + // control flow, so they are passed through untouched. Everything + // else is normalized so consumers can rely on `StateParamError`. + const stateValidationError = + isNotFound(err) || isRedirect(err) || err instanceof StateParamError + ? err + : new StateParamError(err.message, { cause: 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 @@ -1600,6 +1660,10 @@ export class RouterCore< ? nullReplaceEqualDeep(previousMatch.search, preMatchSearch) : nullReplaceEqualDeep(existingMatch.search, preMatchSearch), _strictSearch: strictMatchSearch, + state: previousMatch + ? nullReplaceEqualDeep(previousMatch.state, preMatchState) + : nullReplaceEqualDeep(existingMatch.state, preMatchState), + _strictState: strictMatchState, } } else { const status = @@ -1625,6 +1689,11 @@ export class RouterCore< _strictSearch: strictMatchSearch, searchError: undefined, status, + state: previousMatch + ? nullReplaceEqualDeep(previousMatch.state, preMatchState) + : preMatchState, + _strictState: strictMatchState, + stateError: undefined, isFetching: false, error: undefined, paramsError, @@ -1658,6 +1727,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 = this.getParentContext(parentMatch) @@ -2078,6 +2149,38 @@ export class RouterCore< publicHref = href } + if (opts._includeValidateState) { + // The validators only ever see user state, never the router's own + // bookkeeping keys (`__TSR_index`, `__tempLocation`, ...), which must + // survive untouched so masking and scroll restoration keep working. + const userState = omitInternalKeys(nextState) + const internalState = pickInternalKeys(nextState) + + let validatedState: Record = {} + destRoutes.forEach((route) => { + if (!route.options.validateState) return + try { + validatedState = { + ...validatedState, + ...(validateState(route.options.validateState, { + ...userState, + ...validatedState, + }) ?? {}), + } + } catch { + // ignore errors here because they are already handled in matchRoutes + } + }) + + // Apply the validated output (defaults, transforms) on top of the + // state the caller asked for, keeping keys no validator claimed. + nextState = nullReplaceEqualDeep(nextState, { + ...internalState, + ...userState, + ...validatedState, + }) as any + } + return { publicHref, href, @@ -2294,6 +2397,7 @@ export class RouterCore< const location = this.buildLocation({ ...(rest as any), _includeValidateSearch: true, + _includeValidateState: true, }) this.pendingBuiltLocation = location as ParsedLocation< @@ -2425,6 +2529,7 @@ export class RouterCore< hash: true, state: true, _includeValidateSearch: true, + _includeValidateState: true, }) // Check if location changed - origin check is unnecessary since buildLocation @@ -3023,6 +3128,8 @@ export class RouterCore< /** Error thrown when search parameter validation fails. */ export class SearchParamError extends Error {} +export class StateParamError extends Error {} + /** Error thrown when path parameter parsing/validation fails. */ export class PathParamError extends Error {} @@ -3064,34 +3171,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) +} + /** * Build the matched route chain and extract params for a pathname. * Falls back to the root route if no specific route is found. diff --git a/packages/router-core/src/typePrimitives.ts b/packages/router-core/src/typePrimitives.ts index 5f7d4b8e2f0..e28035b670a 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< @@ -179,3 +180,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 00000000000..a08d6b00389 --- /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 91730800777..33217ce8a05 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-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index 131fccf0951..54f3998ea2b 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -2295,3 +2295,91 @@ describe('buildLocation - _fromLocation override', () => { expect(location.pathname).toBe('/users/456/settings') }) }) + +describe('buildLocation - _includeValidateState', () => { + const makeRouter = () => { + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + validateState: (state: { count?: number }) => ({ + count: state.count === undefined ? 0 : state.count, + }), + }) + + return createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + } + + test('applies validator defaults to the committed state', async () => { + const router = makeRouter() + await router.load() + + const location = router.buildLocation({ + to: '/posts', + _includeValidateState: true, + } as any) + + expect(location.state).toMatchObject({ count: 0 }) + }) + + test('validator output wins over the supplied state', async () => { + const router = makeRouter() + await router.load() + + const location = router.buildLocation({ + to: '/posts', + state: { count: 7 }, + _includeValidateState: true, + } as any) + + expect(location.state).toMatchObject({ count: 7 }) + }) + + test('preserves user keys no validator claimed', async () => { + const router = makeRouter() + await router.load() + + const location = router.buildLocation({ + to: '/posts', + state: { extra: 'kept' }, + _includeValidateState: true, + } as any) + + expect(location.state).toMatchObject({ extra: 'kept', count: 0 }) + }) + + test('preserves the router internal bookkeeping keys', async () => { + const router = makeRouter() + await router.load() + + // Carrying the current state forward is the case where internal keys are + // actually present; they must survive validation, otherwise route masking + // and scroll restoration break. + const location = router.buildLocation({ + to: '/posts', + state: true, + _includeValidateState: true, + } as any) + + expect((location.state as any).__TSR_index).toBe( + (router.latestLocation.state as any).__TSR_index, + ) + expect(location.state).toMatchObject({ count: 0 }) + }) + + test('does not validate when the flag is absent', async () => { + const router = makeRouter() + await router.load() + + const location = router.buildLocation({ to: '/posts' } as any) + + expect((location.state as any).count).toBeUndefined() + }) +}) diff --git a/packages/router-devtools-core/package.json b/packages/router-devtools-core/package.json index 9dd21658840..68bf81fbe3d 100644 --- a/packages/router-devtools-core/package.json +++ b/packages/router-devtools-core/package.json @@ -62,6 +62,7 @@ "node": ">=20.19" }, "dependencies": { + "@tanstack/history": "workspace:*", "clsx": "^2.1.1", "goober": "^2.1.16" }, diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index 79566656b03..ba36e270b84 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -16,6 +16,7 @@ import { onCleanup, untrack, } from 'solid-js' +import { omitInternalKeys } from '@tanstack/history' import { useDevtoolsOnClose } from './context' import { useStyles } from './useStyles' import useLocalStorage from './useLocalStorage' @@ -124,6 +125,7 @@ function RouteComp({ string, '__root__', undefined, + undefined, {}, {}, AnyContext, @@ -250,6 +252,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 @@ -363,6 +376,12 @@ export const BaseTanStackRouterDevtoolsPanel = const hasSearch = createMemo(() => hasKeys(routerState().location.search)) + const validatedState = createMemo(() => + omitInternalKeys(getMergedStrictState(routerState())), + ) + + const hasState = createMemo(() => Object.keys(validatedState()).length) + const explorerState = createMemo(() => { return { ...router(), @@ -405,6 +424,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/Transitioner.tsx b/packages/solid-router/src/Transitioner.tsx index 331aff58642..0318ae1c369 100644 --- a/packages/solid-router/src/Transitioner.tsx +++ b/packages/solid-router/src/Transitioner.tsx @@ -41,6 +41,7 @@ export function Transitioner() { hash: true, state: true, _includeValidateSearch: true, + _includeValidateState: true, }) // Check if the current URL matches the canonical form. diff --git a/packages/solid-router/src/fileRoute.ts b/packages/solid-router/src/fileRoute.ts index 35aa4de1717..df09cdc74ef 100644 --- a/packages/solid-router/src/fileRoute.ts +++ b/packages/solid-router/src/fileRoute.ts @@ -8,9 +8,11 @@ import { useParams } from './useParams' import { useNavigate } from './useNavigate' import { useRouter } from './useRouter' import { useRouteContext } from './useRouteContext' +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, @@ -50,7 +52,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. */ @@ -74,6 +76,7 @@ export class FileRoute< createRoute = < TRegister = Register, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -90,6 +93,7 @@ export class FileRoute< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -107,6 +111,7 @@ export class FileRoute< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, AnyContext, @@ -121,6 +126,7 @@ export class FileRoute< TFilePath, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -146,7 +152,7 @@ export class FileRoute< } } -/** +/** @deprecated It's recommended not to split loaders into separate files. Instead, place the loader function in the main route file, inside the `createFileRoute('/path/to/file)(options)` options. @@ -222,6 +228,13 @@ export class LazyRoute { } as any) as any } + useHistoryState: UseHistoryStateRoute = (opts) => { + 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 31be74086fd..e06381facd0 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -38,6 +38,7 @@ export type { ResolveOptionalParams, ResolveRequiredParams, SearchSchemaInput, + StateSchemaInput, AnyContext, RouteContext, PreloadableObj, @@ -92,6 +93,8 @@ export type { ResolveValidatorOutputFn, ResolveSearchValidatorInput, ResolveSearchValidatorInputFn, + ResolveStateValidatorInput, + ResolveStateValidatorInputFn, Validator, ValidatorAdapter, ValidatorObj, @@ -277,7 +280,7 @@ export type { export { createRouter, Router } from './router' -export { lazyFn, SearchParamError } from '@tanstack/router-core' +export { lazyFn, SearchParamError, StateParamError } from '@tanstack/router-core' export { RouterProvider, RouterContextProvider } from './RouterProvider' export type { RouterProps } from './RouterProvider' @@ -294,6 +297,7 @@ export { useNavigate, Navigate } from './useNavigate' export { useParams } from './useParams' export { useSearch } from './useSearch' +export { useHistoryState } from './useHistoryState' export { useRouteContext } from './useRouteContext' export { useRouter } from './useRouter' diff --git a/packages/solid-router/src/route.tsx b/packages/solid-router/src/route.tsx index 221a79ffa60..16a0924f6b8 100644 --- a/packages/solid-router/src/route.tsx +++ b/packages/solid-router/src/route.tsx @@ -13,6 +13,7 @@ import { useNavigate } from './useNavigate' import { useMatch } from './useMatch' import { useRouteContext } from './useRouteContext' import { useRouter } from './useRouter' +import { useHistoryState } from './useHistoryState' import type { AnyContext, AnyRoute, @@ -44,6 +45,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' @@ -74,6 +76,7 @@ declare module '@tanstack/router-core' { useParams: UseParamsRoute useLoaderDeps: UseLoaderDepsRoute useLoaderData: UseLoaderDataRoute + useHistoryState: UseHistoryStateRoute useNavigate: () => UseNavigateResult Link: LinkComponentRoute } @@ -122,6 +125,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) } @@ -165,6 +176,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, @@ -185,6 +197,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -206,6 +219,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -231,6 +245,7 @@ export class Route< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -270,6 +285,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) } @@ -302,6 +325,7 @@ export function createRoute< TPath >, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -319,6 +343,7 @@ export function createRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -336,6 +361,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -355,6 +381,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -387,6 +414,7 @@ export function createRootRouteWithContext() { TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TSSR = unknown, @@ -395,6 +423,7 @@ export function createRootRouteWithContext() { options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -407,6 +436,7 @@ export function createRootRouteWithContext() { return createRootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -426,6 +456,7 @@ export const rootRouteWithContext = createRootRouteWithContext export class RootRoute< in out TRegister = Register, in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TRouterContext = {}, in out TRouteContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, @@ -439,6 +470,7 @@ export class RootRoute< extends BaseRootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -453,6 +485,7 @@ export class RootRoute< RootRouteCore< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -471,6 +504,7 @@ export class RootRoute< options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -508,6 +542,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) } @@ -561,6 +603,7 @@ export class NotFoundRoute< TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TChildren = unknown, @@ -574,6 +617,7 @@ export class NotFoundRoute< '404', '404', TSearchValidator, + TStateValidator, {}, TRouterContext, TRouteContextFn, @@ -594,6 +638,7 @@ export class NotFoundRoute< string, string, TSearchValidator, + TStateValidator, {}, TLoaderDeps, TLoaderFn, @@ -621,6 +666,7 @@ export class NotFoundRoute< export function createRootRoute< TRegister = Register, TSearchValidator = undefined, + TStateValidator = undefined, TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -632,6 +678,7 @@ export function createRootRoute< options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -643,6 +690,7 @@ export function createRootRoute< ): RootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -656,6 +704,7 @@ export function createRootRoute< return new RootRoute< TRegister, 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 00000000000..d633ee9987f --- /dev/null +++ b/packages/solid-router/src/useHistoryState.tsx @@ -0,0 +1,83 @@ +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) => { + // `_strictState` only ever holds the output of the `validateState` + // validators along the route chain, so it is guaranteed to match the + // validated type. `match.state` is the loose merge of the raw history + // state and would leak unvalidated keys. + const typedState = match._strictState 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 346e8c45cb4..10ac25ca104 100644 --- a/packages/solid-router/tests/Matches.test-d.tsx +++ b/packages/solid-router/tests/Matches.test-d.tsx @@ -21,6 +21,7 @@ type RootMatch = RouteMatch< RootRoute['fullPath'], RootRoute['types']['allParams'], RootRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], RootRoute['types']['loaderData'], RootRoute['types']['allContext'], RootRoute['types']['loaderDeps'] @@ -38,6 +39,7 @@ type IndexMatch = RouteMatch< IndexRoute['fullPath'], IndexRoute['types']['allParams'], IndexRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], IndexRoute['types']['loaderData'], IndexRoute['types']['allContext'], IndexRoute['types']['loaderDeps'] @@ -54,6 +56,7 @@ type InvoiceMatch = RouteMatch< InvoiceRoute['fullPath'], InvoiceRoute['types']['allParams'], InvoiceRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], InvoiceRoute['types']['loaderData'], InvoiceRoute['types']['allContext'], InvoiceRoute['types']['loaderDeps'] @@ -66,6 +69,7 @@ type InvoicesMatch = RouteMatch< InvoicesRoute['fullPath'], InvoicesRoute['types']['allParams'], InvoicesRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], InvoicesRoute['types']['loaderData'], InvoicesRoute['types']['allContext'], InvoicesRoute['types']['loaderDeps'] @@ -83,6 +87,7 @@ type InvoicesIndexMatch = RouteMatch< InvoicesIndexRoute['fullPath'], InvoicesIndexRoute['types']['allParams'], InvoicesIndexRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], InvoicesIndexRoute['types']['loaderData'], InvoicesIndexRoute['types']['allContext'], InvoicesIndexRoute['types']['loaderDeps'] @@ -108,6 +113,7 @@ type LayoutMatch = RouteMatch< LayoutRoute['fullPath'], LayoutRoute['types']['allParams'], LayoutRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], LayoutRoute['types']['loaderData'], LayoutRoute['types']['allContext'], LayoutRoute['types']['loaderDeps'] @@ -131,6 +137,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/fileRoute.test-d.tsx b/packages/solid-router/tests/fileRoute.test-d.tsx index 6c32156b2d8..6f9a9e54463 100644 --- a/packages/solid-router/tests/fileRoute.test-d.tsx +++ b/packages/solid-router/tests/fileRoute.test-d.tsx @@ -9,6 +9,7 @@ declare module '@tanstack/router-core' { TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -33,6 +34,7 @@ declare module '@tanstack/router-core' { TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -177,6 +179,7 @@ test('when creating a file route with middleware options', () => { any, any, any, + any, infer TMiddlewares, any > 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 00000000000..fdafe2b448d --- /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/packages/start-client-core/src/serverRoute.ts b/packages/start-client-core/src/serverRoute.ts index 511412683e8..abd8e21929c 100644 --- a/packages/start-client-core/src/serverRoute.ts +++ b/packages/start-client-core/src/serverRoute.ts @@ -19,6 +19,7 @@ declare module '@tanstack/router-core' { TId extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = {}, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -53,6 +54,7 @@ declare module '@tanstack/router-core' { 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, diff --git a/packages/vue-router/src/Transitioner.tsx b/packages/vue-router/src/Transitioner.tsx index b0133d965c6..e25b51b3f10 100644 --- a/packages/vue-router/src/Transitioner.tsx +++ b/packages/vue-router/src/Transitioner.tsx @@ -112,6 +112,7 @@ export function useTransitionerSetup() { hash: true, state: true, _includeValidateSearch: true, + _includeValidateState: true, }) // Check if the current URL matches the canonical form. diff --git a/packages/vue-router/src/fileRoute.ts b/packages/vue-router/src/fileRoute.ts index 672328b5607..bec678ba89c 100644 --- a/packages/vue-router/src/fileRoute.ts +++ b/packages/vue-router/src/fileRoute.ts @@ -74,6 +74,7 @@ export class FileRoute< createRoute = < TRegister = Register, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -90,6 +91,7 @@ export class FileRoute< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -107,6 +109,7 @@ export class FileRoute< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, AnyContext, @@ -121,6 +124,7 @@ export class FileRoute< TFilePath, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, diff --git a/packages/vue-router/src/index.tsx b/packages/vue-router/src/index.tsx index 2e14e349f06..544e6f1e58f 100644 --- a/packages/vue-router/src/index.tsx +++ b/packages/vue-router/src/index.tsx @@ -271,7 +271,7 @@ export type { export { createRouter, Router } from './router' -export { lazyFn, SearchParamError } from '@tanstack/router-core' +export { lazyFn, SearchParamError, StateParamError } from '@tanstack/router-core' export { RouterProvider, RouterContextProvider } from './RouterProvider' export type { RouterProps } from './RouterProvider' diff --git a/packages/vue-router/src/route.ts b/packages/vue-router/src/route.ts index d409d506fe5..ebec1cde03b 100644 --- a/packages/vue-router/src/route.ts +++ b/packages/vue-router/src/route.ts @@ -171,6 +171,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, @@ -191,6 +192,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -212,6 +214,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -237,6 +240,7 @@ export class Route< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -312,6 +316,7 @@ export function createRoute< TPath >, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -329,6 +334,7 @@ export function createRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -346,6 +352,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -365,6 +372,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -388,6 +396,7 @@ export type AnyRootRoute = RootRoute< any, any, any, + any, any > @@ -397,6 +406,7 @@ export function createRootRouteWithContext() { TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TSSR = unknown, @@ -405,6 +415,7 @@ export function createRootRouteWithContext() { options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -417,6 +428,7 @@ export function createRootRouteWithContext() { return createRootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -436,6 +448,7 @@ export const rootRouteWithContext = createRootRouteWithContext export class RootRoute< in out TRegister = Register, in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TRouterContext = {}, in out TRouteContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, @@ -449,6 +462,7 @@ export class RootRoute< extends BaseRootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -463,6 +477,7 @@ export class RootRoute< RootRouteCore< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -481,6 +496,7 @@ export class RootRoute< options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -568,6 +584,7 @@ export class NotFoundRoute< TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TChildren = unknown, @@ -581,6 +598,7 @@ export class NotFoundRoute< '404', '404', TSearchValidator, + TStateValidator, {}, TRouterContext, TRouteContextFn, @@ -601,6 +619,7 @@ export class NotFoundRoute< string, string, TSearchValidator, + TStateValidator, {}, TLoaderDeps, TLoaderFn, @@ -628,6 +647,7 @@ export class NotFoundRoute< export function createRootRoute< TRegister = Register, TSearchValidator = undefined, + TStateValidator = undefined, TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -639,6 +659,7 @@ export function createRootRoute< options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -650,6 +671,7 @@ export function createRootRoute< ): RootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -663,6 +685,7 @@ export function createRootRoute< return new RootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, diff --git a/packages/vue-router/tests/Matches.test-d.tsx b/packages/vue-router/tests/Matches.test-d.tsx index 533e9573433..a5fd6b00325 100644 --- a/packages/vue-router/tests/Matches.test-d.tsx +++ b/packages/vue-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'], + IndexRoute['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'], + InvoiceRoute['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'], + InvoicesRoute['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'], + InvoicesIndexRoute['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'], + LayoutRoute['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'], + CommentsRoute['types']['fullStateSchema'], CommentsRoute['types']['loaderData'], CommentsRoute['types']['allContext'], CommentsRoute['types']['loaderDeps'] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75be091d067..8c5ffa529a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,7 +78,7 @@ importers: version: 1.26.2(@typescript/typescript6@6.0.2)(eslint@9.22.0(jiti@2.7.0))(ts-api-utils@2.4.0(@typescript/typescript6@6.0.2)) '@nx/devkit': specifier: 22.7.5 - version: 22.7.5(nx@22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(@typescript/typescript6@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23))(debug@4.4.3)) + version: 22.7.5(nx@22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(@typescript/typescript6@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23))(debug@4.4.3)) '@playwright/test': specifier: ^1.61.0 version: 1.61.1 @@ -87,7 +87,7 @@ importers: version: 1.14.1(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26) '@swc-node/register': specifier: ^1.11.1 - version: 1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(@typescript/typescript6@6.0.2) + version: 1.11.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(@typescript/typescript6@6.0.2) '@tanstack/eslint-config': specifier: 0.4.0 version: 0.4.0(@typescript-eslint/utils@8.57.1(@typescript/typescript6@6.0.2)(eslint@9.22.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@9.22.0(jiti@2.7.0)) @@ -141,7 +141,7 @@ importers: version: 4.0.3 nx: specifier: 22.7.5 - version: 22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(@typescript/typescript6@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23))(debug@4.4.3) + version: 22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(@typescript/typescript6@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23))(debug@4.4.3) prettier: specifier: ^3.8.0 version: 3.8.1 @@ -7710,6 +7710,49 @@ importers: specifier: ^8.0.14 version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + examples/react/basic-history-state: + dependencies: + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.2.2(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-router-devtools': + specifier: workspace:^ + version: link:../../../packages/react-router-devtools + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + redaxios: + specifier: ^0.5.1 + version: 0.5.1 + tailwindcss: + specifier: ^4.1.18 + version: 4.3.1 + zod: + specifier: ^3.24.2 + version: 3.25.76 + devDependencies: + '@types/react': + specifier: ^19.2.8 + version: 19.2.9 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.9) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vite: + specifier: ^8.0.14 + version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + examples/react/basic-non-nested-devtools: dependencies: '@tailwindcss/vite': @@ -13620,6 +13663,9 @@ importers: packages/router-devtools-core: dependencies: + '@tanstack/history': + specifier: workspace:* + version: link:../history '@tanstack/router-core': specifier: workspace:* version: link:../router-core @@ -30652,6 +30698,13 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.3 + optional: true + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -31020,13 +31073,13 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.0 - '@nx/devkit@22.7.5(nx@22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(@typescript/typescript6@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23))(debug@4.4.3))': + '@nx/devkit@22.7.5(nx@22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(@typescript/typescript6@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23))(debug@4.4.3))': dependencies: '@zkochan/js-yaml': 0.0.7 ejs: 5.0.1 enquirer: 2.3.6 minimatch: 10.2.5 - nx: 22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(@typescript/typescript6@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23))(debug@4.4.3) + nx: 22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(@typescript/typescript6@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23))(debug@4.4.3) semver: 7.8.2 tslib: 2.8.1 yargs-parser: 21.1.1 @@ -31208,9 +31261,9 @@ snapshots: '@oxc-resolver/binding-openharmony-arm64@11.19.1': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -32398,7 +32451,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': @@ -33179,14 +33232,14 @@ snapshots: '@swc/core': 1.15.33(@swc/helpers@0.5.23) '@swc/types': 0.1.26 - '@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(@typescript/typescript6@6.0.2)': + '@swc-node/register@1.11.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(@typescript/typescript6@6.0.2)': dependencies: '@swc-node/core': 1.14.1(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26) '@swc-node/sourcemap-support': 0.6.1 '@swc/core': 1.15.33(@swc/helpers@0.5.23) colorette: 2.0.20 debug: 4.4.3 - oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + oxc-resolver: 11.19.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) pirates: 4.0.7 tslib: 2.8.1 typescript: '@typescript/typescript6@6.0.2' @@ -39398,7 +39451,7 @@ snapshots: nwsapi@2.2.16: {} - nx@22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(@typescript/typescript6@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23))(debug@4.4.3): + nx@22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(@typescript/typescript6@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23))(debug@4.4.3): dependencies: '@emnapi/core': 1.4.5 '@emnapi/runtime': 1.4.5 @@ -39521,7 +39574,7 @@ snapshots: '@nx/nx-linux-x64-musl': 22.7.5 '@nx/nx-win32-arm64-msvc': 22.7.5 '@nx/nx-win32-x64-msvc': 22.7.5 - '@swc-node/register': 1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(@typescript/typescript6@6.0.2) + '@swc-node/register': 1.11.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(@typescript/typescript6@6.0.2) '@swc/core': 1.15.33(@swc/helpers@0.5.23) transitivePeerDependencies: - debug @@ -39667,7 +39720,7 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + oxc-resolver@11.19.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.19.1 '@oxc-resolver/binding-android-arm64': 11.19.1 @@ -39685,7 +39738,7 @@ snapshots: '@oxc-resolver/binding-linux-x64-gnu': 11.19.1 '@oxc-resolver/binding-linux-x64-musl': 11.19.1 '@oxc-resolver/binding-openharmony-arm64': 11.19.1 - '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) '@oxc-resolver/binding-win32-arm64-msvc': 11.19.1 '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1 '@oxc-resolver/binding-win32-x64-msvc': 11.19.1 @@ -42350,7 +42403,7 @@ snapshots: browserslist: 4.28.1 chrome-trace-event: 1.0.4 enhanced-resolve: 5.21.6 - es-module-lexer: 2.0.0 + es-module-lexer: 2.3.1 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1