diff --git a/.changeset/gold-baboons-roll.md b/.changeset/gold-baboons-roll.md new file mode 100644 index 0000000000..e1daf88273 --- /dev/null +++ b/.changeset/gold-baboons-roll.md @@ -0,0 +1,23 @@ +--- +"@react-router/dev": patch +"react-router": patch +--- + +Fix typegen when same route is used at multiple paths + +For example, `routes/route.tsx` is used at 4 different paths here: + +```ts +import { type RouteConfig, route } from "@react-router/dev/routes"; +export default [ + route("base/:base", "routes/base.tsx", [ + route("home/:home", "routes/route.tsx", { id: "home" }), + route("changelog/:changelog", "routes/route.tsx", { id: "changelog" }), + route("splat/*", "routes/route.tsx", { id: "splat" }), + ]), + route("other/:other", "routes/route.tsx", { id: "other" }), +] satisfies RouteConfig; +``` + +Previously, typegen would arbitrarily pick one of these paths to be the "winner" and generate types for the route module based on that path. +Now, typegen creates unions as necessary for alternate paths for the same route file. diff --git a/integration/typegen-test.ts b/integration/typegen-test.ts index d80251ef2d..d9c021446d 100644 --- a/integration/typegen-test.ts +++ b/integration/typegen-test.ts @@ -610,4 +610,86 @@ test.describe("typegen", () => { expect(proc.status).toBe(0); }); }); + + test("reuse route file at multiple paths", async () => { + const cwd = await createProject({ + "vite.config.ts": viteConfig, + "app/expect-type.ts": expectType, + "app/routes.ts": tsx` + import { type RouteConfig, route } from "@react-router/dev/routes"; + export default [ + route("base/:base", "routes/base.tsx", [ + route("home/:home", "routes/route.tsx", { id: "home" }), + route("changelog/:changelog", "routes/route.tsx", { id: "changelog" }), + route("splat/*", "routes/route.tsx", { id: "splat" }), + ]), + route("other/:other", "routes/route.tsx", { id: "other" }) + ] satisfies RouteConfig; + `, + "app/routes/base.tsx": tsx` + import { Outlet } from "react-router" + import type { Route } from "./+types/base" + + export function loader() { + return { base: "hello" } + } + + export default function Component() { + return ( + <> +

Layout

+ + + ) + } + `, + "app/routes/route.tsx": tsx` + import type { Expect, Equal } from "../expect-type" + import type { Route } from "./+types/route" + + export function loader() { + return { route: "world" } + } + + export default function Component({ params, matches }: Route.ComponentProps) { + type Test = Expect> + return

Hello, world!

+ } + `, + }); + + const proc = typecheck(cwd); + expect(proc.stdout.toString()).toBe(""); + expect(proc.stderr.toString()).toBe(""); + expect(proc.status).toBe(0); + }); }); diff --git a/packages/react-router-dev/typegen/generate.ts b/packages/react-router-dev/typegen/generate.ts index cf89379e33..da847f2d0f 100644 --- a/packages/react-router-dev/typegen/generate.ts +++ b/packages/react-router-dev/typegen/generate.ts @@ -55,29 +55,86 @@ export function generateServerBuild(ctx: Context): VirtualFile { } const { t } = Babel; -export function generatePages(ctx: Context): VirtualFile { - const filename = Path.join(typesDirectory(ctx), "+pages.ts"); +export function generateRoutes(ctx: Context): Array { + // precompute + const fileToRoutes = new Map>(); + const lineages = new Map>(); + const pages = new Set(); + const routeToPages = new Map>(); + for (const route of Object.values(ctx.config.routes)) { + // fileToRoutes + let routeIds = fileToRoutes.get(route.file); + if (!routeIds) { + routeIds = new Set(); + fileToRoutes.set(route.file, routeIds); + } + routeIds.add(route.id); - const fullpaths = new Set(); - Object.values(ctx.config.routes).forEach((route) => { - if (route.id !== "root" && !route.path) return; + // lineages const lineage = Route.lineage(ctx.config.routes, route); - const fullpath = Route.fullpath(lineage); - fullpaths.add(fullpath); - }); + lineages.set(route.id, lineage); + + // pages + const page = Route.fullpath(lineage); + if (!page) continue; + pages.add(page); + + // routePages + lineage.forEach(({ id }) => { + let routePages = routeToPages.get(id); + if (!routePages) { + routePages = new Set(); + routeToPages.set(id, routePages); + } + routePages.add(page); + }); + } + + // +routes.ts + const routesTs: VirtualFile = { + filename: Path.join(typesDirectory(ctx), "+routes.ts"), + content: + ts` + // Generated by React Router + + import "react-router" + + declare module "react-router" { + interface Register { + pages: Pages + routeFiles: RouteFiles + } + } + ` + + "\n\n" + + Babel.generate(pagesType(pages)).code + + "\n\n" + + Babel.generate(routeFilesType({ fileToRoutes, routeToPages })).code, + }; + + // **/+types/*.ts + const allAnnotations: Array = Array.from(fileToRoutes.entries()) + .filter(([file]) => isInAppDirectory(ctx, file)) + .map(([file, routeIds]) => + getRouteAnnotations({ ctx, file, routeIds, lineages }) + ); + + return [routesTs, ...allAnnotations]; +} - const pagesType = t.tsTypeAliasDeclaration( +function pagesType(pages: Set) { + return t.tsTypeAliasDeclaration( t.identifier("Pages"), null, t.tsTypeLiteral( - Array.from(fullpaths).map((fullpath) => { + Array.from(pages).map((page) => { return t.tsPropertySignature( - t.stringLiteral(fullpath), + t.stringLiteral(page), t.tsTypeAnnotation( t.tsTypeLiteral([ t.tsPropertySignature( t.identifier("params"), - t.tsTypeAnnotation(paramsType(fullpath)) + t.tsTypeAnnotation(paramsType(page)) ), ]) ) @@ -85,213 +142,195 @@ export function generatePages(ctx: Context): VirtualFile { }) ) ); - - const content = - ts` - // Generated by React Router - - import "react-router" - - declare module "react-router" { - interface Register { - pages: Pages - } - } - ` + - "\n\n" + - Babel.generate(pagesType).code; - return { filename, content }; } -export function generateRoutes(ctx: Context): VirtualFile { - const filename = Path.join(typesDirectory(ctx), "+routes-pre.ts"); - const routesType = t.tsTypeAliasDeclaration( - t.identifier("routesPre"), +function routeFilesType({ + fileToRoutes, + routeToPages, +}: { + fileToRoutes: Map>; + routeToPages: Map>; +}) { + return t.tsTypeAliasDeclaration( + t.identifier("RouteFiles"), null, t.tsTypeLiteral( - Object.values(ctx.config.routes).map((route) => { - return t.tsPropertySignature( - t.stringLiteral(route.id), + Array.from(fileToRoutes).map(([file, routeIds]) => + t.tsPropertySignature( + t.stringLiteral(file), t.tsTypeAnnotation( + t.tsUnionType( + Array.from(routeIds).map((routeId) => { + const pages = routeToPages.get(routeId) ?? new Set(); + return t.tsTypeLiteral([ + t.tsPropertySignature( + t.identifier("id"), + t.tsTypeAnnotation( + t.tsLiteralType(t.stringLiteral(routeId)) + ) + ), + t.tsPropertySignature( + t.identifier("page"), + t.tsTypeAnnotation( + pages + ? t.tsUnionType( + Array.from(pages).map((page) => + t.tsLiteralType(t.stringLiteral(page)) + ) + ) + : t.tsNeverKeyword() + ) + ), + ]); + }) + ) + ) + ) + ) + ) + ); +} + +function isInAppDirectory(ctx: Context, routeFile: string): boolean { + const path = Path.resolve(ctx.config.appDirectory, routeFile); + return path.startsWith(ctx.config.appDirectory); +} + +function getRouteAnnotations({ + ctx, + file, + routeIds, + lineages, +}: { + ctx: Context; + file: string; + routeIds: Set; + lineages: Map>; +}) { + const filename = Path.join( + typesDirectory(ctx), + Path.relative(ctx.rootDirectory, ctx.config.appDirectory), + Path.dirname(file), + "+types", + Pathe.filename(file) + ".ts" + ); + + const matchesType = t.tsTypeAliasDeclaration( + t.identifier("Matches"), + null, + t.tsUnionType( + Array.from(routeIds).map((routeId) => { + const lineage = lineages.get(routeId)!; + return t.tsTupleType( + lineage.map((route) => t.tsTypeLiteral([ t.tsPropertySignature( - t.identifier("parentId"), - t.tsTypeAnnotation( - route.parentId - ? t.tsLiteralType(t.stringLiteral(route.parentId)) - : t.tsUndefinedKeyword() - ) + t.identifier("id"), + t.tsTypeAnnotation(t.tsLiteralType(t.stringLiteral(route.id))) ), t.tsPropertySignature( - t.identifier("path"), + t.identifier("module"), t.tsTypeAnnotation( - route.path - ? t.tsLiteralType(t.stringLiteral(route.path)) - : t.tsUndefinedKeyword() + t.tsTypeQuery( + t.tsImportType( + t.stringLiteral( + relativeImportSource( + rootDirsPath(ctx, filename), + Path.resolve(ctx.config.appDirectory, route.file) + ) + ) + ) + ) ) ), - t.tsPropertySignature( - t.identifier("params"), - t.tsTypeAnnotation(paramsType(route.path ?? "")) - ), - t.tsPropertySignature( - t.identifier("index"), - t.tsTypeAnnotation( - t.tsLiteralType(t.booleanLiteral(route.index ?? false)) - ) - ), - t.tsPropertySignature( - t.identifier("file"), - t.tsTypeAnnotation(t.tsLiteralType(t.stringLiteral(route.file))) - ), ]) ) ); }) ) ); + + const routeImportSource = relativeImportSource( + rootDirsPath(ctx, filename), + Path.resolve(ctx.config.appDirectory, file) + ); + const content = ts` // Generated by React Router - import "react-router" + import type { GetInfo, GetAnnotations } from "react-router/internal"; - declare module "react-router" { - interface Register { - routesPre: routesPre - } - } + type Module = typeof import("${routeImportSource}") + + type Info = GetInfo<{ + file: "${file}", + module: Module + }> ` + "\n\n" + - Babel.generate(routesType).code; - return { filename, content }; -} - -export function generateRouteModuleAnnotations( - ctx: Context -): Array { - return Object.values(ctx.config.routes) - .filter((route) => isRouteInAppDirectory(ctx, route)) - .map((route) => { - const filename = getRouteModuleAnnotationsFilepath(ctx, route); - - const parents = getParents(ctx, route); - - const content = ts` - // Generated by React Router - - import type { - Params, - RouteModuleAnnotations, - CreateLoaderData, - CreateActionData, - } from "react-router/internal"; - - ${parents.map((parent) => parent.import).join("\n" + " ".repeat(3))} - type Parents = [${parents.map((parent) => parent.name).join(", ")}] - - type Id = "${route.id}" - type Module = typeof import("../${Pathe.filename(route.file)}.js") - - export type unstable_Props = { - params: Params[Id] - loaderData: CreateLoaderData - actionData: CreateActionData - } - - type Annotations = RouteModuleAnnotations; - - export namespace Route { - // links - export type LinkDescriptors = Annotations["LinkDescriptors"]; - export type LinksFunction = Annotations["LinksFunction"]; - - // meta - export type MetaArgs = Annotations["MetaArgs"]; - export type MetaDescriptors = Annotations["MetaDescriptors"]; - export type MetaFunction = Annotations["MetaFunction"]; + Babel.generate(matchesType).code + + "\n\n" + + ts` + type Annotations = GetAnnotations; - // headers - export type HeadersArgs = Annotations["HeadersArgs"]; - export type HeadersFunction = Annotations["HeadersFunction"]; + export namespace Route { + // links + export type LinkDescriptors = Annotations["LinkDescriptors"]; + export type LinksFunction = Annotations["LinksFunction"]; - // unstable_middleware - export type unstable_MiddlewareFunction = Annotations["unstable_MiddlewareFunction"]; + // meta + export type MetaArgs = Annotations["MetaArgs"]; + export type MetaDescriptors = Annotations["MetaDescriptors"]; + export type MetaFunction = Annotations["MetaFunction"]; - // unstable_clientMiddleware - export type unstable_ClientMiddlewareFunction = Annotations["unstable_ClientMiddlewareFunction"]; + // headers + export type HeadersArgs = Annotations["HeadersArgs"]; + export type HeadersFunction = Annotations["HeadersFunction"]; - // loader - export type LoaderArgs = Annotations["LoaderArgs"]; + // unstable_middleware + export type unstable_MiddlewareFunction = Annotations["unstable_MiddlewareFunction"]; - // clientLoader - export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; + // unstable_clientMiddleware + export type unstable_ClientMiddlewareFunction = Annotations["unstable_ClientMiddlewareFunction"]; - // action - export type ActionArgs = Annotations["ActionArgs"]; + // loader + export type LoaderArgs = Annotations["LoaderArgs"]; - // clientAction - export type ClientActionArgs = Annotations["ClientActionArgs"]; + // clientLoader + export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; - // HydrateFallback - export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; + // action + export type ActionArgs = Annotations["ActionArgs"]; - // Component - export type ComponentProps = Annotations["ComponentProps"]; + // clientAction + export type ClientActionArgs = Annotations["ClientActionArgs"]; - // ErrorBoundary - export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; - } - `; - return { filename, content }; - }); -} + // HydrateFallback + export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; -function isRouteInAppDirectory(ctx: Context, route: RouteManifestEntry) { - const absoluteRoutePath = Path.resolve(ctx.config.appDirectory, route.file); - return absoluteRoutePath.startsWith(ctx.config.appDirectory); -} + // Component + export type ComponentProps = Annotations["ComponentProps"]; -function getRouteModuleAnnotationsFilepath( - ctx: Context, - route: RouteManifestEntry -) { - return Path.join( - typesDirectory(ctx), - Path.relative(ctx.rootDirectory, ctx.config.appDirectory), - Path.dirname(route.file), - "+types/" + Pathe.filename(route.file) + ".ts" - ); + // ErrorBoundary + export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; + } + `; + return { filename, content }; } -function getParents(ctx: Context, route: RouteManifestEntry) { - const typesPath = getRouteModuleAnnotationsFilepath(ctx, route); - - const lineage = Route.lineage(ctx.config.routes, route); - - const parents = lineage.slice(0, -1); - return parents.map((parent, i) => { - const rel = Path.relative( - Path.dirname(typesPath), - getRouteModuleAnnotationsFilepath(ctx, parent) - ); - - let source = noExtension(rel); - if (!source.startsWith("../")) source = "./" + source; +function relativeImportSource(from: string, to: string) { + let path = Path.relative(Path.dirname(from), to); + // no extension + path = Path.join(Path.dirname(path), Pathe.filename(path)); + if (!path.startsWith("../")) path = "./" + path; - const name = `Parent${i}`; - return { - name, - import: `import type { unstable_Props as ${name} } from "${source}.js"`, - }; - }); + return path + ".js"; } -function noExtension(path: string) { - return Path.join(Path.dirname(path), Pathe.filename(path)); +function rootDirsPath(ctx: Context, typesPath: string): string { + const rel = Path.relative(typesDirectory(ctx), typesPath); + return Path.join(ctx.rootDirectory, rel); } function paramsType(path: string) { diff --git a/packages/react-router-dev/typegen/index.ts b/packages/react-router-dev/typegen/index.ts index e207cdbb69..7253c73246 100644 --- a/packages/react-router-dev/typegen/index.ts +++ b/packages/react-router-dev/typegen/index.ts @@ -9,8 +9,6 @@ import { type VirtualFile, typesDirectory, generateFuture, - generateRouteModuleAnnotations, - generatePages, generateRoutes, generateServerBuild, } from "./generate"; @@ -36,10 +34,8 @@ export async function run(rootDirectory: string, { mode }: { mode: string }) { await fs.rm(typesDirectory(ctx), { recursive: true, force: true }); await write( generateFuture(ctx), - generatePages(ctx), - generateRoutes(ctx), generateServerBuild(ctx), - ...generateRouteModuleAnnotations(ctx) + ...generateRoutes(ctx) ); } @@ -55,10 +51,8 @@ export async function watch( await fs.rm(typesDirectory(ctx), { recursive: true, force: true }); await write( generateFuture(ctx), - generatePages(ctx), - generateRoutes(ctx), generateServerBuild(ctx), - ...generateRouteModuleAnnotations(ctx) + ...generateRoutes(ctx) ); logger?.info(green("generated types"), { timestamp: true, clear: true }); @@ -80,11 +74,7 @@ export async function watch( if (routeConfigChanged) { await clearRouteModuleAnnotations(ctx); - await write( - generatePages(ctx), - generateRoutes(ctx), - ...generateRouteModuleAnnotations(ctx) - ); + await write(...generateRoutes(ctx)); logger?.info(green("regenerated types"), { timestamp: true, clear: true, diff --git a/packages/react-router-dev/typegen/route.ts b/packages/react-router-dev/typegen/route.ts index 6c48be06d0..6409630cad 100644 --- a/packages/react-router-dev/typegen/route.ts +++ b/packages/react-router-dev/typegen/route.ts @@ -15,7 +15,15 @@ export function lineage( } export function fullpath(lineage: RouteManifestEntry[]) { - if (lineage.length === 1 && lineage[0].id === "root") return "/"; + const route = lineage.at(-1); + + // root + if (lineage.length === 1 && route?.id === "root") return "/"; + + // layout + const isLayout = route && route.index !== true && route.path === undefined; + if (isLayout) return undefined; + return ( "/" + lineage diff --git a/packages/react-router/lib/href.ts b/packages/react-router/lib/href.ts index 048b9c7890..80dee21512 100644 --- a/packages/react-router/lib/href.ts +++ b/packages/react-router/lib/href.ts @@ -1,29 +1,16 @@ -import type { Register } from "./types/register"; +import type { Pages } from "./types/register"; import type { Equal } from "./types/utils"; -type AnyParams = Record; -type AnyPages = Record< - string, - { - params: AnyParams; - } ->; -type Pages = Register extends { - pages: infer RegisteredPages extends AnyPages; -} - ? RegisteredPages - : AnyPages; - type Args = { [K in keyof Pages]: ToArgs }; // prettier-ignore -type ToArgs = +type ToArgs> = // path without params -> no `params` arg - Equal extends true ? [] : + Equal extends true ? [] : // path with only optional params -> optional `params` arg - Partial extends T ? [T] | [] : + Partial extends Params ? [Params] | [] : // otherwise, require `params` arg - [T]; + [Params]; /** Returns a resolved URL path for the specified route. diff --git a/packages/react-router/lib/types/internal.ts b/packages/react-router/lib/types/internal.ts index 279f594399..87e1104b05 100644 --- a/packages/react-router/lib/types/internal.ts +++ b/packages/react-router/lib/types/internal.ts @@ -1,6 +1,13 @@ -export type { Params } from "./params"; -export type { - RouteModuleAnnotations, - CreateLoaderData, - CreateActionData, -} from "./route-module"; +export type { GetAnnotations } from "./route-module-annotations"; + +import type { Params } from "./params"; +import type { RouteFiles } from "./register"; +import type { GetLoaderData, GetActionData } from "./route-data"; +import type { RouteModule } from "./route-module.ts"; + +export type GetInfo = + { + params: Params; + loaderData: GetLoaderData; + actionData: GetActionData; + }; diff --git a/packages/react-router/lib/types/params.ts b/packages/react-router/lib/types/params.ts index ce798a68c3..9cd1e98a51 100644 --- a/packages/react-router/lib/types/params.ts +++ b/packages/react-router/lib/types/params.ts @@ -1,65 +1,6 @@ -import type { Register } from "./register"; +import type { Pages, RouteFiles } from "./register"; import type { Normalize } from "./utils"; -type AnyRoutes = Record< - string, - { - parentId?: string; - path?: string; - index?: boolean; - file: string; - params: Record; - } +export type Params = Normalize< + Pages[RouteFiles[RouteFile]["page"]]["params"] >; -type RoutesPre = Register extends { - routesPre: infer RegisteredRoutes extends AnyRoutes; -} - ? RegisteredRoutes - : AnyRoutes; - -type RouteId = keyof RoutesPre; - -// prettier-ignore -type GetParents = - RoutesPre[Id] extends { parentId: infer P extends RouteId } ? - [...GetParents

, P] : - []; - -// prettier-ignore -type _GetChildren = { - [K in RouteId]: RoutesPre[K] extends { parentId : Id } ? - RoutesPre[K] extends { index: true } ? [K] : - RoutesPre[K] extends { path: undefined } ? [K, ...GetChildren] : - [K] | [K, ...GetChildren] - : - [] -}[RouteId] - -type GetChildren = _GetChildren extends [] - ? [] - : Exclude<_GetChildren, []>; - -type GetBranch = [ - ...GetParents, - Id, - ...(RoutesPre[Id] extends { path: undefined } - ? GetChildren - : _GetChildren) -]; - -type Branches = { - [Id in RouteId]: GetBranch; -}; - -type PartialParams = { - [Id in RouteId]: RoutesPre[Id]["params"]; -}; -type BranchParams> = Branch extends [ - infer Id extends RouteId, - ...infer Ids extends Array -] - ? PartialParams[Id] & BranchParams - : {}; -export type Params = { - [Id in RouteId]: Normalize>; -}; diff --git a/packages/react-router/lib/types/register.ts b/packages/react-router/lib/types/register.ts index 7c5f896756..a5bbd7f92b 100644 --- a/packages/react-router/lib/types/register.ts +++ b/packages/react-router/lib/types/register.ts @@ -5,5 +5,23 @@ * For more on declaration merging and module augmentation, see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation . */ export interface Register { - // params + // pages + // routeFiles } + +// pages +type AnyParams = Record; +type AnyPages = Record; +export type Pages = Register extends { + pages: infer Registered extends AnyPages; +} + ? Registered + : AnyPages; + +// route files +type AnyRouteFiles = Record; +export type RouteFiles = Register extends { + routeFiles: infer Registered extends AnyRouteFiles; +} + ? Registered + : AnyRouteFiles; diff --git a/packages/react-router/lib/types/route-data.ts b/packages/react-router/lib/types/route-data.ts index 9dc2b6b4da..c60e376939 100644 --- a/packages/react-router/lib/types/route-data.ts +++ b/packages/react-router/lib/types/route-data.ts @@ -4,6 +4,7 @@ import type { } from "../dom/ssr/routeModules"; import type { DataWithResponseInit } from "../router/utils"; import type { Serializable } from "../server-runtime/single-fetch"; +import type { RouteModule } from "./route-module"; import type { unstable_SerializesTo } from "./serializes-to"; import type { Equal, Expect, Func, IsAny, Pretty } from "./utils"; @@ -67,7 +68,50 @@ export type SerializeFrom = T extends (...args: infer Args) => unknown : ServerDataFrom : T; -// eslint-disable-next-line @typescript-eslint/no-unused-vars +type IsDefined = Equal extends true ? false : true; + +// prettier-ignore +type IsHydrate = + ClientLoader extends { hydrate: true } ? true : + ClientLoader extends { hydrate: false } ? false : + false + +export type GetLoaderData = _DataLoaderData< + ServerDataFrom, + ClientDataFrom, + IsHydrate, + T extends { HydrateFallback: Func } ? true : false +>; + +// prettier-ignore +type _DataLoaderData< + ServerLoaderData, + ClientLoaderData, + ClientLoaderHydrate extends boolean, + HasHydrateFallback +> = + [HasHydrateFallback, ClientLoaderHydrate] extends [true, true] ? + IsDefined extends true ? ClientLoaderData : + undefined + : + [IsDefined, IsDefined] extends [true, true] ? ServerLoaderData | ClientLoaderData : + IsDefined extends true ? ClientLoaderData : + IsDefined extends true ? ServerLoaderData : + undefined + +export type GetActionData = _DataActionData< + ServerDataFrom, + ClientDataFrom +>; + +// prettier-ignore +type _DataActionData = Awaited< + [IsDefined, IsDefined] extends [true, true] ? ServerActionData | ClientActionData : + IsDefined extends true ? ClientActionData : + IsDefined extends true ? ServerActionData : + undefined +> + type __tests = [ // ServerDataFrom Expect, undefined>>, @@ -130,5 +174,98 @@ type __tests = [ | { data: string; b: Date; c: () => boolean } > >, - Expect { a: string } | Response>, { a: string }>> + Expect { a: string } | Response>, { a: string }>>, + + // GetLoaderData + Expect, undefined>>, + Expect< + Equal< + GetLoaderData<{ + loader: () => { a: string; b: Date; c: () => boolean }; + }>, + { a: string; b: Date; c: undefined } + > + >, + Expect< + Equal< + GetLoaderData<{ + clientLoader: () => { a: string; b: Date; c: () => boolean }; + }>, + { a: string; b: Date; c: () => boolean } + > + >, + Expect< + Equal< + GetLoaderData<{ + loader: () => { a: string; b: Date; c: () => boolean }; + clientLoader: () => { d: string; e: Date; f: () => boolean }; + }>, + | { a: string; b: Date; c: undefined } + | { d: string; e: Date; f: () => boolean } + > + >, + Expect< + Equal< + GetLoaderData<{ + loader: () => { a: string; b: Date; c: () => boolean }; + clientLoader: () => { d: string; e: Date; f: () => boolean }; + HydrateFallback: () => unknown; + }>, + | { a: string; b: Date; c: undefined } + | { d: string; e: Date; f: () => boolean } + > + >, + Expect< + Equal< + GetLoaderData<{ + loader: () => { a: string; b: Date; c: () => boolean }; + clientLoader: (() => { d: string; e: Date; f: () => boolean }) & { + hydrate: true; + }; + }>, + | { a: string; b: Date; c: undefined } + | { d: string; e: Date; f: () => boolean } + > + >, + Expect< + Equal< + GetLoaderData<{ + loader: () => { a: string; b: Date; c: () => boolean }; + clientLoader: (() => { d: string; e: Date; f: () => boolean }) & { + hydrate: true; + }; + HydrateFallback: () => unknown; + }>, + { d: string; e: Date; f: () => boolean } + > + >, + + // ActionData + Expect, undefined>>, + Expect< + Equal< + GetActionData<{ + action: () => { a: string; b: Date; c: () => boolean }; + }>, + { a: string; b: Date; c: undefined } + > + >, + Expect< + Equal< + GetActionData<{ + clientAction: () => { a: string; b: Date; c: () => boolean }; + }>, + { a: string; b: Date; c: () => boolean } + > + >, + Expect< + Equal< + GetActionData<{ + action: () => { a: string; b: Date; c: () => boolean }; + clientAction: () => { d: string; e: Date; f: () => boolean }; + }>, + | { a: string; b: Date; c: undefined } + | { d: string; e: Date; f: () => boolean } + > + > ]; diff --git a/packages/react-router/lib/types/route-module-annotations.ts b/packages/react-router/lib/types/route-module-annotations.ts new file mode 100644 index 0000000000..191ff35d16 --- /dev/null +++ b/packages/react-router/lib/types/route-module-annotations.ts @@ -0,0 +1,267 @@ +import type { MetaDescriptor } from "../dom/ssr/routeModules"; +import type { Location } from "../router/history"; +import type { LinkDescriptor } from "../router/links"; +import type { + unstable_MiddlewareNextFunction, + unstable_RouterContextProvider, +} from "../router/utils"; +import type { AppLoadContext } from "../server-runtime/data"; +import type { MiddlewareEnabled } from "./future"; + +import type { GetLoaderData, ServerDataFrom } from "./route-data"; +import type { RouteModule } from "./route-module"; +import type { Pretty } from "./utils"; + +type MaybePromise = T | Promise; + +type Props = { + params: unknown; + loaderData: unknown; + actionData: unknown; +}; + +type RouteInfo = Props & { + module: RouteModule; + matches: Array; +}; + +type MatchInfo = { + id: string; + module: RouteModule; +}; + +type MetaMatch = Pretty<{ + id: T["id"]; + params: Record; + pathname: string; + meta: MetaDescriptor[]; + data: GetLoaderData; + handle?: unknown; + error?: unknown; +}>; + +// prettier-ignore +type MetaMatches> = + T extends [infer F extends MatchInfo, ...infer R extends Array] + ? [MetaMatch, ...MetaMatches] + : Array | undefined>; + +type CreateMetaArgs = { + /** This is the current router `Location` object. This is useful for generating tags for routes at specific paths or query parameters. */ + location: Location; + /** {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. */ + params: T["params"]; + /** The return value for this route's server loader function */ + data: T["loaderData"] | undefined; + /** Thrown errors that trigger error boundaries will be passed to the meta function. This is useful for generating metadata for error pages. */ + error?: unknown; + /** An array of the current {@link https://api.reactrouter.com/v7/interfaces/react_router.UIMatch.html route matches}, including parent route matches. */ + matches: MetaMatches; +}; +type MetaDescriptors = MetaDescriptor[]; + +type HeadersArgs = { + loaderHeaders: Headers; + parentHeaders: Headers; + actionHeaders: Headers; + errorHeaders: Headers | undefined; +}; + +type ClientDataFunctionArgs = { + /** + * A {@link https://developer.mozilla.org/en-US/docs/Web/API/Request Fetch Request instance} which you can use to read the URL, the method, the "content-type" header, and the request body from the request. + * + * @note Because client data functions are called before a network request is made, the Request object does not include the headers which the browser automatically adds. React Router infers the "content-type" header from the enc-type of the form that performed the submission. + **/ + request: Request; + /** + * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. + * @example + * // app/routes.ts + * route("teams/:teamId", "./team.tsx"), + * + * // app/team.tsx + * export function clientLoader({ + * params, + * }: Route.ClientLoaderArgs) { + * params.teamId; + * // ^ string + * } + **/ + params: T["params"]; + /** + * When `future.unstable_middleware` is not enabled, this is undefined. + * + * When `future.unstable_middleware` is enabled, this is an instance of + * `unstable_RouterContextProvider` and can be used to access context values + * from your route middlewares. You may pass in initial context values in your + * `` prop + */ + context: unstable_RouterContextProvider; +}; + +type ServerDataFunctionArgs = { + /** A {@link https://developer.mozilla.org/en-US/docs/Web/API/Request Fetch Request instance} which you can use to read the url, method, headers (such as cookies), and request body from the request. */ + request: Request; + /** + * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. + * @example + * // app/routes.ts + * route("teams/:teamId", "./team.tsx"), + * + * // app/team.tsx + * export function loader({ + * params, + * }: Route.LoaderArgs) { + * params.teamId; + * // ^ string + * } + **/ + params: T["params"]; + /** + * Without `future.unstable_middleware` enabled, this is the context passed in + * to your server adapter's `getLoadContext` function. It's a way to bridge the + * gap between the adapter's request/response API with your React Router app. + * It is only applicable if you are using a custom server adapter. + * + * With `future.unstable_middleware` enabled, this is an instance of + * `unstable_RouterContextProvider` and can be used for type-safe access to + * context value set in your route middlewares. If you are using a custom + * server adapter, you may provide an initial set of context values from your + * `getLoadContext` function. + */ + context: MiddlewareEnabled extends true + ? unstable_RouterContextProvider + : AppLoadContext; +}; + +type CreateServerMiddlewareFunction = ( + args: ServerDataFunctionArgs, + next: unstable_MiddlewareNextFunction +) => MaybePromise; + +type CreateClientMiddlewareFunction = ( + args: ClientDataFunctionArgs, + next: unstable_MiddlewareNextFunction +) => MaybePromise; + +type CreateServerLoaderArgs = ServerDataFunctionArgs; + +type CreateClientLoaderArgs = ClientDataFunctionArgs & { + /** This is an asynchronous function to get the data from the server loader for this route. On client-side navigations, this will make a {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API fetch} call to the React Router server loader. If you opt-into running your clientLoader on hydration, then this function will return the data that was already loaded on the server (via Promise.resolve). */ + serverLoader: () => Promise>; +}; + +type CreateServerActionArgs = ServerDataFunctionArgs; + +type CreateClientActionArgs = ClientDataFunctionArgs & { + /** This is an asynchronous function that makes the {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API fetch} call to the React Router server action for this route. */ + serverAction: () => Promise>; +}; + +type CreateHydrateFallbackProps = { + params: T["params"]; + loaderData?: T["loaderData"]; + actionData?: T["actionData"]; +}; + +type Match = Pretty<{ + id: T["id"]; + params: Record; + pathname: string; + data: GetLoaderData; + handle: unknown; +}>; + +// prettier-ignore +type Matches> = + T extends [infer F extends MatchInfo, ...infer R extends Array] + ? [Match, ...Matches] + : Array | undefined>; + +type CreateComponentProps = { + /** + * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. + * @example + * // app/routes.ts + * route("teams/:teamId", "./team.tsx"), + * + * // app/team.tsx + * export default function Component({ + * params, + * }: Route.ComponentProps) { + * params.teamId; + * // ^ string + * } + **/ + params: T["params"]; + /** The data returned from the `loader` or `clientLoader` */ + loaderData: T["loaderData"]; + /** The data returned from the `action` or `clientAction` following an action submission. */ + actionData?: T["actionData"]; + /** An array of the current {@link https://api.reactrouter.com/v7/interfaces/react_router.UIMatch.html route matches}, including parent route matches. */ + matches: Matches; +}; + +type CreateErrorBoundaryProps = { + /** + * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. + * @example + * // app/routes.ts + * route("teams/:teamId", "./team.tsx"), + * + * // app/team.tsx + * export function ErrorBoundary({ + * params, + * }: Route.ErrorBoundaryProps) { + * params.teamId; + * // ^ string + * } + **/ + params: T["params"]; + error: unknown; + loaderData?: T["loaderData"]; + actionData?: T["actionData"]; +}; + +export type GetAnnotations = { + // links + LinkDescriptors: LinkDescriptor[]; + LinksFunction: () => LinkDescriptor[]; + + // meta + MetaArgs: CreateMetaArgs; + MetaDescriptors: MetaDescriptors; + MetaFunction: (args: CreateMetaArgs) => MetaDescriptors; + + // headers + HeadersArgs: HeadersArgs; + HeadersFunction: (args: HeadersArgs) => Headers | HeadersInit; + + // middleware + unstable_MiddlewareFunction: CreateServerMiddlewareFunction; + + // clientMiddleware + unstable_ClientMiddlewareFunction: CreateClientMiddlewareFunction; + + // loader + LoaderArgs: CreateServerLoaderArgs; + + // clientLoader + ClientLoaderArgs: CreateClientLoaderArgs; + + // action + ActionArgs: CreateServerActionArgs; + + // clientAction + ClientActionArgs: CreateClientActionArgs; + + // HydrateFallback + HydrateFallbackProps: CreateHydrateFallbackProps; + + // default (Component) + ComponentProps: CreateComponentProps; + + // ErrorBoundary + ErrorBoundaryProps: CreateErrorBoundaryProps; +}; diff --git a/packages/react-router/lib/types/route-module.ts b/packages/react-router/lib/types/route-module.ts index ecb0495714..f2c4b1e35c 100644 --- a/packages/react-router/lib/types/route-module.ts +++ b/packages/react-router/lib/types/route-module.ts @@ -1,20 +1,6 @@ -import type { MetaDescriptor } from "../dom/ssr/routeModules"; -import type { Location } from "../router/history"; -import type { LinkDescriptor } from "../router/links"; -import type { - unstable_MiddlewareNextFunction, - unstable_RouterContextProvider, -} from "../router/utils"; -import type { AppLoadContext } from "../server-runtime/data"; -import type { MiddlewareEnabled } from "./future"; +import type { Func } from "./utils"; -import type { ClientDataFrom, ServerDataFrom } from "./route-data"; -import type { Equal, Expect, Func, Pretty } from "./utils"; - -type IsDefined = Equal extends true ? false : true; -type MaybePromise = T | Promise; - -type RouteModule = { +export type RouteModule = { meta?: Func; links?: Func; headers?: Func; @@ -27,385 +13,3 @@ type RouteModule = { ErrorBoundary?: Func; [key: string]: unknown; // allow user-defined exports }; - -type Props = { - params: unknown; - loaderData: unknown; - actionData: unknown; -}; - -type RouteInfo = Props & { - parents: Props[]; - module: RouteModule; -}; - -type MetaMatch = Pretty< - Pick & { - pathname: string; - meta: MetaDescriptor[]; - data: T["loaderData"]; - handle?: unknown; - error?: unknown; - } ->; - -// prettier-ignore -type MetaMatches = - T extends [infer F extends Props, ...infer R extends Props[]] - ? [MetaMatch, ...MetaMatches] - : Array | undefined>; - -type CreateMetaArgs = { - /** This is the current router `Location` object. This is useful for generating tags for routes at specific paths or query parameters. */ - location: Location; - /** {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. */ - params: T["params"]; - /** The return value for this route's server loader function */ - data: T["loaderData"] | undefined; - /** Thrown errors that trigger error boundaries will be passed to the meta function. This is useful for generating metadata for error pages. */ - error?: unknown; - /** An array of the current {@link https://api.reactrouter.com/v7/interfaces/react_router.UIMatch.html route matches}, including parent route matches. */ - matches: MetaMatches<[...T["parents"], T]>; -}; -type MetaDescriptors = MetaDescriptor[]; - -type HeadersArgs = { - loaderHeaders: Headers; - parentHeaders: Headers; - actionHeaders: Headers; - errorHeaders: Headers | undefined; -}; - -// prettier-ignore -type IsHydrate = - ClientLoader extends { hydrate: true } ? true : - ClientLoader extends { hydrate: false } ? false : - false - -export type CreateLoaderData = _CreateLoaderData< - ServerDataFrom, - ClientDataFrom, - IsHydrate, - T extends { HydrateFallback: Func } ? true : false ->; - -// prettier-ignore -type _CreateLoaderData< - ServerLoaderData, - ClientLoaderData, - ClientLoaderHydrate extends boolean, - HasHydrateFallback -> = - [HasHydrateFallback, ClientLoaderHydrate] extends [true, true] ? - IsDefined extends true ? ClientLoaderData : - undefined - : - [IsDefined, IsDefined] extends [true, true] ? ServerLoaderData | ClientLoaderData : - IsDefined extends true ? ClientLoaderData : - IsDefined extends true ? ServerLoaderData : - undefined - -export type CreateActionData = _CreateActionData< - ServerDataFrom, - ClientDataFrom ->; - -// prettier-ignore -type _CreateActionData = Awaited< - [IsDefined, IsDefined] extends [true, true] ? ServerActionData | ClientActionData : - IsDefined extends true ? ClientActionData : - IsDefined extends true ? ServerActionData : - undefined -> - -type ClientDataFunctionArgs = { - /** - * A {@link https://developer.mozilla.org/en-US/docs/Web/API/Request Fetch Request instance} which you can use to read the URL, the method, the "content-type" header, and the request body from the request. - * - * @note Because client data functions are called before a network request is made, the Request object does not include the headers which the browser automatically adds. React Router infers the "content-type" header from the enc-type of the form that performed the submission. - **/ - request: Request; - /** - * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. - * @example - * // app/routes.ts - * route("teams/:teamId", "./team.tsx"), - * - * // app/team.tsx - * export function clientLoader({ - * params, - * }: Route.ClientLoaderArgs) { - * params.teamId; - * // ^ string - * } - **/ - params: T["params"]; - /** - * This is a `RouterContextProvider` instance generated from your router's - * `unstable_getContext` function and/or populated from route middleware - * functions. - */ - context: unstable_RouterContextProvider; -}; - -type ServerDataFunctionArgs = { - /** A {@link https://developer.mozilla.org/en-US/docs/Web/API/Request Fetch Request instance} which you can use to read the url, method, headers (such as cookies), and request body from the request. */ - request: Request; - /** - * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. - * @example - * // app/routes.ts - * route("teams/:teamId", "./team.tsx"), - * - * // app/team.tsx - * export function loader({ - * params, - * }: Route.LoaderArgs) { - * params.teamId; - * // ^ string - * } - **/ - params: T["params"]; - /** - * Without `future.unstable_middleware` enabled, this is the context passed in - * to your server adapter's `getLoadContext` function. It's a way to bridge the - * gap between the adapter's request/response API with your React Router app. - * It is only applicable if you are using a custom server adapter. - * - * With `future.unstable_middleware` enabled, this is an instance of - * `unstable_RouterContextProvider` and can be used for type-safe access to - * context value set in your route middlewares. If you are using a custom - * server adapter, you may provide an initial set of context values from your - * `getLoadContext` function. - */ - context: MiddlewareEnabled extends true - ? unstable_RouterContextProvider - : AppLoadContext; -}; - -type CreateServerMiddlewareFunction = ( - args: ServerDataFunctionArgs, - next: unstable_MiddlewareNextFunction -) => MaybePromise; - -type CreateClientMiddlewareFunction = ( - args: ClientDataFunctionArgs, - next: unstable_MiddlewareNextFunction -) => MaybePromise; - -type CreateServerLoaderArgs = ServerDataFunctionArgs; - -type CreateClientLoaderArgs = ClientDataFunctionArgs & { - /** This is an asynchronous function to get the data from the server loader for this route. On client-side navigations, this will make a {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API fetch} call to the React Router server loader. If you opt-into running your clientLoader on hydration, then this function will return the data that was already loaded on the server (via Promise.resolve). */ - serverLoader: () => Promise>; -}; - -type CreateServerActionArgs = ServerDataFunctionArgs; - -type CreateClientActionArgs = ClientDataFunctionArgs & { - /** This is an asynchronous function that makes the {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API fetch} call to the React Router server action for this route. */ - serverAction: () => Promise>; -}; - -type CreateHydrateFallbackProps = { - params: T["params"]; - loaderData?: T["loaderData"]; - actionData?: T["actionData"]; -}; - -type Match = Pretty< - Pick & { - pathname: string; - data: T["loaderData"]; - handle: unknown; - } ->; - -// prettier-ignore -type Matches = - T extends [infer F extends Props, ...infer R extends Props[]] - ? [Match, ...Matches] - : Array | undefined>; - -type CreateComponentProps = { - /** - * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. - * @example - * // app/routes.ts - * route("teams/:teamId", "./team.tsx"), - * - * // app/team.tsx - * export default function Component({ - * params, - * }: Route.ComponentProps) { - * params.teamId; - * // ^ string - * } - **/ - params: T["params"]; - /** The data returned from the `loader` or `clientLoader` */ - loaderData: T["loaderData"]; - /** The data returned from the `action` or `clientAction` following an action submission. */ - actionData?: T["actionData"]; - /** An array of the current {@link https://api.reactrouter.com/v7/interfaces/react_router.UIMatch.html route matches}, including parent route matches. */ - matches: Matches<[...T["parents"], T]>; -}; - -type CreateErrorBoundaryProps = { - /** - * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. - * @example - * // app/routes.ts - * route("teams/:teamId", "./team.tsx"), - * - * // app/team.tsx - * export function ErrorBoundary({ - * params, - * }: Route.ErrorBoundaryProps) { - * params.teamId; - * // ^ string - * } - **/ - params: T["params"]; - error: unknown; - loaderData?: T["loaderData"]; - actionData?: T["actionData"]; -}; - -export type RouteModuleAnnotations = { - // links - LinkDescriptors: LinkDescriptor[]; - LinksFunction: () => LinkDescriptor[]; - - // meta - MetaArgs: CreateMetaArgs; - MetaDescriptors: MetaDescriptors; - MetaFunction: (args: CreateMetaArgs) => MetaDescriptors; - - // headers - HeadersArgs: HeadersArgs; - HeadersFunction: (args: HeadersArgs) => Headers | HeadersInit; - - // middleware - unstable_MiddlewareFunction: CreateServerMiddlewareFunction; - - // clientMiddleware - unstable_ClientMiddlewareFunction: CreateClientMiddlewareFunction; - - // loader - LoaderArgs: CreateServerLoaderArgs; - - // clientLoader - ClientLoaderArgs: CreateClientLoaderArgs; - - // action - ActionArgs: CreateServerActionArgs; - - // clientAction - ClientActionArgs: CreateClientActionArgs; - - // HydrateFallback - HydrateFallbackProps: CreateHydrateFallbackProps; - - // default (Component) - ComponentProps: CreateComponentProps; - - // ErrorBoundary - ErrorBoundaryProps: CreateErrorBoundaryProps; -}; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -type __tests = [ - // LoaderData - Expect, undefined>>, - Expect< - Equal< - CreateLoaderData<{ - loader: () => { a: string; b: Date; c: () => boolean }; - }>, - { a: string; b: Date; c: undefined } - > - >, - Expect< - Equal< - CreateLoaderData<{ - clientLoader: () => { a: string; b: Date; c: () => boolean }; - }>, - { a: string; b: Date; c: () => boolean } - > - >, - Expect< - Equal< - CreateLoaderData<{ - loader: () => { a: string; b: Date; c: () => boolean }; - clientLoader: () => { d: string; e: Date; f: () => boolean }; - }>, - | { a: string; b: Date; c: undefined } - | { d: string; e: Date; f: () => boolean } - > - >, - Expect< - Equal< - CreateLoaderData<{ - loader: () => { a: string; b: Date; c: () => boolean }; - clientLoader: () => { d: string; e: Date; f: () => boolean }; - HydrateFallback: () => unknown; - }>, - | { a: string; b: Date; c: undefined } - | { d: string; e: Date; f: () => boolean } - > - >, - Expect< - Equal< - CreateLoaderData<{ - loader: () => { a: string; b: Date; c: () => boolean }; - clientLoader: (() => { d: string; e: Date; f: () => boolean }) & { - hydrate: true; - }; - }>, - | { a: string; b: Date; c: undefined } - | { d: string; e: Date; f: () => boolean } - > - >, - Expect< - Equal< - CreateLoaderData<{ - loader: () => { a: string; b: Date; c: () => boolean }; - clientLoader: (() => { d: string; e: Date; f: () => boolean }) & { - hydrate: true; - }; - HydrateFallback: () => unknown; - }>, - { d: string; e: Date; f: () => boolean } - > - >, - - // ActionData - Expect, undefined>>, - Expect< - Equal< - CreateActionData<{ - action: () => { a: string; b: Date; c: () => boolean }; - }>, - { a: string; b: Date; c: undefined } - > - >, - Expect< - Equal< - CreateActionData<{ - clientAction: () => { a: string; b: Date; c: () => boolean }; - }>, - { a: string; b: Date; c: () => boolean } - > - >, - Expect< - Equal< - CreateActionData<{ - action: () => { a: string; b: Date; c: () => boolean }; - clientAction: () => { d: string; e: Date; f: () => boolean }; - }>, - | { a: string; b: Date; c: undefined } - | { d: string; e: Date; f: () => boolean } - > - > -];