From c32a68004c2cc37719e7a912d6c8a709c0d1092c Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Fri, 21 Mar 2025 09:56:55 +0900 Subject: [PATCH 01/33] feat(router): add validateState function for state validation in router This commit introduces a new `validateState` function to handle state validation within the router. It supports various validation methods, including standard validation and parsing, and integrates with route options to validate state during routing. --- packages/react-router/src/router.ts | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index f98e9a7363..5b65ed20a3 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -201,6 +201,34 @@ function validateSearch(validateSearch: AnyValidator, input: unknown): unknown { return {} } +function validateState(validateState: AnyValidator, input: unknown): unknown { + if (validateState == null) return {} + + if ('~standard' in validateState) { + const result = validateState['~standard'].validate(input) + + if (result instanceof Promise) + throw new Error('Async validation not supported') + + if (result.issues) + throw new Error(JSON.stringify(result.issues, undefined, 2), { + cause: result, + }) + + return result.value + } + + if ('parse' in validateState) { + return validateState.parse(input) + } + + if (typeof validateState === 'function') { + return validateState(input) + } + + return {} +} + export function createRouter< TRouteTree extends AnyRoute, TTrailingSlashOption extends TrailingSlashOption, @@ -1245,6 +1273,26 @@ export class Router< nextState = replaceEqualDeep(this.latestLocation.state, nextState) + if (opts._includeValidateState) { + let validatedState = {} + matchedRoutesResult?.matchedRoutes.forEach((route) => { + try { + if (route.options.validateState) { + validatedState = { + ...validatedState, + ...(validateState(route.options.validateState, { + ...validatedState, + ...nextState, + }) ?? {}), + } + } + } catch { + // ignore errors here because they are already handled in matchRoutes + } + }) + nextState = validatedState + } + return { pathname, search, From 6008e611617d6344d3299e1491d89a5271328b01 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Mon, 24 Mar 2025 23:10:12 +0900 Subject: [PATCH 02/33] feat(router): add TStateValidator type for state validation in routing This commit introduces the TStateValidator type across various routing components, enhancing the validation capabilities for state management. The changes include updates to the createFileRoute and Route classes, as well as adjustments in related files to support the new state validation feature. --- packages/react-router/src/fileRoute.ts | 8 ++++++-- packages/router-core/src/RouterProvider.ts | 1 + packages/router-core/src/fileRoute.ts | 1 + .../src/BaseTanStackRouterDevtoolsPanel.tsx | 1 + packages/solid-router/src/fileRoute.ts | 8 ++++++-- packages/solid-router/src/route.ts | 20 +++++++++++++++++++ 6 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/react-router/src/fileRoute.ts b/packages/react-router/src/fileRoute.ts index d645cc840d..b30cd76f8c 100644 --- a/packages/react-router/src/fileRoute.ts +++ b/packages/react-router/src/fileRoute.ts @@ -48,7 +48,7 @@ export function createFileRoute< }).createRoute } -/** +/** @deprecated It's no longer recommended to use the `FileRoute` class directly. Instead, use `createFileRoute('/path/to/file')(options)` to create a file route. */ @@ -71,6 +71,7 @@ export class FileRoute< createRoute = < TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -83,6 +84,7 @@ export class FileRoute< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -96,6 +98,7 @@ export class FileRoute< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, AnyContext, @@ -109,6 +112,7 @@ export class FileRoute< TFilePath, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -128,7 +132,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. diff --git a/packages/router-core/src/RouterProvider.ts b/packages/router-core/src/RouterProvider.ts index 1c2b42ef21..b471d1d61b 100644 --- a/packages/router-core/src/RouterProvider.ts +++ b/packages/router-core/src/RouterProvider.ts @@ -46,5 +46,6 @@ export type BuildLocationFn = < opts: ToOptions & { leaveParams?: boolean _includeValidateSearch?: boolean + _includeValidateState?: boolean }, ) => ParsedLocation diff --git a/packages/router-core/src/fileRoute.ts b/packages/router-core/src/fileRoute.ts index 8d2e5b33a5..d9696ea212 100644 --- a/packages/router-core/src/fileRoute.ts +++ b/packages/router-core/src/fileRoute.ts @@ -35,6 +35,7 @@ export type LazyRouteOptions = Pick< string, AnyPathParams, AnyValidator, + AnyValidator, {}, AnyContext, AnyContext, diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index b05428c926..47ab33486f 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -81,6 +81,7 @@ function RouteComp({ string, '__root__', undefined, + undefined, {}, {}, AnyContext, diff --git a/packages/solid-router/src/fileRoute.ts b/packages/solid-router/src/fileRoute.ts index b20789e938..d7c332d863 100644 --- a/packages/solid-router/src/fileRoute.ts +++ b/packages/solid-router/src/fileRoute.ts @@ -48,7 +48,7 @@ export function createFileRoute< }).createRoute } -/** +/** @deprecated It's no longer recommended to use the `FileRoute` class directly. Instead, use `createFileRoute('/path/to/file')(options)` to create a file route. */ @@ -71,6 +71,7 @@ export class FileRoute< createRoute = < TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -83,6 +84,7 @@ export class FileRoute< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -96,6 +98,7 @@ export class FileRoute< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, AnyContext, @@ -109,6 +112,7 @@ export class FileRoute< TFilePath, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -128,7 +132,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. diff --git a/packages/solid-router/src/route.ts b/packages/solid-router/src/route.ts index 4f081d738f..61d708bc27 100644 --- a/packages/solid-router/src/route.ts +++ b/packages/solid-router/src/route.ts @@ -144,6 +144,7 @@ export class Route< TPath >, in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TParams = ResolveParams, in out TRouterContext = AnyContext, in out TRouteContextFn = AnyContext, @@ -159,6 +160,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -179,6 +181,7 @@ export class Route< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -246,6 +249,7 @@ export function createRoute< TPath >, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -260,6 +264,7 @@ export function createRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -274,6 +279,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -290,6 +296,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -308,11 +315,13 @@ export function createRootRouteWithContext() { TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, >( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -322,6 +331,7 @@ export function createRootRouteWithContext() { ) => { return createRootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -338,6 +348,7 @@ export const rootRouteWithContext = createRootRouteWithContext export class RootRoute< in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TRouterContext = {}, in out TRouteContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, @@ -347,6 +358,7 @@ export class RootRoute< in out TFileRouteTypes = unknown, > extends BaseRootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -361,6 +373,7 @@ export class RootRoute< constructor( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -445,6 +458,7 @@ export class NotFoundRoute< TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TChildren = unknown, @@ -455,6 +469,7 @@ export class NotFoundRoute< '404', '404', TSearchValidator, + TStateValidator, {}, TRouterContext, TRouteContextFn, @@ -472,6 +487,7 @@ export class NotFoundRoute< string, string, TSearchValidator, + TStateValidator, {}, TLoaderDeps, TLoaderFn, @@ -496,6 +512,7 @@ export class NotFoundRoute< export function createRootRoute< TSearchValidator = undefined, + TStateValidator = undefined, TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -504,6 +521,7 @@ export function createRootRoute< >( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -512,6 +530,7 @@ export function createRootRoute< >, ): RootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -522,6 +541,7 @@ export function createRootRoute< > { return new RootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, From 5249d7813132e7ad90a9fb6845d291731b920ee0 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Wed, 26 Mar 2025 22:41:37 +0900 Subject: [PATCH 03/33] add TStateValidator to route type definition --- packages/router-core/src/route.ts | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 4be19d5c24..4dbffb213e 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -395,6 +395,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, @@ -455,6 +456,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, @@ -491,6 +493,7 @@ export type RouteAddFileChildrenFn< in out TCustomId extends string, in out TId extends string, in out TSearchValidator, + in out TStateValidator, in out TParams, in out TRouterContext, in out TRouteContextFn, @@ -507,6 +510,7 @@ export type RouteAddFileChildrenFn< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -524,6 +528,7 @@ export type RouteAddFileTypesFn< TCustomId extends string, TId extends string, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -538,6 +543,7 @@ export type RouteAddFileTypesFn< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -555,6 +561,7 @@ export interface Route< in out TCustomId extends string, in out TId extends string, in out TSearchValidator, + in out TStateValidator, in out TParams, in out TRouterContext, in out TRouteContextFn, @@ -576,6 +583,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -592,6 +600,7 @@ export interface Route< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -613,6 +622,7 @@ export interface Route< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, TRouterContext, @@ -628,6 +638,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -643,6 +654,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -658,6 +670,7 @@ export interface Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -682,6 +695,7 @@ export type AnyRoute = Route< any, any, any, + any, any > @@ -696,6 +710,7 @@ export type RouteOptions< TFullPath extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = AnyPathParams, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -708,6 +723,7 @@ export type RouteOptions< TCustomId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -721,6 +737,7 @@ export type RouteOptions< NoInfer, NoInfer, NoInfer, + NoInfer, NoInfer, NoInfer, NoInfer, @@ -763,6 +780,7 @@ export type FileBaseRouteOptions< TId extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = {}, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -772,6 +790,7 @@ export type FileBaseRouteOptions< TRemountDepsFn = AnyContext, > = ParamsOptions & { validateSearch?: Constrain + validateState?: Constrain shouldReload?: | boolean @@ -854,6 +873,7 @@ export type BaseRouteOptions< TCustomId extends string = string, TPath extends string = string, TSearchValidator = undefined, + TStateValidator = undefined, TParams = {}, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -866,6 +886,7 @@ export type BaseRouteOptions< TId, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -978,6 +999,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, @@ -1172,6 +1194,7 @@ export interface LoaderFnContext< export type RootRouteOptions< TSearchValidator = undefined, + TStateValidator = undefined, TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -1185,6 +1208,7 @@ export type RootRouteOptions< '', // TFullPath '', // TPath TSearchValidator, + TStateValidator, {}, // TParams TLoaderDeps, TLoaderFn, @@ -1261,6 +1285,7 @@ export class BaseRoute< in out TCustomId extends string = string, in out TId extends string = ResolveId, in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TParams = ResolveParams, in out TRouterContext = AnyContext, in out TRouteContextFn = AnyContext, @@ -1277,6 +1302,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1295,6 +1321,7 @@ export class BaseRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -1347,6 +1374,7 @@ export class BaseRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -1370,6 +1398,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1391,6 +1420,7 @@ export class BaseRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -1455,6 +1485,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1473,6 +1504,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1499,6 +1531,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1532,6 +1565,7 @@ export class BaseRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1550,6 +1584,7 @@ export class BaseRoute< TFullPath, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TLoaderDeps, TRouterContext, @@ -1581,6 +1616,7 @@ export class BaseRouteApi { export class BaseRootRoute< in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TRouterContext = {}, in out TRouteContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, @@ -1595,6 +1631,7 @@ export class BaseRootRoute< string, // TCustomId RootRouteId, // TId TSearchValidator, // TSearchValidator + TStateValidator, // TStateValidator {}, // TParams TRouterContext, TRouteContextFn, @@ -1607,6 +1644,7 @@ export class BaseRootRoute< constructor( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, From 9ffaa51d662206c49f9351bfe1496bdbacd7cb4e Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Sat, 29 Mar 2025 15:26:16 +0900 Subject: [PATCH 04/33] feat(router): add useHistoryState for enhanced state management This commit introduces the `useHistoryState` hook and related types across various routing components, improving state management capabilities. Updates include modifications to the Route and createRoute functions to support the new state handling feature. --- packages/react-router/src/index.tsx | 1 + packages/react-router/src/route.ts | 50 ++++++++++++++++ packages/router-core/src/index.ts | 7 +++ packages/router-core/src/link.ts | 14 ++++- packages/router-core/src/route.ts | 67 ++++++++++++++++++++-- packages/router-core/src/routeInfo.ts | 10 ++++ packages/router-core/src/typePrimitives.ts | 20 +++++++ packages/router-core/src/validators.ts | 15 +++++ 8 files changed, 179 insertions(+), 5 deletions(-) diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index 374fc2916a..b6b1236482 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -310,6 +310,7 @@ export { useNavigate, Navigate } from './useNavigate' export { useParams } from './useParams' export { useSearch } from './useSearch' +export { useHistoryState } from './useHistoryState' export { getRouterContext, // SSR diff --git a/packages/react-router/src/route.ts b/packages/react-router/src/route.ts index 71e0feb1e3..4998a45ca6 100644 --- a/packages/react-router/src/route.ts +++ b/packages/react-router/src/route.ts @@ -11,6 +11,7 @@ import { useSearch } from './useSearch' import { useNavigate } from './useNavigate' import { useMatch } from './useMatch' import { useRouter } from './useRouter' +import { useHistoryState } from './useHistoryState' import type { AnyContext, AnyRoute, @@ -39,6 +40,7 @@ import type { UseMatchRoute } from './useMatch' import type { UseLoaderDepsRoute } from './useLoaderDeps' import type { UseParamsRoute } from './useParams' import type { UseSearchRoute } from './useSearch' +import type { UseHistoryStateRoute } from './useHistoryState' import type * as React from 'react' import type { UseRouteContextRoute } from './useRouteContext' @@ -60,6 +62,7 @@ declare module '@tanstack/router-core' { useParams: UseParamsRoute useLoaderDeps: UseLoaderDepsRoute useLoaderData: UseLoaderDataRoute + useHistoryState: UseHistoryStateRoute useNavigate: () => UseNavigateResult } } @@ -106,6 +109,15 @@ export class RouteApi< } as any) as any } + useHistoryState: UseHistoryStateRoute = (opts) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return useHistoryState({ + select: opts?.select, + structuralSharing: opts?.structuralSharing, + from: this.id, + } as any) as any + } + useParams: UseParamsRoute = (opts) => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion return useParams({ @@ -149,6 +161,7 @@ export class Route< TPath >, in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TParams = ResolveParams, in out TRouterContext = AnyContext, in out TRouteContextFn = AnyContext, @@ -164,6 +177,7 @@ export class Route< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -184,6 +198,7 @@ export class Route< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -220,6 +235,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({ @@ -256,6 +280,7 @@ export function createRoute< TPath >, TSearchValidator = undefined, + TStateValidator = undefined, TParams = ResolveParams, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -270,6 +295,7 @@ export function createRoute< TFullPath, TPath, TSearchValidator, + TStateValidator, TParams, TLoaderDeps, TLoaderFn, @@ -284,6 +310,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -299,6 +326,7 @@ export function createRoute< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, AnyContext, TRouteContextFn, @@ -316,11 +344,13 @@ export function createRootRouteWithContext() { TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, >( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -330,6 +360,7 @@ export function createRootRouteWithContext() { ) => { return createRootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -346,6 +377,7 @@ export const rootRouteWithContext = createRootRouteWithContext export class RootRoute< in out TSearchValidator = undefined, + in out TStateValidator = undefined, in out TRouterContext = {}, in out TRouteContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, @@ -355,6 +387,7 @@ export class RootRoute< in out TFileRouteTypes = unknown, > extends BaseRootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -369,6 +402,7 @@ export class RootRoute< constructor( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -404,6 +438,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({ @@ -428,6 +471,7 @@ export class RootRoute< export function createRootRoute< TSearchValidator = undefined, + TStateValidator = undefined, TRouterContext = {}, TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, @@ -436,6 +480,7 @@ export function createRootRoute< >( options?: RootRouteOptions< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -444,6 +489,7 @@ export function createRootRoute< >, ): RootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -454,6 +500,7 @@ export function createRootRoute< > { return new RootRoute< TSearchValidator, + TStateValidator, TRouterContext, TRouteContextFn, TBeforeLoadFn, @@ -496,6 +543,7 @@ export class NotFoundRoute< TRouteContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, + TStateValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TChildren = unknown, @@ -506,6 +554,7 @@ export class NotFoundRoute< '404', '404', TSearchValidator, + TStateValidator, {}, TRouterContext, TRouteContextFn, @@ -523,6 +572,7 @@ export class NotFoundRoute< string, string, TSearchValidator, + TStateValidator, {}, TLoaderDeps, TLoaderFn, diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 6c124b7184..49ee8782c2 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -350,6 +350,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' @@ -384,6 +389,7 @@ export type { ValidateToPath, ValidateSearch, ValidateParams, + ValidateHistoryState, InferFrom, InferTo, InferMaskTo, @@ -398,4 +404,5 @@ export type { InferSelected, ValidateUseSearchResult, ValidateUseParamsResult, + ValidateUseHistoryStateResult, } from './typePrimitives' diff --git a/packages/router-core/src/link.ts b/packages/router-core/src/link.ts index 5fa5a2a5b3..eeff202b1b 100644 --- a/packages/router-core/src/link.ts +++ b/packages/router-core/src/link.ts @@ -6,6 +6,7 @@ import type { FullSearchSchema, FullSearchSchemaInput, ParentPath, + RouteById, RouteByPath, RouteByToPath, RoutePaths, @@ -344,7 +345,18 @@ export type ToSubOptionsProps< TTo extends string | undefined = '.', > = MakeToRequired & { hash?: true | Updater - state?: true | NonNullableUpdater + state?: TTo extends undefined + ? true | NonNullableUpdater + : true | ResolveRelativePath extends infer TPath + ? TPath extends string + ? TPath extends RoutePaths + ? NonNullableUpdater< + ParsedHistoryState, + RouteById['types']['stateSchema'] + > + : NonNullableUpdater + : NonNullableUpdater + : NonNullableUpdater from?: FromPathOption & {} } diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 4dbffb213e..e70d0bac82 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -30,6 +30,7 @@ import type { AnyValidatorObj, DefaultValidator, ResolveSearchValidatorInput, + ResolveStateValidatorInput, ResolveValidatorOutput, StandardSchemaValidator, ValidatorAdapter, @@ -99,6 +100,13 @@ export type InferFullSearchSchemaInput = TRoute extends { ? TFullSearchSchemaInput : {} +export type InferFullStateSchemaInput = TRoute extends { + types: { + fullStateSchemaInput: infer TFullStateSchemaInput + } +} + ? TFullStateSchemaInput + : {} export type InferAllParams = TRoute extends { types: { allParams: infer TAllParams @@ -156,6 +164,29 @@ export type ParseSplatParams = TPath & ? never : '_splat' : '_splat' +export type ResolveStateSchemaFn = TStateValidator extends ( + ...args: any +) => infer TStateSchema + ? TStateSchema + : AnySchema + +export type ResolveFullStateSchema< + TParentRoute extends AnyRoute, + TStateValidator, +> = unknown extends TParentRoute + ? ResolveStateSchema + : IntersectAssign< + InferFullStateSchema, + ResolveStateSchema + > + +export type InferFullStateSchema = TRoute extends { + types: { + fullStateSchema: infer TFullStateSchema + } +} + ? TFullStateSchema + : {} export interface SplatParams { _splat?: string @@ -182,12 +213,12 @@ export type ParamsOptions = { stringify?: StringifyParamsFn } - /** + /** @deprecated Use params.parse instead */ parseParams?: ParseParamsFn - /** + /** @deprecated Use params.stringify instead */ stringifyParams?: StringifyParamsFn @@ -324,6 +355,24 @@ export type ResolveFullSearchSchemaInput< InferFullSearchSchemaInput, ResolveSearchValidatorInput > +export type ResolveStateSchema = + unknown extends TStateValidator + ? TStateValidator + : TStateValidator extends AnyStandardSchemaValidator + ? NonNullable['output'] + : TStateValidator extends AnyValidatorAdapter + ? TStateValidator['types']['output'] + : TStateValidator extends AnyValidatorObj + ? ResolveStateSchemaFn + : ResolveStateSchemaFn + +export type ResolveFullStateSchemaInput< + TParentRoute extends AnyRoute, + TStateValidator, +> = IntersectAssign< + InferFullStateSchemaInput, + ResolveStateValidatorInput +> export type ResolveAllParamsFromParent< TParentRoute extends AnyRoute, @@ -414,11 +463,18 @@ export interface RouteTypes< searchSchema: ResolveValidatorOutput searchSchemaInput: ResolveSearchValidatorInput searchValidator: TSearchValidator + stateSchema: ResolveStateSchema + stateValidator: TStateValidator fullSearchSchema: ResolveFullSearchSchema fullSearchSchemaInput: ResolveFullSearchSchemaInput< TParentRoute, TSearchValidator > + fullStateSchema: ResolveFullStateSchema + fullStateSchemaInput: ResolveFullStateSchemaInput< + TParentRoute, + TStateValidator + > params: TParams allParams: ResolveAllParamsFromParent routerContext: TRouterContext @@ -476,6 +532,7 @@ export type RouteAddChildrenFn< TCustomId, TId, TSearchValidator, + TStateValidator, TParams, TRouterContext, TRouteContextFn, @@ -1027,13 +1084,13 @@ export interface UpdatableRouteOptions< > > } - /** + /** @deprecated Use search.middlewares instead */ preSearchFilters?: Array< SearchFilter> > - /** + /** @deprecated Use search.middlewares instead */ postSearchFilters?: Array< @@ -1233,6 +1290,8 @@ export type RouteConstraints = { TId: string TSearchSchema: AnySchema TFullSearchSchema: AnySchema + TStateSchema: AnySchema + TFullStateSchema: AnySchema TParams: Record TAllParams: Record TParentContext: AnyContext diff --git a/packages/router-core/src/routeInfo.ts b/packages/router-core/src/routeInfo.ts index 6c9249e091..be31fbf59f 100644 --- a/packages/router-core/src/routeInfo.ts +++ b/packages/router-core/src/routeInfo.ts @@ -219,6 +219,16 @@ export type FullSearchSchemaInput = ? PartialMergeAll : never +export type FullStateSchema = + ParseRoute extends infer TRoutes extends AnyRoute + ? PartialMergeAll + : never + +export type FullStateSchemaInput = + ParseRoute extends infer TRoutes extends AnyRoute + ? PartialMergeAll + : never + export type AllParams = ParseRoute extends infer TRoutes extends AnyRoute ? PartialMergeAll diff --git a/packages/router-core/src/typePrimitives.ts b/packages/router-core/src/typePrimitives.ts index f0c5d2ae9f..4201520e40 100644 --- a/packages/router-core/src/typePrimitives.ts +++ b/packages/router-core/src/typePrimitives.ts @@ -10,6 +10,7 @@ import type { RouteIds } from './routeInfo' import type { AnyRouter, RegisteredRouter } from './router' import type { UseParamsResult } from './useParams' import type { UseSearchResult } from './useSearch' +import type { UseHistoryStateResult } from './useHistoryState' import type { Constrain, ConstrainLiteral } from './utils' export type ValidateFromPath< @@ -29,6 +30,16 @@ export type ValidateSearch< TFrom extends string = string, > = SearchParamOptions +export type ValidateHistoryState< + TOptions, + TRouter extends AnyRouter = RegisteredRouter, +> = UseHistoryStateResult< + TRouter, + InferFrom, + InferStrict, + InferSelected +> + export type ValidateParams< TRouter extends AnyRouter = RegisteredRouter, TTo extends string | undefined = undefined, @@ -179,3 +190,12 @@ export type ValidateUseParamsResult< InferSelected > > +export type ValidateUseHistoryStateResult< + TOptions, + TRouter extends AnyRouter = RegisteredRouter, +> = UseHistoryStateResult< + TRouter, + InferFrom, + InferStrict, + InferSelected +> diff --git a/packages/router-core/src/validators.ts b/packages/router-core/src/validators.ts index 9173080077..01224c9de5 100644 --- a/packages/router-core/src/validators.ts +++ b/packages/router-core/src/validators.ts @@ -89,6 +89,21 @@ export type ResolveSearchValidatorInput = ? ResolveSearchValidatorInputFn : ResolveSearchValidatorInputFn +export type ResolveStateValidatorInputFn = TValidator extends ( + input: infer TSchemaInput, +) => any + ? TSchemaInput + : AnySchema + +export type ResolveStateValidatorInput = + TValidator extends AnyStandardSchemaValidator + ? NonNullable['input'] + : TValidator extends AnyValidatorAdapter + ? TValidator['types']['input'] + : TValidator extends AnyValidatorObj + ? ResolveStateValidatorInputFn + : ResolveStateValidatorInputFn + export type ResolveValidatorInputFn = TValidator extends ( input: infer TInput, ) => any From 1ea838c5a1a2f724cab03da42f5fc5c6612aa123 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Sat, 29 Mar 2025 21:20:54 +0900 Subject: [PATCH 05/33] feat(router): add state params display in devtools panel This commit introduces a new section in the BaseTanStackRouterDevtoolsPanel to display state parameters when available. --- .../src/BaseTanStackRouterDevtoolsPanel.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index 47ab33486f..701a6b974f 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -227,6 +227,10 @@ export const BaseTanStackRouterDevtoolsPanel = () => Object.keys(routerState().location.search).length, ) + const hasState = createMemo( + () => Object.keys(routerState().location.state).length, + ) + const explorerState = createMemo(() => { return { ...router(), @@ -272,6 +276,7 @@ export const BaseTanStackRouterDevtoolsPanel = const activeMatchLoaderData = createMemo(() => activeMatch()?.loaderData) const activeMatchValue = createMemo(() => activeMatch()) const locationSearchValue = createMemo(() => routerState().location.search) + const locationStateValue = createMemo(() => routerState().location.state) return (
) : null} + {hasState() ? ( +
+
State Params
+
+ { + obj[next] = {} + return obj + }, {})} + /> +
+
+ ) : null} ) } From 62b20903be41ab7dbfffd5f7213a3b53edee2808 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Sat, 29 Mar 2025 23:00:37 +0900 Subject: [PATCH 06/33] feat(router): implement useHistoryState hook for custom state management This commit introduces the `useHistoryState` hook along with its associated types, enhancing the state management capabilities within the router. The new implementation allows for more flexible state handling and selection options, improving overall functionality. --- packages/react-router/src/useHistoryState.tsx | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 packages/react-router/src/useHistoryState.tsx diff --git a/packages/react-router/src/useHistoryState.tsx b/packages/react-router/src/useHistoryState.tsx new file mode 100644 index 0000000000..c44dc690cc --- /dev/null +++ b/packages/react-router/src/useHistoryState.tsx @@ -0,0 +1,94 @@ +import { useMatch } from './useMatch' +import type { + AnyRouter, + Constrain, + Expand, + RegisteredRouter, + RouteById, + RouteIds, + UseHistoryStateResult, +} from '@tanstack/router-core' +import type { + StructuralSharingOption, + ValidateSelected, +} from './structuralSharing' + +// Resolving HistoryState from our custom implementation +type ResolveUseHistoryState< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, +> = TStrict extends false + ? Expand>> + : Expand['types']['stateSchema']> + +export interface UseHistoryStateBaseOptions< + TRouter extends AnyRouter, + TFrom extends string | undefined, + TStrict extends boolean, + TSelected, + TStructuralSharing extends boolean, +> { + select?: ( + state: ResolveUseHistoryState, + ) => ValidateSelected + from?: Constrain> + strict?: TStrict +} + +export type UseHistoryStateOptions< + TRouter extends AnyRouter, + TFrom extends string | undefined, + TStrict extends boolean, + TSelected, + TStructuralSharing extends boolean, +> = UseHistoryStateBaseOptions< + TRouter, + TFrom, + TStrict, + TSelected, + TStructuralSharing +> & + StructuralSharingOption + +export function useHistoryState< + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends string | undefined = undefined, + TStrict extends boolean = true, + TState = TStrict extends false + ? Expand>> + : Expand['types']['stateSchema']>, + TSelected = TState, + TStructuralSharing extends boolean = boolean, +>( + opts?: UseHistoryStateOptions< + TRouter, + TFrom, + TStrict, + TSelected, + TStructuralSharing + >, +): UseHistoryStateResult { + return useMatch({ + from: opts?.from, + strict: opts?.strict, + structuralSharing: opts?.structuralSharing, + select: (match: any) => { + // state property should be available on the match object + const state = match.state || {}; + return opts?.select ? opts.select(state) : state; + }, +} as any) as unknown as UseHistoryStateResult +} + +export type UseHistoryStateRoute = < + TRouter extends AnyRouter = RegisteredRouter, + TSelected = RouteById['types']['stateSchema'], + TStructuralSharing extends boolean = boolean, +>( + opts?: { + select?: ( + state: RouteById['types']['stateSchema'], + ) => ValidateSelected + } & StructuralSharingOption, +) => UseHistoryStateResult From 79ef275cb2b75eccd8b971036db60e4adb69e10b Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Sat, 29 Mar 2025 23:00:46 +0900 Subject: [PATCH 07/33] feat(router): add UseHistoryState types for enhanced state management This commit introduces new TypeScript types related to the `useHistoryState` hook, improving type safety and flexibility in state management within the router. The new types facilitate better state selection and handling options, enhancing the overall functionality of the routing system. --- packages/router-core/src/useHistoryState.ts | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 packages/router-core/src/useHistoryState.ts diff --git a/packages/router-core/src/useHistoryState.ts b/packages/router-core/src/useHistoryState.ts new file mode 100644 index 0000000000..bba5659717 --- /dev/null +++ b/packages/router-core/src/useHistoryState.ts @@ -0,0 +1,43 @@ +import type { RouteById, RouteIds } from './routeInfo' +import type { AnyRouter, RegisteredRouter } from './router' +import type { Constrain, Expand } from './utils' + +export type UseHistoryStateOptions< + TRouter extends AnyRouter, + TFrom extends string | undefined, + TStrict extends boolean, + TState, + TSelected, +> = { + select?: (state: TState) => TSelected + from?: Constrain> + strict?: TStrict +} + +export type UseHistoryStateResult< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TSelected, +> = TSelected extends never + ? ResolveUseHistoryState + : TSelected + +export type ResolveUseHistoryState< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, +> = TStrict extends false + ? Expand>> + : Expand['types']['fullStateSchema']> + +export type UseHistoryStateRoute = < + TRouter extends AnyRouter = RegisteredRouter, + TSelected = RouteById['types']['stateSchema'], + TStructuralSharing extends boolean = boolean, +>(opts?: { + select?: ( + state: RouteById['types']['stateSchema'], + ) => TSelected + structuralSharing?: TStructuralSharing +}) => UseHistoryStateResult From cf7ac4d3f1f0af23f7edf4967a3cb340dc16ae5b Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Mon, 31 Mar 2025 09:46:20 +0900 Subject: [PATCH 08/33] refactor(router): delete unused type --- packages/router-core/src/useHistoryState.ts | 29 +++------------------ 1 file changed, 3 insertions(+), 26 deletions(-) diff --git a/packages/router-core/src/useHistoryState.ts b/packages/router-core/src/useHistoryState.ts index bba5659717..d948f4202c 100644 --- a/packages/router-core/src/useHistoryState.ts +++ b/packages/router-core/src/useHistoryState.ts @@ -1,18 +1,6 @@ -import type { RouteById, RouteIds } from './routeInfo' -import type { AnyRouter, RegisteredRouter } from './router' -import type { Constrain, Expand } from './utils' - -export type UseHistoryStateOptions< - TRouter extends AnyRouter, - TFrom extends string | undefined, - TStrict extends boolean, - TState, - TSelected, -> = { - select?: (state: TState) => TSelected - from?: Constrain> - strict?: TStrict -} +import type { RouteById } from './routeInfo' +import type { AnyRouter } from './router' +import type { Expand } from './utils' export type UseHistoryStateResult< TRouter extends AnyRouter, @@ -30,14 +18,3 @@ export type ResolveUseHistoryState< > = TStrict extends false ? Expand>> : Expand['types']['fullStateSchema']> - -export type UseHistoryStateRoute = < - TRouter extends AnyRouter = RegisteredRouter, - TSelected = RouteById['types']['stateSchema'], - TStructuralSharing extends boolean = boolean, ->(opts?: { - select?: ( - state: RouteById['types']['stateSchema'], - ) => TSelected - structuralSharing?: TStructuralSharing -}) => UseHistoryStateResult From 8ed329b69d0e19cc968be4972fc14fe9007d06fd Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Mon, 31 Mar 2025 09:47:03 +0900 Subject: [PATCH 09/33] feat(router): enhance useHistoryState with additional options and improved state selection This commit refactoring the state selection logic to utilize `useRouterState`. --- packages/react-router/src/useHistoryState.tsx | 67 ++++++++++++------- 1 file changed, 43 insertions(+), 24 deletions(-) diff --git a/packages/react-router/src/useHistoryState.tsx b/packages/react-router/src/useHistoryState.tsx index c44dc690cc..3c48e50add 100644 --- a/packages/react-router/src/useHistoryState.tsx +++ b/packages/react-router/src/useHistoryState.tsx @@ -1,4 +1,5 @@ import { useMatch } from './useMatch' +import { useRouterState } from './useRouterState' import type { AnyRouter, Constrain, @@ -6,6 +7,8 @@ import type { RegisteredRouter, RouteById, RouteIds, + ThrowConstraint, + ThrowOrOptional, UseHistoryStateResult, } from '@tanstack/router-core' import type { @@ -24,8 +27,9 @@ type ResolveUseHistoryState< export interface UseHistoryStateBaseOptions< TRouter extends AnyRouter, - TFrom extends string | undefined, + TFrom, TStrict extends boolean, + TThrow extends boolean, TSelected, TStructuralSharing extends boolean, > { @@ -34,61 +38,76 @@ export interface UseHistoryStateBaseOptions< ) => ValidateSelected from?: Constrain> strict?: TStrict + shouldThrow?: TThrow } export type UseHistoryStateOptions< TRouter extends AnyRouter, TFrom extends string | undefined, TStrict extends boolean, + TThrow extends boolean, TSelected, TStructuralSharing extends boolean, > = UseHistoryStateBaseOptions< TRouter, TFrom, TStrict, + TThrow, TSelected, TStructuralSharing > & StructuralSharingOption +export type UseHistoryStateRoute = < + TRouter extends AnyRouter = RegisteredRouter, + TSelected = RouteById['types']['stateSchema'], + TStructuralSharing extends boolean = boolean, +>( + opts?: UseHistoryStateBaseOptions< + TRouter, + TFrom, + /* TStrict */ true, + /* TThrow */ true, + TSelected, + TStructuralSharing + > & + StructuralSharingOption, +) => UseHistoryStateResult + export function useHistoryState< TRouter extends AnyRouter = RegisteredRouter, TFrom extends string | undefined = undefined, TStrict extends boolean = true, + TThrow extends boolean = true, TState = TStrict extends false ? Expand>> : Expand['types']['stateSchema']>, TSelected = TState, TStructuralSharing extends boolean = boolean, >( - opts?: UseHistoryStateOptions< + opts: UseHistoryStateOptions< TRouter, TFrom, TStrict, + ThrowConstraint, TSelected, TStructuralSharing >, -): UseHistoryStateResult { +): ThrowOrOptional< + UseHistoryStateResult, + TThrow +> { return useMatch({ - from: opts?.from, - strict: opts?.strict, - structuralSharing: opts?.structuralSharing, - select: (match: any) => { - // state property should be available on the match object - const state = match.state || {}; - return opts?.select ? opts.select(state) : state; - }, -} as any) as unknown as UseHistoryStateResult + from: opts.from, + strict: opts.strict, + shouldThrow: opts.shouldThrow, + structuralSharing: opts.structuralSharing, + select: () => { + const locationState = useRouterState({ + select: (s) => s.location.state, + }); + const typedState = locationState as unknown as ResolveUseHistoryState; + return opts.select ? opts.select(typedState) : typedState; + }, + } as any) as any; } - -export type UseHistoryStateRoute = < - TRouter extends AnyRouter = RegisteredRouter, - TSelected = RouteById['types']['stateSchema'], - TStructuralSharing extends boolean = boolean, ->( - opts?: { - select?: ( - state: RouteById['types']['stateSchema'], - ) => ValidateSelected - } & StructuralSharingOption, -) => UseHistoryStateResult From 96af77ecedc9b7f9eac8a20758da852e88a7f23a Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Sun, 6 Apr 2025 22:44:24 +0900 Subject: [PATCH 10/33] refactor(router): update useHistoryState.ts types for improved state resolution This commit modifies the `ResolveUseHistoryState` and ` UseHistoryStateResult` types --- packages/router-core/src/useHistoryState.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/useHistoryState.ts b/packages/router-core/src/useHistoryState.ts index d948f4202c..a08d6b0038 100644 --- a/packages/router-core/src/useHistoryState.ts +++ b/packages/router-core/src/useHistoryState.ts @@ -1,4 +1,4 @@ -import type { RouteById } from './routeInfo' +import type { FullStateSchema, RouteById } from './routeInfo' import type { AnyRouter } from './router' import type { Expand } from './utils' @@ -7,7 +7,7 @@ export type UseHistoryStateResult< TFrom, TStrict extends boolean, TSelected, -> = TSelected extends never +> = unknown extends TSelected ? ResolveUseHistoryState : TSelected @@ -16,5 +16,5 @@ export type ResolveUseHistoryState< TFrom, TStrict extends boolean, > = TStrict extends false - ? Expand>> + ? FullStateSchema : Expand['types']['fullStateSchema']> From 4f4bc4c54fd87d7043fac19cbb2520a6853dab72 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Sun, 6 Apr 2025 22:46:27 +0900 Subject: [PATCH 11/33] refactor(router): replace useRouterState with useLocation in useHistoryState This commit updates the `useHistoryState` hook to utilize `useLocation` for state management instead of `useRouterState`, improving the clarity and efficiency of state selection logic. --- packages/react-router/src/useHistoryState.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/react-router/src/useHistoryState.tsx b/packages/react-router/src/useHistoryState.tsx index 3c48e50add..a1fed66b0f 100644 --- a/packages/react-router/src/useHistoryState.tsx +++ b/packages/react-router/src/useHistoryState.tsx @@ -1,5 +1,5 @@ import { useMatch } from './useMatch' -import { useRouterState } from './useRouterState' +import { useLocation } from './useLocation' import type { AnyRouter, Constrain, @@ -16,7 +16,6 @@ import type { ValidateSelected, } from './structuralSharing' -// Resolving HistoryState from our custom implementation type ResolveUseHistoryState< TRouter extends AnyRouter, TFrom, @@ -76,7 +75,7 @@ export type UseHistoryStateRoute = < export function useHistoryState< TRouter extends AnyRouter = RegisteredRouter, - TFrom extends string | undefined = undefined, + const TFrom extends string | undefined = undefined, TStrict extends boolean = true, TThrow extends boolean = true, TState = TStrict extends false @@ -98,14 +97,12 @@ export function useHistoryState< TThrow > { return useMatch({ - from: opts.from, + from: opts.from!, strict: opts.strict, shouldThrow: opts.shouldThrow, structuralSharing: opts.structuralSharing, select: () => { - const locationState = useRouterState({ - select: (s) => s.location.state, - }); + const locationState = useLocation().state const typedState = locationState as unknown as ResolveUseHistoryState; return opts.select ? opts.select(typedState) : typedState; }, From b3b4d8a94c0c3c5cacce0832138eb847cc7e5680 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Wed, 9 Apr 2025 09:23:33 +0900 Subject: [PATCH 12/33] add useHistoryState basic example --- examples/react/basic-history-state/.gitignore | 10 + .../basic-history-state/.vscode/settings.json | 11 + examples/react/basic-history-state/README.md | 6 + examples/react/basic-history-state/index.html | 12 + .../react/basic-history-state/package.json | 29 +++ .../basic-history-state/postcss.config.mjs | 6 + .../react/basic-history-state/src/main.tsx | 234 ++++++++++++++++++ .../basic-history-state/src/posts.lazy.tsx | 36 +++ .../react/basic-history-state/src/posts.ts | 32 +++ .../react/basic-history-state/src/styles.css | 13 + .../basic-history-state/tailwind.config.mjs | 4 + .../basic-history-state/tsconfig.dev.json | 10 + .../react/basic-history-state/tsconfig.json | 12 + .../react/basic-history-state/vite.config.js | 7 + 14 files changed, 422 insertions(+) create mode 100644 examples/react/basic-history-state/.gitignore create mode 100644 examples/react/basic-history-state/.vscode/settings.json create mode 100644 examples/react/basic-history-state/README.md create mode 100644 examples/react/basic-history-state/index.html create mode 100644 examples/react/basic-history-state/package.json create mode 100644 examples/react/basic-history-state/postcss.config.mjs create mode 100644 examples/react/basic-history-state/src/main.tsx create mode 100644 examples/react/basic-history-state/src/posts.lazy.tsx create mode 100644 examples/react/basic-history-state/src/posts.ts create mode 100644 examples/react/basic-history-state/src/styles.css create mode 100644 examples/react/basic-history-state/tailwind.config.mjs create mode 100644 examples/react/basic-history-state/tsconfig.dev.json create mode 100644 examples/react/basic-history-state/tsconfig.json create mode 100644 examples/react/basic-history-state/vite.config.js diff --git a/examples/react/basic-history-state/.gitignore b/examples/react/basic-history-state/.gitignore new file mode 100644 index 0000000000..8354e4d50d --- /dev/null +++ b/examples/react/basic-history-state/.gitignore @@ -0,0 +1,10 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local + +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ \ No newline at end of file diff --git a/examples/react/basic-history-state/.vscode/settings.json b/examples/react/basic-history-state/.vscode/settings.json new file mode 100644 index 0000000000..00b5278e58 --- /dev/null +++ b/examples/react/basic-history-state/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "files.watcherExclude": { + "**/routeTree.gen.ts": true + }, + "search.exclude": { + "**/routeTree.gen.ts": true + }, + "files.readonlyInclude": { + "**/routeTree.gen.ts": true + } +} diff --git a/examples/react/basic-history-state/README.md b/examples/react/basic-history-state/README.md new file mode 100644 index 0000000000..115199d292 --- /dev/null +++ b/examples/react/basic-history-state/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` or `yarn` +- `npm start` or `yarn start` diff --git a/examples/react/basic-history-state/index.html b/examples/react/basic-history-state/index.html new file mode 100644 index 0000000000..9b6335c0ac --- /dev/null +++ b/examples/react/basic-history-state/index.html @@ -0,0 +1,12 @@ + + + + + + Vite App + + +
+ + + diff --git a/examples/react/basic-history-state/package.json b/examples/react/basic-history-state/package.json new file mode 100644 index 0000000000..7f5d824f74 --- /dev/null +++ b/examples/react/basic-history-state/package.json @@ -0,0 +1,29 @@ +{ + "name": "tanstack-router-react-example-basic-history-state", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port=3000", + "build": "vite build && tsc --noEmit", + "serve": "vite preview", + "start": "vite" + }, + "dependencies": { + "@tanstack/react-router": "^1.114.24", + "@tanstack/react-router-devtools": "^1.114.24", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "redaxios": "^0.5.1", + "tailwindcss": "^3.4.17", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.1.0" + } +} diff --git a/examples/react/basic-history-state/postcss.config.mjs b/examples/react/basic-history-state/postcss.config.mjs new file mode 100644 index 0000000000..2e7af2b7f1 --- /dev/null +++ b/examples/react/basic-history-state/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/examples/react/basic-history-state/src/main.tsx b/examples/react/basic-history-state/src/main.tsx new file mode 100644 index 0000000000..8ac8a4a231 --- /dev/null +++ b/examples/react/basic-history-state/src/main.tsx @@ -0,0 +1,234 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { + ErrorComponent, + Link, + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' +import axios from 'redaxios' +import { z } from 'zod' +import type { + ErrorComponentProps, + SearchSchemaInput, +} from '@tanstack/react-router' +import './styles.css' + +type PostType = { + id: number + title: string + body: string +} + +const fetchPosts = async () => { + console.info('Fetching posts...') + await new Promise((r) => setTimeout(r, 300)) + return axios + .get>('https://jsonplaceholder.typicode.com/posts') + .then((r) => r.data.slice(0, 10)) +} + +const fetchPost = async (postId: number) => { + console.info(`Fetching post with id ${postId}...`) + await new Promise((r) => setTimeout(r, 300)) + const post = await axios + .get(`https://jsonplaceholder.typicode.com/posts/${postId}`) + .catch((err) => { + if (err.status === 404) { + throw new NotFoundError(`Post with id "${postId}" not found!`) + } + throw err + }) + .then((r) => r.data) + + return post +} + +const rootRoute = createRootRoute({ + component: RootComponent, + notFoundComponent: () => { + return

This is the notFoundComponent configured on root route

+ }, +}) + +function RootComponent() { + return ( +
+
+ + Home + {' '} + + Posts + {' '} + + This Route Does Not Exist + +
+ + +
+ ) +} +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, +}) + +function IndexComponent() { + return ( +
+

Welcome Home!

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

{post.title}

+

{"I like " + color}

+
+
{post.body}
+
+ ) +} + +const routeTree = rootRoute.addChildren([ + postsLayoutRoute.addChildren([postRoute, postsIndexRoute]), + indexRoute, +]) + +// Set up a Router instance +const router = createRouter({ + routeTree, + defaultPreload: 'intent', + defaultStaleTime: 5000, + scrollRestoration: true, +}) + +// Register things for typesafety +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +const rootElement = document.getElementById('app')! + +if (!rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement) + + root.render() +} diff --git a/examples/react/basic-history-state/src/posts.lazy.tsx b/examples/react/basic-history-state/src/posts.lazy.tsx new file mode 100644 index 0000000000..38e0502714 --- /dev/null +++ b/examples/react/basic-history-state/src/posts.lazy.tsx @@ -0,0 +1,36 @@ +import * as React from 'react' +import { Link, Outlet, createLazyRoute } from '@tanstack/react-router' + +export const Route = createLazyRoute('/posts')({ + component: PostsLayoutComponent, +}) + +function PostsLayoutComponent() { + const posts = Route.useLoaderData() + + return ( +
+
    + {[...posts, { id: 'i-do-not-exist', title: 'Non-existent Post' }].map( + (post) => { + return ( +
  • + +
    {post.title.substring(0, 20)}
    + +
  • + ) + }, + )} +
+ +
+ ) +} diff --git a/examples/react/basic-history-state/src/posts.ts b/examples/react/basic-history-state/src/posts.ts new file mode 100644 index 0000000000..54d62e5788 --- /dev/null +++ b/examples/react/basic-history-state/src/posts.ts @@ -0,0 +1,32 @@ +import axios from 'redaxios' + +export class NotFoundError extends Error {} + +type PostType = { + id: string + title: string + body: string +} + +export const fetchPosts = async () => { + console.info('Fetching posts...') + await new Promise((r) => setTimeout(r, 500)) + return axios + .get>('https://jsonplaceholder.typicode.com/posts') + .then((r) => r.data.slice(0, 10)) +} + +export const fetchPost = async (postId: string) => { + console.info(`Fetching post with id ${postId}...`) + await new Promise((r) => setTimeout(r, 500)) + const post = await axios + .get(`https://jsonplaceholder.typicode.com/posts/${postId}`) + .then((r) => r.data) + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!post) { + throw new NotFoundError(`Post with id "${postId}" not found!`) + } + + return post +} diff --git a/examples/react/basic-history-state/src/styles.css b/examples/react/basic-history-state/src/styles.css new file mode 100644 index 0000000000..0b8e317099 --- /dev/null +++ b/examples/react/basic-history-state/src/styles.css @@ -0,0 +1,13 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html { + color-scheme: light dark; +} +* { + @apply border-gray-200 dark:border-gray-800; +} +body { + @apply bg-gray-50 text-gray-950 dark:bg-gray-900 dark:text-gray-200; +} diff --git a/examples/react/basic-history-state/tailwind.config.mjs b/examples/react/basic-history-state/tailwind.config.mjs new file mode 100644 index 0000000000..4986094b9d --- /dev/null +++ b/examples/react/basic-history-state/tailwind.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'], +} diff --git a/examples/react/basic-history-state/tsconfig.dev.json b/examples/react/basic-history-state/tsconfig.dev.json new file mode 100644 index 0000000000..285a09b0dc --- /dev/null +++ b/examples/react/basic-history-state/tsconfig.dev.json @@ -0,0 +1,10 @@ +{ + "composite": true, + "extends": "../../../tsconfig.base.json", + + "files": ["src/main.tsx"], + "include": [ + "src" + // "__tests__/**/*.test.*" + ] +} diff --git a/examples/react/basic-history-state/tsconfig.json b/examples/react/basic-history-state/tsconfig.json new file mode 100644 index 0000000000..ce3a7d2339 --- /dev/null +++ b/examples/react/basic-history-state/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "target": "ESNext", + "moduleResolution": "Bundler", + "module": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "skipLibCheck": true + } +} diff --git a/examples/react/basic-history-state/vite.config.js b/examples/react/basic-history-state/vite.config.js new file mode 100644 index 0000000000..5a33944a9b --- /dev/null +++ b/examples/react/basic-history-state/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}) From cfe68269d71d0183477f63dbec8c3b6af4ff55d0 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Wed, 9 Apr 2025 22:34:11 +0900 Subject: [PATCH 13/33] feat(examples): add basic-history-state example dependencies --- pnpm-lock.yaml | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0dfea84011..136e670b91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2476,6 +2476,52 @@ importers: specifier: 6.1.0 version: 6.1.0(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) + examples/react/basic-history-state: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-router-devtools': + specifier: workspace:^ + version: link:../../../packages/react-router-devtools + autoprefixer: + specifier: ^10.4.20 + version: 10.4.20(postcss@8.5.1) + postcss: + specifier: ^8.5.1 + version: 8.5.1 + react: + specifier: ^19.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) + redaxios: + specifier: ^0.5.1 + version: 0.5.1 + tailwindcss: + specifier: ^3.4.17 + version: 3.4.17 + zod: + specifier: ^3.24.2 + version: 3.24.2 + devDependencies: + '@types/react': + specifier: ^19.0.8 + version: 19.0.8 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.0.3(@types/react@19.0.8) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.3.4(vite@6.1.0(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)) + typescript: + specifier: ^5.7.2 + version: 5.8.2 + vite: + specifier: 6.1.0 + version: 6.1.0(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) + examples/react/basic-non-nested-devtools: dependencies: '@tanstack/react-router': From 6a24a9bccdf92b83f72f3448ee474507706ade2d Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Fri, 11 Apr 2025 08:56:58 +0900 Subject: [PATCH 14/33] refactor(router): filter internal properties from router state in devtools panel This commit introduces a new function to filter out internal properties (keys starting with '__') from the router state before it is displayed in the BaseTanStackRouterDevtoolsPanel. --- .../src/BaseTanStackRouterDevtoolsPanel.tsx | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index 701a6b974f..207733a685 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -177,6 +177,16 @@ function RouteComp({ ) } +function filterInternalProps(state: Record) { + const filteredState = { ...state } + Object.keys(filteredState).forEach(key => { + if (key.startsWith('__')) { + delete filteredState[key] + } + }) + return filteredState +} + export const BaseTanStackRouterDevtoolsPanel = function BaseTanStackRouterDevtoolsPanel({ ...props @@ -227,8 +237,12 @@ export const BaseTanStackRouterDevtoolsPanel = () => Object.keys(routerState().location.search).length, ) + const filteredState = createMemo(() => + filterInternalProps(routerState().location.state) + ) + const hasState = createMemo( - () => Object.keys(routerState().location.state).length, + () => Object.keys(filteredState()).length ) const explorerState = createMemo(() => { @@ -276,7 +290,7 @@ export const BaseTanStackRouterDevtoolsPanel = const activeMatchLoaderData = createMemo(() => activeMatch()?.loaderData) const activeMatchValue = createMemo(() => activeMatch()) const locationSearchValue = createMemo(() => routerState().location.search) - const locationStateValue = createMemo(() => routerState().location.state) + const locationStateValue = createMemo(() => filteredState()) return (
{ obj[next] = {} return obj From a4783ea698d4b0e4622db062fff6501e1bb4c38b Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Mon, 14 Apr 2025 09:01:15 +0900 Subject: [PATCH 15/33] feat(router): add useHistoryState method to LazyRoute class --- packages/react-router/src/fileRoute.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/react-router/src/fileRoute.ts b/packages/react-router/src/fileRoute.ts index b30cd76f8c..a33e8d2078 100644 --- a/packages/react-router/src/fileRoute.ts +++ b/packages/react-router/src/fileRoute.ts @@ -6,6 +6,7 @@ import { useLoaderDeps } from './useLoaderDeps' import { useLoaderData } from './useLoaderData' import { useSearch } from './useSearch' import { useParams } from './useParams' +import { useHistoryState } from './useHistoryState' import { useNavigate } from './useNavigate' import { useRouter } from './useRouter' import type { UseParamsRoute } from './useParams' @@ -32,6 +33,7 @@ import type { import type { UseLoaderDepsRoute } from './useLoaderDeps' import type { UseLoaderDataRoute } from './useLoaderData' import type { UseRouteContextRoute } from './useRouteContext' +import type { UseHistoryStateRoute } from './useHistoryState' export function createFileRoute< TFilePath extends keyof FileRoutesByPath, @@ -201,6 +203,15 @@ export class LazyRoute { } as any) as any } + useHistoryState: UseHistoryStateRoute = (opts) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return useHistoryState({ + select: opts?.select, + structuralSharing: opts?.structuralSharing, + from: this.options.id, + } as any) as any + } + useParams: UseParamsRoute = (opts) => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion return useParams({ From d70e92074f23a7bdd1a203f902578b5e24a96c36 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Mon, 14 Apr 2025 23:24:46 +0900 Subject: [PATCH 16/33] refactor(router): move locationState declaration outside of select function in useHistoryState --- packages/react-router/src/useHistoryState.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router/src/useHistoryState.tsx b/packages/react-router/src/useHistoryState.tsx index a1fed66b0f..897c290933 100644 --- a/packages/react-router/src/useHistoryState.tsx +++ b/packages/react-router/src/useHistoryState.tsx @@ -96,13 +96,13 @@ export function useHistoryState< UseHistoryStateResult, TThrow > { + const locationState = useLocation().state return useMatch({ from: opts.from!, strict: opts.strict, shouldThrow: opts.shouldThrow, structuralSharing: opts.structuralSharing, select: () => { - const locationState = useLocation().state const typedState = locationState as unknown as ResolveUseHistoryState; return opts.select ? opts.select(typedState) : typedState; }, From 6dbe18ce41cae378ae691749e2f656157dcdb02b Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Mon, 14 Apr 2025 23:48:13 +0900 Subject: [PATCH 17/33] refactor(router): filter out internal properties from locationState in useHistoryState --- packages/react-router/src/useHistoryState.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-router/src/useHistoryState.tsx b/packages/react-router/src/useHistoryState.tsx index 897c290933..db6d27783e 100644 --- a/packages/react-router/src/useHistoryState.tsx +++ b/packages/react-router/src/useHistoryState.tsx @@ -103,7 +103,10 @@ export function useHistoryState< shouldThrow: opts.shouldThrow, structuralSharing: opts.structuralSharing, select: () => { - const typedState = locationState as unknown as ResolveUseHistoryState; + const filteredState = Object.fromEntries( + Object.entries(locationState).filter(([key]) => !key.startsWith('__') && key !== 'key') + ); + const typedState = filteredState as unknown as ResolveUseHistoryState; return opts.select ? opts.select(typedState) : typedState; }, } as any) as any; From 1d1f3ea970fa51cf8fa5946f22c3c731ff438384 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Wed, 16 Apr 2025 23:11:32 +0900 Subject: [PATCH 18/33] feat(router): add FullStateSchema support in RouteMatch and AssetFnContextOptions --- packages/router-core/src/Matches.ts | 16 ++++++++++++---- packages/router-core/src/route.ts | 8 ++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index 5de34b3c1a..2cf84a2ffe 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -4,6 +4,7 @@ import type { AllLoaderData, AllParams, FullSearchSchema, + FullStateSchema, ParseRoute, RouteById, RouteIds, @@ -121,6 +122,7 @@ export interface RouteMatch< out TLoaderData, out TAllContext, out TLoaderDeps, + out TFullStateSchema, > extends RouteMatchExtensions { id: string routeId: TRouteId @@ -144,6 +146,7 @@ export interface RouteMatch< context: TAllContext search: TFullSearchSchema _strictSearch: TFullSearchSchema + state: TFullStateSchema fetchCount: number abortController: AbortController cause: 'preload' | 'enter' | 'stay' @@ -164,7 +167,8 @@ export type MakeRouteMatchFromRoute = RouteMatch< TRoute['types']['fullSearchSchema'], TRoute['types']['loaderData'], TRoute['types']['allContext'], - TRoute['types']['loaderDeps'] + TRoute['types']['loaderDeps'], + TRoute['types']['stateSchema'] > export type MakeRouteMatch< @@ -186,10 +190,13 @@ export type MakeRouteMatch< TStrict extends false ? AllContext : RouteById['types']['allContext'], - RouteById['types']['loaderDeps'] + RouteById['types']['loaderDeps'], + TStrict extends false + ? FullStateSchema + : RouteById['types']['stateSchema'] > -export type AnyRouteMatch = RouteMatch +export type AnyRouteMatch = RouteMatch export type MakeRouteMatchUnion< TRouter extends AnyRouter = RegisteredRouter, @@ -202,7 +209,8 @@ export type MakeRouteMatchUnion< TRoute['types']['fullSearchSchema'], TRoute['types']['loaderData'], TRoute['types']['allContext'], - TRoute['types']['loaderDeps'] + TRoute['types']['loaderDeps'], + TRoute['types']['stateSchema'] > : never diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index e70d0bac82..052a4ec2d4 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -1000,6 +1000,7 @@ type AssetFnContextOptions< in out TParentRoute extends AnyRoute, in out TParams, in out TSearchValidator, + in out TStateValidator, in out TLoaderFn, in out TRouterContext, in out TRouteContextFn, @@ -1012,6 +1013,7 @@ type AssetFnContextOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1027,6 +1029,7 @@ type AssetFnContextOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1106,6 +1109,7 @@ export interface UpdatableRouteOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1122,6 +1126,7 @@ export interface UpdatableRouteOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1138,6 +1143,7 @@ export interface UpdatableRouteOptions< TFullPath, ResolveAllParamsFromParent, ResolveFullSearchSchema, + ResolveFullStateSchema, ResolveLoaderData, ResolveAllContext< TParentRoute, @@ -1158,6 +1164,7 @@ export interface UpdatableRouteOptions< TParentRoute, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TRouterContext, TRouteContextFn, @@ -1176,6 +1183,7 @@ export interface UpdatableRouteOptions< TParentRoute, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TRouterContext, TRouteContextFn, From 7600118ae16057e799f405c6ae67586ca5e04342 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Mon, 21 Apr 2025 09:33:52 +0900 Subject: [PATCH 19/33] feat(router): add stateError handling and strict state validation in Router class --- packages/react-router/src/router.ts | 52 +++++++++++++++++++++++++++++ packages/router-core/src/Matches.ts | 2 ++ 2 files changed, 54 insertions(+) diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 5b65ed20a3..51a3afe77f 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -799,6 +799,44 @@ export class Router< } })() + const [preMatchState, strictMatchState, stateError]: [ + Record, + Record, + Error | undefined, + ] = (() => { + const rawState = next.state + // Exclude keys starting with __ and key named 'key' + const filteredState = Object.fromEntries( + Object.entries(rawState).filter( + ([key]) => !(key.startsWith('__') || key === 'key'), + ), + ) + + try { + if (route.options.validateState) { + const strictState = + validateState(route.options.validateState, filteredState) || {} + return [ + { + ...filteredState, + ...strictState, + }, + 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 // before we create the match so that we can pass the deps to the route's @@ -853,6 +891,12 @@ export class Router< ? replaceEqualDeep(previousMatch.search, preMatchSearch) : replaceEqualDeep(existingMatch.search, preMatchSearch), _strictSearch: strictMatchSearch, + searchError: undefined, + state: previousMatch + ? replaceEqualDeep(previousMatch.state, preMatchState) + : preMatchState, + _strictState: strictMatchState, + stateError: undefined, } } else { const status = @@ -878,6 +922,11 @@ export class Router< : preMatchSearch, _strictSearch: strictMatchSearch, searchError: undefined, + state: previousMatch + ? replaceEqualDeep(previousMatch.state, preMatchState) + : preMatchState, + _strictState: strictMatchState, + stateError: undefined, status, isFetching: false, error: undefined, @@ -911,6 +960,9 @@ export class Router< // update the searchError if there is one match.searchError = searchError + // update the stateError if there is one + match.stateError = stateError + const parentContext = getParentContext(parentMatch) match.context = { diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index 2cf84a2ffe..14ba1b799a 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -136,6 +136,7 @@ export interface RouteMatch< error: unknown paramsError: unknown searchError: unknown + stateError: unknown updatedAt: number loadPromise?: ControlledPromise beforeLoadPromise?: ControlledPromise @@ -147,6 +148,7 @@ export interface RouteMatch< search: TFullSearchSchema _strictSearch: TFullSearchSchema state: TFullStateSchema + _strictState: TFullStateSchema fetchCount: number abortController: AbortController cause: 'preload' | 'enter' | 'stay' From f9facc503e7b65228b456b3c3085ce016bfab04a Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Mon, 21 Apr 2025 09:34:07 +0900 Subject: [PATCH 20/33] feat(router): implement state validation and error handling in solid-router Router class --- packages/solid-router/src/router.ts | 73 +++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/packages/solid-router/src/router.ts b/packages/solid-router/src/router.ts index 0d1159431f..bdea0b054a 100644 --- a/packages/solid-router/src/router.ts +++ b/packages/solid-router/src/router.ts @@ -201,6 +201,34 @@ function validateSearch(validateSearch: AnyValidator, input: unknown): unknown { return {} } +function validateState(validateState: AnyValidator, input: unknown): unknown { + if (validateState == null) return {} + + if ('~standard' in validateState) { + const result = validateState['~standard'].validate(input) + + if (result instanceof Promise) + throw new Error('Async validation not supported') + + if (result.issues) + throw new Error(JSON.stringify(result.issues, undefined, 2), { + cause: result, + }) + + return result.value + } + + if ('parse' in validateState) { + return validateState.parse(input) + } + + if (typeof validateState === 'function') { + return validateState(input) + } + + return {} +} + export function createRouter< TRouteTree extends AnyRoute, TTrailingSlashOption extends TrailingSlashOption, @@ -766,6 +794,43 @@ export class Router< } })() + const [preMatchState, strictMatchState, stateError]: [ + Record, + Record, + Error | undefined, + ] = (() => { + const rawState = next.state + const filteredState = Object.fromEntries( + Object.entries(rawState).filter( + ([key]) => !(key.startsWith('__') || key === 'key'), + ), + ) + + try { + if (route.options.validateState) { + const strictState = + validateState(route.options.validateState, filteredState) || {} + return [ + { + ...filteredState, + ...strictState, + }, + 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 // before we create the match so that we can pass the deps to the route's @@ -845,6 +910,11 @@ export class Router< : preMatchSearch, _strictSearch: strictMatchSearch, searchError: undefined, + state: previousMatch + ? replaceEqualDeep(previousMatch.state, preMatchState) + : preMatchState, + _strictState: strictMatchState, + stateError: undefined, status, isFetching: false, error: undefined, @@ -878,6 +948,9 @@ export class Router< // update the searchError if there is one match.searchError = searchError + // update the stateError if there is one + match.stateError = stateError + const parentContext = getParentContext(parentMatch) match.context = { From c302f737f53eed98ebb274f569138a798f178cc5 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Mon, 21 Apr 2025 09:34:44 +0900 Subject: [PATCH 21/33] refactor(router): rename and enhance internal state filtering in BaseTanStackRouterDevtoolsPanel --- .../src/BaseTanStackRouterDevtoolsPanel.tsx | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index 207733a685..63ef833bf6 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -177,14 +177,10 @@ function RouteComp({ ) } -function filterInternalProps(state: Record) { - const filteredState = { ...state } - Object.keys(filteredState).forEach(key => { - if (key.startsWith('__')) { - delete filteredState[key] - } - }) - return filteredState +function filterInternalState(state: Record) { + return Object.fromEntries( + Object.entries(state).filter(([key]) => !(key.startsWith('__') || key === 'key')) + ) } export const BaseTanStackRouterDevtoolsPanel = @@ -238,7 +234,7 @@ export const BaseTanStackRouterDevtoolsPanel = ) const filteredState = createMemo(() => - filterInternalProps(routerState().location.state) + filterInternalState(routerState().location.state) ) const hasState = createMemo( @@ -290,7 +286,7 @@ export const BaseTanStackRouterDevtoolsPanel = const activeMatchLoaderData = createMemo(() => activeMatch()?.loaderData) const activeMatchValue = createMemo(() => activeMatch()) const locationSearchValue = createMemo(() => routerState().location.search) - const locationStateValue = createMemo(() => filteredState()) + const filteredStateValue = createMemo(() => filteredState()) return (
State Params
{ From d34744c6dabaa821cb2a23781d496843d31d0caf Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Mon, 21 Apr 2025 09:37:24 +0900 Subject: [PATCH 22/33] refactor(router): update state filtering logic in useHistoryState to use match state --- packages/react-router/src/useHistoryState.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/react-router/src/useHistoryState.tsx b/packages/react-router/src/useHistoryState.tsx index db6d27783e..5c52c586ac 100644 --- a/packages/react-router/src/useHistoryState.tsx +++ b/packages/react-router/src/useHistoryState.tsx @@ -1,5 +1,4 @@ import { useMatch } from './useMatch' -import { useLocation } from './useLocation' import type { AnyRouter, Constrain, @@ -96,15 +95,15 @@ export function useHistoryState< UseHistoryStateResult, TThrow > { - const locationState = useLocation().state return useMatch({ from: opts.from!, strict: opts.strict, shouldThrow: opts.shouldThrow, structuralSharing: opts.structuralSharing, - select: () => { + select: (match: any) => { + const matchState = match.state; const filteredState = Object.fromEntries( - Object.entries(locationState).filter(([key]) => !key.startsWith('__') && key !== 'key') + Object.entries(matchState).filter(([key]) => !(key.startsWith('__') || key === 'key')) ); const typedState = filteredState as unknown as ResolveUseHistoryState; return opts.select ? opts.select(typedState) : typedState; From f39a7860332545cb03b2172a59adf170e0990992 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Tue, 22 Apr 2025 08:58:14 +0900 Subject: [PATCH 23/33] refactor(router): enhance state params logic in BaseTanStackRouterDevtoolsPanel by merging strict state --- .../src/BaseTanStackRouterDevtoolsPanel.tsx | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index 63ef833bf6..8bcaa3355c 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -179,10 +179,23 @@ function RouteComp({ function filterInternalState(state: Record) { return Object.fromEntries( - Object.entries(state).filter(([key]) => !(key.startsWith('__') || key === 'key')) + Object.entries(state).filter(([key]) => + !(key.startsWith('__') || key === 'key'), + ), ) } +function getMergedStrictState(routerState: any) { + const matches = [ + ...(routerState.pendingMatches ?? []), + ...routerState.matches, + ] + return Object.assign( + {}, + ...matches.map((m: any) => m._strictState).filter(Boolean), + ) as Record +} + export const BaseTanStackRouterDevtoolsPanel = function BaseTanStackRouterDevtoolsPanel({ ...props @@ -233,13 +246,11 @@ export const BaseTanStackRouterDevtoolsPanel = () => Object.keys(routerState().location.search).length, ) - const filteredState = createMemo(() => - filterInternalState(routerState().location.state) + const validatedState = createMemo(() => + filterInternalState(getMergedStrictState(routerState())), ) - const hasState = createMemo( - () => Object.keys(filteredState()).length - ) + const hasState = createMemo(() => Object.keys(validatedState()).length) const explorerState = createMemo(() => { return { @@ -286,7 +297,7 @@ export const BaseTanStackRouterDevtoolsPanel = const activeMatchLoaderData = createMemo(() => activeMatch()?.loaderData) const activeMatchValue = createMemo(() => activeMatch()) const locationSearchValue = createMemo(() => routerState().location.search) - const filteredStateValue = createMemo(() => filteredState()) + const validatedStateValue = createMemo(() => validatedState()) return (
State Params
{ obj[next] = {} return obj From 6e6f66bf686fe61e766035eb181362f852518139 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Sat, 26 Apr 2025 16:53:41 +0900 Subject: [PATCH 24/33] test(router): add tests for useHistoryState --- .../tests/useHistoryState.test.tsx | 321 ++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 packages/react-router/tests/useHistoryState.test.tsx diff --git a/packages/react-router/tests/useHistoryState.test.tsx b/packages/react-router/tests/useHistoryState.test.tsx new file mode 100644 index 0000000000..14ca50bb41 --- /dev/null +++ b/packages/react-router/tests/useHistoryState.test.tsx @@ -0,0 +1,321 @@ +import { afterEach, describe, expect, test } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { z } from 'zod' +import { + Link, + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + useHistoryState, + useNavigate, +} from '../src' +import type { RouteComponent, RouterHistory } from '../src' + +afterEach(() => { + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +describe('useHistoryState', () => { + function setup({ + RootComponent, + history, + }: { + RootComponent: RouteComponent + history?: RouterHistory + }) { + const rootRoute = createRootRoute({ + component: RootComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> +

IndexTitle

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

PostsTitle

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

IndexTitle

, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + validateState: (input: { testKey?: string; color?: string }) => + z.object({ + testKey: z.string(), + color: z.enum(['red', 'green', 'blue']), + }).parse(input), + component: ValidChecker, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render() + + // Valid state transition + const validButton = await screen.findByTestId('valid-state-btn') + fireEvent.click(validButton) + + const validState = await screen.findByTestId('valid-state') + expect(validState).toHaveTextContent('{"testKey":"valid-key","color":"red"}') + + // Invalid state transition + const invalidButton = await screen.findByTestId('invalid-state-btn') + fireEvent.click(invalidButton) + + await waitFor(async () => { + const stateElement = await screen.findByTestId('valid-state') + expect(stateElement).toHaveTextContent('yellow') + }) + }) + + test('throws when match not found and shouldThrow=true', async () => { + function RootComponent() { + try { + useHistoryState({ from: '/non-existent', shouldThrow: true }) + return
No error
+ } catch (e) { + return
Error occurred: {(e as Error).message}
+ } + } + + setup({ RootComponent }) + + const errorMessage = await screen.findByText(/Error occurred:/) + expect(errorMessage).toBeInTheDocument() + expect(errorMessage).toHaveTextContent(/Could not find an active match/) + }) + + test('returns undefined when match not found and shouldThrow=false', async () => { + function RootComponent() { + const state = useHistoryState({ from: '/non-existent', shouldThrow: false }) + return ( +
+
{state === undefined ? 'undefined' : 'defined'}
+ +
+ ) + } + + setup({ RootComponent }) + + const stateResult = await screen.findByTestId('state-result') + expect(stateResult).toHaveTextContent('undefined') + }) + + test('updates when state changes', async () => { + function RootComponent() { + const navigate = useNavigate() + const state = useHistoryState({ from: '/posts', shouldThrow: false }) + + return ( +
+
{state?.count}
+ + + +
+ ) + } + + setup({ RootComponent }) + + // Initial navigation + const navigateBtn = await screen.findByTestId('navigate-btn') + fireEvent.click(navigateBtn) + + // Check initial state + const stateValue = await screen.findByTestId('state-value') + expect(stateValue).toHaveTextContent('1') + + // Update state + const updateBtn = await screen.findByTestId('update-btn') + fireEvent.click(updateBtn) + + // Check updated state + await waitFor(() => { + expect(screen.getByTestId('state-value')).toHaveTextContent('2') + }) + }) + + test('route.useHistoryState hook works properly', async () => { + function PostsComponent() { + const state = postsRoute.useHistoryState() + return
{state.testValue}
+ } + + const rootRoute = createRootRoute({ + component: () => , + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = useNavigate() + return ( + + ) + }, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: PostsComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render() + + const goToPostsBtn = await screen.findByText('Go to Posts') + fireEvent.click(goToPostsBtn) + + const routeState = await screen.findByTestId('route-state') + expect(routeState).toHaveTextContent('route-state-value') + }) +}) From d70b562bb2af00d7caa35eea8509f387e98fc883 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Sat, 26 Apr 2025 23:27:25 +0900 Subject: [PATCH 25/33] docs(router): add documentation for useHistoryState hook with options and examples --- .../react/api/router/useHistoryStateHook.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/router/framework/react/api/router/useHistoryStateHook.md diff --git a/docs/router/framework/react/api/router/useHistoryStateHook.md b/docs/router/framework/react/api/router/useHistoryStateHook.md new file mode 100644 index 0000000000..3370396802 --- /dev/null +++ b/docs/router/framework/react/api/router/useHistoryStateHook.md @@ -0,0 +1,148 @@ +--- +id: useHistoryStateHook +title: useHistoryState hook +--- + +The `useHistoryState` method 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 `stateSchema`. +- 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

+
+ ) +} +``` From c415ee4f130587fd1fcdca423956c0352e3ea338 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Sun, 27 Apr 2025 23:15:53 +0900 Subject: [PATCH 26/33] feat(router): add state validation and error handling in RouterCore --- packages/router-core/src/router.ts | 96 ++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index f41b47fe2f..2410d4cb83 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1290,6 +1290,44 @@ export class RouterCore< return [parentSearch, {}, searchParamError] } })() + const [preMatchState, strictMatchState, stateError]: [ + Record, + Record, + Error | undefined, + ] = (() => { + const rawState = parentMatch?.state ?? next.state + const parentStrictState = parentMatch?._strictState ?? {} + // Exclude keys starting with __ and key named 'key' + const filteredState = Object.fromEntries( + Object.entries(rawState).filter( + ([key]) => !(key.startsWith('__') || key === 'key'), + ), + ) + + try { + if (route.options.validateState) { + const strictState = + validateState(route.options.validateState, filteredState) || {} + return [ + { + ...filteredState, + ...strictState, + }, + { ...parentStrictState, ...strictState }, + undefined, + ] + } + return [filteredState, {}, undefined] + } catch (err: any) { + const stateValidationError = err + + if (opts?.throwOnError) { + throw stateValidationError + } + + return [filteredState, {}, stateValidationError] + } + })() // This is where we need to call route.options.loaderDeps() to get any additional // deps that the route's loader function might need to run. We need to do this @@ -1345,6 +1383,10 @@ export class RouterCore< ? replaceEqualDeep(previousMatch.search, preMatchSearch) : replaceEqualDeep(existingMatch.search, preMatchSearch), _strictSearch: strictMatchSearch, + state: previousMatch + ? replaceEqualDeep(previousMatch.state, preMatchState) + : replaceEqualDeep(existingMatch.state, preMatchState), + _strictState: strictMatchState, } } else { const status = @@ -1371,6 +1413,11 @@ export class RouterCore< _strictSearch: strictMatchSearch, searchError: undefined, status, + state: previousMatch + ? replaceEqualDeep(previousMatch.state, preMatchState) + : preMatchState, + _strictState: strictMatchState, + stateError: undefined, isFetching: false, error: undefined, paramsError: parseErrors[index], @@ -1402,6 +1449,8 @@ export class RouterCore< // update the searchError if there is one match.searchError = searchError + // update the stateError if there is one + match.stateError = stateError const parentContext = getParentContext(parentMatch) @@ -1765,6 +1814,26 @@ export class RouterCore< nextState = replaceEqualDeep(this.latestLocation.state, nextState) + if (opts._includeValidateState) { + let validatedState = {} + matchedRoutesResult?.matchedRoutes.forEach((route) => { + try { + if (route.options.validateState) { + validatedState = { + ...validatedState, + ...(validateState(route.options.validateState, { + ...validatedState, + ...nextState, + }) ?? {}), + } + } + } catch { + // ignore errors here because they are already handled in matchRoutes + } + }) + nextState = validatedState + } + return { pathname, search, @@ -3161,6 +3230,33 @@ export function getInitialRouterState( statusCode: 200, } } +function validateState(validateState: AnyValidator, input: unknown): unknown { + if (validateState == null) return {} + + if ('~standard' in validateState) { + const result = validateState['~standard'].validate(input) + + if (result instanceof Promise) + throw new Error('Async validation not supported') + + if (result.issues) + throw new Error(JSON.stringify(result.issues, undefined, 2), { + cause: result, + }) + + return result.value + } + + if ('parse' in validateState) { + return validateState.parse(input) + } + + if (typeof validateState === 'function') { + return validateState(input) + } + + return {} +} function validateSearch(validateSearch: AnyValidator, input: unknown): unknown { if (validateSearch == null) return {} From 52c3fb3f9729ec530016046999d848a58b7885d2 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Mon, 28 Apr 2025 22:07:08 +0900 Subject: [PATCH 27/33] feat(router): add ValidateHistoryState type to exports --- packages/react-router/src/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index 47a699214b..81cd262421 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -348,6 +348,7 @@ export type { ValidateToPath, ValidateSearch, ValidateParams, + ValidateHistoryState, InferFrom, InferTo, InferMaskTo, From a563918b053740b8033d94786f5719a118bb5b4d Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Mon, 28 Apr 2025 22:08:10 +0900 Subject: [PATCH 28/33] feat(router): add fullStateSchema to RouteMatch types in Matches.test-d.tsx --- .../react-router/tests/Matches.test-d.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/react-router/tests/Matches.test-d.tsx b/packages/react-router/tests/Matches.test-d.tsx index 2116e78416..9ad2ae31ce 100644 --- a/packages/react-router/tests/Matches.test-d.tsx +++ b/packages/react-router/tests/Matches.test-d.tsx @@ -21,7 +21,8 @@ type RootMatch = RouteMatch< RootRoute['types']['fullSearchSchema'], RootRoute['types']['loaderData'], RootRoute['types']['allContext'], - RootRoute['types']['loaderDeps'] + RootRoute['types']['loaderDeps'], + RootRoute['types']['fullStateSchema'] > const indexRoute = createRoute({ @@ -38,7 +39,8 @@ type IndexMatch = RouteMatch< IndexRoute['types']['fullSearchSchema'], IndexRoute['types']['loaderData'], IndexRoute['types']['allContext'], - IndexRoute['types']['loaderDeps'] + IndexRoute['types']['loaderDeps'], + IndexRoute['types']['fullStateSchema'] > const invoicesRoute = createRoute({ @@ -54,7 +56,8 @@ type InvoiceMatch = RouteMatch< InvoiceRoute['types']['fullSearchSchema'], InvoiceRoute['types']['loaderData'], InvoiceRoute['types']['allContext'], - InvoiceRoute['types']['loaderDeps'] + InvoiceRoute['types']['loaderDeps'], + InvoiceRoute['types']['fullStateSchema'] > type InvoicesRoute = typeof invoicesRoute @@ -66,7 +69,8 @@ type InvoicesMatch = RouteMatch< InvoicesRoute['types']['fullSearchSchema'], InvoicesRoute['types']['loaderData'], InvoicesRoute['types']['allContext'], - InvoicesRoute['types']['loaderDeps'] + InvoicesRoute['types']['loaderDeps'], + InvoicesRoute['types']['fullStateSchema'] > const invoicesIndexRoute = createRoute({ @@ -83,7 +87,8 @@ type InvoicesIndexMatch = RouteMatch< InvoicesIndexRoute['types']['fullSearchSchema'], InvoicesIndexRoute['types']['loaderData'], InvoicesIndexRoute['types']['allContext'], - InvoicesIndexRoute['types']['loaderDeps'] + InvoicesIndexRoute['types']['loaderDeps'], + InvoicesIndexRoute['types']['fullStateSchema'] > const invoiceRoute = createRoute({ @@ -108,7 +113,8 @@ type LayoutMatch = RouteMatch< LayoutRoute['types']['fullSearchSchema'], LayoutRoute['types']['loaderData'], LayoutRoute['types']['allContext'], - LayoutRoute['types']['loaderDeps'] + LayoutRoute['types']['loaderDeps'], + LayoutRoute['types']['fullStateSchema'] > const commentsRoute = createRoute({ @@ -131,7 +137,8 @@ type CommentsMatch = RouteMatch< CommentsRoute['types']['fullSearchSchema'], CommentsRoute['types']['loaderData'], CommentsRoute['types']['allContext'], - CommentsRoute['types']['loaderDeps'] + CommentsRoute['types']['loaderDeps'], + CommentsRoute['types']['fullStateSchema'] > const routeTree = rootRoute.addChildren([ From ea6cf107a2e2674109c942353026c7c059510460 Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Mon, 28 Apr 2025 22:50:23 +0900 Subject: [PATCH 29/33] feat(router): implement state examples and enhance useHistoryState demonstration --- .../react/basic-history-state/src/main.tsx | 79 ++++++++++++++++--- 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/examples/react/basic-history-state/src/main.tsx b/examples/react/basic-history-state/src/main.tsx index 8ac8a4a231..6c230afb21 100644 --- a/examples/react/basic-history-state/src/main.tsx +++ b/examples/react/basic-history-state/src/main.tsx @@ -67,7 +67,7 @@ function RootComponent() { activeOptions={{ exact: true }} > Home - {' '} + Posts - {' '} + - This Route Does Not Exist + State Examples
@@ -125,7 +124,7 @@ function PostsLayoutComponent() { to={postRoute.to} search={{ postId: post.id }} state={{ - color: index % 2 ? 'red' : 'white', + color: index % 2 ? 'red' : 'green', }} className="block py-1 px-2 text-green-300 hover:text-green-200" activeProps={{ className: '!text-white font-bold' }} @@ -194,23 +193,82 @@ function PostErrorComponent({ error }: ErrorComponentProps) { function PostComponent() { const post = postRoute.useLoaderData() - const { color } = postRoute.useHistoryState() + const state = postRoute.useHistoryState() return (

{post.title}

-

{"I like " + color}

+

Color: {state.color}


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

useHistoryState Examples

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

State Data Display

+
+            {JSON.stringify(state, null, 2)}
+          
) } const routeTree = rootRoute.addChildren([ postsLayoutRoute.addChildren([postRoute, postsIndexRoute]), + stateExamplesRoute.addChildren([stateDestinationRoute]), indexRoute, ]) -// Set up a Router instance const router = createRouter({ routeTree, defaultPreload: 'intent', @@ -218,7 +276,6 @@ const router = createRouter({ scrollRestoration: true, }) -// Register things for typesafety declare module '@tanstack/react-router' { interface Register { router: typeof router From c4ed06e63b220e03e406ad365d65485e994484dd Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Mon, 28 Apr 2025 22:56:31 +0900 Subject: [PATCH 30/33] feat(solid-router): add useHistoryState hook and integrate into routing system --- packages/solid-router/src/fileRoute.ts | 10 ++ packages/solid-router/src/index.tsx | 2 + packages/solid-router/src/route.ts | 27 ++++++ packages/solid-router/src/useHistoryState.tsx | 96 +++++++++++++++++++ .../solid-router/tests/Matches.test-d.tsx | 21 ++-- 5 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 packages/solid-router/src/useHistoryState.tsx diff --git a/packages/solid-router/src/fileRoute.ts b/packages/solid-router/src/fileRoute.ts index d7c332d863..6b8b667e3e 100644 --- a/packages/solid-router/src/fileRoute.ts +++ b/packages/solid-router/src/fileRoute.ts @@ -8,9 +8,11 @@ import { useSearch } from './useSearch' import { useParams } from './useParams' import { useNavigate } from './useNavigate' import { useRouter } from './useRouter' +import { useHistoryState } from './useHistoryState' import type { UseParamsRoute } from './useParams' import type { UseMatchRoute } from './useMatch' import type { UseSearchRoute } from './useSearch' +import type { UseHistoryStateRoute } from './useHistoryState' import type { AnyContext, AnyRoute, @@ -197,6 +199,14 @@ export class LazyRoute { } as any) as any } + useHistoryState: UseHistoryStateRoute = (opts) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return useHistoryState({ + select: opts?.select, + from: this.options.id, + } as any) as any + } + useParams: UseParamsRoute = (opts) => { return useParams({ select: opts?.select, diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index fc7c0bfd29..2df618e6fa 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -320,6 +320,7 @@ export { useNavigate, Navigate } from './useNavigate' export { useParams } from './useParams' export { useSearch } from './useSearch' +export { useHistoryState } from './useHistoryState' export { getRouterContext, // SSR @@ -349,6 +350,7 @@ export type { ValidateToPath, ValidateSearch, ValidateParams, + ValidateHistoryState, InferFrom, InferTo, InferMaskTo, diff --git a/packages/solid-router/src/route.ts b/packages/solid-router/src/route.ts index 525e8dcbc3..572777c67e 100644 --- a/packages/solid-router/src/route.ts +++ b/packages/solid-router/src/route.ts @@ -11,6 +11,7 @@ import { useSearch } from './useSearch' import { useNavigate } from './useNavigate' import { useMatch } from './useMatch' import { useRouter } from './useRouter' +import { useHistoryState } from './useHistoryState' import type { AnyContext, AnyRoute, @@ -39,6 +40,7 @@ import type { UseMatchRoute } from './useMatch' import type { UseLoaderDepsRoute } from './useLoaderDeps' import type { UseParamsRoute } from './useParams' import type { UseSearchRoute } from './useSearch' +import type { UseHistoryStateRoute } from './useHistoryState' import type * as Solid from 'solid-js' import type { UseRouteContextRoute } from './useRouteContext' @@ -60,6 +62,7 @@ declare module '@tanstack/router-core' { useParams: UseParamsRoute useLoaderDeps: UseLoaderDepsRoute useLoaderData: UseLoaderDataRoute + useHistoryState: UseHistoryStateRoute useNavigate: () => UseNavigateResult } } @@ -110,6 +113,14 @@ export class RouteApi< } as any) as any } + useHistoryState: UseHistoryStateRoute = (opts?: any) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return useHistoryState({ + select: opts?.select, + from: this.id, + } as any) as any + } + useLoaderDeps: UseLoaderDepsRoute = (opts) => { return useLoaderDeps({ ...opts, from: this.id, strict: false } as any) } @@ -222,6 +233,14 @@ export class Route< } as any) as any } + useHistoryState: UseHistoryStateRoute = (opts?: any) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return useHistoryState({ + select: opts?.select, + from: this.id, + } as any) as any + } + useLoaderDeps: UseLoaderDepsRoute = (opts) => { return useLoaderDeps({ ...opts, from: this.id } as any) } @@ -413,6 +432,14 @@ export class RootRoute< } as any) as any } + useHistoryState: UseHistoryStateRoute = (opts?: any) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return useHistoryState({ + select: opts?.select, + from: this.id, + } as any) as any + } + useLoaderDeps: UseLoaderDepsRoute = (opts) => { return useLoaderDeps({ ...opts, from: this.id } as any) } diff --git a/packages/solid-router/src/useHistoryState.tsx b/packages/solid-router/src/useHistoryState.tsx new file mode 100644 index 0000000000..301d7d4cf4 --- /dev/null +++ b/packages/solid-router/src/useHistoryState.tsx @@ -0,0 +1,96 @@ +import { useMatch } from './useMatch' +import type { Accessor } from 'solid-js' +import type { + AnyRouter, + Expand, + RegisteredRouter, + RouteById, + StrictOrFrom, + ThrowConstraint, + ThrowOrOptional, + UseHistoryStateResult, +} from '@tanstack/router-core' + +type ResolveUseHistoryState< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, +> = TStrict extends false + ? Expand>> + : Expand['types']['stateSchema']> + +export interface UseHistoryStateBaseOptions< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TThrow extends boolean, + TSelected, +> { + select?: ( + state: ResolveUseHistoryState, + ) => TSelected + shouldThrow?: TThrow +} + +export type UseHistoryStateOptions< + TRouter extends AnyRouter, + TFrom extends string | undefined, + TStrict extends boolean, + TThrow extends boolean, + TSelected, +> = StrictOrFrom & + UseHistoryStateBaseOptions< + TRouter, + TFrom, + TStrict, + TThrow, + TSelected + > + +export type UseHistoryStateRoute = < + TRouter extends AnyRouter = RegisteredRouter, + TSelected = RouteById['types']['stateSchema'], +>( + opts?: UseHistoryStateBaseOptions< + TRouter, + TFrom, + /* TStrict */ true, + /* TThrow */ true, + TSelected + >, +) => Accessor> + +export function useHistoryState< + TRouter extends AnyRouter = RegisteredRouter, + const TFrom extends string | undefined = undefined, + TStrict extends boolean = true, + TThrow extends boolean = true, + TState = TStrict extends false + ? Expand>> + : Expand['types']['stateSchema']>, + TSelected = TState, +>( + opts: UseHistoryStateOptions< + TRouter, + TFrom, + TStrict, + ThrowConstraint, + TSelected + >, +): Accessor< + ThrowOrOptional, TThrow> +> { + return useMatch({ + from: opts.from!, + strict: opts.strict, + shouldThrow: opts.shouldThrow, + select: (match: any) => { + const matchState = match.state; + const filteredState = Object.fromEntries( + Object.entries(matchState).filter(([key]) => !(key.startsWith('__') || key === 'key')) + ); + const typedState = filteredState as unknown as ResolveUseHistoryState; + return opts.select ? opts.select(typedState) : typedState; + }, + }) as any; +} diff --git a/packages/solid-router/tests/Matches.test-d.tsx b/packages/solid-router/tests/Matches.test-d.tsx index b107435db6..9fd14dcf1e 100644 --- a/packages/solid-router/tests/Matches.test-d.tsx +++ b/packages/solid-router/tests/Matches.test-d.tsx @@ -22,7 +22,8 @@ type RootMatch = RouteMatch< RootRoute['types']['fullSearchSchema'], RootRoute['types']['loaderData'], RootRoute['types']['allContext'], - RootRoute['types']['loaderDeps'] + RootRoute['types']['loaderDeps'], + RootRoute['types']['fullStateSchema'] > const indexRoute = createRoute({ @@ -39,7 +40,8 @@ type IndexMatch = RouteMatch< IndexRoute['types']['fullSearchSchema'], IndexRoute['types']['loaderData'], IndexRoute['types']['allContext'], - IndexRoute['types']['loaderDeps'] + IndexRoute['types']['loaderDeps'], + IndexRoute['types']['fullStateSchema'] > const invoicesRoute = createRoute({ @@ -55,7 +57,8 @@ type InvoiceMatch = RouteMatch< InvoiceRoute['types']['fullSearchSchema'], InvoiceRoute['types']['loaderData'], InvoiceRoute['types']['allContext'], - InvoiceRoute['types']['loaderDeps'] + InvoiceRoute['types']['loaderDeps'], + InvoiceRoute['types']['fullStateSchema'] > type InvoicesRoute = typeof invoicesRoute @@ -67,7 +70,8 @@ type InvoicesMatch = RouteMatch< InvoicesRoute['types']['fullSearchSchema'], InvoicesRoute['types']['loaderData'], InvoicesRoute['types']['allContext'], - InvoicesRoute['types']['loaderDeps'] + InvoicesRoute['types']['loaderDeps'], + InvoicesRoute['types']['fullStateSchema'] > const invoicesIndexRoute = createRoute({ @@ -84,7 +88,8 @@ type InvoicesIndexMatch = RouteMatch< InvoicesIndexRoute['types']['fullSearchSchema'], InvoicesIndexRoute['types']['loaderData'], InvoicesIndexRoute['types']['allContext'], - InvoicesIndexRoute['types']['loaderDeps'] + InvoicesIndexRoute['types']['loaderDeps'], + InvoicesIndexRoute['types']['fullStateSchema'] > const invoiceRoute = createRoute({ @@ -109,7 +114,8 @@ type LayoutMatch = RouteMatch< LayoutRoute['types']['fullSearchSchema'], LayoutRoute['types']['loaderData'], LayoutRoute['types']['allContext'], - LayoutRoute['types']['loaderDeps'] + LayoutRoute['types']['loaderDeps'], + LayoutRoute['types']['fullStateSchema'] > const commentsRoute = createRoute({ @@ -132,7 +138,8 @@ type CommentsMatch = RouteMatch< CommentsRoute['types']['fullSearchSchema'], CommentsRoute['types']['loaderData'], CommentsRoute['types']['allContext'], - CommentsRoute['types']['loaderDeps'] + CommentsRoute['types']['loaderDeps'], + CommentsRoute['types']['fullStateSchema'] > const routeTree = rootRoute.addChildren([ From 121ff889aec291f1b06b8c56cf1d7ace31a15c9e Mon Sep 17 00:00:00 2001 From: "Naoya Shimizu(Shimmy)" Date: Mon, 28 Apr 2025 23:00:53 +0900 Subject: [PATCH 31/33] Update docs/router/framework/react/api/router/useHistoryStateHook.md Co-authored-by: Ahmed Abdelbaset --- docs/router/framework/react/api/router/useHistoryStateHook.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/router/framework/react/api/router/useHistoryStateHook.md b/docs/router/framework/react/api/router/useHistoryStateHook.md index 3370396802..a72b79dfd4 100644 --- a/docs/router/framework/react/api/router/useHistoryStateHook.md +++ b/docs/router/framework/react/api/router/useHistoryStateHook.md @@ -3,7 +3,7 @@ id: useHistoryStateHook title: useHistoryState hook --- -The `useHistoryState` method returns the state object that was passed during navigation to the closest match or a specific route match. +The `useHistoryState` hook returns the state object that was passed during navigation to the closest match or a specific route match. ## useHistoryState options From 4957780777cf515bbf200d8293885295d8c89073 Mon Sep 17 00:00:00 2001 From: "Naoya Shimizu(Shimmy)" Date: Mon, 28 Apr 2025 23:01:06 +0900 Subject: [PATCH 32/33] Update docs/router/framework/react/api/router/useHistoryStateHook.md Co-authored-by: Ahmed Abdelbaset --- docs/router/framework/react/api/router/useHistoryStateHook.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/router/framework/react/api/router/useHistoryStateHook.md b/docs/router/framework/react/api/router/useHistoryStateHook.md index a72b79dfd4..e272080085 100644 --- a/docs/router/framework/react/api/router/useHistoryStateHook.md +++ b/docs/router/framework/react/api/router/useHistoryStateHook.md @@ -19,7 +19,7 @@ The `useHistoryState` hook accepts an optional `options` object. - Type: `boolean` - Optional - `default: true` -- If `true`, the state object type will be strictly typed based on the route's `stateSchema`. +- 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 From 10d7797104682d9ee216269097f1e609c86f2d6e Mon Sep 17 00:00:00 2001 From: naoya7076 Date: Sat, 17 May 2025 22:34:12 +0900 Subject: [PATCH 33/33] feat: add TStateValidator to UpdatableRouteOptions interface --- packages/router-core/src/route.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index caf0b7d4b6..b6d31b0a45 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -1162,6 +1162,7 @@ export interface UpdatableRouteOptions< TParentRoute, TParams, TSearchValidator, + TStateValidator, TLoaderFn, TRouterContext, TRouteContextFn,