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 +}