Skip to content

Commit 0423079

Browse files
authored
Better types for params (#13543)
* wip * wip * wip * wip * wip * wip * changeset * fix params type for layouts * clear types dir
1 parent 0fe5d6d commit 0423079

File tree

13 files changed

+726
-280
lines changed

13 files changed

+726
-280
lines changed

.changeset/neat-candles-stare.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@react-router/dev": patch
3+
"react-router": patch
4+
---
5+
6+
Better types for `params`
7+
8+
For example:
9+
10+
```ts
11+
// routes.ts
12+
import { type RouteConfig, route } from "@react-router/dev/routes";
13+
14+
export default [
15+
route("parent/:p", "routes/parent.tsx", [
16+
route("route/:r", "routes/route.tsx", [
17+
route("child1/:c1a/:c1b", "routes/child1.tsx"),
18+
route("child2/:c2a/:c2b", "routes/child2.tsx"),
19+
]),
20+
]),
21+
] satisfies RouteConfig;
22+
```
23+
24+
Previously, `params` for `routes/route` were calculated as `{ p: string, r: string }`.
25+
This incorrectly ignores params that could come from child routes.
26+
If visiting `/parent/1/route/2/child1/3/4`, the actual params passed to `routes/route` will have a type of `{ p: string, r: string, c1a: string, c1b: string }`.
27+
28+
Now, `params` are aware of child routes and autocompletion will include child params as optionals:
29+
30+
```ts
31+
params.|
32+
// ^ cursor is here and you ask for autocompletion
33+
// p: string
34+
// r: string
35+
// c1a?: string
36+
// c1b?: string
37+
// c2a?: string
38+
// c2b?: string
39+
```
40+
41+
You can also narrow the types for `params` as it is implemented as a normalized union of params for each page that includes `routes/route`:
42+
43+
```ts
44+
if (typeof params.c1a === 'string') {
45+
params.|
46+
// ^ cursor is here and you ask for autocompletion
47+
// p: string
48+
// r: string
49+
// c1a: string
50+
// c1b: string
51+
}
52+
```
53+
54+
---
55+
56+
UNSTABLE: renamed internal `react-router/route-module` export to `react-router/internal`
57+
UNSTABLE: removed `Info` export from generated `+types/*` files

integration/typegen-test.ts

Lines changed: 110 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,7 @@ test.describe("typegen", () => {
5555
import type { Route } from "./+types/product"
5656
5757
export function loader({ params }: Route.LoaderArgs) {
58-
type Test1 = Expect<Equal<typeof params.id, string>>
59-
type Test2 = Expect<Equal<typeof params.asdf, string | undefined>>
58+
type Test = Expect<Equal<typeof params, { id: string} >>
6059
return { planet: "world" }
6160
}
6261
@@ -92,23 +91,23 @@ test.describe("typegen", () => {
9291
import type { Expect, Equal } from "../expect-type"
9392
import type { Route } from "./+types/only-required"
9493
export function loader({ params }: Route.LoaderArgs) {
95-
type Test = Expect<Equal<typeof params.id, string>>
94+
type Test = Expect<Equal<typeof params, { id: string }>>
9695
return null
9796
}
9897
`,
9998
"app/routes/only-optional.tsx": tsx`
10099
import type { Expect, Equal } from "../expect-type"
101100
import type { Route } from "./+types/only-optional"
102101
export function loader({ params }: Route.LoaderArgs) {
103-
type Test = Expect<Equal<typeof params.id, string | undefined>>
102+
type Test = Expect<Equal<typeof params, { id?: string }>>
104103
return null
105104
}
106105
`,
107106
"app/routes/optional-then-required.tsx": tsx`
108107
import type { Expect, Equal } from "../expect-type"
109108
import type { Route } from "./+types/optional-then-required"
110109
export function loader({ params }: Route.LoaderArgs) {
111-
type Test = Expect<Equal<typeof params.id, string>>
110+
type Test = Expect<Equal<typeof params, { id: string }>>
112111
return null
113112
}
114113
`,
@@ -117,7 +116,7 @@ test.describe("typegen", () => {
117116
import type { Route } from "./+types/required-then-optional"
118117
119118
export function loader({ params }: Route.LoaderArgs) {
120-
type Test = Expect<Equal<typeof params.id, string>>
119+
type Test = Expect<Equal<typeof params, { id: string }>>
121120
return null
122121
}
123122
`,
@@ -144,7 +143,7 @@ test.describe("typegen", () => {
144143
import type { Route } from "./+types/splat"
145144
146145
export function loader({ params }: Route.LoaderArgs) {
147-
type Test = Expect<Equal<typeof params["*"], string>>
146+
type Test = Expect<Equal<typeof params, { "*": string }>>
148147
return null
149148
}
150149
`,
@@ -172,7 +171,7 @@ test.describe("typegen", () => {
172171
import type { Route } from "./+types/param-with-ext"
173172
174173
export function loader({ params }: Route.LoaderArgs) {
175-
type Test = Expect<Equal<typeof params["lang"], string>>
174+
type Test = Expect<Equal<typeof params, { lang: string }>>
176175
return null
177176
}
178177
`,
@@ -181,7 +180,7 @@ test.describe("typegen", () => {
181180
import type { Route } from "./+types/optional-param-with-ext"
182181
183182
export function loader({ params }: Route.LoaderArgs) {
184-
type Test = Expect<Equal<typeof params["user"], string | undefined>>
183+
type Test = Expect<Equal<typeof params, { user?: string }>>
185184
return null
186185
}
187186
`,
@@ -191,6 +190,106 @@ test.describe("typegen", () => {
191190
expect(proc.stderr.toString()).toBe("");
192191
expect(proc.status).toBe(0);
193192
});
193+
194+
test("normalized params", async () => {
195+
const cwd = await createProject({
196+
"vite.config.ts": viteConfig,
197+
"app/expect-type.ts": expectType,
198+
"app/routes.ts": tsx`
199+
import { type RouteConfig, route, layout } from "@react-router/dev/routes";
200+
201+
export default [
202+
route("parent/:p", "routes/parent.tsx", [
203+
route("route/:r", "routes/route.tsx", [
204+
route("child1/:c1a/:c1b", "routes/child1.tsx"),
205+
route("child2/:c2a/:c2b", "routes/child2.tsx")
206+
]),
207+
]),
208+
layout("routes/layout.tsx", [
209+
route("in-layout1/:id", "routes/in-layout1.tsx"),
210+
route("in-layout2/:id/:other", "routes/in-layout2.tsx")
211+
])
212+
] satisfies RouteConfig;
213+
`,
214+
"app/routes/parent.tsx": tsx`
215+
import type { Expect, Equal } from "../expect-type"
216+
import type { Route } from "./+types/parent"
217+
218+
export function loader({ params }: Route.LoaderArgs) {
219+
type Test = Expect<Equal<typeof params,
220+
| { p: string, r?: undefined, c1a?: undefined, c1b?: undefined, c2a?: undefined, c2b?: undefined }
221+
| { p: string, r: string, c1a?: undefined, c1b?: undefined, c2a?: undefined, c2b?: undefined }
222+
| { p: string, r: string, c1a: string, c1b: string, c2a?: undefined, c2b?: undefined }
223+
| { p: string, r: string, c1a?: undefined, c1b?: undefined, c2a: string, c2b: string }
224+
>>
225+
return null
226+
}
227+
`,
228+
"app/routes/route.tsx": tsx`
229+
import type { Expect, Equal } from "../expect-type"
230+
import type { Route } from "./+types/route"
231+
232+
export function loader({ params }: Route.LoaderArgs) {
233+
type Test = Expect<Equal<typeof params,
234+
| { p: string, r: string, c1a?: undefined, c1b?: undefined, c2a?: undefined, c2b?: undefined }
235+
| { p: string, r: string, c1a: string, c1b: string, c2a?: undefined, c2b?: undefined }
236+
| { p: string, r: string, c1a?: undefined, c1b?: undefined, c2a: string, c2b: string }
237+
>>
238+
return null
239+
}
240+
`,
241+
"app/routes/child1.tsx": tsx`
242+
import type { Expect, Equal } from "../expect-type"
243+
import type { Route } from "./+types/child1"
244+
245+
export function loader({ params }: Route.LoaderArgs) {
246+
type Test = Expect<Equal<typeof params, { p: string, r: string, c1a: string, c1b: string }>>
247+
return null
248+
}
249+
`,
250+
"app/routes/child2.tsx": tsx`
251+
import type { Expect, Equal } from "../expect-type"
252+
import type { Route } from "./+types/child2"
253+
254+
export function loader({ params }: Route.LoaderArgs) {
255+
type Test = Expect<Equal<typeof params, { p: string, r: string, c2a: string, c2b: string }>>
256+
return null
257+
}
258+
`,
259+
"app/routes/layout.tsx": tsx`
260+
import type { Expect, Equal } from "../expect-type"
261+
import type { Route } from "./+types/layout"
262+
263+
export function loader({ params }: Route.LoaderArgs) {
264+
type Test = Expect<Equal<typeof params, { id: string, other?: undefined } | { id: string, other: string } >>
265+
return null
266+
}
267+
`,
268+
"app/routes/in-layout1.tsx": tsx`
269+
import type { Expect, Equal } from "../expect-type"
270+
import type { Route } from "./+types/in-layout1"
271+
272+
export function loader({ params }: Route.LoaderArgs) {
273+
type Test = Expect<Equal<typeof params, { id: string }>>
274+
return null
275+
}
276+
`,
277+
"app/routes/in-layout2.tsx": tsx`
278+
import type { Expect, Equal } from "../expect-type"
279+
import type { Route } from "./+types/in-layout2"
280+
281+
export function loader({ params }: Route.LoaderArgs) {
282+
type Test = Expect<Equal<typeof params, { id: string, other: string }>>
283+
return null
284+
}
285+
`,
286+
});
287+
288+
const proc = typecheck(cwd);
289+
expect(proc.stdout.toString()).toBe("");
290+
expect(proc.stderr.toString()).toBe("");
291+
expect(proc.status).toBe(0);
292+
});
194293
});
195294

196295
test("clientLoader.hydrate = true", async () => {
@@ -240,7 +339,7 @@ test.describe("typegen", () => {
240339
import type { Route } from "./+types/products.$id"
241340
242341
export function loader({ params }: Route.LoaderArgs) {
243-
type Test = Expect<Equal<typeof params.id, string>>
342+
type Test = Expect<Equal<typeof params, { id: string }>>
244343
return { planet: "world" }
245344
}
246345
@@ -372,7 +471,7 @@ test.describe("typegen", () => {
372471
import type { Route } from "./+types/absolute"
373472
374473
export function loader({ params }: Route.LoaderArgs) {
375-
type Test = Expect<Equal<typeof params.id, string>>
474+
type Test = Expect<Equal<typeof params, { id: string }>>
376475
return { planet: "world" }
377476
}
378477
Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,36 @@
1-
import type { ConfigLoader, ResolvedReactRouterConfig } from "../config/config";
1+
import {
2+
createConfigLoader,
3+
type ConfigLoader,
4+
type ResolvedReactRouterConfig,
5+
} from "../config/config";
26

37
export type Context = {
48
rootDirectory: string;
59
configLoader: ConfigLoader;
610
config: ResolvedReactRouterConfig;
711
};
12+
13+
export async function createContext({
14+
rootDirectory,
15+
watch,
16+
mode,
17+
}: {
18+
rootDirectory: string;
19+
watch: boolean;
20+
mode: string;
21+
}): Promise<Context> {
22+
const configLoader = await createConfigLoader({ rootDirectory, mode, watch });
23+
const configResult = await configLoader.getConfig();
24+
25+
if (!configResult.ok) {
26+
throw new Error(configResult.error);
27+
}
28+
29+
const config = configResult.value;
30+
31+
return {
32+
configLoader,
33+
rootDirectory,
34+
config,
35+
};
36+
}

0 commit comments

Comments
 (0)