diff --git a/.changeset/typesafety.md b/.changeset/typesafety.md new file mode 100644 index 0000000000..f757afef42 --- /dev/null +++ b/.changeset/typesafety.md @@ -0,0 +1,138 @@ +--- +"@react-router/dev": minor +"react-router": minor +--- + +### Typesafety improvements + +React Router now generates types for each of your route modules. +You can access those types by importing them from `./+types/`. +For example: + +```ts +// app/routes/product.tsx +import type * as Route from "./+types/product"; + +export function loader({ params }: Route.LoaderArgs) {} + +export default function Component({ loaderData }: Route.ComponentProps) {} +``` + +This initial implementation targets type inference for: + +- `Params` : Path parameters from your routing config in `routes.ts` including file-based routing +- `LoaderData` : Loader data from `loader` and/or `clientLoader` within your route module +- `ActionData` : Action data from `action` and/or `clientAction` within your route module + +These types are then used to create types for route export args and props: + +- `LoaderArgs` +- `ClientLoaderArgs` +- `ActionArgs` +- `ClientActionArgs` +- `HydrateFallbackProps` +- `ComponentProps` (for the `default` export) +- `ErrorBoundaryProps` + +In the future, we plan to add types for the rest of the route module exports: `meta`, `links`, `headers`, `shouldRevalidate`, etc. +We also plan to generate types for typesafe `Link`s: + +```tsx + +// ^^^^^^^^^^^^^ ^^^^^^^^^ +// typesafe `to` and `params` based on the available routes in your app +``` + +#### Setup + +React Router will generate types into a `.react-router/` directory at the root of your app. +This directory is fully managed by React Router and is derived based on your route config (`routes.ts`). + +👉 ** Add `.react-router/` to `.gitignore` ** + +```txt +.react-router +``` + +You should also ensure that generated types for routes are always present before running typechecking, +especially for running typechecking in CI. + +👉 ** Add `react-router typegen` to your `typecheck` command in `package.json` ** + +```json +{ + "scripts": { + "typecheck": "react-router typegen && tsc" + } +} +``` + +To get TypeScript to use those generated types, you'll need to add them to `include` in `tsconfig.json`. +And to be able to import them as if they files next to your route modules, you'll also need to configure `rootDirs`. + +👉 ** Configure `tsconfig.json` for generated types ** + +```json +{ + "include": [".react-router/types/**/*"], + "compilerOptions": { + "rootDirs": [".", "./.react-router/types"] + } +} +``` + +#### `typegen` command + +You can manually generate types with the new `typegen` command: + +```sh +react-router typegen +``` + +However, manual type generation is tedious and types can get out of sync quickly if you ever forget to run `typegen`. +Instead, we recommend that you setup our new TypeScript plugin which will automatically generate fresh types whenever routes change. +That way, you'll always have up-to-date types. + +#### TypeScript plugin + +To get automatic type generation, you can use our new TypeScript plugin. + +👉 ** Add the TypeScript plugin to `tsconfig.json` ** + +```json +{ + "compilerOptions": { + "plugins": [{ "name": "@react-router/dev" }] + } +} +``` + +We plan to add some other goodies to our TypeScript plugin soon, including: + +- Automatic `jsdoc` for route exports that include links to official docs +- Autocomplete for route exports +- Warnings for non-HMR compliant exports + +##### VSCode + +TypeScript looks for plugins registered in `tsconfig.json` in the local `node_modules/`, +but VSCode ships with its own copy of TypeScript that is installed outside of your project. +For TypeScript plugins to work, you'll need to tell VSCode to use the local workspace version of TypeScript. + +👉 ** Ensure that VSCode is using the workspace version of TypeScript ** + +This should already be set up for you by a `.vscode/settings.json`: + +```json +{ + "typescript.tsdk": "node_modules/typescript/lib" +} +``` + +Alternatively, you can open up any TypeScript file and use CMD+SHIFT+P to find `Select TypeScript Version` and then select `Use Workspace Version`. You may need to quit VSCode and reopen it for this setting to take effect. + +##### Troubleshooting + +In VSCode, open up any TypeScript file in your project and then use CMD+SHIFT+P to select `Open TS Server log`. +There should be a log for `[react-router] setup` that indicates that the plugin was resolved correctly. +Then look for any errors in the log. diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..3662b3700e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} \ No newline at end of file diff --git a/decisions/0003-infer-types-for-useloaderdata-and-useactiondata-from-loader-and-action-via-generics.md b/decisions/0003-infer-types-for-useloaderdata-and-useactiondata-from-loader-and-action-via-generics.md index 1959f16183..c86a9c818f 100644 --- a/decisions/0003-infer-types-for-useloaderdata-and-useactiondata-from-loader-and-action-via-generics.md +++ b/decisions/0003-infer-types-for-useloaderdata-and-useactiondata-from-loader-and-action-via-generics.md @@ -2,7 +2,7 @@ Date: 2022-07-11 -Status: accepted +Status: Superseded by [#0012](./0012-type-inference.md) ## Context diff --git a/decisions/0012-type-inference.md b/decisions/0012-type-inference.md new file mode 100644 index 0000000000..17069ed710 --- /dev/null +++ b/decisions/0012-type-inference.md @@ -0,0 +1,275 @@ +# Type inference + +Date: 2024-09-20 + +Status: accepted + +Supersedes [#0003](./0003-infer-types-for-useloaderdata-and-useactiondata-from-loader-and-action-via-generics.md) + +## Context + +Now that Remix is being merged upstream into React Router, we have an opportunity to revisit our approach to typesafety. + +### Type inference + +There are three major aspects to typesafety in a framework like React Router: + +1. **Type inference from the route config** + + Some types are defined in the route config (`routes.ts`) but need to be inferred within a route module. + + For example, let's look at URL path parameters. + Remix had no mechanism for inferring path parameters as that information is not present _within_ a route module. + If a route's URL path was `/products/:id`, you'd have to manually specify `"id"` as a valid path parameter within that route module: + + ```ts + const params = useParams<"id">(); + params.id; + ``` + + This generic was nothing more than a convenient way to do a type cast. + You could completely alter the URL path for a route module, typechecking would pass, but then you would get runtime errors. + +2. **Type inference within a route** + + Some types are defined within a route module but need to be inferred across route exports. + + For example, loader data is defined by the return type of `loader` but needs to be accessed within the `default` component export: + + ```ts + export function loader() { + // define here 👇 + return { planet: "world" }; + } + + export default function Component() { + // access here 👇 + const data = useLoaderData(); + } + ``` + + Unlike the `useParams` generic, this isn't just a type cast. + The `useLoaderData` generic ensures that types account for serialization across the network. + However, it still requires you to add `typeof loader` every time. + + Not only that, but complex routes get very tricky to type correctly. + For example, `clientLoader`s don't run during the initial SSR render, but you can force the `clientLoader` data to always be present in your route component if you set `clientLoader.hydrate = true` _and_ provide a `HydrateFallback`. + Here are a couple cases that trip up most users: + + | `loader` | `clientLoader` | `clientLoader.hydrate` | `HydrateFallback` | Generic for `useLoaderData` | + | -------- | -------------- | ---------------------- | ----------------- | -------------------------------------- | + | ✅ | ❌ | `false` | ❌ | `typeof loader` | + | ❌ | ✅ | `false` | ❌ | `typeof clientLoader \| undefined` | + | ✅ | ✅ | `false` | ❌ | `typeof loader \| typeof clientLoader` | + | ✅ | ✅ | `true` | ❌ | `typeof loader \| typeof clientLoader` | + | ✅ | ✅ | `true` | ✅ | `typeof clientLoader` | + + The generic for `useLoaderData` starts to feel a lot like doing your taxes: there's only one right answer, Remix knows what it is, but you're going to get quizzed on it anyway. + +3. **Type inference across routes** + + Some types are defined in one route module but need to be inferred in another route module. + This is common when wanting to access loader data of matched routes like when using `useMatches` or `useRouteLoaderData`. + + ```ts + import type { loader as otherLoader } from "../other-route.ts"; + // hope the other route is also matched 👇 otherwise this will error at runtime + const otherData = useRouteLoaderData(); + ``` + + Again, its up to you to wire up the generics with correct types. + In this case you need to know both types defined in the route config (to know which routes are matched) and types defined in other route modules (to know the loader data for those routes). + +In practice, Remix's generics work fine most of the time. +But they are mostly boilerplate and can become error-prone as the app scales. +An ideal solution would infer types correctly on your behalf, doing away with tedious generics. + +## Goals + +- Type inference from the route config (`routes.ts`) +- Type inference within a route +- Type inference across routes +- Same code path for type inference whether using programmatic routing or file-based routing +- Compatibility with standard tooling for treeshaking, HMR, etc. +- Minimal impact on runtime API design + +## Decisions + +### Route exports API + +Keep the route module export API as is. +Route modules should continue to export separate values for `loader`, `clientLoader`, `action`, `ErrorBoundary`, `default` component, etc. +That way standard transforms like treeshaking and React Fast Refresh (HMR) work out-of-the-box. + +Additionally, this approach introduces no breaking changes allowing Remix users to upgrade to React Router v7 more easily. + +### Pass path params, loader data, and action data as props + +Hooks like `useParams`, `useLoaderData`, and `useActionData` are defined once in `react-router` and are meant to be used in _any_ route. +Without any coupling to a specific route, inferring route-specific types becomes impossible and would necessitate user-supplied generics. + +Instead, each route export should be provided route-specific args: + +```ts +// Imagine that we *somehow* had route-specific types for: +// - LoaderArgs +// - ClientLoaderArgs +// - DefaultProps + +export function loader({ params }: LoaderArgs) {} + +export function clientLoader({ params, serverLoader }: ClientLoaderArgs) {} + +export default function Component({ + params, + loaderData, + actionData, +}: DefaultProps) { + // ... +} +``` + +We'll keep those hooks around for backwards compatibility, but eventually the aim is to deprecate and remove them. +We can design new, typesafe alternatives for any edge cases. + +### Typegen + +While React Router will default to programmatic routing, it can easily be configured for file-based routing. +That means that sometimes route URLs will only be represented as file paths. +Unfortunately, TypeScript cannot use the filesystem as part of its type inference nor type checking. +The only tenable way to infer types based on file paths is through code generation. + +We _could_ have typegen just for file-based routing, but then we'd need to maintain a separate code path for type inference in programmatic routing. +To keep things simple, React Router treats any value returned by `routes.ts` the same; it will not make assumptions about _how_ those routes were constructed and will run typegen in all cases. + +To that end, React Router will generate types for each route module into a special, gitignored `.react-router` directory. +For example: + +```txt +- .react-router/ + - types/ + - app/ + - routes/ + - +types.product.ts +- app/ + - routes/ + - product.tsx +``` + +The path within `.react-router/types` purposefully mirrors the path to the corresponding route module. +By setting things up like this, we can use `tsconfig.json`'s [rootDirs](https://www.typescriptlang.org/tsconfig/#rootDirs) option to let you conveniently import from the typegen file as if it was a sibling: + +```ts +// app/routes/product.tsx +import { LoaderArgs, DefaultProps } from "./+types.product"; +``` + +TypeScript will even give you import autocompletion for the typegen file and the `+` prefix helps to distinguish it as a special file. +Big thanks to Svelte Kit for showing us that [`rootDirs` trick](https://svelte.dev/blog/zero-config-type-safety#virtual-files)! + +### TypeScript plugin + +Typegen solutions often receive criticism due to typegen'd files becoming out of sync during development. +This happens because many typegen solutions require you to then rerun a script to update the typegen'd files. + +Instead, our typegen will automatically run within a TypeScript plugin. +That means you should never need to manually run a typegen command during development. +It also means that you don't need to run our dev server for typegen to take effect. +The only requirement is that your editor is open. + +Additionally, TypeScript plugins work with any LSP-compatible editor. +That means that this single plugin will work in VS Code, Neovim, or any other popular editor. + +Even more exciting is that a TS plugin sets the stage for tons of other DX goodies: + +- jsdoc and links to official documentation when you hover a route export +- Snippet-like autocomplete for route exports +- In-editor warnings when you forget to name your React components, which would cause HMR to fail +- ...and more... + +## Rejected solutions + +### `defineRoute` + +Early on, we considered changing the route module API from many exports to a single `defineRoute` export: + +```tsx +export default defineRoute({ + loader() { + return { planet: "world" }; + }, + Component({ loaderData }) { + return

Hello, {loaderData.planet}!

; + }, +}); +``` + +That way `defineRoute` could do some TypeScript magic to infer `loaderData` based on `loader` (type inference within a route). +With some more work, we envisioned that `defineRoute` could return utilities like a typesafe `useRouteLoaderData` (type inference across routes). + +However, there were still many drawbacks with this design: + +1. Type inference across function arguments depends on the ordering of those arguments. + That means that if you put `Component` before `loader` type inference is busted and you'll get gnarly type errors. + +2. Any mechanism expressible solely as code in a route module cannot infer types from the route config (`routes.ts`). + That means no type inference for things like path params nor for ``. + +3. Transforms that expect to operate on module exports can no longer access parts of the route. + For example, bundlers would only see one big export so they would bail out of treeshaking route modules. + Similarly, React-based HMR via React Fast Refresh looks for React components as exports of a module. + It would be possible to augment React component detection for HMR to look within a function call like `defineRoute`, but it significantly ups the complexity. + +### `defineLoader` and friends + +Instead of a single `defineRoute` function as described above, we could have a `define*` function for each route export: + +```tsx +import { defineLoader } from "./+types.product"; + +export const loader = defineLoader(() => { + return { planet: "world" }; +}); +``` + +That would address the most of the drawbacks of the `defineRoute` approach. +However, this adds significant noise to the code. +It also means we're introducing a runtime API that only exists for typesafety. + +Additionally, utilities like `defineLoader` are implemented with an `extends` generic that [does not pin point incorrect return statements](https://tsplay.dev/WJP7ZN): + +```ts +const defineLoader = (loader: T): T => loader; + +export const loader = defineLoader(() => { + // ^^^^^^^ + // Argument of type '() => "string" | 1' is not assignable to parameter of type 'Loader'. + // Type 'string | number' is not assignable to type 'number'. + // Type 'string' is not assignable to type 'number'.(2345) + + if (Math.random() > 0.5) return "string"; // 👈 don't you wish the error was here instead? + return 1; +}); +``` + +### Zero-effort typesafety + +Svelte Kit has a ["zero-effort" type safety approach](https://svelte.dev/blog/zero-config-type-safety) that uses a TypeScript language service plugin to automatically inject types for framework-specific exports. +Initially, this seemed like a good fit for React Router too, but we ran into a couple drawbacks: + +1. Tools like `typescript-eslint` that need to statically inspect the types of your TS files without running a language server would not be aware of the injected types. + There's an open issue for [`typescript-eslint` interop with Svelte Kit](https://github.com/sveltejs/language-tools/issues/2073) + +2. Running `tsc` would perform typechecking without any knowledge of our custom language service. + To fix this, we would need to wrap `tsc` in our own CLI that programmatically calls the TS typechecker. + For Svelte Kit, this isn't as big of an issue since they already need their own typecheck command for the Svelte language: `svelte-check`. + But since React Router is pure TypeScript, it would be more natural to invoke `tsc` directly in your `package.json` scripts. + +## Summary + +By leaning into automated typegen within a TypeScript plugin, we radically simplify React Router's runtime APIs while providing strong type inference across the entire framework. +We can continue to support programmatic routing _and_ file-based routing in `routes.ts` while providing typesafety with the same approach and same code path. +We can design our runtime APIs without introducing bespoke ways to inform TypeScript of the route hierarchy. + +The initial implementation will be focused on typesafety for path params, loader data, and action data. +That said, this foundation lets us add type inference for things like `` and search params in the future. diff --git a/packages/react-router-dev/__tests__/cli-test.ts b/packages/react-router-dev/__tests__/cli-test.ts index 045490f0d6..aecfb7160f 100644 --- a/packages/react-router-dev/__tests__/cli-test.ts +++ b/packages/react-router-dev/__tests__/cli-test.ts @@ -142,7 +142,11 @@ describe("remix CLI", () => { $ react-router reveal entry.server $ react-router reveal entry.client --no-typescript $ react-router reveal entry.server --no-typescript - $ react-router reveal entry.server --config vite.react-router.config.ts" + $ react-router reveal entry.server --config vite.react-router.config.ts + + Generate types for route modules: + + $ react-router typegen" `); }); }); diff --git a/packages/react-router-dev/cli/commands.ts b/packages/react-router-dev/cli/commands.ts index 0eac22f476..1c7e7e9578 100644 --- a/packages/react-router-dev/cli/commands.ts +++ b/packages/react-router-dev/cli/commands.ts @@ -11,6 +11,7 @@ import type { RoutesFormat } from "../config/format"; import { loadPluginContext } from "../vite/plugin"; import { transpile as convertFileToJS } from "./useJavascript"; import * as profiler from "../vite/profiler"; +import * as Typegen from "../typescript/typegen"; export async function routes( reactRouterRoot?: string, @@ -190,3 +191,12 @@ async function createClientEntry( let contents = await fse.readFile(inputFile, "utf-8"); return contents; } + +export async function typegen(root: string) { + let ctx = await loadPluginContext({ root }); + await Typegen.writeAll({ + rootDirectory: root, + appDirectory: ctx.reactRouterConfig.appDirectory, + routes: ctx.reactRouterConfig.routes, + }); +} diff --git a/packages/react-router-dev/cli/run.ts b/packages/react-router-dev/cli/run.ts index 587ca6d116..5751d49a68 100644 --- a/packages/react-router-dev/cli/run.ts +++ b/packages/react-router-dev/cli/run.ts @@ -68,6 +68,10 @@ ${colors.logoBlue("react-router")} $ react-router reveal entry.client --no-typescript $ react-router reveal entry.server --no-typescript $ react-router reveal entry.server --config vite.react-router.config.ts + + ${colors.heading("Generate types for route modules")}: + + $ react-router typegen `; /** @@ -170,6 +174,9 @@ export async function run(argv: string[] = process.argv.slice(2)) { case "dev": await commands.dev(input[1], flags); break; + case "typegen": + await commands.typegen(input[1]); + break; default: // `react-router ./my-project` is shorthand for `react-router dev ./my-project` await commands.dev(input[0], flags); diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json index 671d7cf2b9..7acf8e2124 100644 --- a/packages/react-router-dev/package.json +++ b/packages/react-router-dev/package.json @@ -12,6 +12,7 @@ "directory": "packages/react-router-dev" }, "license": "MIT", + "main": "./dist/typescript/plugin.ts", "exports": { "./routes": { "types": "./dist/routes.d.ts", @@ -47,6 +48,7 @@ "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", "chalk": "^4.1.2", + "chokidar": "^4.0.0", "dedent": "^1.5.3", "es-module-lexer": "^1.3.1", "exit-hook": "2.2.1", @@ -54,14 +56,15 @@ "gunzip-maybe": "^1.4.2", "jsesc": "3.0.2", "lodash": "^4.17.21", + "pathe": "^1.1.2", "picocolors": "^1.0.0", "picomatch": "^2.3.1", "prettier": "^2.7.1", "react-refresh": "^0.14.0", "semver": "^7.3.7", "set-cookie-parser": "^2.6.0", - "vite-node": "^1.6.0", - "valibot": "^0.41.0" + "valibot": "^0.41.0", + "vite-node": "^1.6.0" }, "devDependencies": { "@react-router/serve": "workspace:*", diff --git a/packages/react-router-dev/rollup.config.js b/packages/react-router-dev/rollup.config.js index 176d127017..f5f36f8ad9 100644 --- a/packages/react-router-dev/rollup.config.js +++ b/packages/react-router-dev/rollup.config.js @@ -29,13 +29,14 @@ module.exports = function rollup() { `${SOURCE_DIR}/routes.ts`, `${SOURCE_DIR}/vite.ts`, `${SOURCE_DIR}/vite/cloudflare.ts`, + `${SOURCE_DIR}/typescript/plugin.ts`, ], output: { banner: createBanner("@react-router/dev", version), dir: OUTPUT_DIR, format: "cjs", preserveModules: true, - exports: "named", + exports: "auto", }, plugins: [ babel({ diff --git a/packages/react-router-dev/typescript/plugin.ts b/packages/react-router-dev/typescript/plugin.ts new file mode 100644 index 0000000000..63de259c53 --- /dev/null +++ b/packages/react-router-dev/typescript/plugin.ts @@ -0,0 +1,22 @@ +// For compatibility with the TS language service plugin API, this entrypoint: +// - MUST only export the typescript plugin as its default export +// - MUST be compiled to CJS +// - MUST be listed as `main` in `package.json` + +import type ts from "typescript/lib/tsserverlibrary"; +import * as Path from "pathe"; + +import * as Typegen from "./typegen"; + +export default function init() { + function create(info: ts.server.PluginCreateInfo) { + const { logger } = info.project.projectService; + logger.info("[react-router] setup"); + + const rootDirectory = Path.normalize(info.project.getCurrentDirectory()); + Typegen.watch(rootDirectory); + + return info.languageService; + } + return { create }; +} diff --git a/packages/react-router-dev/typescript/typegen.ts b/packages/react-router-dev/typescript/typegen.ts new file mode 100644 index 0000000000..bde4fd87de --- /dev/null +++ b/packages/react-router-dev/typescript/typegen.ts @@ -0,0 +1,189 @@ +import fs from "node:fs"; + +import Chokidar from "chokidar"; +import dedent from "dedent"; +import * as Path from "pathe"; +import * as Pathe from "pathe/utils"; + +import { + configRoutesToRouteManifest, + type RouteManifest, + type RouteManifestEntry, +} from "../config/routes"; +import * as ViteNode from "../vite/vite-node"; +import { findEntry } from "../vite/config"; +import { loadPluginContext } from "../vite/plugin"; + +type Context = { + rootDirectory: string; + appDirectory: string; + routes: RouteManifest; +}; + +function getDirectory(ctx: Context) { + return Path.join(ctx.rootDirectory, ".react-router/types"); +} + +export function getPath(ctx: Context, route: RouteManifestEntry): string { + return Path.join( + getDirectory(ctx), + "app", + Path.dirname(route.file), + "+types." + Pathe.filename(route.file) + ".d.ts" + ); +} + +export async function watch(rootDirectory: string) { + const vitePluginCtx = await loadPluginContext({ root: rootDirectory }); + const routesTsPath = Path.join( + vitePluginCtx.reactRouterConfig.appDirectory, + "routes.ts" + ); + + const routesViteNodeContext = await ViteNode.createContext({ + root: rootDirectory, + }); + async function getRoutes(): Promise { + const routes: RouteManifest = {}; + const rootRouteFile = findEntry( + vitePluginCtx.reactRouterConfig.appDirectory, + "root" + ); + if (rootRouteFile) { + routes.root = { path: "", id: "root", file: rootRouteFile }; + } + + routesViteNodeContext.devServer.moduleGraph.invalidateAll(); + routesViteNodeContext.runner.moduleCache.clear(); + + const result = await routesViteNodeContext.runner.executeFile(routesTsPath); + return { + ...routes, + ...configRoutesToRouteManifest(result.routes), + }; + } + + const ctx: Context = { + rootDirectory, + appDirectory: vitePluginCtx.reactRouterConfig.appDirectory, + routes: await getRoutes(), + }; + await writeAll(ctx); + + const watcher = Chokidar.watch(ctx.appDirectory, { ignoreInitial: true }); + watcher.on("all", async (event, path) => { + path = Path.normalize(path); + ctx.routes = await getRoutes(); + + const routeConfigChanged = Boolean( + routesViteNodeContext.devServer.moduleGraph.getModuleById(path) + ); + if (routeConfigChanged) { + await writeAll(ctx); + return; + } + + const isRoute = Object.values(ctx.routes).find( + (route) => path === Path.join(ctx.appDirectory, route.file) + ); + if (isRoute && (event === "add" || event === "unlink")) { + await writeAll(ctx); + return; + } + }); +} + +export async function writeAll(ctx: Context): Promise { + fs.rmSync(getDirectory(ctx), { recursive: true, force: true }); + Object.values(ctx.routes).forEach((route) => { + if (!fs.existsSync(Path.join(ctx.appDirectory, route.file))) return; + const typesPath = getPath(ctx, route); + const content = getModule(ctx.routes, route); + fs.mkdirSync(Path.dirname(typesPath), { recursive: true }); + fs.writeFileSync(typesPath, content); + }); +} + +function getModule(routes: RouteManifest, route: RouteManifestEntry): string { + return dedent` + // React Router generated types for route: + // ${route.file} + + import * as T from "react-router/types" + + export type Params = {${formattedParamsProperties(routes, route)}} + + type Route = typeof import("./${Pathe.filename(route.file)}") + + export type LoaderData = T.CreateLoaderData + export type ActionData = T.CreateActionData + + export type LoaderArgs = T.CreateServerLoaderArgs + export type ClientLoaderArgs = T.CreateClientLoaderArgs + export type ActionArgs = T.CreateServerActionArgs + export type ClientActionArgs = T.CreateClientActionArgs + + export type HydrateFallbackProps = T.CreateHydrateFallbackProps + export type ComponentProps = T.CreateComponentProps + export type ErrorBoundaryProps = T.CreateErrorBoundaryProps + `; +} + +function formattedParamsProperties( + routes: RouteManifest, + route: RouteManifestEntry +) { + const urlpath = routeLineage(routes, route) + .map((route) => route.path) + .join("/"); + const params = parseParams(urlpath); + const indent = " ".repeat(3); + const properties = Object.entries(params).map(([name, values]) => { + if (values.length === 1) { + const isOptional = values[0]; + return indent + (isOptional ? `${name}?: string` : `${name}: string`); + } + const items = values.map((isOptional) => + isOptional ? "string | undefined" : "string" + ); + return indent + `${name}: [${items.join(", ")}]`; + }); + + // prettier-ignore + const body = + properties.length === 0 ? "" : + "\n" + properties.join("\n") + "\n"; + + return body; +} + +function routeLineage(routes: RouteManifest, route: RouteManifestEntry) { + const result: RouteManifestEntry[] = []; + while (route) { + result.push(route); + if (!route.parentId) break; + route = routes[route.parentId]; + } + result.reverse(); + return result; +} + +function parseParams(urlpath: string) { + const result: Record = {}; + + let segments = urlpath.split("/"); + segments + .filter((s) => s.startsWith(":")) + .forEach((param) => { + param = param.slice(1); // omit leading `:` + let isOptional = param.endsWith("?"); + if (isOptional) { + param = param.slice(0, -1); // omit trailing `?` + } + + result[param] ??= []; + result[param].push(isOptional); + return; + }); + return result; +} diff --git a/packages/react-router-dev/vite/config.ts b/packages/react-router-dev/vite/config.ts index 87358d003d..b1e465115b 100644 --- a/packages/react-router-dev/vite/config.ts +++ b/packages/react-router-dev/vite/config.ts @@ -1,5 +1,4 @@ import type * as Vite from "vite"; -import type { ViteNodeRunner } from "vite-node/client"; import { execSync } from "node:child_process"; import path from "node:path"; import fse from "fs-extra"; @@ -7,6 +6,7 @@ import colors from "picocolors"; import pick from "lodash/pick"; import omit from "lodash/omit"; import PackageJson from "@npmcli/package-json"; +import type * as ViteNode from "./vite-node"; import { type RouteManifest, @@ -288,14 +288,14 @@ export async function resolveReactRouterConfig({ routeConfigChanged, viteUserConfig, viteCommand, - viteNodeRunner, + routesViteNodeContext, }: { rootDirectory: string; reactRouterUserConfig: ReactRouterConfig; routeConfigChanged: boolean; viteUserConfig: Vite.UserConfig; viteCommand: Vite.ConfigEnv["command"]; - viteNodeRunner: ViteNodeRunner; + routesViteNodeContext: ViteNode.Context; }) { let vite = importViteEsmSync(); @@ -433,7 +433,9 @@ export async function resolveReactRouterConfig({ setAppDirectory(appDirectory); let routeConfigExport: RouteConfig = ( - await viteNodeRunner.executeFile(path.join(appDirectory, routeConfigFile)) + await routesViteNodeContext.runner.executeFile( + path.join(appDirectory, routeConfigFile) + ) ).routes; let routeConfig = await routeConfigExport; @@ -585,7 +587,7 @@ export async function resolveEntryFiles({ const entryExts = [".js", ".jsx", ".ts", ".tsx"]; -function findEntry(dir: string, basename: string): string | undefined { +export function findEntry(dir: string, basename: string): string | undefined { for (let ext of entryExts) { let file = path.resolve(dir, basename + ext); if (fse.existsSync(file)) return path.relative(dir, file); diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index d0f8f7f2cb..5c67ec9ba6 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -4,9 +4,6 @@ import type * as Vite from "vite"; import { type BinaryLike, createHash } from "node:crypto"; import * as path from "node:path"; import * as url from "node:url"; -import { ViteNodeServer } from "vite-node/server"; -import { ViteNodeRunner } from "vite-node/client"; -import { installSourcemapsSupport } from "vite-node/source-map"; import * as fse from "fs-extra"; import babel from "@babel/core"; import { @@ -46,6 +43,7 @@ import { resolvePublicPath, } from "./config"; import * as WithProps from "./with-props"; +import * as ViteNode from "./vite-node"; export async function resolveViteConfig({ configFile, @@ -430,8 +428,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { let viteConfig: Vite.ResolvedConfig | undefined; let cssModulesManifest: Record = {}; let viteChildCompiler: Vite.ViteDevServer | null = null; - let routeConfigViteServer: Vite.ViteDevServer | null = null; - let viteNodeRunner: ViteNodeRunner | null = null; + let routesViteNodeContext: ViteNode.Context | null = null; let ssrExternals = isInReactRouterMonorepo() ? [ @@ -464,14 +461,14 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { let rootDirectory = viteUserConfig.root ?? process.env.REACT_ROUTER_ROOT ?? process.cwd(); - invariant(viteNodeRunner); + invariant(routesViteNodeContext); let reactRouterConfig = await resolveReactRouterConfig({ rootDirectory, reactRouterUserConfig, routeConfigChanged, viteUserConfig, viteCommand, - viteNodeRunner, + routesViteNodeContext, }); let { entryClientFilePath, entryServerFilePath } = await resolveEntryFiles({ @@ -762,40 +759,15 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { viteConfigEnv = _viteConfigEnv; viteCommand = viteConfigEnv.command; - routeConfigViteServer = await vite.createServer({ + routesViteNodeContext = await ViteNode.createContext({ + root: viteUserConfig.root, mode: viteConfigEnv.mode, server: { watch: viteCommand === "build" ? null : undefined, - preTransformRequests: false, - hmr: false, }, ssr: { external: ssrExternals, }, - optimizeDeps: { - noDiscovery: true, - }, - configFile: false, - envFile: false, - plugins: [], - }); - await routeConfigViteServer.pluginContainer.buildStart({}); - - let viteNodeServer = new ViteNodeServer(routeConfigViteServer); - - installSourcemapsSupport({ - getSourceMap: (source) => viteNodeServer.getSourceMap(source), - }); - - viteNodeRunner = new ViteNodeRunner({ - root: routeConfigViteServer.config.root, - base: routeConfigViteServer.config.base, - fetchModule(id) { - return viteNodeServer.fetchModule(id); - }, - resolveId(id, importer) { - return viteNodeServer.resolveId(id, importer); - }, }); await updatePluginContext(); @@ -1114,12 +1086,14 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { filepath === normalizePath(viteConfig.configFile); let routeConfigChanged = Boolean( - routeConfigViteServer?.moduleGraph.getModuleById(filepath) + routesViteNodeContext?.devServer?.moduleGraph.getModuleById( + filepath + ) ); if (routeConfigChanged || appFileAddedOrRemoved) { - routeConfigViteServer?.moduleGraph.invalidateAll(); - viteNodeRunner?.moduleCache.clear(); + routesViteNodeContext?.devServer?.moduleGraph.invalidateAll(); + routesViteNodeContext?.runner?.moduleCache.clear(); } if ( @@ -1268,7 +1242,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { }, async buildEnd() { await viteChildCompiler?.close(); - await routeConfigViteServer?.close(); + await routesViteNodeContext?.devServer?.close(); }, }, { diff --git a/packages/react-router-dev/vite/vite-node.ts b/packages/react-router-dev/vite/vite-node.ts new file mode 100644 index 0000000000..1261bae36a --- /dev/null +++ b/packages/react-router-dev/vite/vite-node.ts @@ -0,0 +1,57 @@ +import { ViteNodeServer } from "vite-node/server"; +import { ViteNodeRunner } from "vite-node/client"; +import { installSourcemapsSupport } from "vite-node/source-map"; +import type * as Vite from "vite"; + +import { importViteEsmSync, preloadViteEsm } from "./import-vite-esm-sync"; + +export type Context = { + devServer: Vite.ViteDevServer; + server: ViteNodeServer; + runner: ViteNodeRunner; +}; + +export async function createContext( + viteConfig: Vite.InlineConfig = {} +): Promise { + await preloadViteEsm(); + const vite = importViteEsmSync(); + + const devServer = await vite.createServer( + vite.mergeConfig( + { + server: { + preTransformRequests: false, + hmr: false, + }, + optimizeDeps: { + noDiscovery: true, + }, + configFile: false, + envFile: false, + plugins: [], + }, + viteConfig + ) + ); + await devServer.pluginContainer.buildStart({}); + + const server = new ViteNodeServer(devServer); + + installSourcemapsSupport({ + getSourceMap: (source) => server.getSourceMap(source), + }); + + const runner = new ViteNodeRunner({ + root: devServer.config.root, + base: devServer.config.base, + fetchModule(id) { + return server.fetchModule(id); + }, + resolveId(id, importer) { + return server.resolveId(id, importer); + }, + }); + + return { devServer, server, runner }; +} diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index f6396d2e5d..8715cec2b6 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -120,10 +120,6 @@ interface DataFunctionArgs { context?: Context; } -// TODO: (v7) Change the defaults from any to unknown in and remove Remix wrappers: -// ActionFunction, ActionFunctionArgs, LoaderFunction, LoaderFunctionArgs -// Also, make them a type alias instead of an interface - /** * Arguments passed to loader functions */ diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index 246aa0e124..b7d5cf9bbd 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -282,7 +282,7 @@ export function getSingleFetchRedirect( }; } -type Serializable = +export type Serializable = | undefined | null | boolean diff --git a/packages/react-router/lib/types.ts b/packages/react-router/lib/types.ts new file mode 100644 index 0000000000..01fd21b1d4 --- /dev/null +++ b/packages/react-router/lib/types.ts @@ -0,0 +1,260 @@ +import type { AppLoadContext } from "./server-runtime/data"; +import type { Serializable } from "./server-runtime/single-fetch"; + +export type Expect = T; +// prettier-ignore +type Equal = + (() => T extends X ? 1 : 2) extends + (() => T extends Y ? 1 : 2) ? true : false +type IsAny = 0 extends 1 & T ? true : false; +type IsDefined = Equal extends true ? false : true; +type Fn = (...args: any[]) => unknown; + +type RouteModule = { + loader?: Fn; + clientLoader?: Fn; + action?: Fn; + clientAction?: Fn; + HydrateFallback?: unknown; + default?: unknown; + ErrorBoundary?: unknown; +}; + +type VoidToUndefined = Equal extends true ? undefined : T; + +// prettier-ignore +type DataFrom = + IsAny extends true ? undefined : + T extends Fn ? VoidToUndefined>> : + undefined + +type ServerDataFrom = Serialize>; +type ClientDataFrom = DataFrom; + +// 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: Fn } ? 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 ? + ClientLoaderHydrate extends true ? ClientLoaderData : + ClientLoaderData | undefined + : + 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 DataFunctionArgs = { + request: Request; + params: Params; + context?: AppLoadContext; +}; + +// prettier-ignore +type Serialize = + // First, let type stay as-is if its already serializable... + T extends Serializable ? T : + + // ...then don't allow functions to be serialized... + T extends (...args: any[]) => unknown ? undefined : + + // ...lastly handle inner types for all container types allowed by `turbo-stream` + + // Promise + T extends Promise ? Promise> : + + // Map & Set + T extends Map ? Map, Serialize> : + T extends Set ? Set> : + + // Array + T extends [] ? [] : + T extends readonly [infer F, ...infer R] ? [Serialize, ...Serialize] : + T extends Array ? Array> : + T extends readonly unknown[] ? readonly Serialize[] : + + // Record + T extends Record ? {[K in keyof T]: Serialize} : + + undefined + +export type CreateServerLoaderArgs = DataFunctionArgs; + +export type CreateClientLoaderArgs< + Params, + T extends RouteModule +> = DataFunctionArgs & { + serverLoader: () => Promise>; +}; + +export type CreateServerActionArgs = DataFunctionArgs; + +export type CreateClientActionArgs< + Params, + T extends RouteModule +> = DataFunctionArgs & { + serverAction: () => Promise>; +}; + +export type CreateHydrateFallbackProps = { + params: Params; +}; + +export type CreateComponentProps = { + params: Params; + loaderData: LoaderData; + actionData?: ActionData; +}; + +export type CreateErrorBoundaryProps = { + params: Params; + error: unknown; + loaderData?: LoaderData; + actionData?: ActionData; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type __tests = [ + // ServerDataFrom + Expect, undefined>>, + Expect< + Equal< + ServerDataFrom<() => { a: string; b: Date; c: () => boolean }>, + { a: string; b: Date; c: undefined } + > + >, + + // ClientDataFrom + Expect, undefined>>, + Expect< + Equal< + ClientDataFrom<() => { a: string; b: Date; c: () => boolean }>, + { a: string; b: Date; c: () => boolean } + > + >, + + // 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 }; + }>, + undefined | { 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 } + > + > +]; diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 06a8118c9a..215eb32c85 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -28,6 +28,9 @@ "import": "./dist/index.mjs", "require": "./dist/main.js" }, + "./types": { + "types": "./dist/lib/types.d.ts" + }, "./dom": { "types": "./dist/dom-export.d.ts", "import": "./dist/dom-export.mjs", diff --git a/packages/react-router/rollup.config.js b/packages/react-router/rollup.config.js index 3aecb2f14b..08b2c1ad0b 100644 --- a/packages/react-router/rollup.config.js +++ b/packages/react-router/rollup.config.js @@ -93,6 +93,37 @@ module.exports = function rollup() { }), ].concat(PRETTY ? prettier({ parser: "babel" }) : []), }, + { + input: `${SOURCE_DIR}/lib/types.ts`, + output: { + file: `${OUTPUT_DIR}/lib/types.mjs`, + format: "esm", + banner: createBanner("React Router", version), + }, + external: isBareModuleId, + plugins: [ + nodeResolve({ extensions: [".tsx", ".ts"] }), + babel({ + babelHelpers: "bundled", + exclude: /node_modules/, + presets: [ + ["@babel/preset-env", { loose: true }], + "@babel/preset-react", + "@babel/preset-typescript", + ], + plugins: [ + "babel-plugin-dev-expression", + babelPluginReplaceVersionPlaceholder(), + ], + extensions: [".ts", ".tsx"], + }), + typescript({ + // eslint-disable-next-line no-restricted-globals + tsconfig: path.join(__dirname, "tsconfig.dom.json"), + noEmitOnError: !WATCH, + }), + ], + }, ]; // JS modules for