Skip to content

Commit 3b3fbda

Browse files
committed
Type-safe href (#12994)
* wip: type-safe href * consistent params parsing + type generation * href tests * href typegen tests * href types normalize route full path * fix `react-router --version` The `--version` flag reads the local `package.json` at `../package.json`. While this path is correct when running from source, it is incorrect after the CLI is built since `package.json` stays at the root of the package, but the built code gets nested into `dist/`. I only noticed this discrepancy because I was converting the unit tests to integration tests to fix an incompatibility issue with Node v22.14 and `esbuild-register`.
1 parent c9dea37 commit 3b3fbda

File tree

20 files changed

+508
-336
lines changed

20 files changed

+508
-336
lines changed

.changeset/dull-balloons-boil.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@react-router/dev": patch
3+
"react-router": patch
4+
---
5+
6+
Fix typegen for repeated params
7+
8+
In React Router, path parameters are keyed by their name.
9+
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.
10+
For example, `/a/1/b/2/c/3` will result in the value `{ id: 3 }` at runtime.
11+
12+
Previously, generated types for params incorrectly modeled repeated params with an array.
13+
So `/a/1/b/2/c/3` generated a type like `{ id: [1,2,3] }`.
14+
15+
To be consistent with runtime behavior, the generated types now correctly model the "last one wins" semantics of path parameters.
16+
So `/a/1/b/2/c/3` now generates a type like `{ id: 3 }`.

.changeset/khaki-rocks-cover.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-router/dev": patch
3+
---
4+
5+
Fix `ArgError: unknown or unexpected option: --version` when running `react-router --version`

.changeset/three-eyes-flow.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@react-router/dev": patch
3+
"react-router": patch
4+
---
5+
6+
New type-safe `href` utility that guarantees links point to actual paths in your app
7+
8+
```tsx
9+
import { href } from "react-router";
10+
11+
export default function Component() {
12+
const link = href("/blog/:slug", { slug: "my-first-post" });
13+
return (
14+
<main>
15+
<Link to={href("/products/:id", { id: "asdf" })} />
16+
<NavLink to={href("/:lang?/about", { lang: "en" })} />
17+
</main>
18+
);
19+
}
20+
```

integration/cli-test.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { spawnSync } from "node:child_process";
2+
import * as path from "node:path";
3+
4+
import { expect, test } from "@playwright/test";
5+
import dedent from "dedent";
6+
import semver from "semver";
7+
import fse from "fs-extra";
8+
9+
import { createProject } from "./helpers/vite";
10+
11+
const nodeBin = process.argv[0];
12+
const reactRouterBin = "node_modules/@react-router/dev/dist/cli/index.js";
13+
14+
const run = (command: string[], options: Parameters<typeof spawnSync>[2]) =>
15+
spawnSync(nodeBin, [reactRouterBin, ...command], options);
16+
17+
const helpText = dedent`
18+
react-router
19+
20+
Usage:
21+
$ react-router build [projectDir]
22+
$ react-router dev [projectDir]
23+
$ react-router routes [projectDir]
24+
25+
Options:
26+
--help, -h Print this help message and exit
27+
--version, -v Print the CLI version and exit
28+
--no-color Disable ANSI colors in console output
29+
\`build\` Options:
30+
--assetsInlineLimit Static asset base64 inline threshold in bytes (default: 4096) (number)
31+
--clearScreen Allow/disable clear screen when logging (boolean)
32+
--config, -c Use specified config file (string)
33+
--emptyOutDir Force empty outDir when it's outside of root (boolean)
34+
--logLevel, -l Info | warn | error | silent (string)
35+
--minify Enable/disable minification, or specify minifier to use (default: "esbuild") (boolean | "terser" | "esbuild")
36+
--mode, -m Set env mode (string)
37+
--profile Start built-in Node.js inspector
38+
--sourcemapClient Output source maps for client build (default: false) (boolean | "inline" | "hidden")
39+
--sourcemapServer Output source maps for server build (default: false) (boolean | "inline" | "hidden")
40+
\`dev\` Options:
41+
--clearScreen Allow/disable clear screen when logging (boolean)
42+
--config, -c Use specified config file (string)
43+
--cors Enable CORS (boolean)
44+
--force Force the optimizer to ignore the cache and re-bundle (boolean)
45+
--host Specify hostname (string)
46+
--logLevel, -l Info | warn | error | silent (string)
47+
--mode, -m Set env mode (string)
48+
--open Open browser on startup (boolean | string)
49+
--port Specify port (number)
50+
--profile Start built-in Node.js inspector
51+
--strictPort Exit if specified port is already in use (boolean)
52+
\`routes\` Options:
53+
--config, -c Use specified Vite config file (string)
54+
--json Print the routes as JSON
55+
\`reveal\` Options:
56+
--config, -c Use specified Vite config file (string)
57+
--no-typescript Generate plain JavaScript files
58+
\`typegen\` Options:
59+
--watch Automatically regenerate types whenever route config (\`routes.ts\`) or route modules change
60+
61+
Build your project:
62+
63+
$ react-router build
64+
65+
Run your project locally in development:
66+
67+
$ react-router dev
68+
69+
Show all routes in your app:
70+
71+
$ react-router routes
72+
$ react-router routes my-app
73+
$ react-router routes --json
74+
$ react-router routes --config vite.react-router.config.ts
75+
76+
Reveal the used entry point:
77+
78+
$ react-router reveal entry.client
79+
$ react-router reveal entry.server
80+
$ react-router reveal entry.client --no-typescript
81+
$ react-router reveal entry.server --no-typescript
82+
$ react-router reveal entry.server --config vite.react-router.config.ts
83+
84+
Generate types for route modules:
85+
86+
$ react-router typegen
87+
$ react-router typegen --watch
88+
`;
89+
90+
test.describe("cli", () => {
91+
test("--help", async () => {
92+
const cwd = await createProject();
93+
const { stdout, stderr, status } = run(["--help"], {
94+
cwd,
95+
env: {
96+
NO_COLOR: "1",
97+
},
98+
});
99+
expect(stdout.toString().trim()).toBe(helpText);
100+
expect(stderr.toString()).toBe("");
101+
expect(status).toBe(0);
102+
});
103+
104+
test("--version", async () => {
105+
const cwd = await createProject();
106+
let { stdout, stderr, status } = run(["--version"], { cwd });
107+
expect(semver.valid(stdout.toString().trim())).not.toBeNull();
108+
expect(stderr.toString()).toBe("");
109+
expect(status).toBe(0);
110+
});
111+
112+
test("routes", async () => {
113+
const cwd = await createProject();
114+
let { stdout, stderr, status } = run(["routes"], { cwd });
115+
expect(stdout.toString().trim()).toBe(dedent`
116+
<Routes>
117+
<Route file="root.tsx">
118+
<Route index file="routes/_index.tsx" />
119+
</Route>
120+
</Routes>
121+
`);
122+
expect(stderr.toString()).toBe("");
123+
expect(status).toBe(0);
124+
});
125+
126+
test.describe("reveal", async () => {
127+
test("generates entry.{server,client}.tsx in the app directory", async () => {
128+
const cwd = await createProject();
129+
let entryClientFile = path.join(cwd, "app", "entry.client.tsx");
130+
let entryServerFile = path.join(cwd, "app", "entry.server.tsx");
131+
132+
expect(fse.existsSync(entryServerFile)).toBeFalsy();
133+
expect(fse.existsSync(entryClientFile)).toBeFalsy();
134+
135+
run(["reveal"], { cwd });
136+
137+
expect(fse.existsSync(entryServerFile)).toBeTruthy();
138+
expect(fse.existsSync(entryClientFile)).toBeTruthy();
139+
});
140+
141+
test("generates specified entries in the app directory", async () => {
142+
const cwd = await createProject();
143+
144+
let entryClientFile = path.join(cwd, "app", "entry.client.tsx");
145+
let entryServerFile = path.join(cwd, "app", "entry.server.tsx");
146+
147+
expect(fse.existsSync(entryServerFile)).toBeFalsy();
148+
expect(fse.existsSync(entryClientFile)).toBeFalsy();
149+
150+
run(["reveal", "entry.server"], { cwd });
151+
expect(fse.existsSync(entryServerFile)).toBeTruthy();
152+
expect(fse.existsSync(entryClientFile)).toBeFalsy();
153+
fse.removeSync(entryServerFile);
154+
155+
run(["reveal", "entry.client"], { cwd });
156+
expect(fse.existsSync(entryClientFile)).toBeTruthy();
157+
expect(fse.existsSync(entryServerFile)).toBeFalsy();
158+
});
159+
160+
test("generates entry.{server,client}.jsx in the app directory with --no-typescript", async () => {
161+
const cwd = await createProject();
162+
let entryClientFile = path.join(cwd, "app", "entry.client.jsx");
163+
let entryServerFile = path.join(cwd, "app", "entry.server.jsx");
164+
165+
expect(fse.existsSync(entryServerFile)).toBeFalsy();
166+
expect(fse.existsSync(entryClientFile)).toBeFalsy();
167+
168+
run(["reveal", "--no-typescript"], { cwd });
169+
170+
expect(fse.existsSync(entryServerFile)).toBeTruthy();
171+
expect(fse.existsSync(entryClientFile)).toBeTruthy();
172+
});
173+
});
174+
});

integration/helpers/vite-5-template/tsconfig.json

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
{
2-
"include": [
3-
"env.d.ts",
4-
"**/*.ts",
5-
"**/*.tsx",
6-
".react-router/types/**/*.d.ts"
7-
],
2+
"include": ["env.d.ts", "**/*.ts", "**/*.tsx", ".react-router/types/**/*"],
83
"compilerOptions": {
94
"lib": ["DOM", "DOM.Iterable", "ES2022"],
105
"verbatimModuleSyntax": true,

integration/helpers/vite-6-template/tsconfig.json

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
{
2-
"include": [
3-
"env.d.ts",
4-
"**/*.ts",
5-
"**/*.tsx",
6-
".react-router/types/**/*.d.ts"
7-
],
2+
"include": ["env.d.ts", "**/*.ts", "**/*.tsx", ".react-router/types/**/*"],
83
"compilerOptions": {
94
"lib": ["DOM", "DOM.Iterable", "ES2022"],
105
"verbatimModuleSyntax": true,

integration/helpers/vite-cloudflare-template/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"include": ["env.d.ts", "**/*.ts", "**/*.tsx"],
2+
"include": ["env.d.ts", "**/*.ts", "**/*.tsx", ".react-router/types/**/*"],
33
"compilerOptions": {
44
"lib": ["DOM", "DOM.Iterable", "ES2022"],
55
"types": ["vite/client"],

integration/typegen-test.ts

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,16 +82,42 @@ test.describe("typegen", () => {
8282
import { type RouteConfig, route } from "@react-router/dev/routes";
8383
8484
export default [
85-
route("repeated-params/:id/:id?/:id", "routes/repeated-params.tsx")
85+
route("only-required/:id/:id", "routes/only-required.tsx"),
86+
route("only-optional/:id?/:id?", "routes/only-optional.tsx"),
87+
route("optional-then-required/:id?/:id", "routes/optional-then-required.tsx"),
88+
route("required-then-optional/:id/:id?", "routes/required-then-optional.tsx"),
8689
] satisfies RouteConfig;
8790
`,
88-
"app/routes/repeated-params.tsx": tsx`
91+
"app/routes/only-required.tsx": tsx`
8992
import type { Expect, Equal } from "../expect-type"
90-
import type { Route } from "./+types/repeated-params"
93+
import type { Route } from "./+types/only-required"
94+
export function loader({ params }: Route.LoaderArgs) {
95+
type Test = Expect<Equal<typeof params.id, string>>
96+
return null
97+
}
98+
`,
99+
"app/routes/only-optional.tsx": tsx`
100+
import type { Expect, Equal } from "../expect-type"
101+
import type { Route } from "./+types/only-optional"
102+
export function loader({ params }: Route.LoaderArgs) {
103+
type Test = Expect<Equal<typeof params.id, string | undefined>>
104+
return null
105+
}
106+
`,
107+
"app/routes/optional-then-required.tsx": tsx`
108+
import type { Expect, Equal } from "../expect-type"
109+
import type { Route } from "./+types/optional-then-required"
110+
export function loader({ params }: Route.LoaderArgs) {
111+
type Test = Expect<Equal<typeof params.id, string>>
112+
return null
113+
}
114+
`,
115+
"app/routes/required-then-optional.tsx": tsx`
116+
import type { Expect, Equal } from "../expect-type"
117+
import type { Route } from "./+types/required-then-optional"
91118
92119
export function loader({ params }: Route.LoaderArgs) {
93-
type Expected = [string, string | undefined, string]
94-
type Test = Expect<Equal<typeof params.id, Expected>>
120+
type Test = Expect<Equal<typeof params.id, string>>
95121
return null
96122
}
97123
`,
@@ -362,4 +388,60 @@ test.describe("typegen", () => {
362388
expect(proc.stderr.toString()).toBe("");
363389
expect(proc.status).toBe(0);
364390
});
391+
392+
test("href", async () => {
393+
const cwd = await createProject({
394+
"vite.config.ts": viteConfig,
395+
"app/expect-type.ts": expectType,
396+
"app/routes.ts": tsx`
397+
import path from "node:path";
398+
import { type RouteConfig, route } from "@react-router/dev/routes";
399+
400+
export default [
401+
route("no-params", "routes/no-params.tsx"),
402+
route("required-param/:req", "routes/required-param.tsx"),
403+
route("optional-param/:opt?", "routes/optional-param.tsx"),
404+
route("/leading-and-trailing-slash/", "routes/leading-and-trailing-slash.tsx"),
405+
route("some-other-route", "routes/some-other-route.tsx"),
406+
] satisfies RouteConfig;
407+
`,
408+
"app/routes/no-params.tsx": tsx`
409+
export default function Component() {}
410+
`,
411+
"app/routes/required-param.tsx": tsx`
412+
export default function Component() {}
413+
`,
414+
"app/routes/optional-param.tsx": tsx`
415+
export default function Component() {}
416+
`,
417+
"app/routes/leading-and-trailing-slash.tsx": tsx`
418+
export default function Component() {}
419+
`,
420+
"app/routes/some-other-route.tsx": tsx`
421+
import { href } from "react-router"
422+
423+
// @ts-expect-error
424+
href("/does-not-exist")
425+
426+
href("/no-params")
427+
428+
// @ts-expect-error
429+
href("/required-param/:req")
430+
href("/required-param/:req", { req: "hello" })
431+
432+
href("/optional-param/:opt?")
433+
href("/optional-param/:opt?", { opt: "hello" })
434+
435+
href("/leading-and-trailing-slash")
436+
// @ts-expect-error
437+
href("/leading-and-trailing-slash/")
438+
439+
export default function Component() {}
440+
`,
441+
});
442+
const proc = typecheck(cwd);
443+
expect(proc.stdout.toString()).toBe("");
444+
expect(proc.stderr.toString()).toBe("");
445+
expect(proc.status).toBe(0);
446+
});
365447
});

0 commit comments

Comments
 (0)