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

{post.title}

+

Colored by state

+
+ ) +} +``` diff --git a/examples/react/basic-history-state/.gitignore b/examples/react/basic-history-state/.gitignore new file mode 100644 index 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 8d70c60da55..b591b8b08ad 100644 --- a/packages/history/src/index.ts +++ b/packages/history/src/index.ts @@ -96,6 +96,20 @@ const stateIndexKey = '__TSR_index' const popStateEvent = 'popstate' const beforeUnloadEvent = 'beforeunload' +/** + * Filters out internal state keys from a state object. + * Internal keys are those that start with '__' or equal 'key'. + */ +export function omitInternalKeys( + state: Record, +): Record { + return Object.fromEntries( + Object.entries(state).filter( + ([key]) => !(key.startsWith('__') || key === 'key'), + ), + ) +} + export function createHistory(opts: { getLocation: () => HistoryLocation getLength: () => number diff --git a/packages/react-router/src/fileRoute.ts b/packages/react-router/src/fileRoute.ts index 395a4248682..3fe1be78db4 100644 --- a/packages/react-router/src/fileRoute.ts +++ b/packages/react-router/src/fileRoute.ts @@ -6,6 +6,7 @@ import { useLoaderDeps } from './useLoaderDeps' import { useLoaderData } from './useLoaderData' import { useSearch } from './useSearch' import { useParams } from './useParams' +import { useHistoryState } from './useHistoryState' import { useNavigate } from './useNavigate' import { useRouter } from './useRouter' import type { UseParamsRoute } from './useParams' @@ -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. @@ -66,7 +68,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. */ @@ -90,6 +92,7 @@ export class FileRoute< createRoute = < TRegister = Register, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -106,6 +109,7 @@ export class FileRoute< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -123,6 +127,7 @@ export class FileRoute< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, AnyContext, @@ -137,6 +142,7 @@ export class FileRoute< TFilePath, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -240,6 +246,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 3ed13c09c96..5c81225f5e1 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, @@ -96,6 +97,8 @@ export type { ResolveValidatorOutputFn, ResolveSearchValidatorInput, ResolveSearchValidatorInputFn, + ResolveStateValidatorInput, + ResolveStateValidatorInputFn, Validator, ValidatorAdapter, ValidatorObj, @@ -292,6 +295,7 @@ export { useNavigate, Navigate } from './useNavigate' export { useParams } from './useParams' export { useSearch } from './useSearch' +export { useHistoryState } from './useHistoryState' export { getRouterContext, // SSR @@ -325,6 +329,7 @@ export type { ValidateToPath, ValidateSearch, ValidateParams, + ValidateHistoryState, InferFrom, InferTo, InferMaskTo, diff --git a/packages/react-router/src/route.tsx b/packages/react-router/src/route.tsx index 183106eb862..cb8b1d25462 100644 --- a/packages/react-router/src/route.tsx +++ b/packages/react-router/src/route.tsx @@ -12,6 +12,7 @@ import { useSearch } from './useSearch' import { useNavigate } from './useNavigate' import { useMatch } from './useMatch' import { useRouter } from './useRouter' +import { useHistoryState } from './useHistoryState' import { Link } from './link' import type { AnyContext, @@ -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 { UseRouteContextRoute } from './useRouteContext' import type { LinkComponentRoute } from './link' @@ -73,6 +75,7 @@ declare module '@tanstack/router-core' { useParams: UseParamsRoute useLoaderDeps: UseLoaderDepsRoute useLoaderData: UseLoaderDataRoute + useHistoryState: UseHistoryStateRoute useNavigate: () => UseNavigateResult Link: LinkComponentRoute } @@ -129,6 +132,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({ @@ -182,6 +194,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, @@ -202,6 +215,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -223,6 +237,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -248,6 +263,7 @@ export class Route< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -288,6 +304,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({ @@ -342,6 +367,7 @@ export function createRoute< TPath >, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -359,6 +385,7 @@ export function createRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -376,6 +403,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -394,6 +422,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -438,6 +467,7 @@ export function createRootRouteWithContext() { TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TSSR = unknown, @@ -446,6 +476,7 @@ export function createRootRouteWithContext() { options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -458,6 +489,7 @@ export function createRootRouteWithContext() { return createRootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -477,6 +509,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, @@ -491,6 +524,7 @@ export class RootRoute< extends BaseRootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -506,6 +540,7 @@ export class RootRoute< RootRouteCore< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -525,6 +560,7 @@ export class RootRoute< options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -564,6 +600,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({ @@ -605,6 +650,7 @@ export class RootRoute< export function createRootRoute< TRegister = Register, TSearchValidator = undefined, + TStateValidator = undefined, TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -617,6 +663,7 @@ export function createRootRoute< options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -629,6 +676,7 @@ export function createRootRoute< ): RootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -643,6 +691,7 @@ export function createRootRoute< return new RootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -692,6 +741,7 @@ export class NotFoundRoute< TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TChildren = unknown, @@ -705,6 +755,7 @@ export class NotFoundRoute< '404', '404', TSearchValidator, + TStateValidator, {}, TRouterContext, TRouteContextFn, @@ -725,6 +776,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..0fee862021f --- /dev/null +++ b/packages/react-router/src/useHistoryState.tsx @@ -0,0 +1,101 @@ +import { omitInternalKeys } from '@tanstack/history' +import { useMatch } from './useMatch' +import type { + AnyRouter, + RegisteredRouter, + ResolveUseHistoryState, + StrictOrFrom, + ThrowConstraint, + ThrowOrOptional, + UseHistoryStateResult, +} from '@tanstack/router-core' +import type { + StructuralSharingOption, + ValidateSelected, +} from './structuralSharing' + +export interface UseHistoryStateBaseOptions< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TThrow extends boolean, + TSelected, + TStructuralSharing, +> { + select?: ( + state: ResolveUseHistoryState, + ) => ValidateSelected + shouldThrow?: TThrow +} + +export type UseHistoryStateOptions< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TThrow extends boolean, + TSelected, + TStructuralSharing, +> = StrictOrFrom & + UseHistoryStateBaseOptions< + TRouter, + TFrom, + TStrict, + TThrow, + TSelected, + TStructuralSharing + > & + StructuralSharingOption + +export type UseHistoryStateRoute = < + TRouter extends AnyRouter = RegisteredRouter, + TSelected = unknown, + TStructuralSharing extends boolean = boolean, +>( + opts?: UseHistoryStateBaseOptions< + TRouter, + TFrom, + /* TStrict */ true, + /* TThrow */ true, + TSelected, + TStructuralSharing + > & + StructuralSharingOption, +) => UseHistoryStateResult + +export function useHistoryState< + TRouter extends AnyRouter = RegisteredRouter, + const TFrom extends string | undefined = undefined, + TStrict extends boolean = true, + TThrow extends boolean = true, + TSelected = unknown, + TStructuralSharing extends boolean = boolean, +>( + opts: UseHistoryStateOptions< + TRouter, + TFrom, + TStrict, + ThrowConstraint, + TSelected, + TStructuralSharing + >, +): ThrowOrOptional< + UseHistoryStateResult, + TThrow +> { + return useMatch({ + from: opts.from!, + strict: opts.strict, + shouldThrow: opts.shouldThrow, + structuralSharing: opts.structuralSharing, + select: (match: any) => { + const matchState = match.state + const filteredState = omitInternalKeys(matchState) + const typedState = filteredState as unknown as ResolveUseHistoryState< + TRouter, + TFrom, + TStrict + > + return opts.select ? opts.select(typedState) : typedState + }, + } as any) as any +} diff --git a/packages/react-router/tests/Matches.test-d.tsx b/packages/react-router/tests/Matches.test-d.tsx index 2116e784167..38d05a008ed 100644 --- a/packages/react-router/tests/Matches.test-d.tsx +++ b/packages/react-router/tests/Matches.test-d.tsx @@ -19,6 +19,7 @@ type RootMatch = RouteMatch< RootRoute['fullPath'], RootRoute['types']['allParams'], RootRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], RootRoute['types']['loaderData'], RootRoute['types']['allContext'], RootRoute['types']['loaderDeps'] @@ -36,6 +37,7 @@ type IndexMatch = RouteMatch< IndexRoute['fullPath'], IndexRoute['types']['allParams'], IndexRoute['types']['fullSearchSchema'], + IndexRoute['types']['fullStateSchema'], IndexRoute['types']['loaderData'], IndexRoute['types']['allContext'], IndexRoute['types']['loaderDeps'] @@ -52,6 +54,7 @@ type InvoiceMatch = RouteMatch< InvoiceRoute['fullPath'], InvoiceRoute['types']['allParams'], InvoiceRoute['types']['fullSearchSchema'], + InvoiceRoute['types']['fullStateSchema'], InvoiceRoute['types']['loaderData'], InvoiceRoute['types']['allContext'], InvoiceRoute['types']['loaderDeps'] @@ -64,6 +67,7 @@ type InvoicesMatch = RouteMatch< InvoicesRoute['fullPath'], InvoicesRoute['types']['allParams'], InvoicesRoute['types']['fullSearchSchema'], + InvoicesRoute['types']['fullStateSchema'], InvoicesRoute['types']['loaderData'], InvoicesRoute['types']['allContext'], InvoicesRoute['types']['loaderDeps'] @@ -81,6 +85,7 @@ type InvoicesIndexMatch = RouteMatch< InvoicesIndexRoute['fullPath'], InvoicesIndexRoute['types']['allParams'], InvoicesIndexRoute['types']['fullSearchSchema'], + InvoicesIndexRoute['types']['fullStateSchema'], InvoicesIndexRoute['types']['loaderData'], InvoicesIndexRoute['types']['allContext'], InvoicesIndexRoute['types']['loaderDeps'] @@ -106,6 +111,7 @@ type LayoutMatch = RouteMatch< LayoutRoute['fullPath'], LayoutRoute['types']['allParams'], LayoutRoute['types']['fullSearchSchema'], + LayoutRoute['types']['fullStateSchema'], LayoutRoute['types']['loaderData'], LayoutRoute['types']['allContext'], LayoutRoute['types']['loaderDeps'] @@ -129,6 +135,7 @@ type CommentsMatch = RouteMatch< CommentsRoute['fullPath'], CommentsRoute['types']['allParams'], CommentsRoute['types']['fullSearchSchema'], + CommentsRoute['types']['fullStateSchema'], CommentsRoute['types']['loaderData'], CommentsRoute['types']['allContext'], CommentsRoute['types']['loaderDeps'] diff --git a/packages/react-router/tests/useHistoryState.test-d.tsx b/packages/react-router/tests/useHistoryState.test-d.tsx new file mode 100644 index 00000000000..0a50ec63fb6 --- /dev/null +++ b/packages/react-router/tests/useHistoryState.test-d.tsx @@ -0,0 +1,532 @@ +import { describe, expectTypeOf, test } from 'vitest' +import { + createRootRoute, + createRoute, + createRouter, + useHistoryState, +} from '../src' +import type { StateSchemaInput } from '../src' + +describe('useHistoryState', () => { + test('when there are no state params', () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('from') + .toEqualTypeOf< + '/invoices' | '__root__' | '/invoices/$invoiceId' | '/invoices/' | '/' + >() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('strict') + .toEqualTypeOf() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .parameter(0) + .toEqualTypeOf<{}>() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .returns.toEqualTypeOf() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{}>() + + expectTypeOf( + useHistoryState({ + strict: false, + }), + ).toEqualTypeOf<{}>() + }) + + test('when there is one state param', () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateState: () => ({ page: 0 }), + }) + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{}>() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{ + page: number + }>() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((state: { page: number }) => unknown) | undefined>() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{ page?: number }>() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((state: { page?: number }) => unknown) | undefined>() + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + number + >, + ).returns.toEqualTypeOf() + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + { func: () => void } + >, + ) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + ((state: { page?: number }) => { func: () => void }) | undefined + >() + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + { func: () => void } + >, + ) + .parameter(0) + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + { func: () => void }, + true + >, + ) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + | ((state: { page?: number }) => { + func: 'Function is not serializable' + }) + | undefined + >() + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + { func: () => void }, + true + >, + ) + .parameter(0) + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + { hi: any }, + true + >, + ) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + | ((state: { page?: number }) => { + hi: never + }) + | undefined + >() + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + { hi: any }, + true + >, + ) + .parameter(0) + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + // eslint-disable-next-line unused-imports/no-unused-vars + const routerWithStructuralSharing = createRouter({ + routeTree, + defaultStructuralSharing: true, + }) + + expectTypeOf( + useHistoryState< + typeof routerWithStructuralSharing, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + { func: () => void } + >, + ) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + | ((state: { page?: number }) => { + func: 'Function is not serializable' + }) + | undefined + >() + + expectTypeOf( + useHistoryState< + typeof routerWithStructuralSharing, + '/invoices', + /* strict */ false, + /* shouldThrow */ true, + { date: () => void }, + true + >, + ) + .parameter(0) + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + }) + + test('when there are multiple state params', () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateState: () => ({ page: 0 }), + }) + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateState: () => ({ detail: 'detail' }), + }) + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{}>() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{ + page: number + }>() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((state: { page: number }) => unknown) | undefined>() + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{ page?: number; detail?: string }>() + + expectTypeOf(useHistoryState) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + ((state: { page?: number; detail?: string }) => unknown) | undefined + >() + }) + + test('when there are overlapping state params', () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateState: () => ({ page: 0 }), + }) + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + validateState: () => ({ detail: 50 }) as const, + }) + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute]), + indexRoute, + ]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState< + DefaultRouter, + '/invoices/', + /* strict */ true, + /* shouldThrow */ true, + { page: number; detail: 50 } + >({ + from: '/invoices/', + }), + ).toEqualTypeOf<{ + page: number + detail: 50 + }>() + }) + + test('when the root has no state params but the index route does', () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateState: () => ({ isHome: true }), + }) + const routeTree = rootRoute.addChildren([indexRoute]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{}>() + + expectTypeOf(useHistoryState).returns.toEqualTypeOf<{ + isHome: boolean + }>() + }) + + test('when the root has state params but the index route does not', () => { + const rootRoute = createRootRoute({ + validateState: () => ({ theme: 'dark' }), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const routeTree = rootRoute.addChildren([indexRoute]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{ + theme: string + }>() + + expectTypeOf(useHistoryState).returns.toEqualTypeOf<{ + theme: string + }>() + }) + + test('when the root has state params and the index does too', () => { + const rootRoute = createRootRoute({ + validateState: () => ({ theme: 'dark' }), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateState: () => ({ isHome: true }), + }) + const routeTree = rootRoute.addChildren([indexRoute]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{ + theme: string + }>() + + expectTypeOf(useHistoryState).returns.toEqualTypeOf<{ + theme: string + isHome: boolean + }>() + }) + + test('when route has a union of state params', () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const unionRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/union', + validateState: () => ({ status: 'active' as 'active' | 'inactive' }), + }) + const routeTree = rootRoute.addChildren([indexRoute, unionRoute]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState, + ).returns.toEqualTypeOf<{ + status: 'active' | 'inactive' + }>() + }) + + test('when a route has state params using StateSchemaInput', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateState: (input: { page?: number } & StateSchemaInput) => { + return { page: input.page ?? 0 } + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ routeTree }) + expectTypeOf(useHistoryState).returns.toEqualTypeOf<{ + page: number + }>() + }) + + describe('shouldThrow', () => { + test('when shouldThrow is true', () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const routeTree = rootRoute.addChildren([indexRoute]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState({ + from: '/', + shouldThrow: true, + }), + ).toEqualTypeOf<{}>() + }) + + test('when shouldThrow is false', () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const routeTree = rootRoute.addChildren([indexRoute]) + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useHistoryState({ + from: '/', + shouldThrow: false, + }), + ).toEqualTypeOf<{} | undefined>() + }) + }) +}) diff --git a/packages/react-router/tests/useHistoryState.test.tsx b/packages/react-router/tests/useHistoryState.test.tsx new file mode 100644 index 00000000000..61aa7fc2887 --- /dev/null +++ b/packages/react-router/tests/useHistoryState.test.tsx @@ -0,0 +1,352 @@ +import { afterEach, describe, expect, test } from 'vitest' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { z } from 'zod' +import { + Link, + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + useHistoryState, + useNavigate, +} from '../src' +import type { RouteComponent, RouterHistory } from '../src' + +afterEach(() => { + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +describe('useHistoryState', () => { + function setup({ + RootComponent, + history, + }: { + RootComponent: RouteComponent + history?: RouterHistory + }) { + const rootRoute = createRootRoute({ + component: RootComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> +

IndexTitle

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

PostsTitle

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

IndexTitle

, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + validateState: (input: { testKey?: string; color?: string }) => + z + .object({ + testKey: z.string(), + color: z.enum(['red', 'green', 'blue']), + }) + .parse(input), + component: ValidChecker, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render() + + // Valid state transition + const validButton = await screen.findByTestId('valid-state-btn') + fireEvent.click(validButton) + + const validState = await screen.findByTestId('valid-state') + expect(validState).toHaveTextContent( + '{"testKey":"valid-key","color":"red"}', + ) + + // Invalid state transition + const invalidButton = await screen.findByTestId('invalid-state-btn') + fireEvent.click(invalidButton) + + await waitFor(async () => { + const stateElement = await screen.findByTestId('valid-state') + expect(stateElement).toHaveTextContent('yellow') + }) + }) + + test('throws when match not found and shouldThrow=true', async () => { + function RootComponent() { + try { + useHistoryState({ from: '/non-existent', shouldThrow: true }) + return
No error
+ } catch (e) { + return
Error occurred: {(e as Error).message}
+ } + } + + setup({ RootComponent }) + + const errorMessage = await screen.findByText(/Error occurred:/) + expect(errorMessage).toBeInTheDocument() + expect(errorMessage).toHaveTextContent(/Could not find an active match/) + }) + + test('returns undefined when match not found and shouldThrow=false', async () => { + function RootComponent() { + const state = useHistoryState({ + from: '/non-existent', + shouldThrow: false, + }) + return ( +
+
+ {state === undefined ? 'undefined' : 'defined'} +
+ +
+ ) + } + + setup({ RootComponent }) + + const stateResult = await screen.findByTestId('state-result') + expect(stateResult).toHaveTextContent('undefined') + }) + + test('updates when state changes', async () => { + function RootComponent() { + const navigate = useNavigate() + const state = useHistoryState({ from: '/posts', shouldThrow: false }) + + return ( +
+
{state?.count}
+ + + +
+ ) + } + + setup({ RootComponent }) + + // Initial navigation + const navigateBtn = await screen.findByTestId('navigate-btn') + fireEvent.click(navigateBtn) + + // Check initial state + const stateValue = await screen.findByTestId('state-value') + expect(stateValue).toHaveTextContent('1') + + // Update state + const updateBtn = await screen.findByTestId('update-btn') + fireEvent.click(updateBtn) + + // Check updated state + await waitFor(() => { + expect(screen.getByTestId('state-value')).toHaveTextContent('2') + }) + }) + + test('route.useHistoryState hook works properly', async () => { + function PostsComponent() { + const state = postsRoute.useHistoryState() + return
{state.testValue}
+ } + + const rootRoute = createRootRoute({ + component: () => , + }) + + const 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..f0e2b962fc4 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' @@ -212,6 +217,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 +236,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 +248,7 @@ export type MakeRouteMatch< RouteById['types']['loaderDeps'] > -export type AnyRouteMatch = RouteMatch +export type AnyRouteMatch = RouteMatch export type MakeRouteMatchUnion< TRouter extends AnyRouter = RegisteredRouter, @@ -250,6 +259,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 1897f078801..c4985d4dca5 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -110,6 +110,7 @@ export { BaseRoute, BaseRouteApi, BaseRootRoute } from './route' export type { AnyPathParams, SearchSchemaInput, + StateSchemaInput, AnyContext, RouteContext, PreloadableObj, @@ -337,6 +338,8 @@ export type { DefaultValidator, ResolveSearchValidatorInputFn, ResolveSearchValidatorInput, + ResolveStateValidatorInputFn, + ResolveStateValidatorInput, ResolveValidatorInputFn, ResolveValidatorInput, ResolveValidatorOutputFn, @@ -351,6 +354,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' @@ -398,6 +406,7 @@ export type { ValidateToPath, ValidateSearch, ValidateParams, + ValidateHistoryState, InferFrom, InferTo, InferMaskTo, @@ -412,6 +421,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 1931a55a508..af8b024d658 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,18 @@ export type ToSubOptionsProps< TTo extends string | undefined = '.', > = MakeToRequired & { hash?: true | Updater - state?: true | NonNullableUpdater + state?: TTo extends undefined + ? true | NonNullableUpdater + : true | ResolveRelativePath extends infer TPath + ? TPath extends string + ? TPath extends RoutePaths + ? NonNullableUpdater< + ParsedHistoryState, + RouteById['types']['stateSchema'] + > + : NonNullableUpdater + : NonNullableUpdater + : NonNullableUpdater from?: FromPathOption & {} unsafeRelative?: 'path' } diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 243e93c9893..1318ec8fc6d 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 {} @@ -107,6 +112,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 @@ -158,6 +170,35 @@ export type ResolveSearchSchema = ? ResolveSearchSchemaFn : ResolveSearchSchemaFn +export type ParseSplatParams = TPath & + `${string}$` extends never + ? TPath & `${string}$/${string}` extends never + ? never + : '_splat' + : '_splat' +export type ResolveStateSchemaFn = TStateValidator extends ( + ...args: any +) => infer TStateSchema + ? TStateSchema + : AnySchema + +export type ResolveFullStateSchema< + TParentRoute extends AnyRoute, + TStateValidator, +> = unknown extends TParentRoute + ? ResolveStateSchema + : IntersectAssign< + InferFullStateSchema, + ResolveStateSchema + > + +export type InferFullStateSchema = TRoute extends { + types: { + fullStateSchema: infer TFullStateSchema + } +} + ? TFullStateSchema + : {} export type ResolveRequiredParams = { [K in ParsePathParams['required']]: T } @@ -187,12 +228,12 @@ export type ParamsOptions = { stringify?: StringifyParamsFn } - /** + /** @deprecated Use params.parse instead */ parseParams?: ParseParamsFn - /** + /** @deprecated Use params.stringify instead */ stringifyParams?: StringifyParamsFn @@ -315,6 +356,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, @@ -387,6 +446,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, @@ -408,11 +468,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 @@ -469,6 +537,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, @@ -492,6 +561,7 @@ export type RouteAddChildrenFn< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -513,6 +583,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, @@ -533,6 +604,7 @@ export type RouteAddFileChildrenFn< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -554,6 +626,7 @@ export type RouteAddFileTypesFn< TCustomId extends string, TId extends string, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -572,6 +645,7 @@ export type RouteAddFileTypesFn< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -593,6 +667,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, @@ -616,6 +691,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -636,6 +712,7 @@ export interface Route< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -661,6 +738,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -689,6 +767,7 @@ export interface Route< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, TRouterContext, @@ -705,6 +784,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -726,6 +806,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -745,6 +826,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -764,6 +846,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -803,6 +886,7 @@ export type AnyRoute = Route< any, any, any, + any, any > @@ -818,6 +902,7 @@ export type RouteOptions< TFullPath extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = AnyPathParams, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -834,6 +919,7 @@ export type RouteOptions< TCustomId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -850,6 +936,7 @@ export type RouteOptions< NoInfer, NoInfer, NoInfer, + NoInfer, NoInfer, NoInfer, NoInfer, @@ -879,6 +966,7 @@ export type FileBaseRouteOptions< TId extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = {}, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -896,6 +984,7 @@ export type FileBaseRouteOptions< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -914,6 +1003,7 @@ export interface FilebaseRouteOptionsInterface< TId extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = {}, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -926,6 +1016,7 @@ export interface FilebaseRouteOptionsInterface< THandlers = undefined, > { validateSearch?: Constrain + validateState?: Constrain shouldReload?: | boolean @@ -1039,6 +1130,7 @@ export type BaseRouteOptions< TCustomId extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = {}, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -1055,6 +1147,7 @@ export type BaseRouteOptions< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -1145,6 +1238,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, @@ -1160,6 +1254,7 @@ type AssetFnContextOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1175,6 +1270,7 @@ type AssetFnContextOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1203,6 +1299,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, @@ -1267,13 +1364,13 @@ export interface UpdatableRouteOptions< > > } - /** + /** @deprecated Use search.middlewares instead */ preSearchFilters?: Array< SearchFilter> > - /** + /** @deprecated Use search.middlewares instead */ postSearchFilters?: Array< @@ -1289,6 +1386,7 @@ export interface UpdatableRouteOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1305,6 +1403,7 @@ export interface UpdatableRouteOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1321,6 +1420,7 @@ export interface UpdatableRouteOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1338,6 +1438,7 @@ export interface UpdatableRouteOptions< TParentRoute, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TRouterContext, TRouteContextFn, @@ -1352,6 +1453,7 @@ export interface UpdatableRouteOptions< TParentRoute, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TRouterContext, TRouteContextFn, @@ -1371,6 +1473,7 @@ export interface UpdatableRouteOptions< TParentRoute, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TRouterContext, TRouteContextFn, @@ -1461,6 +1564,7 @@ export interface RootRouteOptionsExtensions extends DefaultRootRouteOptionsExten export interface RootRouteOptions< TRegister = unknown, TSearchValidator = undefined, + TStateValidator = undefined, TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -1480,6 +1584,7 @@ export interface RootRouteOptions< '', // TFullPath '', // TPath TSearchValidator, + TStateValidator, {}, // TParams TLoaderDeps, TLoaderFn, @@ -1508,6 +1613,8 @@ export type RouteConstraints = { TId: string TSearchSchema: AnySchema TFullSearchSchema: AnySchema + TStateSchema: AnySchema + TFullStateSchema: AnySchema TParams: Record TAllParams: Record TParentContext: AnyContext @@ -1563,6 +1670,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, @@ -1584,6 +1692,7 @@ export class BaseRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -1632,6 +1741,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1660,6 +1770,7 @@ export class BaseRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -1687,6 +1798,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1712,6 +1824,7 @@ export class BaseRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -1779,6 +1892,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1801,6 +1915,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1831,6 +1946,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1869,6 +1985,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1890,6 +2007,7 @@ export class BaseRoute< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, TRouterContext, @@ -1910,6 +2028,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1964,6 +2083,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, @@ -1982,6 +2102,7 @@ export interface RootRoute< string, // TCustomId RootRouteId, // TId TSearchValidator, // TSearchValidator + TStateValidator, // TStateValidator {}, // TParams TRouterContext, TRouteContextFn, @@ -1998,6 +2119,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, @@ -2016,6 +2138,7 @@ export class BaseRootRoute< string, // TCustomId RootRouteId, // TId TSearchValidator, // TSearchValidator + TStateValidator, // TStateValidator {}, // TParams TRouterContext, TRouteContextFn, @@ -2032,6 +2155,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 d677f5530ea..823e8e0adfc 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1,5 +1,9 @@ import { Store } from '@tanstack/store' -import { createBrowserHistory, parseHref } from '@tanstack/history' +import { + createBrowserHistory, + omitInternalKeys, + parseHref, +} from '@tanstack/history' import { isServer } from '@tanstack/router-core/isServer' import { batch } from './utils/batch' import { @@ -1448,6 +1452,39 @@ export class RouterCore< searchError = searchParamError } } + const [preMatchState, strictMatchState, stateError]: [ + Record, + Record, + Error | undefined, + ] = (() => { + const rawState = parentMatch?.state ?? next.state + const parentStrictState = parentMatch?._strictState ?? {} + const filteredState = rawState ? omitInternalKeys(rawState) : {} + + try { + if (route.options.validateState) { + const strictState = + validateState(route.options.validateState, filteredState) || {} + return [ + { + ...filteredState, + ...strictState, + }, + { ...parentStrictState, ...strictState }, + undefined, + ] + } + return [filteredState, {}, undefined] + } catch (err: any) { + const stateValidationError = err + + if (opts?.throwOnError) { + throw stateValidationError + } + + return [filteredState, {}, stateValidationError] + } + })() // This is where we need to call route.options.loaderDeps() to get any additional // deps that the route's loader function might need to run. We need to do this @@ -1528,6 +1565,10 @@ export class RouterCore< ? replaceEqualDeep(previousMatch.search, preMatchSearch) : replaceEqualDeep(existingMatch.search, preMatchSearch), _strictSearch: strictMatchSearch, + state: previousMatch + ? replaceEqualDeep(previousMatch.state, preMatchState) + : replaceEqualDeep(existingMatch.state, preMatchState), + _strictState: strictMatchState, } } else { const status = @@ -1555,6 +1596,11 @@ export class RouterCore< _strictSearch: strictMatchSearch, searchError: undefined, status, + state: previousMatch + ? replaceEqualDeep(previousMatch.state, preMatchState) + : preMatchState, + _strictState: strictMatchState, + stateError: undefined, isFetching: false, error: undefined, paramsError, @@ -1588,6 +1634,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) @@ -1969,6 +2017,26 @@ export class RouterCore< publicHref = href } + if (opts._includeValidateState) { + let validatedState = {} + destRoutes.forEach((route) => { + try { + if (route.options.validateState) { + validatedState = { + ...validatedState, + ...(validateState(route.options.validateState, { + ...validatedState, + ...nextState, + }) ?? {}), + } + } + } catch { + // ignore errors here because they are already handled in matchRoutes + } + }) + nextState = validatedState + } + return { publicHref, href, @@ -2867,6 +2935,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 {} @@ -2910,34 +2980,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..8fd67044367 100644 --- a/packages/router-core/src/typePrimitives.ts +++ b/packages/router-core/src/typePrimitives.ts @@ -10,6 +10,7 @@ import type { RouteIds } from './routeInfo' import type { AnyRouter, RegisteredRouter } from './router' import type { UseParamsResult } from './useParams' import type { UseSearchResult } from './useSearch' +import type { UseHistoryStateResult } from './useHistoryState' import type { Constrain, ConstrainLiteral } from './utils' export type ValidateFromPath< @@ -29,6 +30,16 @@ export type ValidateSearch< TFrom extends string = string, > = SearchParamOptions +export type ValidateHistoryState< + TOptions, + TRouter extends AnyRouter = RegisteredRouter, +> = UseHistoryStateResult< + TRouter, + InferFrom, + InferStrict, + InferSelected +> + export type ValidateParams< TRouter extends AnyRouter = RegisteredRouter, TTo extends string | undefined = undefined, @@ -179,3 +190,12 @@ export type ValidateUseParamsResult< InferSelected > > +export type ValidateUseHistoryStateResult< + TOptions, + TRouter extends AnyRouter = RegisteredRouter, +> = UseHistoryStateResult< + TRouter, + InferFrom, + InferStrict, + InferSelected +> diff --git a/packages/router-core/src/useHistoryState.ts b/packages/router-core/src/useHistoryState.ts new file mode 100644 index 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-devtools-core/package.json b/packages/router-devtools-core/package.json index 8eefc0f3045..4df6d3a34d5 100644 --- a/packages/router-devtools-core/package.json +++ b/packages/router-devtools-core/package.json @@ -64,6 +64,7 @@ "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", + "@tanstack/history": "workspace:*", "tiny-invariant": "^1.3.3" }, "devDependencies": { diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index c306d82d809..387182e5e34 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -12,6 +12,7 @@ import { onCleanup, untrack, } from 'solid-js' +import { omitInternalKeys } from '@tanstack/history' import { useDevtoolsOnClose } from './context' import { useStyles } from './useStyles' import useLocalStorage from './useLocalStorage' @@ -119,6 +120,7 @@ function RouteComp({ string, '__root__', undefined, + undefined, {}, {}, AnyContext, @@ -243,6 +245,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 @@ -322,6 +335,12 @@ export const BaseTanStackRouterDevtoolsPanel = () => Object.keys(routerState().location.search).length, ) + const validatedState = createMemo(() => + omitInternalKeys(getMergedStrictState(routerState())), + ) + + const hasState = createMemo(() => Object.keys(validatedState()).length) + const explorerState = createMemo(() => { return { ...router(), @@ -366,6 +385,7 @@ export const BaseTanStackRouterDevtoolsPanel = const activeMatchLoaderData = createMemo(() => activeMatch()?.loaderData) const activeMatchValue = createMemo(() => activeMatch()) const locationSearchValue = createMemo(() => routerState().location.search) + const validatedStateValue = createMemo(() => validatedState()) return (
) : null} + {hasState() ? ( +
+
State Params
+
+ { + obj[next] = {} + return obj + }, + {}, + )} + /> +
+
+ ) : null} ) } diff --git a/packages/solid-router/src/fileRoute.ts b/packages/solid-router/src/fileRoute.ts index 5095dab6a37..808a475c69e 100644 --- a/packages/solid-router/src/fileRoute.ts +++ b/packages/solid-router/src/fileRoute.ts @@ -8,9 +8,11 @@ import { useSearch } from './useSearch' import { useParams } from './useParams' import { useNavigate } from './useNavigate' import { useRouter } from './useRouter' +import { useHistoryState } from './useHistoryState' import type { UseParamsRoute } from './useParams' import type { UseMatchRoute } from './useMatch' import type { UseSearchRoute } from './useSearch' +import type { UseHistoryStateRoute } from './useHistoryState' import type { AnyContext, AnyRoute, @@ -55,7 +57,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. */ @@ -79,6 +81,7 @@ export class FileRoute< createRoute = < TRegister = Register, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -95,6 +98,7 @@ export class FileRoute< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -112,6 +116,7 @@ export class FileRoute< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, AnyContext, @@ -126,6 +131,7 @@ export class FileRoute< TFilePath, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -148,7 +154,7 @@ export class FileRoute< } } -/** +/** @deprecated It's recommended not to split loaders into separate files. Instead, place the loader function in the the main route file, inside the `createFileRoute('/path/to/file)(options)` options. @@ -226,6 +232,14 @@ export class LazyRoute { } as any) as any } + useHistoryState: UseHistoryStateRoute = (opts) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return useHistoryState({ + select: opts?.select, + from: this.options.id, + } as any) as any + } + useParams: UseParamsRoute = (opts) => { return useParams({ select: opts?.select, diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index b370c360a12..0de74ee718a 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -40,6 +40,7 @@ export type { ResolveOptionalParams, ResolveRequiredParams, SearchSchemaInput, + StateSchemaInput, AnyContext, RouteContext, PreloadableObj, @@ -94,6 +95,8 @@ export type { ResolveValidatorOutputFn, ResolveSearchValidatorInput, ResolveSearchValidatorInputFn, + ResolveStateValidatorInput, + ResolveStateValidatorInputFn, Validator, ValidatorAdapter, ValidatorObj, @@ -299,6 +302,7 @@ export { useNavigate, Navigate } from './useNavigate' export { useParams } from './useParams' export { useSearch } from './useSearch' +export { useHistoryState } from './useHistoryState' export { getRouterContext, // SSR @@ -328,6 +332,7 @@ export type { ValidateToPath, ValidateSearch, ValidateParams, + ValidateHistoryState, InferFrom, InferTo, InferMaskTo, diff --git a/packages/solid-router/src/route.tsx b/packages/solid-router/src/route.tsx index 78a8ba72b75..53c6469d932 100644 --- a/packages/solid-router/src/route.tsx +++ b/packages/solid-router/src/route.tsx @@ -12,6 +12,7 @@ import { useSearch } from './useSearch' import { useNavigate } from './useNavigate' import { useMatch } from './useMatch' import { useRouter } from './useRouter' +import { useHistoryState } from './useHistoryState' import type { AnyContext, AnyRoute, @@ -43,6 +44,7 @@ import type { UseMatchRoute } from './useMatch' import type { UseLoaderDepsRoute } from './useLoaderDeps' import type { UseParamsRoute } from './useParams' import type { UseSearchRoute } from './useSearch' +import type { UseHistoryStateRoute } from './useHistoryState' import type * as Solid from 'solid-js' import type { UseRouteContextRoute } from './useRouteContext' import type { LinkComponentRoute } from './link' @@ -73,6 +75,7 @@ declare module '@tanstack/router-core' { useParams: UseParamsRoute useLoaderDeps: UseLoaderDepsRoute useLoaderData: UseLoaderDataRoute + useHistoryState: UseHistoryStateRoute useNavigate: () => UseNavigateResult Link: LinkComponentRoute } @@ -124,6 +127,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) } @@ -167,6 +178,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, @@ -187,6 +199,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -208,6 +221,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -233,6 +247,7 @@ export class Route< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -276,6 +291,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) } @@ -308,6 +331,7 @@ export function createRoute< TPath >, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -325,6 +349,7 @@ export function createRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -342,6 +367,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -361,6 +387,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -393,6 +420,7 @@ export function createRootRouteWithContext() { TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TSSR = unknown, @@ -401,6 +429,7 @@ export function createRootRouteWithContext() { options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -413,6 +442,7 @@ export function createRootRouteWithContext() { return createRootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -432,6 +462,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, @@ -445,6 +476,7 @@ export class RootRoute< extends BaseRootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -459,6 +491,7 @@ export class RootRoute< RootRouteCore< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -477,6 +510,7 @@ export class RootRoute< options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -518,6 +552,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) } @@ -571,6 +613,7 @@ export class NotFoundRoute< TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TChildren = unknown, @@ -584,6 +627,7 @@ export class NotFoundRoute< '404', '404', TSearchValidator, + TStateValidator, {}, TRouterContext, TRouteContextFn, @@ -604,6 +648,7 @@ export class NotFoundRoute< string, string, TSearchValidator, + TStateValidator, {}, TLoaderDeps, TLoaderFn, @@ -631,6 +676,7 @@ export class NotFoundRoute< export function createRootRoute< TRegister = Register, TSearchValidator = undefined, + TStateValidator = undefined, TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -642,6 +688,7 @@ export function createRootRoute< options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -653,6 +700,7 @@ export function createRootRoute< ): RootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -666,6 +714,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..c628f0a5654 --- /dev/null +++ b/packages/solid-router/src/useHistoryState.tsx @@ -0,0 +1,82 @@ +import { omitInternalKeys } from '@tanstack/history' +import { useMatch } from './useMatch' +import type { Accessor } from 'solid-js' +import type { + AnyRouter, + RegisteredRouter, + ResolveUseHistoryState, + StrictOrFrom, + ThrowConstraint, + ThrowOrOptional, + UseHistoryStateResult, +} from '@tanstack/router-core' + +export interface UseHistoryStateBaseOptions< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TThrow extends boolean, + TSelected, +> { + select?: (state: ResolveUseHistoryState) => TSelected + shouldThrow?: TThrow +} + +export type UseHistoryStateOptions< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TThrow extends boolean, + TSelected, +> = StrictOrFrom & + UseHistoryStateBaseOptions + +export type UseHistoryStateRoute = < + TRouter extends AnyRouter = RegisteredRouter, + TSelected = unknown, +>( + opts?: UseHistoryStateBaseOptions< + TRouter, + TFrom, + /* TStrict */ true, + /* TThrow */ true, + TSelected + >, +) => Accessor> + +export function useHistoryState< + TRouter extends AnyRouter = RegisteredRouter, + const TFrom extends string | undefined = undefined, + TStrict extends boolean = true, + TThrow extends boolean = true, + TSelected = unknown, +>( + opts: UseHistoryStateOptions< + TRouter, + TFrom, + TStrict, + ThrowConstraint, + TSelected + >, +): Accessor< + ThrowOrOptional< + UseHistoryStateResult, + TThrow + > +> { + return useMatch({ + from: opts.from!, + strict: opts.strict, + shouldThrow: opts.shouldThrow, + select: (match: any) => { + const matchState = match.state + const filteredState = omitInternalKeys(matchState) + const typedState = filteredState as unknown as ResolveUseHistoryState< + TRouter, + TFrom, + TStrict + > + return opts.select ? opts.select(typedState) : typedState + }, + }) as any +} diff --git a/packages/solid-router/tests/Matches.test-d.tsx b/packages/solid-router/tests/Matches.test-d.tsx index b107435db62..a26ebb0b293 100644 --- a/packages/solid-router/tests/Matches.test-d.tsx +++ b/packages/solid-router/tests/Matches.test-d.tsx @@ -20,6 +20,7 @@ type RootMatch = RouteMatch< RootRoute['fullPath'], RootRoute['types']['allParams'], RootRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], RootRoute['types']['loaderData'], RootRoute['types']['allContext'], RootRoute['types']['loaderDeps'] @@ -37,6 +38,7 @@ type IndexMatch = RouteMatch< IndexRoute['fullPath'], IndexRoute['types']['allParams'], IndexRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], IndexRoute['types']['loaderData'], IndexRoute['types']['allContext'], IndexRoute['types']['loaderDeps'] @@ -53,6 +55,7 @@ type InvoiceMatch = RouteMatch< InvoiceRoute['fullPath'], InvoiceRoute['types']['allParams'], InvoiceRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], InvoiceRoute['types']['loaderData'], InvoiceRoute['types']['allContext'], InvoiceRoute['types']['loaderDeps'] @@ -65,6 +68,7 @@ type InvoicesMatch = RouteMatch< InvoicesRoute['fullPath'], InvoicesRoute['types']['allParams'], InvoicesRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], InvoicesRoute['types']['loaderData'], InvoicesRoute['types']['allContext'], InvoicesRoute['types']['loaderDeps'] @@ -82,6 +86,7 @@ type InvoicesIndexMatch = RouteMatch< InvoicesIndexRoute['fullPath'], InvoicesIndexRoute['types']['allParams'], InvoicesIndexRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], InvoicesIndexRoute['types']['loaderData'], InvoicesIndexRoute['types']['allContext'], InvoicesIndexRoute['types']['loaderDeps'] @@ -107,6 +112,7 @@ type LayoutMatch = RouteMatch< LayoutRoute['fullPath'], LayoutRoute['types']['allParams'], LayoutRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], LayoutRoute['types']['loaderData'], LayoutRoute['types']['allContext'], LayoutRoute['types']['loaderDeps'] @@ -130,6 +136,7 @@ type CommentsMatch = RouteMatch< CommentsRoute['fullPath'], CommentsRoute['types']['allParams'], CommentsRoute['types']['fullSearchSchema'], + RootRoute['types']['fullStateSchema'], CommentsRoute['types']['loaderData'], CommentsRoute['types']['allContext'], CommentsRoute['types']['loaderDeps'] diff --git a/packages/solid-router/tests/useHistoryState.test-d.tsx b/packages/solid-router/tests/useHistoryState.test-d.tsx new file mode 100644 index 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/fileRoute.ts b/packages/vue-router/src/fileRoute.ts index d29859e10a2..2a809c7e1a1 100644 --- a/packages/vue-router/src/fileRoute.ts +++ b/packages/vue-router/src/fileRoute.ts @@ -79,6 +79,7 @@ export class FileRoute< createRoute = < TRegister = Register, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -95,6 +96,7 @@ export class FileRoute< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -112,6 +114,7 @@ export class FileRoute< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, AnyContext, @@ -126,6 +129,7 @@ export class FileRoute< TFilePath, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, diff --git a/packages/vue-router/src/route.ts b/packages/vue-router/src/route.ts index 1cfaddc559b..38d66cf58f5 100644 --- a/packages/vue-router/src/route.ts +++ b/packages/vue-router/src/route.ts @@ -173,6 +173,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, @@ -193,6 +194,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -214,6 +216,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -239,6 +242,7 @@ export class Route< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -318,6 +322,7 @@ export function createRoute< TPath >, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -335,6 +340,7 @@ export function createRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -352,6 +358,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -371,6 +378,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -394,6 +402,7 @@ export type AnyRootRoute = RootRoute< any, any, any, + any, any > @@ -403,6 +412,7 @@ export function createRootRouteWithContext() { TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TSSR = unknown, @@ -411,6 +421,7 @@ export function createRootRouteWithContext() { options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -423,6 +434,7 @@ export function createRootRouteWithContext() { return createRootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -442,6 +454,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, @@ -455,6 +468,7 @@ export class RootRoute< extends BaseRootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -469,6 +483,7 @@ export class RootRoute< RootRouteCore< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -487,6 +502,7 @@ export class RootRoute< options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -582,6 +598,7 @@ export class NotFoundRoute< TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TChildren = unknown, @@ -595,6 +612,7 @@ export class NotFoundRoute< '404', '404', TSearchValidator, + TStateValidator, {}, TRouterContext, TRouteContextFn, @@ -615,6 +633,7 @@ export class NotFoundRoute< string, string, TSearchValidator, + TStateValidator, {}, TLoaderDeps, TLoaderFn, @@ -642,6 +661,7 @@ export class NotFoundRoute< export function createRootRoute< TRegister = Register, TSearchValidator = undefined, + TStateValidator = undefined, TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -653,6 +673,7 @@ export function createRootRoute< options?: RootRouteOptions< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -664,6 +685,7 @@ export function createRootRoute< ): RootRoute< TRegister, TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -677,6 +699,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 e9453614209..42dd06ecbbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6272,6 +6272,49 @@ importers: specifier: ^7.3.1 version: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + examples/react/basic-history-state: + dependencies: + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.1.18(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + '@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.1.18 + zod: + specifier: ^3.24.2 + version: 3.25.57 + devDependencies: + '@types/react': + specifier: ^19.2.8 + version: 19.2.8 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.8) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + typescript: + specifier: ^5.7.2 + version: 5.8.2 + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + examples/react/basic-non-nested-devtools: dependencies: '@tailwindcss/vite': @@ -11817,6 +11860,9 @@ importers: packages/router-devtools-core: dependencies: + '@tanstack/history': + specifier: workspace:* + version: link:../history '@tanstack/router-core': specifier: workspace:* version: link:../router-core @@ -18916,9 +18962,6 @@ packages: camel-case@4.1.2: resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} - caniuse-lite@1.0.30001696: - resolution: {integrity: sha512-pDCPkvzfa39ehJtJ+OwGT/2yvT2SbjfHhiIW2LWOAcMQ7BzwxT/XuyUp4OTOd0XFWA6BKw0JalnBHgSi5DGJBQ==} - caniuse-lite@1.0.30001760: resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} @@ -27105,7 +27148,7 @@ snapshots: '@rushstack/ts-command-line': 4.22.3(@types/node@25.0.9) lodash: 4.17.21 minimatch: 3.0.8 - resolve: 1.22.10 + resolve: 1.22.11 semver: 7.5.4 source-map: 0.6.1 typescript: 5.4.2 @@ -27117,7 +27160,7 @@ snapshots: '@microsoft/tsdoc': 0.15.1 ajv: 8.12.0 jju: 1.4.0 - resolve: 1.22.10 + resolve: 1.22.11 '@microsoft/tsdoc@0.15.1': {} @@ -29220,7 +29263,7 @@ snapshots: '@module-federation/runtime-tools': 0.8.4 '@rspack/binding': 1.2.2 '@rspack/lite-tapable': 1.0.1 - caniuse-lite: 1.0.30001696 + caniuse-lite: 1.0.30001760 optionalDependencies: '@swc/helpers': 0.5.15 @@ -29241,14 +29284,14 @@ snapshots: fs-extra: 7.0.1 import-lazy: 4.0.0 jju: 1.4.0 - resolve: 1.22.10 + resolve: 1.22.11 semver: 7.5.4 optionalDependencies: '@types/node': 25.0.9 '@rushstack/rig-package@0.5.3': dependencies: - resolve: 1.22.10 + resolve: 1.22.11 strip-json-comments: 3.1.1 '@rushstack/terminal@0.13.3(@types/node@25.0.9)': @@ -31795,7 +31838,7 @@ snapshots: dependencies: '@babel/runtime': 7.26.7 cosmiconfig: 7.1.0 - resolve: 1.22.10 + resolve: 1.22.11 babel-plugin-vue-jsx-hmr@1.0.0: dependencies: @@ -31932,7 +31975,7 @@ snapshots: browserslist@4.24.4: dependencies: - caniuse-lite: 1.0.30001696 + caniuse-lite: 1.0.30001760 electron-to-chromium: 1.5.90 node-releases: 2.0.19 update-browserslist-db: 1.1.2(browserslist@4.24.4) @@ -32039,8 +32082,6 @@ snapshots: pascal-case: 3.1.2 tslib: 2.8.1 - caniuse-lite@1.0.30001696: {} - caniuse-lite@1.0.30001760: {} chai@5.2.0: