diff --git a/.changeset/typesafety.md b/.changeset/typesafety.md new file mode 100644 index 0000000000..b4e20296db --- /dev/null +++ b/.changeset/typesafety.md @@ -0,0 +1,151 @@ +--- +"@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. +For security reasons, [VSCode won't use the workspace version of TypeScript](https://code.visualstudio.com/docs/typescript/typescript-compiling#_using-the-workspace-version-of-typescript) until you manually opt-in. + +Your project should have a `.vscode/settings.json` with the following settings: + +```json +{ + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true +} +``` + +That way [VSCode will ask you](https://code.visualstudio.com/updates/v1_45#_prompt-users-to-switch-to-the-workspace-version-of-typescript) if you want to use the workspace version of TypeScript the first time you open a TS file in that project. + +> [!IMPORTANT] +> You'll need to install dependencies first so that the workspace version of TypeScript is available. + +👉 **Select "Allow" when VSCode asks if you want to use the workspace version of TypeScript** + +Otherwise, you can also manually opt-in to the workspace version: + +1. Open up any TypeScript file in your project +2. Open up the VSCode Command Palette (Cmd+Shift+P) +3. Search for `Select TypeScript Version` +4. Choose `Use Workspace Version` +5. Quit and reopen VSCode + +##### 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..25fa6215fd --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/contributors.yml b/contributors.yml index 2082053191..cff1a1df41 100644 --- a/contributors.yml +++ b/contributors.yml @@ -15,6 +15,7 @@ - alany411 - alberto - alexandernanberg +- alexanderson1993 - alexlbr - AmRo045 - amsal 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/docs/deploying/cloudflare-spa.md b/docs/deploying/cloudflare-spa.md index bd4a5d0858..4ebdbb00a1 100644 --- a/docs/deploying/cloudflare-spa.md +++ b/docs/deploying/cloudflare-spa.md @@ -1,3 +1,9 @@ --- title: Cloudflare SPA --- + +# Deploying to Cloudflare with SPA + + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/deploying/cloudflare.md b/docs/deploying/cloudflare.md index 0c9647029e..b65995059d 100644 --- a/docs/deploying/cloudflare.md +++ b/docs/deploying/cloudflare.md @@ -1,3 +1,9 @@ --- title: Cloudflare --- + +# Deploying to Cloudflare with SPA + + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/deploying/custom-node.md b/docs/deploying/custom-node.md index 3491593f3f..d3021a698b 100644 --- a/docs/deploying/custom-node.md +++ b/docs/deploying/custom-node.md @@ -1,3 +1,9 @@ --- title: Custom Node.js --- + +# Deploying to a Custom Node.js Runtime + + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/deploying/custom-spa.md b/docs/deploying/custom-spa.md index 61d3c8b41e..a61ea93b02 100644 --- a/docs/deploying/custom-spa.md +++ b/docs/deploying/custom-spa.md @@ -1,3 +1,9 @@ --- title: Custom SPA --- + +# Deploying to a Custom SPA + + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/deploying/custom-wintercg.md b/docs/deploying/custom-wintercg.md index 2a2645ad33..64bea3c003 100644 --- a/docs/deploying/custom-wintercg.md +++ b/docs/deploying/custom-wintercg.md @@ -1,3 +1,9 @@ --- title: Custom WinterCG --- + +# Deploying to a Custom WinterCG + + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/deploying/netlify.md b/docs/deploying/netlify.md index 0683e9a393..e42f70ec01 100644 --- a/docs/deploying/netlify.md +++ b/docs/deploying/netlify.md @@ -1,3 +1,9 @@ --- title: Netlify --- + +# Deploying to Netlify + + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/deploying/vercel-spa.md b/docs/deploying/vercel-spa.md index cb735a4014..b27959d881 100644 --- a/docs/deploying/vercel-spa.md +++ b/docs/deploying/vercel-spa.md @@ -1,3 +1,9 @@ --- title: Vercel SPA --- + +# Deploying to Vercel SPA + + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/deploying/vercel.md b/docs/deploying/vercel.md index 44418fb5b6..5580a6b41b 100644 --- a/docs/deploying/vercel.md +++ b/docs/deploying/vercel.md @@ -1,3 +1,9 @@ --- title: Vercel --- + +# Deploying to Vercel + + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/discussion/code-splitting.md b/docs/discussion/code-splitting.md index 1fb68f766f..d88568985b 100644 --- a/docs/discussion/code-splitting.md +++ b/docs/discussion/code-splitting.md @@ -2,3 +2,9 @@ title: Automatic Code Splitting new: true --- + +# Automatic Code Splitting + + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/discussion/race-conditions.md b/docs/discussion/race-conditions.md index 4b536f25ca..ff258279e9 100644 --- a/docs/discussion/race-conditions.md +++ b/docs/discussion/race-conditions.md @@ -2,3 +2,9 @@ title: Race Conditions new: true --- + +# Race Conditions + + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/discussion/route-matching.md b/docs/discussion/route-matching.md index 982a5af492..ede7f00732 100644 --- a/docs/discussion/route-matching.md +++ b/docs/discussion/route-matching.md @@ -2,3 +2,9 @@ title: Route Matching new: true --- + +# Route Matching + + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/discussion/route-module-limitations.md b/docs/discussion/route-module-limitations.md index 366a46bdf9..ed3cd0a287 100644 --- a/docs/discussion/route-module-limitations.md +++ b/docs/discussion/route-module-limitations.md @@ -4,3 +4,7 @@ new: true --- # Route Module Limitations + + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/misc/form-validation.md b/docs/misc/form-validation.md index f1bf81160d..6f5d015e94 100644 --- a/docs/misc/form-validation.md +++ b/docs/misc/form-validation.md @@ -2,3 +2,9 @@ title: Form Validation new: true --- + +# Form Validation + + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/misc/manual-deployment.md b/docs/misc/manual-deployment.md index 3e65bff910..b218dbd8d1 100644 --- a/docs/misc/manual-deployment.md +++ b/docs/misc/manual-deployment.md @@ -4,6 +4,10 @@ title: Manual Deployment # Manual Deployment + + This document is a work in progress. There's not much to see here (yet). + + - static files - running the server - polyfilling globals diff --git a/docs/misc/react-server-components.md b/docs/misc/react-server-components.md index bbbc081604..23d42aa911 100644 --- a/docs/misc/react-server-components.md +++ b/docs/misc/react-server-components.md @@ -5,4 +5,6 @@ new: true # React Server Components -They work! + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/misc/server-rendering.md b/docs/misc/server-rendering.md index 6c50da8f5a..037a642a57 100644 --- a/docs/misc/server-rendering.md +++ b/docs/misc/server-rendering.md @@ -4,3 +4,7 @@ new: true --- # Server Rendering + + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/misc/streaming.md b/docs/misc/streaming.md index 5566b24c4a..c3dc663bbb 100644 --- a/docs/misc/streaming.md +++ b/docs/misc/streaming.md @@ -2,3 +2,9 @@ title: Streaming new: true --- + +# Streaming + + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/misc/view-transitions.md b/docs/misc/view-transitions.md index e84f45ee8c..e1b9683c82 100644 --- a/docs/misc/view-transitions.md +++ b/docs/misc/view-transitions.md @@ -2,3 +2,9 @@ title: View Transitions new: true --- + +# View Transitions + + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/start/actions.md b/docs/start/actions.md index d1b22d5e03..c67f40a37f 100644 --- a/docs/start/actions.md +++ b/docs/start/actions.md @@ -5,6 +5,10 @@ order: 6 # Actions + + The types for route modules are still in development, this API may change. + + Data mutations are done through Route actions. When the action completes, all loader data on the page is revalidated to keep your UI in sync with the data without writing any code to do it. Route actions defined with `action` are only called on the server while actions defined with `clientAction` are run in the browser. @@ -15,31 +19,37 @@ Client actions only run in the browser and take priority over a server action wh ```tsx filename=app/project.tsx // route('/projects/:projectId', './project.tsx') -import { defineRoute$, Form } from "react-router"; - -export default defineRoute$({ - clientAction({ request }) { - let formData = await request.formData(); - let title = await formData.get("title"); - let project = await someApi.updateProject({ title }); - return project; - }, - - Component({ data, actionData }) { - return ( -
-

Project

-
- - -
- {actionData ? ( -

{actionData.title} updated

- ) : null} -
- ); - }, -}); +import type { + DefaultProps, + ClientActionArgs, +} from "./+types.project"; +import { Form } from "react-router"; + +export async function clientAction({ + request, +}: ClientActionArgs) { + let formData = await request.formData(); + let title = await formData.get("title"); + let project = await someApi.updateProject({ title }); + return project; +} + +export default function Project({ + clientActionData, +}: DefaultProps) { + return ( +
+

Project

+
+ + +
+ {clientActionData ? ( +

{clientActionData.title} updated

+ ) : null} +
+ ); +} ``` ## Server Actions @@ -48,31 +58,35 @@ Server actions only run on the server and are removed from client bundles. ```tsx filename=app/project.tsx // route('/projects/:projectId', './project.tsx') -import { defineRoute$, Form } from "react-router"; - -export default defineRoute$({ - action({ request }) { - let formData = await request.formData(); - let title = await formData.get("title"); - let project = await someApi.updateProject({ title }); - return project; - }, - - Component({ data, actionData }) { - return ( -
-

Project

-
- - -
- {actionData ? ( -

{actionData.title} updated

- ) : null} -
- ); - }, -}); +import type { + DefaultProps, + ActionArgs, +} from "./+types.project"; +import { Form } from "react-router"; + +export async function action({ request }: ActionArgs) { + let formData = await request.formData(); + let title = await formData.get("title"); + let project = await someApi.updateProject({ title }); + return project; +} + +export default function Project({ + actionData, +}: DefaultProps) { + return ( +
+

Project

+
+ + +
+ {actionData ? ( +

{actionData.title} updated

+ ) : null} +
+ ); +} ``` ## Calling Actions @@ -153,4 +167,4 @@ fetcher.submit( ); ``` -See the [Using Fetchers](../guides/fetchers) guide for more information. +See the [Using Fetchers](../misc/fetchers) guide for more information. diff --git a/docs/start/data-loading.md b/docs/start/data-loading.md index 69c8f6bf16..11479af1b0 100644 --- a/docs/start/data-loading.md +++ b/docs/start/data-loading.md @@ -5,6 +5,10 @@ order: 5 # Data Loading + + The types for route modules are still in development, this API may change. + + Data is provided to the route via `loader` and `clientLoader`, and accessed in the `data` prop of the Route Component. ## Client Data Loading @@ -13,26 +17,31 @@ Data is provided to the route via `loader` and `clientLoader`, and accessed in t ```tsx filename=app/product.tsx // route("products/:pid", "./product.tsx"); -import { defineRoute$ } from "react-router"; - -export default defineRoute$({ - params: ["pid"], - - async clientLoader({ params }) { - const res = await fetch(`/api/products/${params.pid}`); - const product = await res.json(); - return { product }; - }, - - component: function Product({ data }) { - return ( -
-

{data.product.name}

-

{data.product.description}

-
- ); - }, -}); +import type { + DefaultProps, + ClientLoaderArgs, +} from "./+types.product"; +import { useLoaderData } from "react-router"; + +export async function clientLoader({ + params, +}: ClientLoaderArgs) { + const res = await fetch(`/api/products/${params.pid}`); + const product = await res.json(); + return { product }; +} + +export default function Product({ + clientLoaderData, +}: DefaultProps) { + const { name, description } = clientLoaderData.product; + return ( +
+

{name}

+

{description}

+
+ ); +} ``` ## Server Data Loading @@ -41,59 +50,64 @@ When server rendering, the `loader` method is used to fetch data on the server f ```tsx filename=app/product.tsx // route("products/:pid", "./product.tsx"); -import { defineRoute$ } from "react-router"; +import type { + DefaultProps, + LoaderArgs, +} from "./+types.product"; import { fakeDb } from "../db"; -export default defineRoute$({ - params: ["pid"], - - async loader({ params }) { - const product = await fakeDb.getProduct(params.pid); - return { product }; - }, - - Component({ data }) { - return ( -
-

{data.product.name}

-

{data.product.description}

-
- ); - }, -}); +export async function loader({ params }: LoaderArgs) { + const product = await fakeDb.getProduct(params.pid); + return { product }; +} + +export default function Product({ + loaderData, +}: DefaultProps) { + const { name, description } = loaderData.product; + return ( +
+

{name}

+

{description}

+
+ ); +} ``` Note that the `loader` function is removed from client bundles so you can use server only APIs without worrying about them being included in the browser. ## React Server Components +RSC is not supported yet + RSC is supported by returning components from loaders and actions. ```tsx filename=app/product.tsx // route("products/:pid", "./product.tsx"); -import { defineRoute$ } from "react-router"; +import type { + DefaultProps, + LoaderArgs, +} from "./+types.product"; import Product from "./product"; import Reviews from "./reviews"; -export default defineRoute$({ - params: ["pid"], - - async loader({ params }) { - return { - product: , - reviews: , - }; - }, - - Component({ data }) { - return ( -
- {data.product} - {data.reviews} -
- ); - }, -}); +export async function loader({ params }: LoaderArgs) { + return { + product: , + reviews: , + }; +} + +export default function ProductPage({ + loaderData, +}: DefaultProps) { + return ( +
+ {loaderData.product} + {loaderData.reviews} +
+ ); +} ``` ## Static Data Loading @@ -102,36 +116,38 @@ When pre-rendering, the `loader` method is used to fetch data at build time. ```tsx filename=app/product.tsx // route("products/:pid", "./product.tsx"); -import { defineRoute$ } from "react-router"; - -export default defineRoute$({ - params: ["pid"], - - async loader({ params }) { - let product = await getProductFromCSVFile(params.pid); - return { product }; - }, - - Component({ data }) { - return ( -
-

{data.product.name}

-

{data.product.description}

-
- ); - }, -}); +import type { + DefaultProps, + LoaderArgs, +} from "./+types.product"; + +export async function loader({ params }: LoaderArgs) { + let product = await getProductFromCSVFile(params.pid); + return { product }; +} + +export default function Product({ + loaderData, +}: DefaultProps) { + const { name, description } = loaderData.product; + return ( +
+

{name}

+

{description}

+
+ ); +} ``` The URLs to pre-render are specified in the Vite plugin. ```ts filename=vite.config.ts -import { plugin as app } from "@react-router/vite"; +import { reactRouter } from "@react-router/dev/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [ - app({ + reactRouter({ async prerender() { let products = await readProductsFromCSVFile(); return products.map( @@ -151,31 +167,38 @@ Note that when server rendering, any URLs that aren't pre-rendered will be serve ```tsx filename=app/product.tsx // route("products/:pid", "./product.tsx"); -import { defineRoute$ } from "react-router"; +import type { + DefaultProps, + ClientLoader, + LoaderArgs, +} from "./+types.product"; import { fakeDb } from "../db"; -export default defineRoute$({ - // SSR loads directly from the database - async loader({ params }) { - return fakeDb.getProduct(params.pid); - }, - - // client navigations fetch directly from the browser, - // skipping the react router server - async clientLoader({ params }) { - const res = await fetch(`/api/products/${params.pid}`); - return res.json(); - }, - - Component({ data }) { - return ( -
-

{data.name}

-

{data.description}

-
- ); - }, -}); +export async function loader({ params }: LoaderArgs) { + return fakeDb.getProduct(params.pid); +} + +export async function clientLoader({ + params, +}: ClientLoader) { + const res = await fetch(`/api/products/${params.pid}`); + return res.json(); +} + +export default function Product({ + loaderData, + clientLoaderData, +}: DefaultProps) { + const { name, description } = + clientLoaderData.product || loaderData.product; + + return ( +
+

{name}

+

{description}

+
+ ); +} ``` For more advanced use cases with `clientLoader` like caching, refer to [Advanced Data Fetching][advanced_data_fetching]. diff --git a/docs/start/deploying.md b/docs/start/deploying.md index aa4b542481..daf89b8be8 100644 --- a/docs/start/deploying.md +++ b/docs/start/deploying.md @@ -6,6 +6,10 @@ new: true # Deploying + + This document is a work in progress, and will be moved to to the deployment guides. + + React Router can be deployed two ways: - Fullstack Hosting @@ -100,7 +104,7 @@ This template includes: ### Manual Fullstack Deployment -If you want to deploy to your own server or a different hosting provider, see the [Manual Deployment](../guides/manual-deployment.md) guide. +If you want to deploy to your own server or a different hosting provider, see the [Manual Deployment](../misc/manual-deployment) guide. ## Static Hosting diff --git a/docs/start/installation.md b/docs/start/installation.md index 9264076e5f..8cc424879d 100644 --- a/docs/start/installation.md +++ b/docs/start/installation.md @@ -10,7 +10,7 @@ order: 1 Most projects start with a template. Let's use a basic template maintained by React Router with `degit`: ```shellscript nonumber -npx degit @remix-run/templates/basic my-app +npx degit remix-run/react-router/templates/basic#dev my-app ``` Now change into the new directory and start the app @@ -33,8 +33,8 @@ First create a new directory and install dependencies: mkdir my-new-app cd my-new-app npm init -y -npm install react react-dom react-router -npm install -D vite @react-router/dev +npm install react react-dom react-router@pre @react-router/node@pre @react-router/serve@pre +npm install -D vite @react-router/dev@pre ``` Now create the following files: @@ -97,14 +97,25 @@ export const routes: RouteConfig = [index("./home.tsx")]; ``` ```tsx filename=vite.config.ts -import react from "@react-router/dev/vite"; +import { reactRouter } from "@react-router/dev/vite"; import { defineConfig } from "vite"; export default defineConfig({ - plugins: [react()], + plugins: [reactRouter()], }); ``` +```json filename=package.json +{ + "type": "module", + "scripts": { + "dev": "react-router dev", + "build": "react-router build", + "start": "react-router-serve ./build/server/index.js" + } +} +``` + And finally run the app: ```shellscript nonumber @@ -117,4 +128,4 @@ React Router's full feature-set is easiest to use with the React Router Vite plu Refer to [Manual Usage][manual_usage] for more information. -[manual_usage]: ../guides/manual-usage +[manual_usage]: ../misc/manual-usage diff --git a/docs/start/rendering.md b/docs/start/rendering.md index 91353b5efb..ab251b0d0f 100644 --- a/docs/start/rendering.md +++ b/docs/start/rendering.md @@ -16,11 +16,12 @@ All routes are always client side rendered as the user navigates around the app. ## Server Side Rendering ```ts filename=vite.config.ts -import { plugin as app } from "@react-router/vite"; +import { reactRouter } from "@react-router/dev/vite"; import { defineConfig } from "vite"; + export default defineConfig({ plugins: [ - app({ + reactRouter({ // defaults to false ssr: true, }), @@ -33,11 +34,12 @@ Server side rendering requires a deployment that supports it. Though it's a glob ## Static Pre-rendering ```ts filename=vite.config.ts -import { plugin as app } from "@react-router/vite"; +import { reactRouter } from "@react-router/dev/vite"; import { defineConfig } from "vite"; + export default defineConfig({ plugins: [ - app({ + reactRouter({ // return a list of URLs to prerender at build time async prerender() { return ["/", "/about", "/contact"]; @@ -51,24 +53,24 @@ Pre-rendering is a build-time operation that generates static HTML and client na ## React Server Components +RSC is not supported yet, this is a future API that we plan to support + You can return elements from loaders and actions to keep them out of browser bundles. ```tsx -export default defineRoute$({ - async loader() { - return { - products: , - reviews: , - }; - }, - - Component({ data }) { - return ( -
- {data.products} - {data.reviews} -
- ); - }, -}); +export async function loader() { + return { + products: , + reviews: , + }; +} + +export default function App({ data }) { + return ( +
+ {data.products} + {data.reviews} +
+ ); +} ``` diff --git a/docs/start/route-module.md b/docs/start/route-module.md index 064f14eaaf..bfed78d77a 100644 --- a/docs/start/route-module.md +++ b/docs/start/route-module.md @@ -5,6 +5,10 @@ order: 3 # Route Module + + The types for route modules are still in development, this API is stale and needs to be updated. + + The files referenced in `routes.ts` are the entry points for all of your routes: ```tsx filename=app/routes.ts diff --git a/docs/start/routing.md b/docs/start/routing.md index deff697fa5..f27ceba1c1 100644 --- a/docs/start/routing.md +++ b/docs/start/routing.md @@ -106,19 +106,17 @@ export const routes: RouteConfig = [ ``` ```tsx filename=app/dashboard.tsx -import { defineRoute$, Outlet } from "react-router"; - -export default defineRoute$({ - component: function Dashboard() { - return ( -
-

Dashboard

- {/* will either be home.tsx or settings.tsx */} - -
- ); - }, -}); +import { Outlet } from "react-router"; + +export default function Dashboard() { + return ( +
+

Dashboard

+ {/* will either be home.tsx or settings.tsx */} + +
+ ); +} ``` ## Layout Routes @@ -185,25 +183,25 @@ route("teams/:teamId", "./team.tsx"); ``` ```tsx filename=app/team.tsx -import { defineRoute$ } from "react-router"; - -export default defineRoute$({ - // ensures this route is configured correctly in routes.ts - // and provides type hints for the rest of this route - params: ["teamId"], - - async loader({ params }) { - // params.teamId will be available - }, +import type { + LoaderArgs, + ActionArgs, + DefaultProps, +} from "./+types.team"; + +async function loader({ params }: LoaderArgs) { + // ^? { teamId: string } +} - async action({ params }) { - // params.teamId will be available - }, +async function action({ params }: ActionArgs) { + // ^? { teamId: string } +} - Component({ params }) { - console.log(params.teamId); // "hotspur" - }, -}); +export default function Component({ + params, +}: DefaultProps) { + console.log(params.teamId); // "hotspur" +} ``` You can have multiple dynamic segments in one route path: @@ -213,13 +211,11 @@ route("c/:categoryId/p/:productId", "./product.tsx"); ``` ```tsx filename=app/product.tsx -export default defineRoute$({ - params: ["categoryId", "productId"], +import type { LoaderArgs } from "./+types.product"; - async loader({ params }) { - // params.categoryId and params.productId will be available - }, -}); +async function loader({ params }: LoaderArgs) { + // ^? { categoryId: string; productId: string } +} ``` ## Optional Segments @@ -279,6 +275,5 @@ function Wizard() { Note that these routes do not participate in data loading, actions, code splitting, or any other route module features, so their use cases are more limited than those of the route module. -[file-route-conventions]: ../guides/file-route-conventions -[outlet]: ../components/outlet -[code_splitting]: ../discussion/code-splitting +[file-route-conventions]: ../misc/file-route-conventions +[outlet]: ../../api/react-router/Outlet diff --git a/docs/tutorials/advanced-data-fetching.md b/docs/tutorials/advanced-data-fetching.md index 9620b8df5e..5240b193d9 100644 --- a/docs/tutorials/advanced-data-fetching.md +++ b/docs/tutorials/advanced-data-fetching.md @@ -5,4 +5,6 @@ new: true # Advanced Data Fetching -TODO! + + This document is a work in progress. There's not much to see here (yet). + diff --git a/docs/upgrading/future.md b/docs/upgrading/future.md deleted file mode 100644 index dcfbfea06c..0000000000 --- a/docs/upgrading/future.md +++ /dev/null @@ -1,268 +0,0 @@ ---- -title: Current Future Flags -order: 1 -new: true ---- - -# Future Flags - -The following future flags are stable and ready to adopt. To read more about future flags see [Development Strategy](../guides/api-development-strategy) - -## Update to latest v6.x - -First update to the latest minor version of v6.x to have the latest future flags. - -👉 **Update to latest v6** - -```shellscript nonumber -npm install react-router-dom@6 -``` - -## v7_relativeSplatPath - -**Background** - -Changes the relative path matching and linking for multi-segment splats paths like `dashboard/*` (vs. just `*`). [View the CHANGELOG](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md#minor-changes-2) for more information. - -👉 **Enable the flag** - -Enabling the flag depends on the type of router: - -```tsx - -``` - -```tsx -createBrowserRouter(routes, { - future: { - v7_relativeSplatPath: true, - }, -}); -``` - -**Update your Code** - -If you have any routes with a path + a splat like `` and has relative links like `` or `` beneath it, you will need to update your code. - -👉 **Split the `` into two** - -Split any multi-segment splat `` into a parent route with the path and a child route with the splat: - -```diff - - } /> -- } /> -+ -+ } /> -+ - - -// or -createBrowserRouter([ - { path: "/", element: }, - { -- path: "dashboard/*", -- element: , -+ path: "dashboard", -+ children: [{ path: "*", element: }], - }, -]); -``` - -👉 **Update relative links** - -Update any `` elements within that route tree to include the extra `..` relative segment to continue linking to the same place: - -```diff -function Dashboard() { - return ( -
-

Dashboard

- - - - } /> - } /> - } - /> - -
- ); -} -``` - -## v7_startTransition - -**Background** - -This uses `React.useTransition` instead of `React.useState` for Router state updates. View the [CHANGELOG](https://github.com/remix-run/react-router/blob/main/CHANGELOG.md#v7_starttransition) for more information. - -👉 **Enable the flag** - -```tsx - - -// or - -``` - -👉 **Update your Code** - -You don't need to update anything unless you are using `React.lazy` _inside_ of a component. - -Using `React.lazy` inside of a component is incompatible with `React.useTransition` (or other code that makes promises inside of components). Move `React.lazy` to the module scope and stop making promises inside of components. This is not a limitation of React Router but rather incorrect usage of React. - -## v7_fetcherPersist - -If you are not using a `createBrowserRouter` you can skip this - -**Background** - -The fetcher lifecycle is now based on when it returns to an idle state rather than when its owner component unmounts: [View the CHANGELOG](https://github.com/remix-run/react-router/blob/main/CHANGELOG.md#persistence-future-flag-futurev7_fetcherpersist) for more information. - -**Enable the Flag** - -```tsx -createBrowserRouter(routes, { - future: { - v7_fetcherPersist: true, - }, -}); -``` - -**Update your Code** - -It's unlikely to affect your app. You may want to check any usage of `useFetchers` as they may persist longer than they did before. Depending on what you're doing, you may render something longer than before. - -## v7_normalizeFormMethod - -If you are not using a `createBrowserRouter` you can skip this - -This normalizes `formMethod` fields as uppercase HTTP methods to align with the `fetch()` behavior. [View the CHANGELOG](https://github.com/remix-run/react-router/blob/main/CHANGELOG.md#futurev7_normalizeformmethod) for more information. - -👉 **Enable the Flag** - -```tsx -createBrowserRouter(routes, { - future: { - v7_normalizeFormMethod: true, - }, -}); -``` - -**Update your Code** - -If any of your code is checking for lowercase HTTP methods, you will need to update it to check for uppercase HTTP methods (or call `toLowerCase()` on it). - -👉 **Compare `formMethod` to UPPERCASE** - -```diff --useNavigation().formMethod === "post" --useFetcher().formMethod === "get"; -+useNavigation().formMethod === "POST" -+useFetcher().formMethod === "GET"; -``` - -## v7_partialHydration - -If you are not using a `createBrowserRouter` you can skip this - -This allows SSR frameworks to provide only partial hydration data. It's unlikely you need to worry about this, just turn the flag on. [View the CHANGELOG](https://github.com/remix-run/react-router/blob/main/CHANGELOG.md#partial-hydration) for more information. - -👉 **Enable the Flag** - -```tsx -createBrowserRouter(routes, { - future: { - v7_partialHydration: true, - }, -}); -``` - -## v7_skipActionStatusRevalidation - -If you are not using a `createBrowserRouter` you can skip this - -When this flag is enabled, loaders will no longer revalidate by default after an action throws/returns a `Response` with a `4xx`/`5xx` status code. You may opt-into revalidation in these scenarios via `shouldRevalidate` and the `actionStatus` parameter. - -👉 **Enable the Flag** - -```tsx -createBrowserRouter(routes, { - future: { - v7_skipActionStatusRevalidation: true, - }, -}); -``` - -**Update your Code** - -In most cases, you probably won't have to make changes to your app code. Usually, if an action errors, it's unlikely data was mutated and needs revalidation. If any of your code _does_ mutate data in action error scenarios you have 2 options: - -👉 **Option 1: Change the `action` to avoid mutations in error scenarios** - -```js -// Before -async function action() { - await mutateSomeData(); - if (detectError()) { - throw new Response(error, { status: 400 }); - } - await mutateOtherData(); - // ... -} - -// After -async function action() { - if (detectError()) { - throw new Response(error, { status: 400 }); - } - // All data is now mutated after validations - await mutateSomeData(); - await mutateOtherData(); - // ... -} -``` - -👉 **Option 2: Opt-into revalidation via `shouldRevalidate` and `actionStatus`** - -```js -async function action() { - await mutateSomeData(); - if (detectError()) { - throw new Response(error, { status: 400 }); - } - await mutateOtherData(); -} - -async function loader() { ... } - -function shouldRevalidate({ actionStatus, defaultShouldRevalidate }) { - if (actionStatus != null && actionStatus >= 400) { - // Revalidate this loader when actions return a 4xx/5xx status - return true; - } - return defaultShouldRevalidate; -} -``` diff --git a/docs/upgrading/remix.md b/docs/upgrading/remix.md index a64dbe2f74..05d033622f 100644 --- a/docs/upgrading/remix.md +++ b/docs/upgrading/remix.md @@ -21,6 +21,8 @@ export interface FutureConfig { ### Codemod +This codemod is still in development, this doc is a hypothetical of what it might look like. + You can use the following command that will automatically: - update your Remix dependencies to their corresponding React Router v7 dependencies diff --git a/docs/upgrading/vite-component-routes.md b/docs/upgrading/vite-component-routes.md index 861a99fea5..cf330450c6 100644 --- a/docs/upgrading/vite-component-routes.md +++ b/docs/upgrading/vite-component-routes.md @@ -33,18 +33,18 @@ First install the React Router vite plugin: npm install -D @react-router/dev ``` -Then swap out the React plugin for React Router. The `react` key accepts the same options as the React plugin. +Then swap out the React plugin for React Router. ```diff filename=vite.config.ts -import react from '@vitejs/plugin-react' -+import { plugin as app } from "@react-router/vite"; ++import { reactRouter } from "@react-router/dev/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [ -- react(reactOptions) -+ app({ react: reactOptions }) +- react() ++ reactRouter() ], }); ``` @@ -189,7 +189,6 @@ export const routes: RouteConfig = [ And then create the catchall route module and render your existing root App component within it. ```tsx filename=src/catchall.tsx -import { defineRoute } from "react-router"; import App from "./App"; export default function Component() { @@ -260,12 +259,12 @@ The first few routes you migrate are the hardest because you often have to acces If you want to enable server rendering and static pre-rendering, you can do so with the `ssr` and `prerender` options in the bundler plugin. ```ts filename=vite.config.ts -import { plugin as app } from "@react-router/vite"; +import { reactRouter } from "@react-router/dev/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [ - app({ + reactRouter({ ssr: true, async prerender() { return ["/", "/about", "/contact"]; diff --git a/docs/upgrading/vite-router-provider.md b/docs/upgrading/vite-router-provider.md index b8c98ff956..030e2a873b 100644 --- a/docs/upgrading/vite-router-provider.md +++ b/docs/upgrading/vite-router-provider.md @@ -31,18 +31,18 @@ First install the React Router vite plugin: npm install -D @react-router/dev ``` -Then swap out the React plugin for React Router. The `react` key accepts the same options as the React plugin. +Then swap out the React plugin for React Router. ```diff filename=vite.config.ts -import react from '@vitejs/plugin-react' -+import { plugin as app } from "@react-router/vite"; ++import { reactRouter } from "@react-router/dev/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [ -- react(reactOptions) -+ app({ react: reactOptions }) +- react() ++ reactRouter() ], }); ``` @@ -232,12 +232,12 @@ The first few routes you migrate are the hardest because you often have to acces If you want to enable server rendering and static pre-rendering, you can do so with the `ssr` and `prerender` options in the bundler plugin. For SSR you'll need to also deploy the server build to a server. See [Deploying](./deploying) for more information. ```ts filename=vite.config.ts -import { plugin as app } from "@react-router/vite"; +import { reactRouter } from "@react-router/dev/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [ - app({ + reactRouter({ ssr: true, async prerender() { return ["/", "/pages/about"]; 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 ff560b1892..0bedfc4bf9 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, @@ -293,14 +293,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(); @@ -439,7 +439,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; @@ -593,7 +595,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 f978634928..ac1c8ea260 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 { @@ -56,6 +53,7 @@ import { resolvePublicPath, } from "./config"; import * as WithProps from "./with-props"; +import * as ViteNode from "./vite-node"; export async function resolveViteConfig({ configFile, @@ -502,8 +500,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 cache: Cache = new Map(); let ssrExternals = isInReactRouterMonorepo() @@ -537,14 +534,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({ @@ -878,40 +875,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(); @@ -1179,6 +1151,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { plugin !== null && "name" in plugin && plugin.name !== "react-router" && + plugin.name !== "react-router-route-exports" && plugin.name !== "react-router-hmr-updates" ), ], @@ -1259,12 +1232,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 ( @@ -1413,7 +1388,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { }, async buildEnd() { await viteChildCompiler?.close(); - await routeConfigViteServer?.close(); + await routesViteNodeContext?.devServer?.close(); }, }, { @@ -1678,12 +1653,10 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { { name: "react-router-route-exports", async transform(code, id, options) { - if (options?.ssr) return; - let route = getRoute(ctx.reactRouterConfig, id); if (!route) return; - if (!ctx.reactRouterConfig.ssr) { + if (!options?.ssr && !ctx.reactRouterConfig.ssr) { let serverOnlyExports = esModuleLexer(code)[1] .map((exp) => exp.n) .filter((exp) => SERVER_ONLY_ROUTE_EXPORTS.includes(exp)); @@ -1714,7 +1687,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { let [filepath] = id.split("?"); let ast = parse(code, { sourceType: "module" }); - removeExports(ast, SERVER_ONLY_ROUTE_EXPORTS); + if (!options?.ssr) { + removeExports(ast, SERVER_ONLY_ROUTE_EXPORTS); + } WithProps.transform(ast); return generate(ast, { sourceMaps: true, 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/router.ts b/packages/react-router/lib/router/router.ts index f0bd699c6c..d3cbc4fe5f 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1968,9 +1968,7 @@ export function createRouter(init: RouterInit): Router { } revalidatingFetchers.forEach((rf) => { - if (fetchControllers.has(rf.key)) { - abortFetcher(rf.key); - } + abortFetcher(rf.key); if (rf.controller) { // Fetchers use an independent AbortController so that aborting a fetcher // (via deleteFetcher) does not abort the triggering navigation that @@ -2011,6 +2009,7 @@ export function createRouter(init: RouterInit): Router { abortPendingFetchRevalidations ); } + revalidatingFetchers.forEach((rf) => fetchControllers.delete(rf.key)); // If any loaders returned a redirect Response, start a new REPLACE navigation @@ -2038,7 +2037,6 @@ export function createRouter(init: RouterInit): Router { let { loaderData, errors } = processLoaderData( state, matches, - matchesToLoad, loaderResults, pendingActionResult, revalidatingFetchers, @@ -2107,7 +2105,8 @@ export function createRouter(init: RouterInit): Router { href: string | null, opts?: RouterFetchOptions ) { - if (fetchControllers.has(key)) abortFetcher(key); + abortFetcher(key); + let flushSync = (opts && opts.flushSync) === true; let routesToUse = inFlightDataRoutes || dataRoutes; @@ -2370,9 +2369,7 @@ export function createRouter(init: RouterInit): Router { existingFetcher ? existingFetcher.data : undefined ); state.fetchers.set(staleKey, revalidatingFetcher); - if (fetchControllers.has(staleKey)) { - abortFetcher(staleKey); - } + abortFetcher(staleKey); if (rf.controller) { fetchControllers.set(staleKey, rf.controller); } @@ -2438,7 +2435,6 @@ export function createRouter(init: RouterInit): Router { let { loaderData, errors } = processLoaderData( state, matches, - matchesToLoad, loaderResults, undefined, revalidatingFetchers, @@ -2863,8 +2859,8 @@ export function createRouter(init: RouterInit): Router { fetchLoadMatches.forEach((_, key) => { if (fetchControllers.has(key)) { cancelledFetcherLoads.add(key); - abortFetcher(key); } + abortFetcher(key); }); } @@ -2941,9 +2937,10 @@ export function createRouter(init: RouterInit): Router { function abortFetcher(key: string) { let controller = fetchControllers.get(key); - invariant(controller, `Expected fetch controller: ${key}`); - controller.abort(); - fetchControllers.delete(key); + if (controller) { + controller.abort(); + fetchControllers.delete(key); + } } function markFetchersDone(keys: string[]) { @@ -5154,7 +5151,6 @@ function processRouteLoaderData( function processLoaderData( state: RouterState, matches: AgnosticDataRouteMatch[], - matchesToLoad: AgnosticDataRouteMatch[], results: Record, pendingActionResult: PendingActionResult | undefined, revalidatingFetchers: RevalidatingFetcher[], 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