diff --git a/.changeset/dull-balloons-boil.md b/.changeset/dull-balloons-boil.md
new file mode 100644
index 0000000000..19162efa77
--- /dev/null
+++ b/.changeset/dull-balloons-boil.md
@@ -0,0 +1,16 @@
+---
+"@react-router/dev": patch
+"react-router": patch
+---
+
+Fix typegen for repeated params
+
+In React Router, path parameters are keyed by their name.
+So for a path pattern like `/a/:id/b/:id?/c/:id`, the last `:id` will set the value for `id` in `useParams` and the `params` prop.
+For example, `/a/1/b/2/c/3` will result in the value `{ id: 3 }` at runtime.
+
+Previously, generated types for params incorrectly modeled repeated params with an array.
+So `/a/1/b/2/c/3` generated a type like `{ id: [1,2,3] }`.
+
+To be consistent with runtime behavior, the generated types now correctly model the "last one wins" semantics of path parameters.
+So `/a/1/b/2/c/3` now generates a type like `{ id: 3 }`.
diff --git a/.changeset/khaki-rocks-cover.md b/.changeset/khaki-rocks-cover.md
new file mode 100644
index 0000000000..4da885b2c5
--- /dev/null
+++ b/.changeset/khaki-rocks-cover.md
@@ -0,0 +1,5 @@
+---
+"@react-router/dev": patch
+---
+
+Fix `ArgError: unknown or unexpected option: --version` when running `react-router --version`
diff --git a/.changeset/three-eyes-flow.md b/.changeset/three-eyes-flow.md
new file mode 100644
index 0000000000..2346636fd4
--- /dev/null
+++ b/.changeset/three-eyes-flow.md
@@ -0,0 +1,20 @@
+---
+"@react-router/dev": minor
+"react-router": minor
+---
+
+New type-safe `href` utility that guarantees links point to actual paths in your app
+
+```tsx
+import { href } from "react-router";
+
+export default function Component() {
+ const link = href("/blog/:slug", { slug: "my-first-post" });
+ return (
+
+
+
+
+ );
+}
+```
diff --git a/integration/cli-test.ts b/integration/cli-test.ts
new file mode 100644
index 0000000000..2c1c16c105
--- /dev/null
+++ b/integration/cli-test.ts
@@ -0,0 +1,174 @@
+import { spawnSync } from "node:child_process";
+import * as path from "node:path";
+
+import { expect, test } from "@playwright/test";
+import dedent from "dedent";
+import semver from "semver";
+import fse from "fs-extra";
+
+import { createProject } from "./helpers/vite";
+
+const nodeBin = process.argv[0];
+const reactRouterBin = "node_modules/@react-router/dev/dist/cli/index.js";
+
+const run = (command: string[], options: Parameters[2]) =>
+ spawnSync(nodeBin, [reactRouterBin, ...command], options);
+
+const helpText = dedent`
+ react-router
+
+ Usage:
+ $ react-router build [projectDir]
+ $ react-router dev [projectDir]
+ $ react-router routes [projectDir]
+
+ Options:
+ --help, -h Print this help message and exit
+ --version, -v Print the CLI version and exit
+ --no-color Disable ANSI colors in console output
+ \`build\` Options:
+ --assetsInlineLimit Static asset base64 inline threshold in bytes (default: 4096) (number)
+ --clearScreen Allow/disable clear screen when logging (boolean)
+ --config, -c Use specified config file (string)
+ --emptyOutDir Force empty outDir when it's outside of root (boolean)
+ --logLevel, -l Info | warn | error | silent (string)
+ --minify Enable/disable minification, or specify minifier to use (default: "esbuild") (boolean | "terser" | "esbuild")
+ --mode, -m Set env mode (string)
+ --profile Start built-in Node.js inspector
+ --sourcemapClient Output source maps for client build (default: false) (boolean | "inline" | "hidden")
+ --sourcemapServer Output source maps for server build (default: false) (boolean | "inline" | "hidden")
+ \`dev\` Options:
+ --clearScreen Allow/disable clear screen when logging (boolean)
+ --config, -c Use specified config file (string)
+ --cors Enable CORS (boolean)
+ --force Force the optimizer to ignore the cache and re-bundle (boolean)
+ --host Specify hostname (string)
+ --logLevel, -l Info | warn | error | silent (string)
+ --mode, -m Set env mode (string)
+ --open Open browser on startup (boolean | string)
+ --port Specify port (number)
+ --profile Start built-in Node.js inspector
+ --strictPort Exit if specified port is already in use (boolean)
+ \`routes\` Options:
+ --config, -c Use specified Vite config file (string)
+ --json Print the routes as JSON
+ \`reveal\` Options:
+ --config, -c Use specified Vite config file (string)
+ --no-typescript Generate plain JavaScript files
+ \`typegen\` Options:
+ --watch Automatically regenerate types whenever route config (\`routes.ts\`) or route modules change
+
+ Build your project:
+
+ $ react-router build
+
+ Run your project locally in development:
+
+ $ react-router dev
+
+ Show all routes in your app:
+
+ $ react-router routes
+ $ react-router routes my-app
+ $ react-router routes --json
+ $ react-router routes --config vite.react-router.config.ts
+
+ Reveal the used entry point:
+
+ $ react-router reveal entry.client
+ $ 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
+
+ Generate types for route modules:
+
+ $ react-router typegen
+ $ react-router typegen --watch
+`;
+
+test.describe("cli", () => {
+ test("--help", async () => {
+ const cwd = await createProject();
+ const { stdout, stderr, status } = run(["--help"], {
+ cwd,
+ env: {
+ NO_COLOR: "1",
+ },
+ });
+ expect(stdout.toString().trim()).toBe(helpText);
+ expect(stderr.toString()).toBe("");
+ expect(status).toBe(0);
+ });
+
+ test("--version", async () => {
+ const cwd = await createProject();
+ let { stdout, stderr, status } = run(["--version"], { cwd });
+ expect(semver.valid(stdout.toString().trim())).not.toBeNull();
+ expect(stderr.toString()).toBe("");
+ expect(status).toBe(0);
+ });
+
+ test("routes", async () => {
+ const cwd = await createProject();
+ let { stdout, stderr, status } = run(["routes"], { cwd });
+ expect(stdout.toString().trim()).toBe(dedent`
+
+
+
+
+
+ `);
+ expect(stderr.toString()).toBe("");
+ expect(status).toBe(0);
+ });
+
+ test.describe("reveal", async () => {
+ test("generates entry.{server,client}.tsx in the app directory", async () => {
+ const cwd = await createProject();
+ let entryClientFile = path.join(cwd, "app", "entry.client.tsx");
+ let entryServerFile = path.join(cwd, "app", "entry.server.tsx");
+
+ expect(fse.existsSync(entryServerFile)).toBeFalsy();
+ expect(fse.existsSync(entryClientFile)).toBeFalsy();
+
+ run(["reveal"], { cwd });
+
+ expect(fse.existsSync(entryServerFile)).toBeTruthy();
+ expect(fse.existsSync(entryClientFile)).toBeTruthy();
+ });
+
+ test("generates specified entries in the app directory", async () => {
+ const cwd = await createProject();
+
+ let entryClientFile = path.join(cwd, "app", "entry.client.tsx");
+ let entryServerFile = path.join(cwd, "app", "entry.server.tsx");
+
+ expect(fse.existsSync(entryServerFile)).toBeFalsy();
+ expect(fse.existsSync(entryClientFile)).toBeFalsy();
+
+ run(["reveal", "entry.server"], { cwd });
+ expect(fse.existsSync(entryServerFile)).toBeTruthy();
+ expect(fse.existsSync(entryClientFile)).toBeFalsy();
+ fse.removeSync(entryServerFile);
+
+ run(["reveal", "entry.client"], { cwd });
+ expect(fse.existsSync(entryClientFile)).toBeTruthy();
+ expect(fse.existsSync(entryServerFile)).toBeFalsy();
+ });
+
+ test("generates entry.{server,client}.jsx in the app directory with --no-typescript", async () => {
+ const cwd = await createProject();
+ let entryClientFile = path.join(cwd, "app", "entry.client.jsx");
+ let entryServerFile = path.join(cwd, "app", "entry.server.jsx");
+
+ expect(fse.existsSync(entryServerFile)).toBeFalsy();
+ expect(fse.existsSync(entryClientFile)).toBeFalsy();
+
+ run(["reveal", "--no-typescript"], { cwd });
+
+ expect(fse.existsSync(entryServerFile)).toBeTruthy();
+ expect(fse.existsSync(entryClientFile)).toBeTruthy();
+ });
+ });
+});
diff --git a/integration/helpers/vite-5-template/tsconfig.json b/integration/helpers/vite-5-template/tsconfig.json
index 4f19efc420..62bbb55722 100644
--- a/integration/helpers/vite-5-template/tsconfig.json
+++ b/integration/helpers/vite-5-template/tsconfig.json
@@ -1,10 +1,5 @@
{
- "include": [
- "env.d.ts",
- "**/*.ts",
- "**/*.tsx",
- ".react-router/types/**/*.d.ts"
- ],
+ "include": ["env.d.ts", "**/*.ts", "**/*.tsx", ".react-router/types/**/*"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"verbatimModuleSyntax": true,
diff --git a/integration/helpers/vite-6-template/tsconfig.json b/integration/helpers/vite-6-template/tsconfig.json
index 4f19efc420..62bbb55722 100644
--- a/integration/helpers/vite-6-template/tsconfig.json
+++ b/integration/helpers/vite-6-template/tsconfig.json
@@ -1,10 +1,5 @@
{
- "include": [
- "env.d.ts",
- "**/*.ts",
- "**/*.tsx",
- ".react-router/types/**/*.d.ts"
- ],
+ "include": ["env.d.ts", "**/*.ts", "**/*.tsx", ".react-router/types/**/*"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"verbatimModuleSyntax": true,
diff --git a/integration/helpers/vite-cloudflare-template/tsconfig.json b/integration/helpers/vite-cloudflare-template/tsconfig.json
index 32573c8bb2..012e5b3f95 100644
--- a/integration/helpers/vite-cloudflare-template/tsconfig.json
+++ b/integration/helpers/vite-cloudflare-template/tsconfig.json
@@ -1,5 +1,5 @@
{
- "include": ["env.d.ts", "**/*.ts", "**/*.tsx"],
+ "include": ["env.d.ts", "**/*.ts", "**/*.tsx", ".react-router/types/**/*"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["vite/client"],
diff --git a/integration/typegen-test.ts b/integration/typegen-test.ts
index 7de40c08f8..e1faa8f9a4 100644
--- a/integration/typegen-test.ts
+++ b/integration/typegen-test.ts
@@ -82,16 +82,42 @@ test.describe("typegen", () => {
import { type RouteConfig, route } from "@react-router/dev/routes";
export default [
- route("repeated-params/:id/:id?/:id", "routes/repeated-params.tsx")
+ route("only-required/:id/:id", "routes/only-required.tsx"),
+ route("only-optional/:id?/:id?", "routes/only-optional.tsx"),
+ route("optional-then-required/:id?/:id", "routes/optional-then-required.tsx"),
+ route("required-then-optional/:id/:id?", "routes/required-then-optional.tsx"),
] satisfies RouteConfig;
`,
- "app/routes/repeated-params.tsx": tsx`
+ "app/routes/only-required.tsx": tsx`
import type { Expect, Equal } from "../expect-type"
- import type { Route } from "./+types/repeated-params"
+ import type { Route } from "./+types/only-required"
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return null
+ }
+ `,
+ "app/routes/only-optional.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/only-optional"
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return null
+ }
+ `,
+ "app/routes/optional-then-required.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/optional-then-required"
+ export function loader({ params }: Route.LoaderArgs) {
+ type Test = Expect>
+ return null
+ }
+ `,
+ "app/routes/required-then-optional.tsx": tsx`
+ import type { Expect, Equal } from "../expect-type"
+ import type { Route } from "./+types/required-then-optional"
export function loader({ params }: Route.LoaderArgs) {
- type Expected = [string, string | undefined, string]
- type Test = Expect>
+ type Test = Expect>
return null
}
`,
@@ -362,4 +388,60 @@ test.describe("typegen", () => {
expect(proc.stderr.toString()).toBe("");
expect(proc.status).toBe(0);
});
+
+ test("href", async () => {
+ const cwd = await createProject({
+ "vite.config.ts": viteConfig,
+ "app/expect-type.ts": expectType,
+ "app/routes.ts": tsx`
+ import path from "node:path";
+ import { type RouteConfig, route } from "@react-router/dev/routes";
+
+ export default [
+ route("no-params", "routes/no-params.tsx"),
+ route("required-param/:req", "routes/required-param.tsx"),
+ route("optional-param/:opt?", "routes/optional-param.tsx"),
+ route("/leading-and-trailing-slash/", "routes/leading-and-trailing-slash.tsx"),
+ route("some-other-route", "routes/some-other-route.tsx"),
+ ] satisfies RouteConfig;
+ `,
+ "app/routes/no-params.tsx": tsx`
+ export default function Component() {}
+ `,
+ "app/routes/required-param.tsx": tsx`
+ export default function Component() {}
+ `,
+ "app/routes/optional-param.tsx": tsx`
+ export default function Component() {}
+ `,
+ "app/routes/leading-and-trailing-slash.tsx": tsx`
+ export default function Component() {}
+ `,
+ "app/routes/some-other-route.tsx": tsx`
+ import { href } from "react-router"
+
+ // @ts-expect-error
+ href("/does-not-exist")
+
+ href("/no-params")
+
+ // @ts-expect-error
+ href("/required-param/:req")
+ href("/required-param/:req", { req: "hello" })
+
+ href("/optional-param/:opt?")
+ href("/optional-param/:opt?", { opt: "hello" })
+
+ href("/leading-and-trailing-slash")
+ // @ts-expect-error
+ href("/leading-and-trailing-slash/")
+
+ export default function Component() {}
+ `,
+ });
+ const proc = typecheck(cwd);
+ expect(proc.stdout.toString()).toBe("");
+ expect(proc.stderr.toString()).toBe("");
+ expect(proc.status).toBe(0);
+ });
});
diff --git a/packages/react-router-dev/__tests__/cli-reveal-test.ts b/packages/react-router-dev/__tests__/cli-reveal-test.ts
deleted file mode 100644
index 5aa40f4f81..0000000000
--- a/packages/react-router-dev/__tests__/cli-reveal-test.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import path from "node:path";
-import fse from "fs-extra";
-import execa from "execa";
-
-function getProjectDir() {
- let projectDir = path.join(
- __dirname,
- ".tmp",
- `reveal-test-${Math.random().toString(32).slice(2)}`
- );
- fse.copySync(path.join(__dirname, "fixtures", "basic"), projectDir);
- return projectDir;
-}
-
-async function runCli(cwd: string, args: string[]) {
- return await execa(
- "node",
- [
- "--require",
- require.resolve("esbuild-register"),
- path.resolve(__dirname, "../cli/index.ts"),
- ...args,
- ],
- { cwd }
- );
-}
-
-describe("the reveal command", () => {
- it("generates an entry.server.tsx file in the app directory", async () => {
- let projectDir = getProjectDir();
-
- let entryClientFile = path.join(projectDir, "app", "entry.client.tsx");
- let entryServerFile = path.join(projectDir, "app", "entry.server.tsx");
-
- expect(fse.existsSync(entryServerFile)).toBeFalsy();
- expect(fse.existsSync(entryClientFile)).toBeFalsy();
-
- await runCli(projectDir, ["reveal"]);
-
- expect(fse.existsSync(entryServerFile)).toBeTruthy();
- expect(fse.existsSync(entryClientFile)).toBeTruthy();
- });
-
- it("generates an entry.server.tsx file in the app directory when specific entries are provided", async () => {
- let projectDir = getProjectDir();
-
- let entryClientFile = path.join(projectDir, "app", "entry.client.tsx");
- let entryServerFile = path.join(projectDir, "app", "entry.server.tsx");
-
- expect(fse.existsSync(entryServerFile)).toBeFalsy();
- expect(fse.existsSync(entryClientFile)).toBeFalsy();
-
- await runCli(projectDir, ["reveal", "entry.server"]);
- expect(fse.existsSync(entryServerFile)).toBeTruthy();
- expect(fse.existsSync(entryClientFile)).toBeFalsy();
- fse.removeSync(entryServerFile);
-
- await runCli(projectDir, ["reveal", "entry.client"]);
- expect(fse.existsSync(entryClientFile)).toBeTruthy();
- expect(fse.existsSync(entryServerFile)).toBeFalsy();
- });
-
- it("generates an entry.server.jsx file in the app directory", async () => {
- let projectDir = getProjectDir();
-
- let entryClientFile = path.join(projectDir, "app", "entry.client.jsx");
- let entryServerFile = path.join(projectDir, "app", "entry.server.jsx");
-
- expect(fse.existsSync(entryServerFile)).toBeFalsy();
- expect(fse.existsSync(entryClientFile)).toBeFalsy();
-
- await runCli(projectDir, ["reveal", "--no-typescript"]);
-
- expect(fse.existsSync(entryServerFile)).toBeTruthy();
- expect(fse.existsSync(entryClientFile)).toBeTruthy();
- });
-});
diff --git a/packages/react-router-dev/__tests__/cli-routes-test.ts b/packages/react-router-dev/__tests__/cli-routes-test.ts
deleted file mode 100644
index be7c1b5abd..0000000000
--- a/packages/react-router-dev/__tests__/cli-routes-test.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import path from "node:path";
-import execa from "execa";
-
-async function runCli(cwd: string, args: string[]) {
- return await execa(
- "node",
- [
- "--require",
- require.resolve("esbuild-register"),
- path.resolve(__dirname, "../cli/index.ts"),
- ...args,
- ],
- { cwd }
- );
-}
-
-describe("the routes command", () => {
- it("displays routes", async () => {
- let projectDir = path.join(__dirname, "fixtures", "basic");
-
- let result = await runCli(projectDir, ["routes"]);
-
- expect(result.stdout).toMatchInlineSnapshot(`
- "
-
-
-
- "
- `);
- });
-});
diff --git a/packages/react-router-dev/__tests__/cli-test.ts b/packages/react-router-dev/__tests__/cli-test.ts
deleted file mode 100644
index e46bbac1d2..0000000000
--- a/packages/react-router-dev/__tests__/cli-test.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-import childProcess from "node:child_process";
-import path from "node:path";
-import util from "node:util";
-import fse from "fs-extra";
-import semver from "semver";
-
-let execFile = util.promisify(childProcess.execFile);
-
-const TEMP_DIR = path.join(
- path.join(__dirname, ".tmp"),
- `remix-tests-${Math.random().toString(32).slice(2)}`
-);
-
-jest.setTimeout(30_000);
-beforeAll(async () => {
- await fse.remove(TEMP_DIR);
- await fse.ensureDir(TEMP_DIR);
-});
-
-afterAll(async () => {
- await fse.remove(TEMP_DIR);
-});
-
-async function execRemix(
- args: Array,
- options: Exclude[2], null | undefined> = {}
-) {
- if (process.platform === "win32") {
- let cp = childProcess.spawnSync(
- "node",
- [
- "--require",
- require.resolve("esbuild-register"),
- path.resolve(__dirname, "../cli/index.ts"),
- ...args,
- ],
- {
- cwd: TEMP_DIR,
- ...options,
- env: {
- ...process.env,
- NO_COLOR: "1",
- ...options.env,
- },
- }
- );
-
- return {
- stdout: cp.stdout?.toString("utf-8"),
- };
- } else {
- let result = await execFile(
- "node",
- [
- "--require",
- require.resolve("esbuild-register"),
- path.resolve(__dirname, "../cli/index.ts"),
- ...args,
- ],
- {
- cwd: TEMP_DIR,
- ...options,
- env: {
- ...process.env,
- NO_COLOR: "1",
- ...options.env,
- },
- }
- );
- return {
- ...result,
- stdout: result.stdout.replace(TEMP_DIR, "").trim(),
- };
- }
-}
-
-describe("remix CLI", () => {
- describe("the --help flag", () => {
- it("prints help info", async () => {
- let { stdout } = await execRemix(["--help"]);
- expect(stdout.trim()).toMatchInlineSnapshot(`
- "react-router
-
- Usage:
- $ react-router build [projectDir]
- $ react-router dev [projectDir]
- $ react-router routes [projectDir]
-
- Options:
- --help, -h Print this help message and exit
- --version, -v Print the CLI version and exit
- --no-color Disable ANSI colors in console output
- \`build\` Options:
- --assetsInlineLimit Static asset base64 inline threshold in bytes (default: 4096) (number)
- --clearScreen Allow/disable clear screen when logging (boolean)
- --config, -c Use specified config file (string)
- --emptyOutDir Force empty outDir when it's outside of root (boolean)
- --logLevel, -l Info | warn | error | silent (string)
- --minify Enable/disable minification, or specify minifier to use (default: "esbuild") (boolean | "terser" | "esbuild")
- --mode, -m Set env mode (string)
- --profile Start built-in Node.js inspector
- --sourcemapClient Output source maps for client build (default: false) (boolean | "inline" | "hidden")
- --sourcemapServer Output source maps for server build (default: false) (boolean | "inline" | "hidden")
- \`dev\` Options:
- --clearScreen Allow/disable clear screen when logging (boolean)
- --config, -c Use specified config file (string)
- --cors Enable CORS (boolean)
- --force Force the optimizer to ignore the cache and re-bundle (boolean)
- --host Specify hostname (string)
- --logLevel, -l Info | warn | error | silent (string)
- --mode, -m Set env mode (string)
- --open Open browser on startup (boolean | string)
- --port Specify port (number)
- --profile Start built-in Node.js inspector
- --strictPort Exit if specified port is already in use (boolean)
- \`routes\` Options:
- --config, -c Use specified Vite config file (string)
- --json Print the routes as JSON
- \`reveal\` Options:
- --config, -c Use specified Vite config file (string)
- --no-typescript Generate plain JavaScript files
- \`typegen\` Options:
- --watch Automatically regenerate types whenever route config (\`routes.ts\`) or route modules change
-
- Build your project:
-
- $ react-router build
-
- Run your project locally in development:
-
- $ react-router dev
-
- Show all routes in your app:
-
- $ react-router routes
- $ react-router routes my-app
- $ react-router routes --json
- $ react-router routes --config vite.react-router.config.ts
-
- Reveal the used entry point:
-
- $ react-router reveal entry.client
- $ 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
-
- Generate types for route modules:
-
- $ react-router typegen
- $ react-router typegen --watch"
- `);
- });
- });
-
- describe("the --version flag", () => {
- it("prints the current version", async () => {
- let { stdout } = await execRemix(["--version"]);
- expect(!!semver.valid(stdout.trim())).toBe(true);
- });
- });
-});
diff --git a/packages/react-router-dev/cli/run.ts b/packages/react-router-dev/cli/run.ts
index 588fe50af3..f394c0a97b 100644
--- a/packages/react-router-dev/cli/run.ts
+++ b/packages/react-router-dev/cli/run.ts
@@ -156,7 +156,7 @@ export async function run(argv: string[] = process.argv.slice(2)) {
return;
}
if (flags.version) {
- let version = require("../package.json").version;
+ let version = require("../../package.json").version;
console.log(version);
return;
}
diff --git a/packages/react-router-dev/typegen/generate.ts b/packages/react-router-dev/typegen/generate.ts
index 3ac02209ab..049897aae7 100644
--- a/packages/react-router-dev/typegen/generate.ts
+++ b/packages/react-router-dev/typegen/generate.ts
@@ -2,13 +2,15 @@ import ts from "dedent";
import * as Path from "pathe";
import * as Pathe from "pathe/utils";
-import { type RouteManifest, type RouteManifestEntry } from "../config/routes";
+import { type RouteManifestEntry } from "../config/routes";
import { type Context } from "./context";
import { getTypesPath } from "./paths";
+import * as Params from "./params";
+import * as Route from "./route";
export function generate(ctx: Context, route: RouteManifestEntry): string {
- const lineage = getRouteLineage(ctx.config.routes, route);
- const urlpath = lineage.map((route) => route.path).join("/");
+ const lineage = Route.lineage(ctx.config.routes, route);
+ const fullpath = Route.fullpath(lineage);
const typesPath = getTypesPath(ctx, route);
const parents = lineage.slice(0, -1);
@@ -42,7 +44,7 @@ export function generate(ctx: Context, route: RouteManifestEntry): string {
file: "${route.file}"
path: "${route.path}"
params: {${formatParamProperties(
- urlpath
+ fullpath
)}} & { [key: string]: string | undefined }
module: Module
loaderData: T.CreateLoaderData
@@ -75,48 +77,10 @@ export function generate(ctx: Context, route: RouteManifestEntry): string {
const noExtension = (path: string) =>
Path.join(Path.dirname(path), Pathe.filename(path));
-function getRouteLineage(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 formatParamProperties(urlpath: string) {
- const params = parseParams(urlpath);
- const properties = Object.entries(params).map(([name, values]) => {
- if (values.length === 1) {
- const isOptional = values[0];
- return isOptional ? `"${name}"?: string` : `"${name}": string`;
- }
- const items = values.map((isOptional) =>
- isOptional ? "string | undefined" : "string"
- );
- return `"${name}": [${items.join(", ")}]`;
- });
+function formatParamProperties(fullpath: string) {
+ const params = Params.parse(fullpath);
+ const properties = Object.entries(params).map(([name, isRequired]) =>
+ isRequired ? `"${name}": string` : `"${name}"?: string`
+ );
return properties.join("; ");
}
-
-function parseParams(urlpath: string) {
- const result: Record = {};
-
- let segments = urlpath.split("/");
- segments.forEach((segment) => {
- const match = segment.match(/^:([\w-]+)(\?)?/);
- if (!match) return;
- const param = match[1];
- const isOptional = match[2] !== undefined;
-
- result[param] ??= [];
- result[param].push(isOptional);
- return;
- });
-
- const hasSplat = segments.at(-1) === "*";
- if (hasSplat) result["*"] = [false];
- return result;
-}
diff --git a/packages/react-router-dev/typegen/index.ts b/packages/react-router-dev/typegen/index.ts
index 06a1df81fc..b49d06bdd8 100644
--- a/packages/react-router-dev/typegen/index.ts
+++ b/packages/react-router-dev/typegen/index.ts
@@ -1,14 +1,18 @@
import fs from "node:fs";
+import ts from "dedent";
import * as Path from "pathe";
import pc from "picocolors";
import type vite from "vite";
import { createConfigLoader } from "../config/config";
+import * as Babel from "../vite/babel";
import { generate } from "./generate";
import type { Context } from "./context";
import { getTypesDir, getTypesPath } from "./paths";
+import * as Params from "./params";
+import * as Route from "./route";
export async function run(rootDirectory: string) {
const ctx = await createContext({ rootDirectory, watch: false });
@@ -81,4 +85,55 @@ async function writeAll(ctx: Context): Promise {
fs.mkdirSync(Path.dirname(typesPath), { recursive: true });
fs.writeFileSync(typesPath, content);
});
+
+ const registerPath = Path.join(typegenDir, "+register.ts");
+ fs.writeFileSync(registerPath, register(ctx));
+}
+
+function register(ctx: Context) {
+ const register = ts`
+ import "react-router";
+
+ declare module "react-router" {
+ interface Register {
+ params: Params;
+ }
+ }
+ `;
+
+ const { t } = Babel;
+
+ const typeParams = t.tsTypeAliasDeclaration(
+ t.identifier("Params"),
+ null,
+ t.tsTypeLiteral(
+ Object.values(ctx.config.routes)
+ .map((route) => {
+ // filter out pathless (layout) routes
+ if (route.id !== "root" && !route.path) return undefined;
+
+ const lineage = Route.lineage(ctx.config.routes, route);
+ const fullpath = Route.fullpath(lineage);
+ const params = Params.parse(fullpath);
+ return t.tsPropertySignature(
+ t.stringLiteral(fullpath),
+ t.tsTypeAnnotation(
+ t.tsTypeLiteral(
+ Object.entries(params).map(([param, isRequired]) => {
+ const property = t.tsPropertySignature(
+ t.stringLiteral(param),
+ t.tsTypeAnnotation(t.tsStringKeyword())
+ );
+ property.optional = !isRequired;
+ return property;
+ })
+ )
+ )
+ );
+ })
+ .filter((x): x is Babel.Babel.TSPropertySignature => x !== undefined)
+ )
+ );
+
+ return [register, Babel.generate(typeParams).code].join("\n\n");
}
diff --git a/packages/react-router-dev/typegen/params.ts b/packages/react-router-dev/typegen/params.ts
new file mode 100644
index 0000000000..9af95d85bb
--- /dev/null
+++ b/packages/react-router-dev/typegen/params.ts
@@ -0,0 +1,18 @@
+export function parse(fullpath: string) {
+ const result: Record = {};
+
+ let segments = fullpath.split("/");
+ segments.forEach((segment) => {
+ const match = segment.match(/^:([\w-]+)(\?)?/);
+ if (!match) return;
+ const param = match[1];
+ const isRequired = match[2] === undefined;
+
+ result[param] ||= isRequired;
+ return;
+ });
+
+ const hasSplat = segments.at(-1) === "*";
+ if (hasSplat) result["*"] = true;
+ return result;
+}
diff --git a/packages/react-router-dev/typegen/route.ts b/packages/react-router-dev/typegen/route.ts
new file mode 100644
index 0000000000..6c48be06d0
--- /dev/null
+++ b/packages/react-router-dev/typegen/route.ts
@@ -0,0 +1,26 @@
+import type { RouteManifest, RouteManifestEntry } from "../config/routes";
+
+export function lineage(
+ routes: RouteManifest,
+ route: RouteManifestEntry
+): RouteManifestEntry[] {
+ const result: RouteManifestEntry[] = [];
+ while (route) {
+ result.push(route);
+ if (!route.parentId) break;
+ route = routes[route.parentId];
+ }
+ result.reverse();
+ return result;
+}
+
+export function fullpath(lineage: RouteManifestEntry[]) {
+ if (lineage.length === 1 && lineage[0].id === "root") return "/";
+ return (
+ "/" +
+ lineage
+ .map((route) => route.path?.replace(/^\//, "")?.replace(/\/$/, ""))
+ .filter((path) => path !== undefined && path !== "")
+ .join("/")
+ );
+}
diff --git a/packages/react-router/__tests__/href-test.ts b/packages/react-router/__tests__/href-test.ts
new file mode 100644
index 0000000000..e4deef7846
--- /dev/null
+++ b/packages/react-router/__tests__/href-test.ts
@@ -0,0 +1,25 @@
+import { href } from "../lib/href";
+
+describe("href", () => {
+ it("works with param-less paths", () => {
+ expect(href("/a/b/c")).toBe("/a/b/c");
+ });
+
+ it("works with params", () => {
+ expect(href("/a/:b", { b: "hello", z: "ignored" })).toBe("/a/hello");
+ expect(href("/a/:b?", { b: "hello", z: "ignored" })).toBe("/a/hello");
+ expect(href("/a/:b?")).toBe("/a");
+ });
+
+ it("works with repeated params", () => {
+ expect(href("/a/:b?/:b/:b?/:b", { b: "hello" })).toBe(
+ "/a/hello/hello/hello/hello"
+ );
+ });
+
+ it("throws when required params are missing", () => {
+ expect(() => href("/a/:b")).toThrow(
+ `Path '/a/:b' requires param 'b' but it was not provided`
+ );
+ });
+});
diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts
index 7a43cebd01..fa3838bfd4 100644
--- a/packages/react-router/index.ts
+++ b/packages/react-router/index.ts
@@ -263,6 +263,9 @@ export type {
FlashSessionData,
} from "./lib/server-runtime/sessions";
+export type { Register } from "./lib/types/register";
+export { href } from "./lib/href";
+
///////////////////////////////////////////////////////////////////////////////
// DANGER! PLEASE READ ME!
// We provide these exports as an escape hatch in the event that you need any
diff --git a/packages/react-router/lib/href.ts b/packages/react-router/lib/href.ts
new file mode 100644
index 0000000000..6f0523d1ef
--- /dev/null
+++ b/packages/react-router/lib/href.ts
@@ -0,0 +1,55 @@
+import type { Register } from "./types/register";
+import type { Equal } from "./types/utils";
+
+type AnyParams = Record>;
+type Params = Register extends {
+ params: infer RegisteredParams extends AnyParams;
+}
+ ? RegisteredParams
+ : AnyParams;
+
+type Args = { [K in keyof Params]: ToArgs };
+
+// prettier-ignore
+type ToArgs =
+ // path without params -> no `params` arg
+ Equal extends true ? [] :
+ // path with only optional params -> optional `params` arg
+ Partial extends T ? [T] | [] :
+ // otherwise, require `params` arg
+ [T];
+
+/**
+ Returns a resolved URL path for the specified route.
+
+ ```tsx
+ const h = href("/:lang?/about", { lang: "en" })
+ // -> `/en/about`
+
+
+ ```
+ */
+export function href(
+ path: Path,
+ ...args: Args[Path]
+): string {
+ let params = args[0];
+ return path
+ .split("/")
+ .map((segment) => {
+ const match = segment.match(/^:([\w-]+)(\?)?/);
+ if (!match) return segment;
+ const param = match[1];
+ const value = params ? params[param] : undefined;
+
+ const isRequired = match[2] === undefined;
+ if (isRequired && value === undefined) {
+ throw Error(
+ `Path '${path}' requires param '${param}' but it was not provided`
+ );
+ }
+ return value;
+ })
+ .filter((segment) => segment !== undefined)
+ .join("/");
+}
diff --git a/packages/react-router/lib/types/register.ts b/packages/react-router/lib/types/register.ts
new file mode 100644
index 0000000000..7c5f896756
--- /dev/null
+++ b/packages/react-router/lib/types/register.ts
@@ -0,0 +1,9 @@
+/**
+ * Apps can use this interface to "register" app-wide types for React Router via interface declaration merging and module augmentation.
+ * React Router should handle this for you via type generation.
+ *
+ * For more on declaration merging and module augmentation, see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation .
+ */
+export interface Register {
+ // params
+}