From 1597fe275cb9350ac29dc8c34017258f15a0191d Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Tue, 16 Apr 2024 20:34:58 +0200 Subject: [PATCH 1/4] chore: cleanup --- packages/react-router/src/Matches.tsx | 7 +------ packages/react-router/src/router.ts | 1 - 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 52f1dfe4f39..58c988a6e2f 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -1,18 +1,13 @@ import * as React from 'react' import invariant from 'tiny-invariant' import warning from 'tiny-warning' -import { set } from 'zod' import { CatchBoundary, ErrorComponent } from './CatchBoundary' import { useRouterState } from './useRouterState' import { useRouter } from './useRouter' import { createControlledPromise, pick } from './utils' import { CatchNotFound, DefaultGlobalNotFound, isNotFound } from './not-found' import { isRedirect } from './redirects' -import { - type AnyRouter, - type RegisteredRouter, - type RouterState, -} from './router' +import { type AnyRouter, type RegisteredRouter } from './router' import type { ResolveRelativePath, ToOptions } from './link' import type { AnyRoute, diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index b7e92eeb65a..26626c493b3 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -719,7 +719,6 @@ export class Router< // which is used to uniquely identify the route match in state const parentMatch = matches[index - 1] - const isLast = index === matchedRoutes.length - 1 const [preMatchSearch, searchError]: [Record, any] = (() => { // Validate the search params and stabilize them From a68170ada9306933504f363499e097d67a08960a Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Wed, 17 Apr 2024 19:14:47 +0200 Subject: [PATCH 2/4] feat: strong typing for `onEnter`, `onStay` and `onLeave` --- .../react/api/router/AnyRouteMatchType.md | 2 +- packages/react-router/src/Matches.tsx | 86 +++++++----- packages/react-router/src/RouterProvider.tsx | 4 +- packages/react-router/src/fileRoute.ts | 28 +++- packages/react-router/src/route.ts | 123 +++++++++++------- packages/react-router/src/router.ts | 33 +++-- packages/react-router/src/useRouteContext.ts | 6 +- packages/react-router/src/useSearch.tsx | 7 +- 8 files changed, 178 insertions(+), 111 deletions(-) diff --git a/docs/framework/react/api/router/AnyRouteMatchType.md b/docs/framework/react/api/router/AnyRouteMatchType.md index 5734c81c2f8..dacd001f65a 100644 --- a/docs/framework/react/api/router/AnyRouteMatchType.md +++ b/docs/framework/react/api/router/AnyRouteMatchType.md @@ -6,7 +6,7 @@ title: AnyRouteMatch type The `AnyRouteMatch` type represents a route match in TanStack Router that is not specific to a particular route. ```tsx -type AnyRouteMatch = RouteMatch +type AnyRouteMatch = RouteMatch ``` - [`RouteMatch`](../RouteMatchType) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 58c988a6e2f..a50c227e540 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -9,12 +9,7 @@ import { CatchNotFound, DefaultGlobalNotFound, isNotFound } from './not-found' import { isRedirect } from './redirects' import { type AnyRouter, type RegisteredRouter } from './router' import type { ResolveRelativePath, ToOptions } from './link' -import type { - AnyRoute, - ReactNode, - RootSearchSchema, - StaticDataRouteOption, -} from './route' +import type { AnyRoute, ReactNode, StaticDataRouteOption } from './route' import type { AllParams, FullSearchSchema, @@ -35,16 +30,18 @@ import type { export const matchContext = React.createContext(undefined) export interface RouteMatch< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TRouteId extends RouteIds = ParseRoute['id'], - TReturnIntersection extends boolean = false, + TRouteId, + TAllParams, + TFullSearchSchema, + TLoaderData, + TAllContext, + TRouteContext, + TLoaderDeps, > { id: string routeId: TRouteId pathname: string - params: TReturnIntersection extends false - ? RouteById['types']['allParams'] - : Expand>> + params: TAllParams status: 'pending' | 'success' | 'error' | 'redirected' | 'notFound' isFetching: boolean error: unknown @@ -52,22 +49,15 @@ export interface RouteMatch< searchError: unknown updatedAt: number loadPromise: ControlledPromise - loaderPromise: Promise['types']['loaderData']> - loaderData?: RouteById['types']['loaderData'] - routeContext: RouteById['types']['routeContext'] - context: RouteById['types']['allContext'] - search: TReturnIntersection extends false - ? Exclude< - RouteById['types']['fullSearchSchema'], - RootSearchSchema - > - : Expand< - Partial, keyof RootSearchSchema>> - > + loaderPromise: Promise + loaderData?: TLoaderData + routeContext: TRouteContext + context: TAllContext + search: TFullSearchSchema fetchCount: number abortController: AbortController cause: 'preload' | 'enter' | 'stay' - loaderDeps: RouteById['types']['loaderDeps'] + loaderDeps: TLoaderDeps preload: boolean invalid: boolean meta?: Array @@ -79,7 +69,35 @@ export interface RouteMatch< minPendingPromise?: ControlledPromise } -export type AnyRouteMatch = RouteMatch +export type MakeRouteMatch< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TRouteId extends RouteIds = ParseRoute['id'], + TReturnIntersection extends boolean = false, + TTypes extends RouteById['types'] = RouteById< + TRouteTree, + TRouteId + >['types'], + TAllParams = TReturnIntersection extends false + ? TTypes['allParams'] + : Expand>>, + TFullSearchSchema = TReturnIntersection extends false + ? TTypes['fullSearchSchema'] + : Expand>>, + TLoaderData = TTypes['loaderData'], + TAllContext = TTypes['allContext'], + TRouteContext = TTypes['routeContext'], + TLoaderDeps = TTypes['loaderDeps'], +> = RouteMatch< + TRouteId, + TAllParams, + TFullSearchSchema, + TLoaderData, + TAllContext, + TRouteContext, + TLoaderDeps +> + +export type AnyRouteMatch = RouteMatch export function Matches() { const matchId = useRouterState({ @@ -498,11 +516,11 @@ export function useMatch< TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], TFrom extends RouteIds = RouteIds, TReturnIntersection extends boolean = false, - TRouteMatchState = RouteMatch, - TSelected = TRouteMatchState, + TRouteMatch = MakeRouteMatch, + TSelected = TRouteMatch, >( opts: StrictOrFrom & { - select?: (match: TRouteMatchState) => TSelected + select?: (match: TRouteMatch) => TSelected }, ): TSelected { const nearestMatchId = React.useContext(matchContext) @@ -531,7 +549,7 @@ export function useMatches< TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], TRouteId extends RouteIds = ParseRoute['id'], TReturnIntersection extends boolean = false, - TRouteMatch = RouteMatch, + TRouteMatch = MakeRouteMatch, T = Array, >(opts?: { select?: (matches: Array) => T @@ -551,7 +569,7 @@ export function useParentMatches< TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], TRouteId extends RouteIds = ParseRoute['id'], TReturnIntersection extends boolean = false, - TRouteMatch = RouteMatch, + TRouteMatch = MakeRouteMatch, T = Array, >(opts?: { select?: (matches: Array) => T @@ -576,7 +594,7 @@ export function useChildMatches< TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], TRouteId extends RouteIds = ParseRoute['id'], TReturnIntersection extends boolean = false, - TRouteMatch = RouteMatch, + TRouteMatch = MakeRouteMatch, T = Array, >(opts?: { select?: (matches: Array) => T @@ -599,7 +617,7 @@ export function useChildMatches< export function useLoaderDeps< TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], TFrom extends RouteIds = RouteIds, - TRouteMatch extends RouteMatch = RouteMatch< + TRouteMatch extends MakeRouteMatch = MakeRouteMatch< TRouteTree, TFrom >, @@ -622,7 +640,7 @@ export function useLoaderDeps< export function useLoaderData< TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], TFrom extends RouteIds = RouteIds, - TRouteMatch extends RouteMatch = RouteMatch< + TRouteMatch extends MakeRouteMatch = MakeRouteMatch< TRouteTree, TFrom >, diff --git a/packages/react-router/src/RouterProvider.tsx b/packages/react-router/src/RouterProvider.tsx index 301b470c7fc..1bc7abc75e8 100644 --- a/packages/react-router/src/RouterProvider.tsx +++ b/packages/react-router/src/RouterProvider.tsx @@ -16,7 +16,7 @@ import type { RouterState, } from './router' -import type { RouteMatch } from './Matches' +import type { MakeRouteMatch } from './Matches' export interface CommitLocationOptions { replace?: boolean @@ -229,7 +229,7 @@ function Transitioner() { export function getRouteMatch( state: RouterState, id: string, -): undefined | RouteMatch { +): undefined | MakeRouteMatch { return [ ...state.cachedMatches, ...(state.pendingMatches ?? []), diff --git a/packages/react-router/src/fileRoute.ts b/packages/react-router/src/fileRoute.ts index 0f97cc1e3cc..bfcd910c391 100644 --- a/packages/react-router/src/fileRoute.ts +++ b/packages/react-router/src/fileRoute.ts @@ -25,7 +25,7 @@ import type { UpdatableRouteOptions, } from './route' import type { Assign, IsAny } from './utils' -import type { RouteMatch } from './Matches' +import type { MakeRouteMatch } from './Matches' import type { NoInfer } from '@tanstack/react-store' import type { RegisteredRouter } from './router' import type { RouteById, RouteIds } from './routeInfo' @@ -189,7 +189,15 @@ export class FileRoute< TLoaderDeps, TLoaderDataReturn > & - UpdatableRouteOptions, + UpdatableRouteOptions< + TId, + TAllParams, + TFullSearchSchema, + TLoaderData, + TAllContext, + TRouteContext, + TLoaderDeps + >, ): Route< TParentRoute, TPath, @@ -255,7 +263,15 @@ export function FileRouteLoader< } export type LazyRouteOptions = Pick< - UpdatableRouteOptions, + UpdatableRouteOptions< + string, + AnyPathParams, + AnySearchSchema, + {}, + AnyContext, + AnyContext, + {} + >, 'component' | 'errorComponent' | 'pendingComponent' | 'notFoundComponent' > @@ -274,13 +290,13 @@ export class LazyRoute { } useMatch = < - TRouteMatchState = RouteMatch< + TRouteMatch = MakeRouteMatch< RegisteredRouter['routeTree'], TRoute['types']['id'] >, - TSelected = TRouteMatchState, + TSelected = TRouteMatch, >(opts?: { - select?: (match: TRouteMatchState) => TSelected + select?: (match: TRouteMatch) => TSelected }): TSelected => { return useMatch({ select: opts?.select, from: this.options.id }) } diff --git a/packages/react-router/src/route.ts b/packages/react-router/src/route.ts index 5a34ed4dec8..8229dda3472 100644 --- a/packages/react-router/src/route.ts +++ b/packages/react-router/src/route.ts @@ -7,8 +7,7 @@ import { notFound } from './not-found' import { useNavigate } from './useNavigate' import type { UseNavigateResult } from './useNavigate' import type * as React from 'react' -import type { RouteMatch } from './Matches' -import type { AnyRouteMatch } from './Matches' +import type { MakeRouteMatch, RouteMatch } from './Matches' import type { NavigateOptions, ParsePathParams, ToSubOptions } from './link' import type { ParsedLocation } from './location' import type { RouteById, RouteIds, RoutePaths } from './routeInfo' @@ -93,9 +92,13 @@ export type RouteOptions< TLoaderDataReturn > & UpdatableRouteOptions< + NoInfer, NoInfer, NoInfer, - NoInfer + NoInfer, + NoInfer, + NoInfer, + NoInfer > export type ParamsFallback< @@ -221,44 +224,60 @@ type BeforeLoadFn< cause: 'preload' | 'enter' | 'stay' }) => Promise | TRouteContextReturn | void -export type UpdatableRouteOptions = - { - // test?: (args: TAllContext) => void - // If true, this route will be matched as case-sensitive - caseSensitive?: boolean - // If true, this route will be forcefully wrapped in a suspense boundary - wrapInSuspense?: boolean - // The content to be rendered when the route is matched. If no component is provided, defaults to `` - component?: RouteComponent - errorComponent?: false | null | ErrorRouteComponent - notFoundComponent?: NotFoundRouteComponent - pendingComponent?: RouteComponent - pendingMs?: number - pendingMinMs?: number - staleTime?: number - gcTime?: number - preloadStaleTime?: number - preloadGcTime?: number - // Filter functions that can manipulate search params *before* they are passed to links and navigate - // calls that match this route. - preSearchFilters?: Array> - // Filter functions that can manipulate search params *after* they are passed to links and navigate - // calls that match this route. - postSearchFilters?: Array> - onError?: (err: any) => void - // These functions are called as route matches are loaded, stick around and leave the active - // matches - onEnter?: (match: AnyRouteMatch) => void - onStay?: (match: AnyRouteMatch) => void - onLeave?: (match: AnyRouteMatch) => void - meta?: (ctx: { - params: TAllParams - loaderData: TLoaderData - }) => Array - links?: () => Array - scripts?: () => Array - headers?: (ctx: { loaderData: TLoaderData }) => Record - } & UpdatableStaticRouteOption +export type UpdatableRouteOptions< + TRouteId, + TAllParams, + TFullSearchSchema, + TLoaderData, + TAllContext, + TRouteContext, + TLoaderDeps, + TRouteMatch = RouteMatch< + TRouteId, + TAllParams, + TFullSearchSchema, + TLoaderData, + TAllContext, + TRouteContext, + TLoaderDeps + >, +> = { + // test?: (args: TAllContext) => void + // If true, this route will be matched as case-sensitive + caseSensitive?: boolean + // If true, this route will be forcefully wrapped in a suspense boundary + wrapInSuspense?: boolean + // The content to be rendered when the route is matched. If no component is provided, defaults to `` + component?: RouteComponent + errorComponent?: false | null | ErrorRouteComponent + notFoundComponent?: NotFoundRouteComponent + pendingComponent?: RouteComponent + pendingMs?: number + pendingMinMs?: number + staleTime?: number + gcTime?: number + preloadStaleTime?: number + preloadGcTime?: number + // Filter functions that can manipulate search params *before* they are passed to links and navigate + // calls that match this route. + preSearchFilters?: Array> + // Filter functions that can manipulate search params *after* they are passed to links and navigate + // calls that match this route. + postSearchFilters?: Array> + onError?: (err: any) => void + // These functions are called as route matches are loaded, stick around and leave the active + // matches + onEnter?: (match: TRouteMatch) => void + onStay?: (match: TRouteMatch) => void + onLeave?: (match: TRouteMatch) => void + meta?: (ctx: { + params: TAllParams + loaderData: TLoaderData + }) => Array + links?: () => Array + scripts?: () => Array + headers?: (ctx: { loaderData: TLoaderData }) => Record +} & UpdatableStaticRouteOption export type UpdatableStaticRouteOption = {} extends PickRequired @@ -483,10 +502,10 @@ export class RouteApi< useMatch = < TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TRouteMatchState = RouteMatch, - TSelected = TRouteMatchState, + TRouteMatch = MakeRouteMatch, + TSelected = TRouteMatch, >(opts?: { - select?: (match: TRouteMatchState) => TSelected + select?: (match: TRouteMatch) => TSelected }): TSelected => { return useMatch({ select: opts?.select, from: this.id }) } @@ -806,7 +825,15 @@ export class Route< } update = ( - options: UpdatableRouteOptions, + options: UpdatableRouteOptions< + TCustomId, + TAllParams, + TFullSearchSchema, + TLoaderData, + TAllContext, + TRouteContext, + TLoaderDeps + >, ): this => { Object.assign(this.options, options) return this @@ -819,10 +846,10 @@ export class Route< useMatch = < TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TRouteMatchState = RouteMatch, - TSelected = TRouteMatchState, + TRouteMatch = MakeRouteMatch, + TSelected = TRouteMatch, >(opts?: { - select?: (match: TRouteMatchState) => TSelected + select?: (match: TRouteMatch) => TSelected }): TSelected => { return useMatch({ ...opts, from: this.id }) } diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 26626c493b3..c5ce98e2562 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -60,7 +60,12 @@ import type { Updater, } from './utils' import type { RouteComponent } from './route' -import type { AnyRouteMatch, MatchRouteOptions, RouteMatch } from './Matches' +import type { + AnyRouteMatch, + MakeRouteMatch, + MatchRouteOptions, + RouteMatch, +} from './Matches' import type { ParsedLocation } from './location' import type { SearchParser, SearchSerializer } from './searchParams' import type { @@ -164,13 +169,16 @@ export interface RouterErrorSerializer { deserialize: (err: TSerializedError) => unknown } -export interface RouterState { +export interface RouterState< + TRouteTree extends AnyRoute = AnyRoute, + TRouteMatch extends MakeRouteMatch = MakeRouteMatch, +> { status: 'pending' | 'idle' isLoading: boolean isTransitioning: boolean - matches: Array> - pendingMatches?: Array> - cachedMatches: Array> + matches: Array + pendingMatches?: Array + cachedMatches: Array location: ParsedLocation> resolvedLocation: ParsedLocation> statusCode: number @@ -202,7 +210,7 @@ export interface DehydratedRouterState { } export type DehydratedRouteMatch = Pick< - RouteMatch, + MakeRouteMatch, 'id' | 'status' | 'updatedAt' | 'loaderData' > @@ -607,12 +615,11 @@ export class Router< return this.routesById as Record } - // eslint-disable-next-line no-shadow - matchRoutes = ( + matchRoutes = ( pathname: string, locationSearch: AnySearchSchema, opts?: { preload?: boolean; throwOnError?: boolean }, - ): Array> => { + ): Array => { let routeParams: Record = {} const foundRoute = this.flatRoutes.find((route) => { @@ -880,7 +887,7 @@ export class Router< dest: BuildNextOptions & { unmaskOnReload?: boolean } = {}, - matches?: Array, + matches?: Array>, ): ParsedLocation => { const fromPath = dest.from || this.latestLocation.pathname let fromSearch = dest._fromLocation?.search || this.latestLocation.search @@ -1171,7 +1178,7 @@ export class Router< location: ParsedLocation matches: Array preload?: boolean - }): Promise> => { + }): Promise> => { let latestPromise let firstBadMatchIndex: number | undefined @@ -1606,7 +1613,7 @@ export class Router< } invalidate = () => { - const invalidate = (d: RouteMatch) => ({ + const invalidate = (d: MakeRouteMatch) => ({ ...d, invalid: true, ...(d.status === 'error' ? ({ status: 'pending' } as const) : {}), @@ -1651,7 +1658,7 @@ export class Router< pathChanged: pathDidChange, }) - let pendingMatches!: Array> + let pendingMatches!: Array const previousMatches = this.state.matches this.__store.batch(() => { diff --git a/packages/react-router/src/useRouteContext.ts b/packages/react-router/src/useRouteContext.ts index cad24cf91a2..8e9201770e6 100644 --- a/packages/react-router/src/useRouteContext.ts +++ b/packages/react-router/src/useRouteContext.ts @@ -1,5 +1,5 @@ import { useMatch } from './Matches' -import type { RouteMatch } from './Matches' +import type { MakeRouteMatch } from './Matches' import type { AnyRoute } from './route' import type { RouteById, RouteIds } from './routeInfo' import type { RegisteredRouter } from './router' @@ -17,7 +17,7 @@ export function useRouteContext< ): TSelected { return useMatch({ ...(opts as any), - select: (match: RouteMatch) => - opts.select ? opts.select(match.context as TRouteContext) : match.context, + select: (match: MakeRouteMatch) => + opts.select ? opts.select(match.context) : match.context, }) } diff --git a/packages/react-router/src/useSearch.tsx b/packages/react-router/src/useSearch.tsx index ead6f3d1517..120218ee2f0 100644 --- a/packages/react-router/src/useSearch.tsx +++ b/packages/react-router/src/useSearch.tsx @@ -1,9 +1,8 @@ import { useMatch } from './Matches' -import { Expand } from './utils' import type { AnyRoute, RootSearchSchema } from './route' import type { FullSearchSchema, RouteById, RouteIds } from './routeInfo' import type { RegisteredRouter } from './router' -import type { RouteMatch } from './Matches' +import type { MakeRouteMatch } from './Matches' import type { StrictOrFrom } from './utils' export function useSearch< @@ -24,8 +23,8 @@ export function useSearch< ): TSelected { return useMatch({ ...opts, - select: (match: RouteMatch) => { - return opts.select ? opts.select(match.search as TSearch) : match.search + select: (match: MakeRouteMatch) => { + return opts.select ? opts.select(match.search) : match.search }, }) } From d6b01d4bc398afe45cb25524884cfbe46e447cdb Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Fri, 19 Apr 2024 22:06:29 +0200 Subject: [PATCH 3/4] relax type constraints --- packages/react-router/src/Matches.tsx | 11 ++++------- packages/react-router/src/router.ts | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index a50c227e540..10045c6bc38 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -71,18 +71,15 @@ export interface RouteMatch< export type MakeRouteMatch< TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TRouteId extends RouteIds = ParseRoute['id'], + TRouteId = ParseRoute['id'], TReturnIntersection extends boolean = false, - TTypes extends RouteById['types'] = RouteById< - TRouteTree, - TRouteId - >['types'], + TTypes extends AnyRoute['types'] = RouteById['types'], TAllParams = TReturnIntersection extends false ? TTypes['allParams'] - : Expand>>, + : Partial>, TFullSearchSchema = TReturnIntersection extends false ? TTypes['fullSearchSchema'] - : Expand>>, + : Partial>, TLoaderData = TTypes['loaderData'], TAllContext = TTypes['allContext'], TRouteContext = TTypes['routeContext'], diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index c5ce98e2562..8d34fe6522d 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -171,7 +171,7 @@ export interface RouterErrorSerializer { export interface RouterState< TRouteTree extends AnyRoute = AnyRoute, - TRouteMatch extends MakeRouteMatch = MakeRouteMatch, + TRouteMatch = MakeRouteMatch, > { status: 'pending' | 'idle' isLoading: boolean From 6755cfd7c4cad85677d49db812a46b5969b84872 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Fri, 19 Apr 2024 22:07:07 +0200 Subject: [PATCH 4/4] add type test --- packages/react-router/tests/route.test-d.tsx | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/react-router/tests/route.test-d.tsx b/packages/react-router/tests/route.test-d.tsx index 671d73249bf..9c1d865ad8e 100644 --- a/packages/react-router/tests/route.test-d.tsx +++ b/packages/react-router/tests/route.test-d.tsx @@ -417,3 +417,56 @@ test('when creating a child route with context, search, params and beforeLoad', }>(), }) }) + +test('when creating a child route with context, search, params, loader, loaderDeps and onEnter, onStay, onLeave', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + validateSearch: () => ({ page: 0 }), + beforeLoad: () => ({ invoicePermissions: ['view'] as const }), + }) + + const invoiceRoute = createRoute({ + path: '$invoiceId', + getParentRoute: () => invoicesRoute, + }) + + const detailsRoute = createRoute({ + path: 'details', + getParentRoute: () => invoiceRoute, + validateSearch: () => ({ detailPage: 0 }), + beforeLoad: () => ({ detailsPermissions: ['view'] as const }), + }) + + type TExpectedParams = { detailId: string; invoiceId: string } + type TExpectedSearch = { detailPage: number; page: number } + type TExpectedContext = { + userId: string + detailsPermissions: readonly ['view'] + invoicePermissions: readonly ['view'] + } + type TExpectedLoaderData = { detailLoader: 'detailResult' } + type TExpectedMatch = { + params: TExpectedParams + search: TExpectedSearch + context: TExpectedContext + loaderDeps: { detailPage: number; invoicePage: number } + loaderPromise: Promise + loaderData?: TExpectedLoaderData + } + + createRoute({ + path: '$detailId', + getParentRoute: () => detailsRoute, + loaderDeps: (deps) => ({ + detailPage: deps.search.detailPage, + invoicePage: deps.search.page, + }), + loader: () => ({ detailLoader: 'detailResult' }) as const, + onEnter: (match) => expectTypeOf(match).toMatchTypeOf(), + onStay: (match) => expectTypeOf(match).toMatchTypeOf(), + onLeave: (match) => expectTypeOf(match).toMatchTypeOf(), + }) +})