From 385a1c47391ce200599742e4b52d925ba2588f99 Mon Sep 17 00:00:00 2001 From: chorobin Date: Mon, 22 Apr 2024 23:57:49 +0200 Subject: [PATCH 1/2] feat: infer trailing slash option from router to use at the type level - always will always add trailing slashes to the leaves of `to` prop - never will always remove trailing slashes from the leaves of `to` prop - preserve will allow both paths with or without slashes --- packages/react-router/src/Matches.tsx | 62 ++-- packages/react-router/src/RouterProvider.tsx | 45 ++- packages/react-router/src/link.tsx | 241 +++++++------- packages/react-router/src/redirects.ts | 28 +- packages/react-router/src/route.ts | 6 +- packages/react-router/src/routeInfo.ts | 48 ++- packages/react-router/src/router.ts | 63 +++- packages/react-router/src/routerContext.tsx | 2 +- packages/react-router/src/useNavigate.tsx | 19 +- packages/react-router/src/useRouter.tsx | 9 +- packages/react-router/src/useRouterState.tsx | 11 +- packages/react-router/tests/link.test-d.tsx | 315 ++++++++++++++++--- 12 files changed, 602 insertions(+), 247 deletions(-) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 10045c6bc38..79c6f3f552d 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -428,37 +428,41 @@ export interface MatchRouteOptions { } export type UseMatchRouteOptions< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths = RoutePaths, + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths = RoutePaths< + TRouter['routeTree'] + >, TTo extends string = '', - TMaskFrom extends RoutePaths = TFrom, + TMaskFrom extends RoutePaths = TFrom, TMaskTo extends string = '', TOptions extends ToOptions< - TRouteTree, + TRouter, TFrom, TTo, TMaskFrom, TMaskTo - > = ToOptions, + > = ToOptions, TRelaxedOptions = Omit & DeepPartial>, > = TRelaxedOptions & MatchRouteOptions -export function useMatchRoute< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], ->() { +export function useMatchRoute() { const router = useRouter() return React.useCallback( < - TFrom extends RoutePaths = RoutePaths, + TFrom extends RoutePaths = RoutePaths< + TRouter['routeTree'] + >, TTo extends string = '', - TMaskFrom extends RoutePaths = TFrom, + TMaskFrom extends RoutePaths = TFrom, TMaskTo extends string = '', TResolved extends string = ResolveRelativePath>, >( - opts: UseMatchRouteOptions, - ): false | RouteById['types']['allParams'] => { + opts: UseMatchRouteOptions, + ): + | false + | RouteById['types']['allParams'] => { const { pending, caseSensitive, fuzzy, includeSearch, ...rest } = opts return router.matchRoute(rest as any, { @@ -473,17 +477,25 @@ export function useMatchRoute< } export type MakeMatchRouteOptions< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths = RoutePaths, + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths = RoutePaths< + TRouter['routeTree'] + >, TTo extends string = '', - TMaskFrom extends RoutePaths = TFrom, + TMaskFrom extends RoutePaths = TFrom, TMaskTo extends string = '', -> = UseMatchRouteOptions & { +> = UseMatchRouteOptions< + TRouter['routeTree'], + TFrom, + TTo, + TMaskFrom, + TMaskTo +> & { // If a function is passed as a child, it will be given the `isActive` boolean to aid in further styling on the element it returns children?: | (( params?: RouteByPath< - TRouteTree, + TRouter['routeTree'], ResolveRelativePath> >['types']['allParams'], ) => ReactNode) @@ -491,13 +503,21 @@ export type MakeMatchRouteOptions< } export function MatchRoute< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths = RoutePaths, + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths = RoutePaths< + TRouter['routeTree'] + >, TTo extends string = '', - TMaskFrom extends RoutePaths = TFrom, + TMaskFrom extends RoutePaths = TFrom, TMaskTo extends string = '', >( - props: MakeMatchRouteOptions, + props: MakeMatchRouteOptions< + TRouter['routeTree'], + TFrom, + TTo, + TMaskFrom, + TMaskTo + >, ): any { const matchRoute = useMatchRoute() const params = matchRoute(props as any) diff --git a/packages/react-router/src/RouterProvider.tsx b/packages/react-router/src/RouterProvider.tsx index 1bc7abc75e8..207c3abdd1f 100644 --- a/packages/react-router/src/RouterProvider.tsx +++ b/packages/react-router/src/RouterProvider.tsx @@ -10,6 +10,7 @@ import type { ParsedLocation } from './location' import type { AnyRoute } from './route' import type { RoutePaths } from './routeInfo' import type { + AnyRouter, RegisteredRouter, Router, RouterOptions, @@ -37,12 +38,12 @@ export interface MatchLocation { export type NavigateFn = < TTo extends string, - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths | string = string, - TMaskFrom extends RoutePaths | string = TFrom, + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths | string = string, + TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', >( - opts: NavigateOptions, + opts: NavigateOptions, ) => Promise export type BuildLocationFn = < @@ -51,7 +52,13 @@ export type BuildLocationFn = < TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', >( - opts: ToOptions & { + opts: ToOptions< + Router, + TFrom, + TTo, + TMaskFrom, + TMaskTo + > & { leaveParams?: boolean }, ) => ParsedLocation @@ -59,9 +66,9 @@ export type BuildLocationFn = < export type InjectedHtmlEntry = string | (() => Promise | string) export function RouterProvider< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TRouter extends AnyRouter = RegisteredRouter, TDehydrated extends Record = Record, ->({ router, ...rest }: RouterProps) { +>({ router, ...rest }: RouterProps) { // Allow the router to update options on the router instance router.update({ ...router.options, @@ -238,11 +245,27 @@ export function getRouteMatch( } export type RouterProps< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TRouter extends AnyRouter = RegisteredRouter, TDehydrated extends Record = Record, -> = Omit, 'context'> & { - router: Router - context?: Partial['context']> +> = Omit< + RouterOptions< + TRouter['routeTree'], + NonNullable, + TDehydrated + >, + 'context' +> & { + router: Router< + TRouter['routeTree'], + NonNullable + > + context?: Partial< + RouterOptions< + TRouter['routeTree'], + NonNullable, + TDehydrated + >['context'] + > } function usePrevious(value: T) { diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index cae8bfc5e82..735de5490b2 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -4,20 +4,20 @@ import { useMatch } from './Matches' import { useRouterState } from './useRouterState' import { useRouter } from './useRouter' import { deepEqual, exactPathTest, functionalUpdate } from './utils' -import type { ParsedLocation } from '.' +import type { AnyRouter, ParsedLocation } from '.' import type { HistoryState } from '@tanstack/history' import type { Trim } from './fileRoute' import type { AnyRoute, RootSearchSchema } from './route' import type { RouteByPath, - RouteLeaves, + RouteLeafByPath, + RouteLeafPaths, RoutePaths, RoutePathsAutoComplete, } from './routeInfo' import type { RegisteredRouter } from './router' import type { Expand, - IsUnion, MakeDifferenceOptional, NoInfer, NonNullableUpdater, @@ -79,6 +79,10 @@ export type Last> = T extends [...infer _, infer L] ? L : never +export type AddTrailingSlash = T extends `${string}/` + ? T + : `${T}/` + export type RemoveTrailingSlashes = T extends `${infer R}/` ? RemoveTrailingSlashes : T @@ -87,86 +91,88 @@ export type RemoveLeadingSlashes = T extends `/${infer R}` ? RemoveLeadingSlashes : T -export type ResolvePaths = - RouteByPath> extends never - ? RouteLeaves - : RouteLeaves>> +export type ResolvePaths = + RouteByPath< + TRouter['routeTree'], + RemoveTrailingSlashes + > extends never + ? RouteLeafPaths + : RouteLeafPaths< + TRouter, + RouteByPath> + > export type SearchPaths< - TRouteTree extends AnyRoute, + TRouter extends AnyRouter, TSearchPath extends string, - TPaths = ResolvePaths, + TPaths = ResolvePaths, > = TPaths extends `${RemoveTrailingSlashes}/${infer TRest}` ? TRest : never export type SearchRelativePathAutoComplete< - TRouteTree extends AnyRoute, + TRouter extends AnyRouter, TTo extends string, TSearchPath extends string, -> = `${TTo}/${SearchPaths}` +> = `${TTo}/${SearchPaths}` export type RelativeToParentPathAutoComplete< - TRouteTree extends AnyRoute, + TRouter extends AnyRouter, TFrom extends string, TTo extends string, TResolvedPath extends string = RemoveTrailingSlashes< ResolveRelativePath >, > = - | SearchRelativePathAutoComplete + | SearchRelativePathAutoComplete | (TResolvedPath extends '' ? never : `${TTo}/../`) export type RelativeToCurrentPathAutoComplete< - TRouteTree extends AnyRoute, + TRouter extends AnyRouter, TFrom extends string, TTo extends string, TRestTo extends string, TResolvedPath extends string = RemoveTrailingSlashes<`${RemoveTrailingSlashes}/${RemoveLeadingSlashes}`>, -> = SearchRelativePathAutoComplete +> = SearchRelativePathAutoComplete export type AbsolutePathAutoComplete< - TRouteTree extends AnyRoute, + TRouter extends AnyRouter, TFrom extends string, > = | (string extends TFrom ? './' : TFrom extends `/` ? never - : SearchPaths extends '' + : SearchPaths extends '' ? never : './') | (string extends TFrom ? '../' : TFrom extends `/` ? never : '../') - | RouteLeaves - | (TFrom extends '/' ? never : SearchPaths) + | RouteLeafPaths + | (TFrom extends '/' ? never : SearchPaths) export type RelativeToPathAutoComplete< - TRouteTree extends AnyRoute, + TRouter extends AnyRouter, TFrom extends string, TTo extends string, > = TTo extends `..${string}` - ? RelativeToParentPathAutoComplete< - TRouteTree, - TFrom, - RemoveTrailingSlashes - > + ? RelativeToParentPathAutoComplete> : TTo extends `./${infer TRestTTo}` ? RelativeToCurrentPathAutoComplete< - TRouteTree, + TRouter, TFrom, RemoveTrailingSlashes, TRestTTo > - : AbsolutePathAutoComplete + : AbsolutePathAutoComplete export type NavigateOptions< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths | string = string, + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths | string = string, TTo extends string = '', - TMaskFrom extends RoutePaths | string = TFrom, + TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', -> = ToOptions & { +> = ToOptions & { // `replace` is a boolean that determines whether the navigation should replace the current history entry or push a new one. replace?: boolean resetScroll?: boolean @@ -177,37 +183,37 @@ export type NavigateOptions< } export type ToOptions< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths | string = string, + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths | string = string, TTo extends string = '', - TMaskFrom extends RoutePaths | string = TFrom, + TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', -> = ToSubOptions & { +> = ToSubOptions & { _fromLocation?: ParsedLocation - mask?: ToMaskOptions + mask?: ToMaskOptions } export type ToMaskOptions< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TMaskFrom extends RoutePaths | string = string, + TRouteTree extends AnyRouter = RegisteredRouter, + TMaskFrom extends RoutePaths | string = string, TMaskTo extends string = '', > = ToSubOptions & { unmaskOnReload?: boolean } export type ToSubOptions< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths | string = string, + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths | string = string, TTo extends string = '', > = { - to?: ToPathOption & {} + to?: ToPathOption & {} hash?: true | Updater state?: true | NonNullableUpdater // The source route path. This is automatically set when using route-level APIs, but for type-safe relative routing on the router itself, this is required - from?: RoutePathsAutoComplete & {} + from?: RoutePathsAutoComplete & {} // // When using relative route paths, this option forces resolution from the current path, instead of the route API's path or `from` path -} & SearchParamOptions & - PathParamOptions +} & SearchParamOptions & + PathParamOptions type ParamsReducer = TTo | ((current: TFrom) => TTo) @@ -220,7 +226,7 @@ type ExcludeRootSearchSchema> = [ : TExcluded export type ResolveRoute< - TRouteTree extends AnyRoute, + TRouter extends AnyRouter, TFrom, TTo, TPath = RemoveTrailingSlashes< @@ -231,9 +237,9 @@ export type ResolveRoute< : ResolveRelativePath >, > = TPath extends string - ? RouteByPath extends never - ? RouteByPath - : RouteByPath + ? string extends TTo + ? RouteByPath + : RouteLeafByPath : never type PostProcessParams< @@ -242,25 +248,22 @@ type PostProcessParams< > = TParamVariant extends 'SEARCH' ? ExcludeRootSearchSchema : T type ResolveFromParams< - TRouteTree extends AnyRoute, + TRouter extends AnyRouter, TParamVariant extends ParamVariant, TFrom, - TFromRouteType extends - | 'allParams' - | 'fullSearchSchema' = TParamVariant extends 'PATH' - ? 'allParams' - : 'fullSearchSchema', > = PostProcessParams< - RouteByPath['types'][TFromRouteType], + RouteByPath['types'][TParamVariant extends 'PATH' + ? 'allParams' + : 'fullSearchSchema'], TParamVariant > type ResolveToParams< - TRouteTree extends AnyRoute, + TRouter extends AnyRouter, TParamVariant extends ParamVariant, TFrom, TTo, - TRoute extends AnyRoute = ResolveRoute, + TRoute extends AnyRoute = ResolveRoute, > = PostProcessParams< TRoute['types'][TParamVariant extends 'PATH' ? 'allParams' @@ -269,22 +272,22 @@ type ResolveToParams< > type ResolveRelativeToParams< - TRouteTree extends AnyRoute, + TRouter extends AnyRouter, TParamVariant extends ParamVariant, TFrom, TTo, - TToParams = ResolveToParams, + TToParams = ResolveToParams, > = TParamVariant extends 'SEARCH' ? TToParams : string extends TFrom ? TToParams : MakeDifferenceOptional< - ResolveFromParams, + ResolveFromParams, TToParams > type MakeOptionalParams< - TRouteTree extends AnyRoute, + TRouter extends AnyRouter, TParamVariant extends ParamVariant, TFrom, TTo, @@ -293,29 +296,25 @@ type MakeOptionalParams< search?: | true | (ParamsReducer< - Expand>, - Expand< - ResolveRelativeToParams - > + Expand>, + Expand> > & {}) } : { params?: | true | (ParamsReducer< - Expand>, - Expand< - ResolveRelativeToParams - > + Expand>, + Expand> > & {}) } type MakeRequiredParamsReducer< - TRouteTree extends AnyRoute, + TRouter extends AnyRouter, TParamVariant extends ParamVariant, TFrom, TToParams, - TFromParams = ResolveFromParams, + TFromParams = ResolveFromParams, > = | ([TFromParams] extends [WithoutEmpty>] ? true @@ -323,7 +322,7 @@ type MakeRequiredParamsReducer< | ParamsReducer, TToParams> export type MakeRequiredParams< - TRouteTree extends AnyRoute, + TRouter extends AnyRouter, TParamVariant extends ParamVariant, TFrom, TTo, @@ -331,20 +330,20 @@ export type MakeRequiredParams< ? { search: Expand< MakeRequiredParamsReducer< - TRouteTree, + TRouter, TParamVariant, TFrom, - Expand> + Expand> > > & {} } : { params: Expand< MakeRequiredParamsReducer< - TRouteTree, + TRouter, TParamVariant, TFrom, - Expand> + Expand> > > & {} } @@ -359,7 +358,7 @@ export type IsRequiredParams = keyof TParams extends infer K extends : never export type IsRequired< - TRouteTree extends AnyRoute, + TRouter extends AnyRouter, TParamVariant extends ParamVariant, TFrom, TTo, @@ -367,42 +366,42 @@ export type IsRequired< ? string extends TFrom ? never : IsRequiredParams< - ResolveRelativeToParams + ResolveRelativeToParams > : IsRequiredParams< - ResolveRelativeToParams + ResolveRelativeToParams > export type ParamOptions< - TRouteTree extends AnyRoute, + TRouter extends AnyRouter, TFrom, TTo extends string, TParamVariant extends ParamVariant, > = - IsRequired extends never - ? MakeOptionalParams - : MakeRequiredParams + IsRequired extends never + ? MakeOptionalParams + : MakeRequiredParams export type SearchParamOptions< - TRouteTree extends AnyRoute, + TRouter extends AnyRouter, TFrom, TTo extends string, -> = ParamOptions +> = ParamOptions export type PathParamOptions< - TRouteTree extends AnyRoute, + TRouter extends AnyRouter, TFrom, TTo extends string, -> = ParamOptions +> = ParamOptions export type ToPathOption< - TRouteTree extends AnyRoute = AnyRoute, - TFrom extends RoutePaths | string = string, + TRouter extends AnyRouter = AnyRouter, + TFrom extends RoutePaths | string = string, TTo extends string = string, > = - | CheckPath + | CheckPath | RelativeToPathAutoComplete< - TRouteTree, + TRouter, NoInfer extends string ? NoInfer : '', NoInfer & string > @@ -414,12 +413,12 @@ export interface ActiveOptions { } export type LinkOptions< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths | string = string, + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths | string = string, TTo extends string = '', - TMaskFrom extends RoutePaths | string = TFrom, + TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', -> = NavigateOptions & { +> = NavigateOptions & { // The standard anchor tag target attribute target?: HTMLAnchorElement['target'] // Defaults to `{ exact: false, includeHash: false }` @@ -432,16 +431,8 @@ export type LinkOptions< disabled?: boolean } -export type CheckPath = - ResolveRoute extends infer TRoute extends AnyRoute - ? [TRoute] extends [never] - ? TFail - : string extends TTo - ? TPass - : unknown extends TRoute['children'] - ? TPass - : TFail - : TFail +export type CheckPath = + ResolveRoute extends never ? TFail : TPass export type ResolveRelativePath = TFrom extends string ? TTo extends string @@ -493,13 +484,13 @@ type LinkCurrentTargetElement = { const preloadWarning = 'Error preloading route! ☝️' export function useLinkProps< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths | string = string, + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths | string = string, TTo extends string = '', - TMaskFrom extends RoutePaths | string = TFrom, + TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', >( - options: UseLinkPropsOptions, + options: UseLinkPropsOptions, ): React.AnchorHTMLAttributes { const router = useRouter() const matchPathname = useMatch({ @@ -739,21 +730,21 @@ export function useLinkProps< } export type UseLinkPropsOptions< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths | string = string, + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths | string = string, TTo extends string = '', - TMaskFrom extends RoutePaths | string = TFrom, + TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', -> = ActiveLinkOptions & +> = ActiveLinkOptions & React.AnchorHTMLAttributes export type ActiveLinkOptions< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths | string = string, + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths | string = string, TTo extends string = '', - TMaskFrom extends RoutePaths | string = TFrom, + TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', -> = LinkOptions & { +> = LinkOptions & { // A function that returns additional props for the `active` state of this link. These props override other props passed to the link (`style`'s are merged, `className`'s are concatenated) activeProps?: | React.AnchorHTMLAttributes @@ -765,12 +756,12 @@ export type ActiveLinkOptions< } export type LinkProps< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths | string = string, + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths | string = string, TTo extends string = string, - TMaskFrom extends RoutePaths | string = TFrom, + TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', -> = ActiveLinkOptions & { +> = ActiveLinkOptions & { // If a function is passed as a child, it will be given the `isActive` boolean to aid in further styling on the element it returns children?: | React.ReactNode @@ -798,13 +789,13 @@ type LinkComponentProps = React.PropsWithoutRef< > export type LinkComponent = < - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths | string = string, + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths | string = string, TTo extends string = '', - TMaskFrom extends RoutePaths | string = TFrom, + TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', >( - props: LinkProps & + props: LinkProps & LinkComponentProps, ) => React.ReactElement diff --git a/packages/react-router/src/redirects.ts b/packages/react-router/src/redirects.ts index 41f99c856ac..3de9e7e394c 100644 --- a/packages/react-router/src/redirects.ts +++ b/packages/react-router/src/redirects.ts @@ -1,16 +1,16 @@ import type { NavigateOptions } from './link' import type { AnyRoute } from './route' import type { RoutePaths } from './routeInfo' -import type { RegisteredRouter } from './router' +import type { AnyRouter, RegisteredRouter } from './router' import type { PickAsRequired } from './utils' export type AnyRedirect = Redirect export type Redirect< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths = '/', + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths = '/', TTo extends string = '', - TMaskFrom extends RoutePaths = TFrom, + TMaskFrom extends RoutePaths = TFrom, TMaskTo extends string = '', > = { /** @@ -21,30 +21,30 @@ export type Redirect< statusCode?: number throw?: any headers?: HeadersInit -} & NavigateOptions +} & NavigateOptions export type ResolvedRedirect< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths = '/', + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths = '/', TTo extends string = '', - TMaskFrom extends RoutePaths = TFrom, + TMaskFrom extends RoutePaths = TFrom, TMaskTo extends string = '', > = PickAsRequired< - Redirect, + Redirect, 'code' | 'statusCode' | 'headers' > & { href: string } export function redirect< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths = '/', + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths = '/', TTo extends string = '', - TMaskFrom extends RoutePaths = TFrom, + TMaskFrom extends RoutePaths = TFrom, TMaskTo extends string = '', >( - opts: Redirect, -): Redirect { + opts: Redirect, +): Redirect { ;(opts as any).isRedirect = true opts.statusCode = opts.statusCode || opts.code || 301 opts.headers = opts.headers || {} diff --git a/packages/react-router/src/route.ts b/packages/react-router/src/route.ts index 8229dda3472..6db6a8bd004 100644 --- a/packages/react-router/src/route.ts +++ b/packages/react-router/src/route.ts @@ -11,7 +11,7 @@ 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' -import type { AnyRouter, RegisteredRouter } from './router' +import type { AnyRouter, RegisteredRouter, Router } from './router' import type { Assign, Expand, @@ -360,7 +360,7 @@ export interface LoaderFnContext< /** * @deprecated Use `throw redirect({ to: '/somewhere' })` instead **/ - navigate: (opts: NavigateOptions) => Promise + navigate: (opts: NavigateOptions) => Promise parentMatchPromise?: Promise cause: 'preload' | 'enter' | 'stay' route: Route @@ -1230,7 +1230,7 @@ export function createRouteMask< >( opts: { routeTree: TRouteTree - } & ToSubOptions, + } & ToSubOptions, TFrom, TTo>, ): RouteMask { return opts as any } diff --git a/packages/react-router/src/routeInfo.ts b/packages/react-router/src/routeInfo.ts index 3e9d796645e..8a1008812e7 100644 --- a/packages/react-router/src/routeInfo.ts +++ b/packages/react-router/src/routeInfo.ts @@ -1,4 +1,6 @@ +import type { AddTrailingSlash, RemoveTrailingSlashes } from './link' import type { AnyRoute } from './route' +import type { AnyRouter, Router, TrailingSlashOption } from './router' import type { UnionToIntersection, UnionToTuple } from './utils' export type ParseRoute = TRouteTree extends { @@ -9,12 +11,12 @@ export type ParseRoute = TRouteTree extends { : TAcc : TAcc -export type RouteLeaves = +export type ParseRouteLeaves = ParseRoute extends infer TRoute extends AnyRoute ? TRoute extends any ? TRoute['types']['children'] extends ReadonlyArray ? never - : TRoute['fullPath'] + : TRoute : never : never @@ -29,9 +31,14 @@ export type RouteById = Extract< export type RouteIds = ParseRoute['id'] +export type CatchAllPaths = Record< + '.' | '..', + ParseRoute +> + export type RoutesByPath = { [K in ParseRoute as K['fullPath']]: K -} & Record<'.' | '..', ParseRoute> +} & CatchAllPaths export type RouteByPath = Extract< string extends TPath @@ -44,6 +51,41 @@ export type RoutePaths = | ParseRoute['fullPath'] | '/' +export type RouteLeafPathByTrailingSlashOption = { + always: AddTrailingSlash + preserve: RemoveTrailingSlashes | AddTrailingSlash + never: RemoveTrailingSlashes +} + +export type TrailingSlashOptionByRouter = + TrailingSlashOption extends TRouter['options']['trailingSlash'] + ? 'never' + : NonNullable + +export type RouteLeafPath< + TRouter extends AnyRouter, + TFullPath extends string, +> = RouteLeafPathByTrailingSlashOption[TrailingSlashOptionByRouter] + +export type RouteLeafPaths< + TRouter extends AnyRouter, + TRouteTree extends AnyRoute, +> = RouteLeafPath['fullPath']> + +export type RouteLeavesByPath = { + [TRoute in ParseRouteLeaves as RouteLeafPath< + TRouter, + TRoute['fullPath'] + >]: TRoute +} & CatchAllPaths + +export type RouteLeafByPath = Extract< + string extends TPath + ? ParseRouteLeaves + : RouteLeavesByPath[TPath], + AnyRoute +> + export type RoutePathsAutoComplete = | (string extends T ? T & {} : T) | RoutePaths diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index c60b97a5d67..47633df0f4e 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -87,7 +87,7 @@ import type { DeferredPromiseState } from './defer' declare global { interface Window { __TSR_DEHYDRATED__?: { data: string } - __TSR_ROUTER_CONTEXT__?: React.Context> + __TSR_ROUTER_CONTEXT__?: React.Context> } } @@ -95,7 +95,7 @@ export interface Register { // router: Router } -export type AnyRouter = Router +export type AnyRouter = Router export type RegisteredRouter = Register extends { router: infer TRouter extends AnyRouter @@ -117,8 +117,11 @@ export type RouterContextOptions = context: TRouteTree['types']['routerContext'] } +export type TrailingSlashOption = 'always' | 'never' | 'preserve' + export interface RouterOptions< TRouteTree extends AnyRoute, + TTrailingSlashOption extends TrailingSlashOption, TDehydrated extends Record = Record, TSerializedError extends Record = Record, > { @@ -157,7 +160,7 @@ export interface RouterOptions< defaultNotFoundComponent?: NotFoundRouteComponent transformer?: RouterTransformer errorSerializer?: RouterErrorSerializer - trailingSlash?: 'always' | 'never' | 'preserve' + trailingSlash?: TTrailingSlashOption } export interface RouterTransformer { @@ -220,9 +223,18 @@ export interface DehydratedRouter { export type RouterConstructorOptions< TRouteTree extends AnyRoute, + TTrailingSlashOption extends TrailingSlashOption, TDehydrated extends Record, TSerializedError extends Record, -> = Omit, 'context'> & +> = Omit< + RouterOptions< + TRouteTree, + TTrailingSlashOption, + TDehydrated, + TSerializedError + >, + 'context' +> & RouterContextOptions export const componentTypes = [ @@ -261,17 +273,29 @@ export type RouterListener = { } export function createRouter< - TRouteTree extends AnyRoute = AnyRoute, + TRouteTree extends AnyRoute, + TTrailingSlashOption extends TrailingSlashOption, TDehydrated extends Record = Record, TSerializedError extends Record = Record, >( - options: RouterConstructorOptions, + options: RouterConstructorOptions< + TRouteTree, + TTrailingSlashOption, + TDehydrated, + TSerializedError + >, ) { - return new Router(options) + return new Router< + TRouteTree, + TTrailingSlashOption, + TDehydrated, + TSerializedError + >(options) } export class Router< - in out TRouteTree extends AnyRoute = AnyRoute, + in out TRouteTree extends AnyRoute, + in out TTrailingSlashOption extends TrailingSlashOption, in out TDehydrated extends Record = Record, in out TSerializedError extends Record = Record, > { @@ -292,7 +316,12 @@ export class Router< __store!: Store> options!: PickAsRequired< Omit< - RouterOptions, + RouterOptions< + TRouteTree, + TTrailingSlashOption, + TDehydrated, + TSerializedError + >, 'transformer' > & { transformer: RouterTransformer @@ -313,6 +342,7 @@ export class Router< constructor( options: RouterConstructorOptions< TRouteTree, + TTrailingSlashOption, TDehydrated, TSerializedError >, @@ -343,6 +373,7 @@ export class Router< update = ( newOptions: RouterConstructorOptions< TRouteTree, + TTrailingSlashOption, TDehydrated, TSerializedError >, @@ -1843,7 +1874,13 @@ export class Router< TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', >( - opts: NavigateOptions, + opts: NavigateOptions< + Router, + TFrom, + TTo, + TMaskFrom, + TMaskTo + >, ): Promise | undefined> => { const next = this.buildLocation(opts as any) @@ -1916,7 +1953,11 @@ export class Router< TTo extends string = '', TResolved = ResolveRelativePath>, >( - location: ToOptions, + location: ToOptions< + Router, + TFrom, + TTo + >, opts?: MatchRouteOptions, ): false | RouteById['types']['allParams'] => { const matchLocation = { diff --git a/packages/react-router/src/routerContext.tsx b/packages/react-router/src/routerContext.tsx index b5c5b7deadb..c6fd19bc2d7 100644 --- a/packages/react-router/src/routerContext.tsx +++ b/packages/react-router/src/routerContext.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import type { Router } from './router' -const routerContext = React.createContext>(null!) +const routerContext = React.createContext>(null!) export function getRouterContext() { if (typeof document === 'undefined') { diff --git a/packages/react-router/src/useNavigate.tsx b/packages/react-router/src/useNavigate.tsx index f207e7f6942..d60627ba03a 100644 --- a/packages/react-router/src/useNavigate.tsx +++ b/packages/react-router/src/useNavigate.tsx @@ -3,20 +3,19 @@ import { useMatch } from './Matches' import { useRouter } from './useRouter' import type { NavigateOptions } from './link' -import type { AnyRoute } from './route' import type { RoutePaths, RoutePathsAutoComplete } from './routeInfo' -import type { RegisteredRouter } from './router' +import type { AnyRouter, RegisteredRouter } from './router' export type UseNavigateResult = < TTo extends string, - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths | string = TDefaultFrom, - TMaskFrom extends RoutePaths | string = TFrom, + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths | string = TDefaultFrom, + TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', >({ from, ...rest -}: NavigateOptions) => Promise +}: NavigateOptions) => Promise export function useNavigate< TDefaultFrom extends string = string, @@ -52,12 +51,12 @@ export function useNavigate< // } // export function Navigate< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths | string = string, + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths | string = string, TTo extends string = '', - TMaskFrom extends RoutePaths | string = TFrom, + TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', ->(props: NavigateOptions): null { +>(props: NavigateOptions): null { const { navigate } = useRouter() const match = useMatch({ strict: false }) diff --git a/packages/react-router/src/useRouter.tsx b/packages/react-router/src/useRouter.tsx index 52f4e1ab78c..f179baa5df3 100644 --- a/packages/react-router/src/useRouter.tsx +++ b/packages/react-router/src/useRouter.tsx @@ -1,12 +1,11 @@ import * as React from 'react' import warning from 'tiny-warning' import { getRouterContext } from './routerContext' -import type { AnyRoute } from './route' -import type { RegisteredRouter, Router } from './router' +import type { AnyRouter, RegisteredRouter, Router } from './router' -export function useRouter< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], ->(opts?: { warn?: boolean }): Router { +export function useRouter(opts?: { + warn?: boolean +}): TRouter { const value = React.useContext(getRouterContext()) warning( !((opts?.warn ?? true) && !value), diff --git a/packages/react-router/src/useRouterState.tsx b/packages/react-router/src/useRouterState.tsx index 5493fd94dcf..b27ae7d08f9 100644 --- a/packages/react-router/src/useRouterState.tsx +++ b/packages/react-router/src/useRouterState.tsx @@ -1,16 +1,15 @@ import { useStore } from '@tanstack/react-store' import { useRouter } from './useRouter' -import type { AnyRoute } from './route' -import type { RegisteredRouter, Router, RouterState } from './router' +import type { AnyRouter, RegisteredRouter, Router, RouterState } from './router' export function useRouterState< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TSelected = RouterState, + TRouter extends AnyRouter = RegisteredRouter, + TSelected = RouterState, >(opts?: { - router?: Router + router?: TRouter select: (state: RouterState) => TSelected }): TSelected { - const contextRouter = useRouter({ + const contextRouter = useRouter({ warn: opts?.router === undefined, }) return useStore((opts?.router || contextRouter).__store, opts?.select as any) diff --git a/packages/react-router/tests/link.test-d.tsx b/packages/react-router/tests/link.test-d.tsx index a542599d12f..68b0bf8282a 100644 --- a/packages/react-router/tests/link.test-d.tsx +++ b/packages/react-router/tests/link.test-d.tsx @@ -1,5 +1,5 @@ import { expectTypeOf, test } from 'vitest' -import { Link, createRoute } from '../src' +import { Link, createRoute, createRouter } from '../src' import { createRootRoute } from '../src' const rootRoute = createRootRoute() @@ -68,10 +68,99 @@ const routeTree = rootRoute.addChildren([ indexRoute, ]) -type RouteTree = typeof routeTree +const defaultRouter = createRouter({ + routeTree, +}) + +const routerAlwaysTrailingSlashes = createRouter({ + routeTree, + trailingSlash: 'always', +}) + +const routerNeverTrailingSlashes = createRouter({ + routeTree, + trailingSlash: 'never', +}) + +const routerPreserveTrailingSlashes = createRouter({ + routeTree, + trailingSlash: 'preserve', +}) + +type DefaultRouter = typeof defaultRouter + +type RouterAlwaysTrailingSlashes = typeof routerAlwaysTrailingSlashes + +type RouterNeverTrailingSlashes = typeof routerNeverTrailingSlashes + +type RouterPreserveTrailingSlashes = typeof routerPreserveTrailingSlashes test('when navigating to the root', () => { - expectTypeOf(Link) + type hi = DefaultRouter['options']['trailingSlash'] + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../' + | './' + | '' + | '/' + | '/invoices' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/edit' + | '/posts' + | '/posts/$postId' + | 'invoices' + | 'invoices/$invoiceId/details/$detailId' + | 'invoices/$invoiceId/edit' + | 'posts' + | 'posts/$postId' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../' + | './' + | '' + | '/' + | '/invoices/' + | '/invoices/$invoiceId/details/$detailId/' + | '/invoices/$invoiceId/edit/' + | '/posts/' + | '/posts/$postId/' + | 'invoices/' + | 'invoices/$invoiceId/details/$detailId/' + | 'invoices/$invoiceId/edit/' + | 'posts/' + | 'posts/$postId/' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../' + | './' + | '' + | '/' + | '/invoices' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/edit' + | '/posts' + | '/posts/$postId' + | 'invoices' + | 'invoices/$invoiceId/details/$detailId' + | 'invoices/$invoiceId/edit' + | 'posts' + | 'posts/$postId' + | undefined + >() + + expectTypeOf(Link) .parameter(0) .toHaveProperty('to') .toEqualTypeOf< @@ -79,22 +168,83 @@ test('when navigating to the root', () => { | './' | '' | '/' + | '/invoices' | '/invoices/' | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/' | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/edit/' + | '/posts' | '/posts/' | '/posts/$postId' + | '/posts/$postId/' + | 'invoices' | 'invoices/' | 'invoices/$invoiceId/details/$detailId' + | 'invoices/$invoiceId/details/$detailId/' | 'invoices/$invoiceId/edit' + | 'invoices/$invoiceId/edit/' + | 'posts' | 'posts/' | 'posts/$postId' + | 'posts/$postId/' | undefined >() }) test('when navigating from a route with no params and no search to the root', () => { - expectTypeOf(Link) + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../' + | './' + | '/' + | '' + | '/invoices' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details/$detailId' + | '/posts' + | '/posts/$postId' + | '$postId' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../' + | './' + | '/' + | '' + | '/invoices/' + | '/invoices/$invoiceId/edit/' + | '/invoices/$invoiceId/details/$detailId/' + | '/posts/' + | '/posts/$postId/' + | '$postId/' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../' + | './' + | '/' + | '' + | '/invoices' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details/$detailId' + | '/posts' + | '/posts/$postId' + | '$postId' + | undefined + >() + + expectTypeOf(Link) .parameter(0) .toHaveProperty('to') .toEqualTypeOf< @@ -103,31 +253,96 @@ test('when navigating from a route with no params and no search to the root', () | '/' | '' | '/invoices/' + | '/invoices' + | '/invoices/$invoiceId/edit/' | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details/$detailId/' | '/invoices/$invoiceId/details/$detailId' | '/posts/' + | '/posts' + | '/posts/$postId/' | '/posts/$postId' + | '$postId/' | '$postId' | undefined >() }) test('when navigating from a route with no params and no search to the current route', () => { - expectTypeOf(Link) + expectTypeOf(Link) .parameter(0) .toHaveProperty('to') .toEqualTypeOf<'./$postId' | undefined | './'>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf<'./$postId/' | undefined | './'>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf<'./$postId' | undefined | './'>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf<'./$postId/' | './$postId' | undefined | './'>() }) test('when navigating from a route with no params and no search to the parent route', () => { - expectTypeOf(Link) + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../posts' + | '../posts/$postId' + | '../invoices/$invoiceId/edit' + | '../invoices/$invoiceId/details/$detailId' + | '../invoices' + | '../' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../posts/' + | '../posts/$postId/' + | '../invoices/$invoiceId/edit/' + | '../invoices/$invoiceId/details/$detailId/' + | '../invoices/' + | '../' + | undefined + >() + + expectTypeOf(Link) .parameter(0) .toHaveProperty('to') .toEqualTypeOf< + | '../posts' + | '../posts/$postId' + | '../invoices/$invoiceId/edit' + | '../invoices/$invoiceId/details/$detailId' + | '../invoices' + | '../' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../posts' | '../posts/' | '../posts/$postId' + | '../posts/$postId/' | '../invoices/$invoiceId/edit' + | '../invoices/$invoiceId/edit/' | '../invoices/$invoiceId/details/$detailId' + | '../invoices/$invoiceId/details/$detailId/' + | '../invoices' | '../invoices/' | '../' | undefined @@ -135,30 +350,52 @@ test('when navigating from a route with no params and no search to the parent ro }) test('cannot navigate to a branch', () => { - expectTypeOf(Link) + expectTypeOf(Link) .parameter(0) .toHaveProperty('to') .toEqualTypeOf< | '' - | '../' + | 'posts' + | '/posts' + | '/posts/$postId' + | 'posts/$postId' + | 'invoices' + | '/invoices' + | '/invoices/$invoiceId/edit' + | 'invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details/$detailId' + | 'invoices/$invoiceId/details/$detailId' | './' + | '../' + | undefined + >() + + expectTypeOf( + Link, + ) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< | '/' - | '/invoices/' - | '/invoices/$invoiceId/details/$detailId' - | '/invoices/$invoiceId/edit' + | '' + | 'posts/' | '/posts/' - | '/posts/$postId' + | '/posts/$postId/' + | 'posts/$postId/' | 'invoices/' - | 'invoices/$invoiceId/details/$detailId' - | 'invoices/$invoiceId/edit' - | 'posts/' - | 'posts/$postId' + | '/invoices/' + | '/invoices/$invoiceId/edit/' + | 'invoices/$invoiceId/edit/' + | '/invoices/$invoiceId/details/$detailId/' + | 'invoices/$invoiceId/details/$detailId/' + | './' + | '../' | undefined >() }) test('from autocompletes to all absolute routes', () => { - const TestLink = Link + const TestLink = Link expectTypeOf(TestLink) .parameter(0) .toHaveProperty('from') @@ -179,7 +416,7 @@ test('from autocompletes to all absolute routes', () => { }) test('when navigating to the same route', () => { - const TestLink = Link + const TestLink = Link expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() @@ -205,7 +442,7 @@ test('when navigating to the same route', () => { }) test('when navigating to the parent route', () => { - const TestLink = Link + const TestLink = Link expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() expectTypeOf(TestLink) @@ -224,7 +461,7 @@ test('when navigating to the parent route', () => { }) test('when navigating from a route with params to the same route', () => { - const TestLink = Link + const TestLink = Link expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() expectTypeOf(TestLink) @@ -235,7 +472,7 @@ test('when navigating from a route with params to the same route', () => { }) test('when navigating to a route with params', () => { - const TestLink = Link + const TestLink = Link expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ params: unknown }>() @@ -255,7 +492,7 @@ test('when navigating to a route with params', () => { }) test('when navigating from a route with no params to a route with params', () => { - const TestLink = Link + const TestLink = Link expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ params: unknown }>() @@ -268,7 +505,7 @@ test('when navigating from a route with no params to a route with params', () => }) test('when navigating from a route to a route with the same params', () => { - const TestLink = Link + const TestLink = Link const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('params') expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() @@ -283,7 +520,7 @@ test('when navigating from a route to a route with the same params', () => { test('when navigating from a route with params to a route with different params', () => { const TestLink = Link< - RouteTree, + DefaultRouter, '/invoices/$invoiceId', '../../posts/$postId' > @@ -299,7 +536,7 @@ test('when navigating from a route with params to a route with different params' test('when navigating from a route with params to a route with an additional param', () => { const TestLink = Link< - RouteTree, + DefaultRouter, '/invoices/$invoiceId', './details/$detailId' > @@ -317,9 +554,9 @@ test('when navigating from a route with params to a route with an additional par test('when navigating to a union of routes with params', () => { const TestLink = Link< - RouteTree, + DefaultRouter, string, - '/invoices/$invoiceId/' | '/posts/$postId/' + '/invoices/$invoiceId/edit' | '/posts/$postId' > const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('params') @@ -343,9 +580,9 @@ test('when navigating to a union of routes with params', () => { test('when navigating to a union of routes including the root', () => { const TestLink = Link< - RouteTree, + DefaultRouter, string, - '/' | '/invoices/$invoiceId/' | '/posts/$postId/' + '/' | '/invoices/$invoiceId/edit' | '/posts/$postId' > const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('params') @@ -372,7 +609,7 @@ test('when navigating to a union of routes including the root', () => { }) test('when navigating from a route with search params to the same route', () => { - const TestLink = Link + const TestLink = Link expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ search: unknown }>() expectTypeOf(TestLink) @@ -383,7 +620,7 @@ test('when navigating from a route with search params to the same route', () => }) test('when navigating to a route with search params', () => { - const TestLink = Link + const TestLink = Link const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('search') expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ search: unknown }>() @@ -394,7 +631,11 @@ test('when navigating to a route with search params', () => { }) test('when navigating to a route with optional search params', () => { - const TestLink = Link + const TestLink = Link< + DefaultRouter, + string, + '/invoices/$invoiceId/details/$detailId' + > const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('search') expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ search: unknown }>() @@ -408,7 +649,7 @@ test('when navigating to a route with optional search params', () => { }) test('when navigating from a route with no search params to a route with search params', () => { - const TestLink = Link + const TestLink = Link const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('search') expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ search: unknown }>() @@ -419,9 +660,9 @@ test('when navigating from a route with no search params to a route with search test('when navigating to a union of routes with search params', () => { const TestLink = Link< - RouteTree, + DefaultRouter, string, - '/invoices/$invoiceId/' | '/posts/$postId/' + '/invoices/$invoiceId/edit' | '/posts/$postId' > const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('search') @@ -438,9 +679,9 @@ test('when navigating to a union of routes with search params', () => { test('when navigating to a union of routes with search params including the root', () => { const TestLink = Link< - RouteTree, + DefaultRouter, string, - '/' | '/invoices/$invoiceId/' | '/posts/$postId/' + '/' | '/invoices/$invoiceId/edit' | '/posts/$postId' > const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('search') From c6b2e90e8c6e435b0904b73036346d51d7e6c7ee Mon Sep 17 00:00:00 2001 From: chorobin Date: Thu, 25 Apr 2024 16:30:20 +0200 Subject: [PATCH 2/2] fix: allow branches but not when there is an index route --- packages/react-router/src/Matches.tsx | 6 +- packages/react-router/src/link.tsx | 46 +- packages/react-router/src/redirects.ts | 4 +- packages/react-router/src/routeInfo.ts | 66 +- .../react-router/tests/Matches.test-d.tsx | 68 + .../tests/RouterProvider.test-d.tsx | 50 + packages/react-router/tests/link.test-d.tsx | 1831 +++++++++++++++-- .../react-router/tests/redirects.test.-d.tsx | 53 + .../react-router/tests/useNavigate.test-d.tsx | 55 + .../tests/useRouterState.test-d.tsx | 53 + 10 files changed, 2021 insertions(+), 211 deletions(-) create mode 100644 packages/react-router/tests/Matches.test-d.tsx create mode 100644 packages/react-router/tests/RouterProvider.test-d.tsx create mode 100644 packages/react-router/tests/redirects.test.-d.tsx create mode 100644 packages/react-router/tests/useNavigate.test-d.tsx create mode 100644 packages/react-router/tests/useRouterState.test-d.tsx diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 79c6f3f552d..7967685a3e0 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -451,11 +451,9 @@ export function useMatchRoute() { return React.useCallback( < - TFrom extends RoutePaths = RoutePaths< - TRouter['routeTree'] - >, + TFrom extends RoutePaths | string = string, TTo extends string = '', - TMaskFrom extends RoutePaths = TFrom, + TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', TResolved extends string = ResolveRelativePath>, >( diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 735de5490b2..008391f6397 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -10,10 +10,10 @@ import type { Trim } from './fileRoute' import type { AnyRoute, RootSearchSchema } from './route' import type { RouteByPath, - RouteLeafByPath, - RouteLeafPaths, + RouteByToPath, RoutePaths, RoutePathsAutoComplete, + RouteToPath, } from './routeInfo' import type { RegisteredRouter } from './router' import type { @@ -79,25 +79,17 @@ export type Last> = T extends [...infer _, infer L] ? L : never -export type AddTrailingSlash = T extends `${string}/` - ? T - : `${T}/` +export type RemoveTrailingSlashes = T extends `${infer R}/` ? R : T -export type RemoveTrailingSlashes = T extends `${infer R}/` - ? RemoveTrailingSlashes - : T - -export type RemoveLeadingSlashes = T extends `/${infer R}` - ? RemoveLeadingSlashes - : T +export type RemoveLeadingSlashes = T extends `/${infer R}` ? R : T export type ResolvePaths = RouteByPath< TRouter['routeTree'], RemoveTrailingSlashes > extends never - ? RouteLeafPaths - : RouteLeafPaths< + ? RouteToPath + : RouteToPath< TRouter, RouteByPath> > @@ -106,7 +98,7 @@ export type SearchPaths< TRouter extends AnyRouter, TSearchPath extends string, TPaths = ResolvePaths, -> = TPaths extends `${RemoveTrailingSlashes}/${infer TRest}` +> = TPaths extends `${RemoveTrailingSlashes}${infer TRest}` ? TRest : never @@ -114,7 +106,7 @@ export type SearchRelativePathAutoComplete< TRouter extends AnyRouter, TTo extends string, TSearchPath extends string, -> = `${TTo}/${SearchPaths}` +> = `${TTo}/${RemoveLeadingSlashes>}` export type RelativeToParentPathAutoComplete< TRouter extends AnyRouter, @@ -148,8 +140,12 @@ export type AbsolutePathAutoComplete< ? never : './') | (string extends TFrom ? '../' : TFrom extends `/` ? never : '../') - | RouteLeafPaths - | (TFrom extends '/' ? never : SearchPaths) + | RouteToPath + | (TFrom extends '/' + ? never + : string extends TFrom + ? RemoveLeadingSlashes> + : RemoveLeadingSlashes>) export type RelativeToPathAutoComplete< TRouter extends AnyRouter, @@ -229,17 +225,15 @@ export type ResolveRoute< TRouter extends AnyRouter, TFrom, TTo, - TPath = RemoveTrailingSlashes< - string extends TFrom - ? TTo - : string extends TTo - ? TFrom - : ResolveRelativePath - >, + TPath = string extends TFrom + ? TTo + : string extends TTo + ? TFrom + : ResolveRelativePath, > = TPath extends string ? string extends TTo ? RouteByPath - : RouteLeafByPath + : RouteByToPath : never type PostProcessParams< diff --git a/packages/react-router/src/redirects.ts b/packages/react-router/src/redirects.ts index 3de9e7e394c..7c24098c41a 100644 --- a/packages/react-router/src/redirects.ts +++ b/packages/react-router/src/redirects.ts @@ -38,9 +38,9 @@ export type ResolvedRedirect< export function redirect< TRouter extends AnyRouter = RegisteredRouter, - TFrom extends RoutePaths = '/', + TFrom extends RoutePaths | string = string, TTo extends string = '', - TMaskFrom extends RoutePaths = TFrom, + TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', >( opts: Redirect, diff --git a/packages/react-router/src/routeInfo.ts b/packages/react-router/src/routeInfo.ts index 8a1008812e7..91d8edf668e 100644 --- a/packages/react-router/src/routeInfo.ts +++ b/packages/react-router/src/routeInfo.ts @@ -1,4 +1,3 @@ -import type { AddTrailingSlash, RemoveTrailingSlashes } from './link' import type { AnyRoute } from './route' import type { AnyRouter, Router, TrailingSlashOption } from './router' import type { UnionToIntersection, UnionToTuple } from './utils' @@ -11,11 +10,13 @@ export type ParseRoute = TRouteTree extends { : TAcc : TAcc -export type ParseRouteLeaves = +export type ParseRouteWithoutBranches = ParseRoute extends infer TRoute extends AnyRoute ? TRoute extends any ? TRoute['types']['children'] extends ReadonlyArray - ? never + ? '/' extends TRoute['types']['children'][number]['path'] + ? never + : TRoute : TRoute : never : never @@ -32,7 +33,7 @@ export type RouteById = Extract< export type RouteIds = ParseRoute['id'] export type CatchAllPaths = Record< - '.' | '..', + '.' | '..' | '', ParseRoute > @@ -51,10 +52,30 @@ export type RoutePaths = | ParseRoute['fullPath'] | '/' -export type RouteLeafPathByTrailingSlashOption = { - always: AddTrailingSlash - preserve: RemoveTrailingSlashes | AddTrailingSlash - never: RemoveTrailingSlashes +export type RouteToPathAlwaysTrailingSlash = + TRoute['path'] extends '/' + ? TRoute['fullPath'] + : TRoute['fullPath'] extends '/' + ? TRoute['fullPath'] + : `${TRoute['fullPath']}/` + +export type RouteToPathNeverTrailingSlash = + TRoute['path'] extends '/' + ? TRoute['fullPath'] extends '/' + ? TRoute['fullPath'] + : TRoute['fullPath'] extends `${infer TRest}/` + ? TRest + : TRoute['fullPath'] + : TRoute['fullPath'] + +export type RouteToPathPreserveTrailingSlash = + | RouteToPathNeverTrailingSlash + | RouteToPathAlwaysTrailingSlash + +export type RouteToPathByTrailingSlashOption = { + always: RouteToPathAlwaysTrailingSlash + preserve: RouteToPathPreserveTrailingSlash + never: RouteToPathNeverTrailingSlash } export type TrailingSlashOptionByRouter = @@ -62,27 +83,32 @@ export type TrailingSlashOptionByRouter = ? 'never' : NonNullable -export type RouteLeafPath< +export type RouteToByRouter< TRouter extends AnyRouter, - TFullPath extends string, -> = RouteLeafPathByTrailingSlashOption[TrailingSlashOptionByRouter] + TRoute extends AnyRoute, +> = RouteToPathByTrailingSlashOption[TrailingSlashOptionByRouter] -export type RouteLeafPaths< +export type RouteToPath< TRouter extends AnyRouter, TRouteTree extends AnyRoute, -> = RouteLeafPath['fullPath']> +> = + ParseRouteWithoutBranches extends infer TRoute extends AnyRoute + ? TRoute extends any + ? RouteToByRouter + : never + : never -export type RouteLeavesByPath = { - [TRoute in ParseRouteLeaves as RouteLeafPath< +export type RoutesByToPath = { + [TRoute in ParseRouteWithoutBranches as RouteToByRouter< TRouter, - TRoute['fullPath'] + TRoute >]: TRoute } & CatchAllPaths -export type RouteLeafByPath = Extract< - string extends TPath - ? ParseRouteLeaves - : RouteLeavesByPath[TPath], +export type RouteByToPath = Extract< + string extends TTo + ? ParseRouteWithoutBranches + : RoutesByToPath[TTo], AnyRoute > diff --git a/packages/react-router/tests/Matches.test-d.tsx b/packages/react-router/tests/Matches.test-d.tsx new file mode 100644 index 00000000000..ed437d30eff --- /dev/null +++ b/packages/react-router/tests/Matches.test-d.tsx @@ -0,0 +1,68 @@ +import { expectTypeOf, test } from 'vitest' +import { + createRootRoute, + createRoute, + createRouter, + useMatchRoute, +} from '../src' + +const rootRoute = createRootRoute() + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', +}) + +const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', +}) + +const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', +}) + +const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateSearch: () => ({ page: 0 }), +}) + +const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, +]) + +const defaultRouter = createRouter({ + routeTree, +}) + +type DefaultRouter = typeof defaultRouter + +const useDefaultMatchRoute = useMatchRoute + +test('when matching a route with params', () => { + const matchRoute = useDefaultMatchRoute() + + expectTypeOf(matchRoute) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '' + | '/' + | './' + | '../' + | '/invoices' + | '/invoices/$invoiceId' + | 'invoices' + | 'invoices/$invoiceId' + | undefined + >() + + expectTypeOf( + matchRoute({ + to: '/invoices/$invoiceId', + }), + ).toEqualTypeOf() +}) diff --git a/packages/react-router/tests/RouterProvider.test-d.tsx b/packages/react-router/tests/RouterProvider.test-d.tsx new file mode 100644 index 00000000000..31c5d667382 --- /dev/null +++ b/packages/react-router/tests/RouterProvider.test-d.tsx @@ -0,0 +1,50 @@ +import { expectTypeOf, test } from 'vitest' +import { + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +const rootRoute = createRootRoute() + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', +}) + +const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', +}) + +const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', +}) + +const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateSearch: () => ({ page: 0 }), +}) + +const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, +]) + +const defaultRouter = createRouter({ + routeTree, +}) + +type DefaultRouter = typeof defaultRouter + +test('can pass default router to the provider', () => { + expectTypeOf(RouterProvider) + .parameter(0) + .toMatchTypeOf<{ + router: DefaultRouter + routeTree?: DefaultRouter['routeTree'] + }>() +}) diff --git a/packages/react-router/tests/link.test-d.tsx b/packages/react-router/tests/link.test-d.tsx index 68b0bf8282a..3ea44acf3e2 100644 --- a/packages/react-router/tests/link.test-d.tsx +++ b/packages/react-router/tests/link.test-d.tsx @@ -96,7 +96,6 @@ type RouterNeverTrailingSlashes = typeof routerNeverTrailingSlashes type RouterPreserveTrailingSlashes = typeof routerPreserveTrailingSlashes test('when navigating to the root', () => { - type hi = DefaultRouter['options']['trailingSlash'] expectTypeOf(Link) .parameter(0) .toHaveProperty('to') @@ -106,12 +105,16 @@ test('when navigating to the root', () => { | '' | '/' | '/invoices' + | '/invoices/$invoiceId' | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details' | '/invoices/$invoiceId/edit' | '/posts' | '/posts/$postId' | 'invoices' + | 'invoices/$invoiceId' | 'invoices/$invoiceId/details/$detailId' + | 'invoices/$invoiceId/details' | 'invoices/$invoiceId/edit' | 'posts' | 'posts/$postId' @@ -127,12 +130,16 @@ test('when navigating to the root', () => { | '' | '/' | '/invoices/' + | '/invoices/$invoiceId/' | '/invoices/$invoiceId/details/$detailId/' + | '/invoices/$invoiceId/details/' | '/invoices/$invoiceId/edit/' | '/posts/' | '/posts/$postId/' | 'invoices/' + | 'invoices/$invoiceId/' | 'invoices/$invoiceId/details/$detailId/' + | 'invoices/$invoiceId/details/' | 'invoices/$invoiceId/edit/' | 'posts/' | 'posts/$postId/' @@ -148,12 +155,16 @@ test('when navigating to the root', () => { | '' | '/' | '/invoices' + | '/invoices/$invoiceId' | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details' | '/invoices/$invoiceId/edit' | '/posts' | '/posts/$postId' | 'invoices' + | 'invoices/$invoiceId' | 'invoices/$invoiceId/details/$detailId' + | 'invoices/$invoiceId/details' | 'invoices/$invoiceId/edit' | 'posts' | 'posts/$postId' @@ -170,8 +181,12 @@ test('when navigating to the root', () => { | '/' | '/invoices' | '/invoices/' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/' | '/invoices/$invoiceId/details/$detailId' | '/invoices/$invoiceId/details/$detailId/' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/' | '/invoices/$invoiceId/edit' | '/invoices/$invoiceId/edit/' | '/posts' @@ -180,8 +195,12 @@ test('when navigating to the root', () => { | '/posts/$postId/' | 'invoices' | 'invoices/' + | 'invoices/$invoiceId' + | 'invoices/$invoiceId/' | 'invoices/$invoiceId/details/$detailId' | 'invoices/$invoiceId/details/$detailId/' + | 'invoices/$invoiceId/details' + | 'invoices/$invoiceId/details/' | 'invoices/$invoiceId/edit' | 'invoices/$invoiceId/edit/' | 'posts' @@ -193,7 +212,7 @@ test('when navigating to the root', () => { }) test('when navigating from a route with no params and no search to the root', () => { - expectTypeOf(Link) + expectTypeOf(Link) .parameter(0) .toHaveProperty('to') .toEqualTypeOf< @@ -202,7 +221,9 @@ test('when navigating from a route with no params and no search to the root', () | '/' | '' | '/invoices' + | '/invoices/$invoiceId' | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details' | '/invoices/$invoiceId/details/$detailId' | '/posts' | '/posts/$postId' @@ -219,7 +240,9 @@ test('when navigating from a route with no params and no search to the root', () | '/' | '' | '/invoices/' + | '/invoices/$invoiceId/' | '/invoices/$invoiceId/edit/' + | '/invoices/$invoiceId/details/' | '/invoices/$invoiceId/details/$detailId/' | '/posts/' | '/posts/$postId/' @@ -236,7 +259,9 @@ test('when navigating from a route with no params and no search to the root', () | '/' | '' | '/invoices' + | '/invoices/$invoiceId' | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details' | '/invoices/$invoiceId/details/$detailId' | '/posts' | '/posts/$postId' @@ -248,22 +273,27 @@ test('when navigating from a route with no params and no search to the root', () .parameter(0) .toHaveProperty('to') .toEqualTypeOf< + | '../' | '../' | './' | '/' | '' - | '/invoices/' | '/invoices' - | '/invoices/$invoiceId/edit/' + | '/invoices/' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/' | '/invoices/$invoiceId/edit' - | '/invoices/$invoiceId/details/$detailId/' + | '/invoices/$invoiceId/edit/' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/' | '/invoices/$invoiceId/details/$detailId' - | '/posts/' + | '/invoices/$invoiceId/details/$detailId/' | '/posts' - | '/posts/$postId/' + | '/posts/' | '/posts/$postId' - | '$postId/' + | '/posts/$postId/' | '$postId' + | '$postId/' | undefined >() }) @@ -297,7 +327,9 @@ test('when navigating from a route with no params and no search to the parent ro .toEqualTypeOf< | '../posts' | '../posts/$postId' + | '../invoices/$invoiceId' | '../invoices/$invoiceId/edit' + | '../invoices/$invoiceId/details' | '../invoices/$invoiceId/details/$detailId' | '../invoices' | '../' @@ -310,7 +342,9 @@ test('when navigating from a route with no params and no search to the parent ro .toEqualTypeOf< | '../posts/' | '../posts/$postId/' + | '../invoices/$invoiceId/' | '../invoices/$invoiceId/edit/' + | '../invoices/$invoiceId/details/' | '../invoices/$invoiceId/details/$detailId/' | '../invoices/' | '../' @@ -323,7 +357,9 @@ test('when navigating from a route with no params and no search to the parent ro .toEqualTypeOf< | '../posts' | '../posts/$postId' + | '../invoices/$invoiceId' | '../invoices/$invoiceId/edit' + | '../invoices/$invoiceId/details' | '../invoices/$invoiceId/details/$detailId' | '../invoices' | '../' @@ -338,8 +374,12 @@ test('when navigating from a route with no params and no search to the parent ro | '../posts/' | '../posts/$postId' | '../posts/$postId/' + | '../invoices/$invoiceId' + | '../invoices/$invoiceId/' | '../invoices/$invoiceId/edit' | '../invoices/$invoiceId/edit/' + | '../invoices/$invoiceId/details' + | '../invoices/$invoiceId/details/' | '../invoices/$invoiceId/details/$detailId' | '../invoices/$invoiceId/details/$detailId/' | '../invoices' @@ -355,15 +395,20 @@ test('cannot navigate to a branch', () => { .toHaveProperty('to') .toEqualTypeOf< | '' + | '/' | 'posts' | '/posts' | '/posts/$postId' | 'posts/$postId' | 'invoices' | '/invoices' + | '/invoices/$invoiceId' | '/invoices/$invoiceId/edit' + | 'invoices/$invoiceId' | 'invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details' | '/invoices/$invoiceId/details/$detailId' + | 'invoices/$invoiceId/details' | 'invoices/$invoiceId/details/$detailId' | './' | '../' @@ -376,17 +421,87 @@ test('cannot navigate to a branch', () => { .parameter(0) .toHaveProperty('to') .toEqualTypeOf< + | '' + | '/' + | 'posts/' + | '/posts/' + | '/posts/$postId/' + | 'posts/$postId/' + | 'invoices/' + | '/invoices/' + | '/invoices/$invoiceId/' + | '/invoices/$invoiceId/edit/' + | 'invoices/$invoiceId/' + | 'invoices/$invoiceId/edit/' + | '/invoices/$invoiceId/details/' + | '/invoices/$invoiceId/details/$detailId/' + | 'invoices/$invoiceId/details/' + | 'invoices/$invoiceId/details/$detailId/' + | './' + | '../' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '' | '/' + | 'posts' + | '/posts' + | '/posts/$postId' + | 'posts/$postId' + | 'invoices' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/edit' + | 'invoices/$invoiceId' + | 'invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/$detailId' + | 'invoices/$invoiceId/details' + | 'invoices/$invoiceId/details/$detailId' + | './' + | '../' + | undefined + >() + + expectTypeOf( + Link, + ) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< | '' + | '/' + | 'posts' | 'posts/' + | '/posts' | '/posts/' + | '/posts/$postId' | '/posts/$postId/' + | 'posts/$postId' | 'posts/$postId/' + | 'invoices' | 'invoices/' + | '/invoices' | '/invoices/' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/' + | '/invoices/$invoiceId/edit' | '/invoices/$invoiceId/edit/' + | 'invoices/$invoiceId' + | 'invoices/$invoiceId/' + | 'invoices/$invoiceId/edit' | 'invoices/$invoiceId/edit/' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/' + | '/invoices/$invoiceId/details/$detailId' | '/invoices/$invoiceId/details/$detailId/' + | 'invoices/$invoiceId/details' + | 'invoices/$invoiceId/details/' + | 'invoices/$invoiceId/details/$detailId' | 'invoices/$invoiceId/details/$detailId/' | './' | '../' @@ -416,281 +531,1679 @@ test('from autocompletes to all absolute routes', () => { }) test('when navigating to the same route', () => { - const TestLink = Link + const DefaultRouterLink = Link + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + string, + string + > + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + string + > + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + string, + string + > - expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() - expectTypeOf(TestLink) + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(DefaultRouterLink) .parameter(0) .toHaveProperty('params') .extract() .toEqualTypeOf() - expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ search: unknown }>() + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() - expectTypeOf(TestLink) + expectTypeOf(RouterNeverTrailingSlashesLink) .parameter(0) - .toHaveProperty('search') + .toHaveProperty('params') .extract() .toEqualTypeOf() - expectTypeOf(TestLink) + expectTypeOf(RouterPreserveTrailingSlashesLink) .parameter(0) - .toHaveProperty('search') + .toHaveProperty('params') .extract() .toEqualTypeOf() -}) -test('when navigating to the parent route', () => { - const TestLink = Link + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() - expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() - expectTypeOf(TestLink) + expectTypeOf(RouterAlwaysTrailingSlashesLink) .parameter(0) - .toHaveProperty('params') + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') .extract() .toEqualTypeOf() - expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ search: unknown }>() + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() - expectTypeOf(TestLink) + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterPreserveTrailingSlashesLink) .parameter(0) .toHaveProperty('search') .extract() .toEqualTypeOf() }) -test('when navigating from a route with params to the same route', () => { - const TestLink = Link +test('when navigating to the parent route', () => { + const DefaultRouterLink = Link + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + string, + '..' + > + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '..' + > + const RouterPreserveTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '..' + > - expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() - expectTypeOf(TestLink) + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(DefaultRouterLink) .parameter(0) .toHaveProperty('params') .extract() .toEqualTypeOf() -}) -test('when navigating to a route with params', () => { - const TestLink = Link + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() - expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ params: unknown }>() + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() - const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('params') + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() - params.exclude().toEqualTypeOf<{ postId: string }>() + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() - params.returns.toEqualTypeOf<{ postId: string }>() - params + expectTypeOf(RouterNeverTrailingSlashesLink) .parameter(0) - .toEqualTypeOf< - | {} - | { invoiceId: string } - | { postId: string } - | { invoiceId: string; detailId: string } - >() -}) + .not.toMatchTypeOf<{ search: unknown }>() -test('when navigating from a route with no params to a route with params', () => { - const TestLink = Link + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() - expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ params: unknown }>() + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() - const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('params') + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() - params.exclude().toEqualTypeOf<{ invoiceId: string }>() + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() - params.returns.toEqualTypeOf<{ invoiceId: string }>() - params.parameter(0).toEqualTypeOf<{}>() + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() }) -test('when navigating from a route to a route with the same params', () => { - const TestLink = Link - const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('params') - - expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() +test('when navigating from a route with params to the same route', () => { + const DefaultRouterLink = Link - params - .exclude() - .toEqualTypeOf<{ invoiceId?: string | undefined } | undefined>() + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + '/posts/$postId', + string + > - params.returns.toEqualTypeOf<{ invoiceId?: string | undefined }>() - params.parameter(0).toEqualTypeOf<{ invoiceId: string }>() -}) + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/posts/$postId', + string + > -test('when navigating from a route with params to a route with different params', () => { - const TestLink = Link< - DefaultRouter, - '/invoices/$invoiceId', - '../../posts/$postId' + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + '/posts/$postId', + string > - const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('params') - expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ params: unknown }>() + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() - params.exclude().toEqualTypeOf<{ postId: string }>() + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() - params.returns.toEqualTypeOf<{ postId: string }>() - params.parameter(0).toEqualTypeOf<{ invoiceId: string }>() -}) + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() -test('when navigating from a route with params to a route with an additional param', () => { - const TestLink = Link< - DefaultRouter, - '/invoices/$invoiceId', - './details/$detailId' - > - const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('params') + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() - expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ params: unknown }>() + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() - params - .exclude() - .toEqualTypeOf<{ invoiceId?: string | undefined; detailId: string }>() + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() - params.returns.toEqualTypeOf<{ invoiceId?: string; detailId: string }>() - params.parameter(0).toEqualTypeOf<{ invoiceId: string }>() + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() }) -test('when navigating to a union of routes with params', () => { - const TestLink = Link< - DefaultRouter, +test('when navigating to a route with params', () => { + const DefaultRouterLink = Link + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, string, - '/invoices/$invoiceId/edit' | '/posts/$postId' + '/posts/$postId/' > - const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('params') - expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '/posts/$postId' + > - params - .exclude() - .toEqualTypeOf<{ invoiceId: string } | { postId: string } | undefined>() + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + string, + '/posts/$postId/' | '/posts/$postId' + > - params.returns.toEqualTypeOf<{ invoiceId: string } | { postId: string }>() + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() - params + expectTypeOf(RouterAlwaysTrailingSlashesLink) .parameter(0) - .toEqualTypeOf< - | {} - | { invoiceId: string } - | { postId: string } - | { invoiceId: string; detailId: string } - >() -}) + .toMatchTypeOf<{ params: unknown }>() -test('when navigating to a union of routes including the root', () => { - const TestLink = Link< - DefaultRouter, - string, - '/' | '/invoices/$invoiceId/edit' | '/posts/$postId' - > - const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('params') + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() - expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ params: unknown }>() + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() - params + const defaultRouterLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const routerAlwaysTrailingSlashesLinkParams = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerNeverTrailingSlashesLinkParams = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerPreserveTrailingSlashesLinkParams = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + defaultRouterLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + routerAlwaysTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + routerNeverTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + routerPreserveTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + defaultRouterLinkParams.returns.toEqualTypeOf<{ postId: string }>() + + routerAlwaysTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + postId: string + }>() + + routerNeverTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + postId: string + }>() + + routerPreserveTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + postId: string + }>() + + defaultRouterLinkParams + .parameter(0) + .toEqualTypeOf< + | {} + | { invoiceId: string } + | { postId: string } + | { invoiceId: string; detailId: string } + >() + + routerAlwaysTrailingSlashesLinkParams + .parameter(0) + .toEqualTypeOf< + | {} + | { invoiceId: string } + | { postId: string } + | { invoiceId: string; detailId: string } + >() + + routerNeverTrailingSlashesLinkParams + .parameter(0) + .toEqualTypeOf< + | {} + | { invoiceId: string } + | { postId: string } + | { invoiceId: string; detailId: string } + >() + + routerPreserveTrailingSlashesLinkParams + .parameter(0) + .toEqualTypeOf< + | {} + | { invoiceId: string } + | { postId: string } + | { invoiceId: string; detailId: string } + >() +}) + +test('when navigating from a route with no params to a route with params', () => { + const DefaultRouterLink = Link< + DefaultRouter, + '/invoices', + './$invoiceId/edit' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + '/invoices', + './$invoiceId/edit/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices', + './$invoiceId/edit' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices', + './$invoiceId/edit' | './invoicesId/edit/' + > + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + const defaultRouterLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const routerAlwaysTrailingSlashesLinkParams = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerNeverTrailingSlashesLinkParams = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerPreserveTrailingSlashesLinkParams = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + defaultRouterLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string }>() + + routerAlwaysTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string }>() + + routerNeverTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string }>() + + routerPreserveTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string }>() + + defaultRouterLinkParams.returns.toEqualTypeOf<{ invoiceId: string }>() + + routerAlwaysTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId: string + }>() + + routerNeverTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId: string + }>() + + routerPreserveTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId: string + }>() + + defaultRouterLinkParams.parameter(0).toEqualTypeOf<{}>() + + routerAlwaysTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{}>() + + routerNeverTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{}>() + + routerPreserveTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{}>() +}) + +test('when navigating from a route to a route with the same params', () => { + const DefaultRouterLink = Link< + DefaultRouter, + '/invoices/$invoiceId', + './edit' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + '/invoices/$invoiceId', + './edit/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices/$invoiceId', + './edit' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices/$invoiceId', + './edit' | './edit/' + > + + const defaultRouterLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const routerAlwaysTrailingSlashesLinkParams = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerNeverTrailingSlashesLinkParams = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerPreserveTrailingSlashesLinkParams = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + defaultRouterLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined } | undefined>() + + routerAlwaysTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined } | undefined>() + + routerNeverTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined } | undefined>() + + routerPreserveTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined } | undefined>() + + defaultRouterLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string | undefined + }>() + + routerAlwaysTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string | undefined + }>() + + routerNeverTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string | undefined + }>() + + routerPreserveTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string | undefined + }>() + + defaultRouterLinkParams.parameter(0).toEqualTypeOf<{ invoiceId: string }>() + + routerAlwaysTrailingSlashesLinkParams + .parameter(0) + .toEqualTypeOf<{ invoiceId: string }>() + + routerNeverTrailingSlashesLinkParams + .parameter(0) + .toEqualTypeOf<{ invoiceId: string }>() + + routerPreserveTrailingSlashesLinkParams + .parameter(0) + .toEqualTypeOf<{ invoiceId: string }>() +}) + +test('when navigating from a route with params to a route with different params', () => { + const DefaultRouterLink = Link< + DefaultRouter, + '/invoices/$invoiceId', + '../../posts/$postId' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + '/invoices/$invoiceId', + '../../posts/$postId/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices/$invoiceId', + '../../posts/$postId' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + '/invoices/$invoiceId', + '../../posts/$postId' | '../../posts/$postId/' + > + + const defaultRouterLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const routerAlwaysTrailingSlashesLinkParams = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerNeverTrailingSlashesLinkParams = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerPreserveTrailingSlashesLinkParams = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + defaultRouterLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + routerAlwaysTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + routerNeverTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + routerPreserveTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + defaultRouterLinkParams.returns.toEqualTypeOf<{ postId: string }>() + + routerAlwaysTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + postId: string + }>() + + routerNeverTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + postId: string + }>() + + routerPreserveTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + postId: string + }>() + + defaultRouterLinkParams.parameter(0).toEqualTypeOf<{ invoiceId: string }>() + + routerAlwaysTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId: string + }>() + + routerNeverTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId: string + }>() + + routerPreserveTrailingSlashesLinkParams + .parameter(0) + .toEqualTypeOf<{ invoiceId: string }>() +}) + +test('when navigating from a route with params to a route with an additional param', () => { + const DefaultRouterLink = Link< + DefaultRouter, + '/invoices/$invoiceId', + './details/$detailId' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + '/invoices/$invoiceId', + './details/$detailId/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices/$invoiceId', + './details/$detailId' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + '/invoices/$invoiceId', + './details/$detailId' | './details/$detailId' + > + + const defaultRouterLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const routerAlwaysTrailingSlashesLinkParams = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerNeverTrailingSlashesLinkParams = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerPreserveTrailingSlashesLinkParams = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + defaultRouterLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined; detailId: string }>() + + routerAlwaysTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined; detailId: string }>() + + routerNeverTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined; detailId: string }>() + + routerPreserveTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined; detailId: string }>() + + defaultRouterLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string + detailId: string + }>() + + routerAlwaysTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string + detailId: string + }>() + + routerNeverTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string + detailId: string + }>() + + routerPreserveTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string + detailId: string + }>() +}) + +test('when navigating to a union of routes with params', () => { + const DefaultRouterLink = Link< + DefaultRouter, + string, + '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + string, + '/invoices/$invoiceId/edit/' | '/posts/$postId/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + string, + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/edit/' + | '/posts/$postId' + | '/posts/$postId/' + > + + const defaultRouterLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const routerAlwaysTrailingSlashesLinkParams = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerNeverTrailingSlashesLinkParams = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerPreserveTrailingSlashesLinkParams = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + defaultRouterLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string } | { postId: string } | undefined>() + + routerAlwaysTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string } | { postId: string } | undefined>() + + routerNeverTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string } | { postId: string } | undefined>() + + routerPreserveTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string } | { postId: string } | undefined>() + + defaultRouterLinkParams.returns.toEqualTypeOf< + { invoiceId: string } | { postId: string } + >() + + routerAlwaysTrailingSlashesLinkParams.returns.toEqualTypeOf< + { invoiceId: string } | { postId: string } + >() + + routerNeverTrailingSlashesLinkParams.returns.toEqualTypeOf< + { invoiceId: string } | { postId: string } + >() + + routerPreserveTrailingSlashesLinkParams.returns.toEqualTypeOf< + { invoiceId: string } | { postId: string } + >() + + defaultRouterLinkParams + .parameter(0) + .toEqualTypeOf< + | {} + | { invoiceId: string } + | { postId: string } + | { invoiceId: string; detailId: string } + >() + + routerAlwaysTrailingSlashesLinkParams + .parameter(0) + .toEqualTypeOf< + | {} + | { invoiceId: string } + | { postId: string } + | { invoiceId: string; detailId: string } + >() + + routerNeverTrailingSlashesLinkParams + .parameter(0) + .toEqualTypeOf< + | {} + | { invoiceId: string } + | { postId: string } + | { invoiceId: string; detailId: string } + >() + + routerPreserveTrailingSlashesLinkParams + .parameter(0) + .toEqualTypeOf< + | {} + | { invoiceId: string } + | { postId: string } + | { invoiceId: string; detailId: string } + >() +}) + +test('when navigating to a union of routes including the root', () => { + const DefaultRouterLink = Link< + DefaultRouter, + string, + '/' | '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + string, + '/' | '/invoices/$invoiceId/edit/' | '/posts/$postId/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '/' | '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + string, + | '/' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/edit/' + | '/posts/$postId' + | '/posts/$postId/' + > + + const defaultRouterLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const routerAlwaysTrailingSlashesLinkParams = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerNeverTrailingSlashesLinkParams = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerPreserveTrailingSlashesLinkParams = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + defaultRouterLinkParams + .exclude() + .toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} | undefined + >() + + routerAlwaysTrailingSlashesLinkParams .exclude() .toEqualTypeOf< { invoiceId: string } | { postId: string } | {} | undefined >() - params.returns.toEqualTypeOf< + routerNeverTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} | undefined + >() + + routerPreserveTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} | undefined + >() + + defaultRouterLinkParams.returns.toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} + >() + + routerAlwaysTrailingSlashesLinkParams.returns.toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} + >() + + routerNeverTrailingSlashesLinkParams.returns.toEqualTypeOf< { invoiceId: string } | { postId: string } | {} >() - params + routerPreserveTrailingSlashesLinkParams.returns.toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} + >() + + defaultRouterLinkParams + .parameter(0) + .toEqualTypeOf< + | {} + | { invoiceId: string } + | { postId: string } + | { invoiceId: string; detailId: string } + >() +}) + +test('when navigating from a route with search params to the same route', () => { + const DefaultRouterLink = Link + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + '/invoices/$invoiceId', + string + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices/$invoiceId', + string + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices/$invoiceId', + string + > + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() +}) + +test('when navigating to a route with search params', () => { + const DefaultRouterLink = Link< + DefaultRouter, + string, + '/invoices/$invoiceId/edit' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + string, + '/invoices/$invoiceId/edit/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '/invoices/$invoiceId/edit' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + string, + '/invoices/$invoiceId/edit' | '/invoices/$invoiceId/edit/' + > + + const defaultRouterLinkSearch = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + + const routerAlwaysTrailingSlashesLinkSearch = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) .parameter(0) - .toEqualTypeOf< - | {} - | { invoiceId: string } - | { postId: string } - | { invoiceId: string; detailId: string } - >() -}) + .toHaveProperty('search') -test('when navigating from a route with search params to the same route', () => { - const TestLink = Link + const routerNeverTrailingSlashesLinkSearch = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') - expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ search: unknown }>() - expectTypeOf(TestLink) + const routerPreserveTrailingSlashesLinkSearch = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) .parameter(0) .toHaveProperty('search') - .extract() - .toEqualTypeOf() -}) -test('when navigating to a route with search params', () => { - const TestLink = Link - const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('search') + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + defaultRouterLinkSearch + .exclude() + .toEqualTypeOf<{ page: number }>() + + routerAlwaysTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf<{ page: number }>() + + routerNeverTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf<{ page: number }>() - expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ search: unknown }>() + routerPreserveTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf<{ page: number }>() + + defaultRouterLinkSearch.returns.toEqualTypeOf<{ page: number }>() + + routerAlwaysTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ + page: number + }>() + + routerNeverTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ page: number }>() + + routerPreserveTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ + page: number + }>() + + defaultRouterLinkSearch + .parameter(0) + .toEqualTypeOf<{} | { page: number } | { page?: number }>() + + routerAlwaysTrailingSlashesLinkSearch + .parameter(0) + .toEqualTypeOf<{} | { page: number } | { page?: number }>() + + routerNeverTrailingSlashesLinkSearch + .parameter(0) + .toEqualTypeOf<{} | { page: number } | { page?: number }>() - params.exclude().toEqualTypeOf<{ page: number }>() - params.returns.toEqualTypeOf<{ page: number }>() - params.parameter(0).toEqualTypeOf<{} | { page: number } | { page?: number }>() + routerPreserveTrailingSlashesLinkSearch + .parameter(0) + .toEqualTypeOf<{} | { page: number } | { page?: number }>() }) test('when navigating to a route with optional search params', () => { - const TestLink = Link< + const DefaultRouterLink = Link< DefaultRouter, string, '/invoices/$invoiceId/details/$detailId' > - const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('search') - expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ search: unknown }>() + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + string, + '/invoices/$invoiceId/details/$detailId/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '/invoices/$invoiceId/details/$detailId' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + string, + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/' + > + + const defaultRouterLinkSearch = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + + const routerAlwaysTrailingSlashesLinkSearch = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerNeverTrailingSlashesLinkSearch = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerPreserveTrailingSlashesLinkSearch = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + defaultRouterLinkSearch + .exclude() + .toEqualTypeOf<{ page?: number | undefined } | undefined>() + + routerAlwaysTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf<{ page?: number | undefined } | undefined>() + + routerNeverTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf<{ page?: number | undefined } | undefined>() - params + routerPreserveTrailingSlashesLinkSearch .exclude() .toEqualTypeOf<{ page?: number | undefined } | undefined>() - params.returns.toEqualTypeOf<{ page?: number }>() - params.parameter(0).toEqualTypeOf<{} | { page: number } | { page?: number }>() + defaultRouterLinkSearch.returns.toEqualTypeOf<{ page?: number }>() + + routerAlwaysTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ + page?: number + }>() + + routerNeverTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ + page?: number + }>() + + routerPreserveTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ + page?: number + }>() + + defaultRouterLinkSearch + .parameter(0) + .toEqualTypeOf<{} | { page: number } | { page?: number }>() + + routerAlwaysTrailingSlashesLinkSearch + .parameter(0) + .toEqualTypeOf<{} | { page: number } | { page?: number }>() + + routerNeverTrailingSlashesLinkSearch + .parameter(0) + .toEqualTypeOf<{} | { page: number } | { page?: number }>() + + routerPreserveTrailingSlashesLinkSearch + .parameter(0) + .toEqualTypeOf<{} | { page: number } | { page?: number }>() }) test('when navigating from a route with no search params to a route with search params', () => { - const TestLink = Link - const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('search') + const DefaultRouterLink = Link< + DefaultRouter, + '/invoices/', + './$invoiceId/edit' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + '/invoices/', + './$invoiceId/edit/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices/', + './$invoiceId/edit' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + '/invoices/', + './$invoiceId/edit/' | './$invoiceId/edit' + > + + const defaultRouterLinkSearch = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + + const routerAlwaysTrailingSlashesLinkSearch = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerNeverTrailingSlashesLinkSearch = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerPreserveTrailingSlashesLinkSearch = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + defaultRouterLinkSearch + .exclude() + .toEqualTypeOf<{ page: number }>() + + routerAlwaysTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf<{ page: number }>() + + routerNeverTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf<{ page: number }>() + + defaultRouterLinkSearch.returns.toEqualTypeOf<{ page: number }>() + + routerAlwaysTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ + page: number + }>() + + routerNeverTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ + page: number + }>() - expectTypeOf(TestLink).parameter(0).toMatchTypeOf<{ search: unknown }>() - params.exclude().toEqualTypeOf<{ page: number }>() - params.returns.toEqualTypeOf<{ page: number }>() - params.parameter(0).toEqualTypeOf<{}>() + routerPreserveTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ + page: number + }>() + + defaultRouterLinkSearch.parameter(0).toEqualTypeOf<{}>() + + routerAlwaysTrailingSlashesLinkSearch.parameter(0).toEqualTypeOf<{}>() + + routerNeverTrailingSlashesLinkSearch.parameter(0).toEqualTypeOf<{}>() + + routerPreserveTrailingSlashesLinkSearch.parameter(0).toEqualTypeOf<{}>() }) test('when navigating to a union of routes with search params', () => { - const TestLink = Link< + const DefaultRouterLink = Link< DefaultRouter, string, '/invoices/$invoiceId/edit' | '/posts/$postId' > - const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('search') - expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ search: unknown }>() + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + string, + '/invoices/$invoiceId/edit/' | '/posts/$postId/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + string, + | '/invoices/$invoiceId/edit' + | '/posts/$postId' + | '/invoices/$invoiceId/edit/' + | '/posts/$postId/' + > + + const defaultRouterLinkSearch = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + + const routerAlwaysTrailingSlashesSearch = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerNeverTrailingSlashesSearch = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerPreserveTrailingSlashesSearch = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + defaultRouterLinkSearch + .exclude() + .toEqualTypeOf<{ page: number } | {} | undefined>() + + routerAlwaysTrailingSlashesSearch + .exclude() + .toEqualTypeOf<{ page: number } | {} | undefined>() + + routerNeverTrailingSlashesSearch + .exclude() + .toEqualTypeOf<{ page: number } | {} | undefined>() - params + routerPreserveTrailingSlashesSearch .exclude() .toEqualTypeOf<{ page: number } | {} | undefined>() - params.returns.toEqualTypeOf<{ page: number } | {}>() + defaultRouterLinkSearch.returns.toEqualTypeOf<{ page: number } | {}>() + + routerAlwaysTrailingSlashesSearch.returns.toEqualTypeOf< + { page: number } | {} + >() + + routerNeverTrailingSlashesSearch.returns.toEqualTypeOf< + { page: number } | {} + >() + + routerPreserveTrailingSlashesSearch.returns.toEqualTypeOf< + { page: number } | {} + >() + + defaultRouterLinkSearch.returns.toEqualTypeOf<{ page: number } | {}>() + + routerAlwaysTrailingSlashesSearch.returns.toEqualTypeOf< + { page: number } | {} + >() + + routerNeverTrailingSlashesSearch.returns.toEqualTypeOf< + { page: number } | {} + >() + + routerPreserveTrailingSlashesSearch.returns.toEqualTypeOf< + { page: number } | {} + >() + + defaultRouterLinkSearch + .parameter(0) + .toEqualTypeOf<{} | { page: number } | { page?: number }>() + + routerAlwaysTrailingSlashesSearch + .parameter(0) + .toEqualTypeOf<{} | { page: number } | { page?: number }>() + + routerNeverTrailingSlashesSearch + .parameter(0) + .toEqualTypeOf<{} | { page: number } | { page?: number }>() - params.parameter(0).toEqualTypeOf<{} | { page: number } | { page?: number }>() + routerPreserveTrailingSlashesSearch + .parameter(0) + .toEqualTypeOf<{} | { page: number } | { page?: number }>() }) test('when navigating to a union of routes with search params including the root', () => { - const TestLink = Link< + const DefaultRouterLink = Link< DefaultRouter, string, '/' | '/invoices/$invoiceId/edit' | '/posts/$postId' > - const params = expectTypeOf(TestLink).parameter(0).toHaveProperty('search') - expectTypeOf(TestLink).parameter(0).not.toMatchTypeOf<{ search: unknown }>() + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + string, + '/' | '/invoices/$invoiceId/edit/' | '/posts/$postId/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '/' | '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + string, + | '/' + | '/invoices/$invoiceId/edit' + | '/posts/$postId' + | '/invoices/$invoiceId/edit/' + | '/posts/$postId/' + > + + const defaultRouterSearch = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + + const routerAlwaysTrailingSlashesLinkSearch = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerNeverTrailingSlashesLinkSearch = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerPreserveTrailingSlashesLinkSearch = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + defaultRouterSearch + .exclude() + .toEqualTypeOf<{ page: number } | {} | undefined>() + + routerAlwaysTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf<{ page: number } | {} | undefined>() + + routerNeverTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf<{ page: number } | {} | undefined>() - params + routerPreserveTrailingSlashesLinkSearch .exclude() .toEqualTypeOf<{ page: number } | {} | undefined>() - params.returns.toEqualTypeOf<{ page: number } | {}>() - params.parameter(0).toEqualTypeOf<{} | { page: number } | { page?: number }>() + defaultRouterSearch.returns.toEqualTypeOf<{ page: number } | {}>() + + routerAlwaysTrailingSlashesLinkSearch.returns.toEqualTypeOf< + { page: number } | {} + >() + + routerNeverTrailingSlashesLinkSearch.returns.toEqualTypeOf< + { page: number } | {} + >() + + routerPreserveTrailingSlashesLinkSearch.returns.toEqualTypeOf< + { page: number } | {} + >() + + defaultRouterSearch + .parameter(0) + .toEqualTypeOf<{} | { page: number } | { page?: number }>() + + routerAlwaysTrailingSlashesLinkSearch + .parameter(0) + .toEqualTypeOf<{} | { page: number } | { page?: number }>() + + routerNeverTrailingSlashesLinkSearch + .parameter(0) + .toEqualTypeOf<{} | { page: number } | { page?: number }>() + + routerPreserveTrailingSlashesLinkSearch + .parameter(0) + .toEqualTypeOf<{} | { page: number } | { page?: number }>() }) diff --git a/packages/react-router/tests/redirects.test.-d.tsx b/packages/react-router/tests/redirects.test.-d.tsx new file mode 100644 index 00000000000..e962d6b9c24 --- /dev/null +++ b/packages/react-router/tests/redirects.test.-d.tsx @@ -0,0 +1,53 @@ +import { expectTypeOf, test } from 'vitest' +import { createRootRoute, createRoute, createRouter, redirect } from '../src' + +const rootRoute = createRootRoute() + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', +}) + +const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', +}) + +const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', +}) + +const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateSearch: () => ({ page: 0 }), +}) + +const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, +]) + +const defaultRouter = createRouter({ + routeTree, +}) + +type DefaultRouter = typeof defaultRouter + +test('can redirect to valid route', () => { + expectTypeOf(redirect) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '' + | '/' + | 'invoices' + | '/invoices' + | '/invoices/$invoiceId' + | 'invoices/$invoiceId' + | './' + | '../' + | undefined + >() +}) diff --git a/packages/react-router/tests/useNavigate.test-d.tsx b/packages/react-router/tests/useNavigate.test-d.tsx new file mode 100644 index 00000000000..f8eb68e662f --- /dev/null +++ b/packages/react-router/tests/useNavigate.test-d.tsx @@ -0,0 +1,55 @@ +import { expectTypeOf, test } from 'vitest' +import { createRootRoute, createRoute, createRouter, useNavigate } from '../src' + +const rootRoute = createRootRoute() + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', +}) + +const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', +}) + +const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', +}) + +const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateSearch: () => ({ page: 0 }), +}) + +const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, +]) + +const defaultRouter = createRouter({ + routeTree, +}) + +type DefaultRouter = typeof defaultRouter + +test('can redirect to valid route', () => { + const navigate = useNavigate() + + expectTypeOf(navigate<'/invoices', DefaultRouter>) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '' + | '/' + | 'invoices' + | '/invoices' + | '/invoices/$invoiceId' + | 'invoices/$invoiceId' + | './' + | '../' + | undefined + >() +}) diff --git a/packages/react-router/tests/useRouterState.test-d.tsx b/packages/react-router/tests/useRouterState.test-d.tsx new file mode 100644 index 00000000000..39f135a3e41 --- /dev/null +++ b/packages/react-router/tests/useRouterState.test-d.tsx @@ -0,0 +1,53 @@ +import { expectTypeOf, test } from 'vitest' +import { + createRootRoute, + createRoute, + createRouter, + useRouterState, +} from '../src' + +const rootRoute = createRootRoute({ + validateSearch: () => ({ + page: 0, + }), +}) + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', +}) + +const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', +}) + +const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', +}) + +const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateSearch: () => ({ page: 0 }), +}) + +const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, +]) + +const defaultRouter = createRouter({ + routeTree, +}) + +type DefaultRouter = typeof defaultRouter + +test('can select router state', () => { + expectTypeOf(useRouterState) + .returns.toHaveProperty('location') + .toMatchTypeOf<{ + search: { page?: number | undefined } + }>() +})