Skip to content

Commit c1a2f11

Browse files
authored
typegen: dynamic segments with dot (#12246)
* test that dynamic params only match `(\w-)+` * fix PathParams to match `(\w+)` and add type tests * typegen: fix param detection * internal: configure wireit for typegen/ dir * refactor typegen tests to use react-router style path patterns instead of solely relying on flat routes for everything
1 parent 0da5831 commit c1a2f11

File tree

7 files changed

+148
-45
lines changed

7 files changed

+148
-45
lines changed

integration/typegen-test.ts

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,25 @@ const viteConfig = tsx`
3030
};
3131
`;
3232

33+
const assertType = tsx`
34+
export function assertType<T>(t: T) {}
35+
`;
36+
3337
test.describe("typegen", () => {
3438
test("basic", async () => {
3539
const cwd = await createProject({
3640
"vite.config.ts": viteConfig,
37-
"app/routes/products.$id.tsx": tsx`
38-
import type { Route } from "./+types.products.$id"
41+
"app/assertType.ts": assertType,
42+
"app/routes.ts": tsx`
43+
import { type RouteConfig, route } from "@react-router/dev/routes";
3944
40-
function assertType<T>(t: T) {}
45+
export const routes: RouteConfig = [
46+
route("products/:id", "routes/product.tsx")
47+
]
48+
`,
49+
"app/routes/product.tsx": tsx`
50+
import { assertType } from "../assertType"
51+
import type { Route } from "./+types.product"
4152
4253
export function loader({ params }: Route.LoaderArgs) {
4354
assertType<string>(params.id)
@@ -61,10 +72,17 @@ test.describe("typegen", () => {
6172
test("repeated", async () => {
6273
const cwd = await createProject({
6374
"vite.config.ts": viteConfig,
64-
"app/routes/repeated.$id.($id).$id.tsx": tsx`
65-
import type { Route } from "./+types.repeated.$id.($id).$id"
75+
"app/assertType.ts": assertType,
76+
"app/routes.ts": tsx`
77+
import { type RouteConfig, route } from "@react-router/dev/routes";
6678
67-
function assertType<T>(t: T) {}
79+
export const routes: RouteConfig = [
80+
route("repeated-params/:id/:id?/:id", "routes/repeated-params.tsx")
81+
]
82+
`,
83+
"app/routes/repeated-params.tsx": tsx`
84+
import { assertType } from "../assertType"
85+
import type { Route } from "./+types.repeated-params"
6886
6987
export function loader({ params }: Route.LoaderArgs) {
7088
assertType<[string, string | undefined, string]>(params.id)
@@ -81,10 +99,17 @@ test.describe("typegen", () => {
8199
test("splat", async () => {
82100
const cwd = await createProject({
83101
"vite.config.ts": viteConfig,
84-
"app/routes/splat.$.tsx": tsx`
85-
import type { Route } from "./+types.splat.$"
102+
"app/assertType.ts": assertType,
103+
"app/routes.ts": tsx`
104+
import { type RouteConfig, route } from "@react-router/dev/routes";
86105
87-
function assertType<T>(t: T) {}
106+
export const routes: RouteConfig = [
107+
route("splat/*", "routes/splat.tsx")
108+
]
109+
`,
110+
"app/routes/splat.tsx": tsx`
111+
import { assertType } from "../assertType"
112+
import type { Route } from "./+types.splat"
88113
89114
export function loader({ params }: Route.LoaderArgs) {
90115
assertType<string>(params["*"])
@@ -97,16 +122,53 @@ test.describe("typegen", () => {
97122
expect(proc.stderr.toString()).toBe("");
98123
expect(proc.status).toBe(0);
99124
});
125+
126+
test("with extension", async () => {
127+
const cwd = await createProject({
128+
"vite.config.ts": viteConfig,
129+
"app/assertType.ts": assertType,
130+
"app/routes.ts": tsx`
131+
import { type RouteConfig, route } from "@react-router/dev/routes";
132+
133+
export const routes: RouteConfig = [
134+
route(":lang.xml", "routes/param-with-ext.tsx"),
135+
route(":user?.pdf", "routes/optional-param-with-ext.tsx"),
136+
]
137+
`,
138+
"app/routes/param-with-ext.tsx": tsx`
139+
import { assertType } from "../assertType"
140+
import type { Route } from "./+types.param-with-ext"
141+
142+
export function loader({ params }: Route.LoaderArgs) {
143+
assertType<string>(params["lang"])
144+
return null
145+
}
146+
`,
147+
"app/routes/optional-param-with-ext.tsx": tsx`
148+
import { assertType } from "../assertType"
149+
import type { Route } from "./+types.optional-param-with-ext"
150+
151+
export function loader({ params }: Route.LoaderArgs) {
152+
assertType<string | undefined>(params["user"])
153+
return null
154+
}
155+
`,
156+
});
157+
const proc = typecheck(cwd);
158+
expect(proc.stdout.toString()).toBe("");
159+
expect(proc.stderr.toString()).toBe("");
160+
expect(proc.status).toBe(0);
161+
});
100162
});
101163

102164
test("clientLoader.hydrate = true", async () => {
103165
const cwd = await createProject({
104166
"vite.config.ts": viteConfig,
167+
"app/assertType.ts": assertType,
105168
"app/routes/_index.tsx": tsx`
169+
import { assertType } from "../assertType"
106170
import type { Route } from "./+types._index"
107171
108-
function assertType<T>(t: T) {}
109-
110172
export function loader() {
111173
return { server: "server" }
112174
}
@@ -141,11 +203,11 @@ test.describe("typegen", () => {
141203
plugins: [reactRouter({ appDirectory: "src/myapp" })],
142204
};
143205
`,
206+
"app/assertType.ts": assertType,
144207
"app/routes/products.$id.tsx": tsx`
208+
import { assertType } from "../assertType"
145209
import type { Route } from "./+types.products.$id"
146210
147-
function assertType<T>(t: T) {}
148-
149211
export function loader({ params }: Route.LoaderArgs) {
150212
assertType<string>(params.id)
151213
return { planet: "world" }

packages/react-router-dev/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"files": [
4040
"cli/**",
4141
"config/**",
42-
"typescript/**",
42+
"typegen/**",
4343
"vite/**",
4444
"*.ts",
4545
"bin.js",

packages/react-router-dev/typegen/generate.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -76,19 +76,16 @@ function parseParams(urlpath: string) {
7676
const result: Record<string, boolean[]> = {};
7777

7878
let segments = urlpath.split("/");
79-
segments
80-
.filter((s) => s.startsWith(":"))
81-
.forEach((param) => {
82-
param = param.slice(1); // omit leading `:`
83-
let isOptional = param.endsWith("?");
84-
if (isOptional) {
85-
param = param.slice(0, -1); // omit trailing `?`
86-
}
87-
88-
result[param] ??= [];
89-
result[param].push(isOptional);
90-
return;
91-
});
79+
segments.forEach((segment) => {
80+
const match = segment.match(/^:([\w-]+)(\?)?/);
81+
if (!match) return;
82+
const param = match[1];
83+
const isOptional = match[2] !== undefined;
84+
85+
result[param] ??= [];
86+
result[param].push(isOptional);
87+
return;
88+
});
9289

9390
const hasSplat = segments.at(-1) === "*";
9491
if (hasSplat) result["*"] = [false];

packages/react-router/__tests__/path-matching-test.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { RouteObject } from "react-router";
2-
import { matchRoutes } from "react-router";
2+
import { matchPath, matchRoutes } from "react-router";
33

44
function pickPaths(routes: RouteObject[], pathname: string): string[] | null {
55
let matches = matchRoutes(routes, pathname);
@@ -168,6 +168,24 @@ describe("path matching", () => {
168168
]
169169
`);
170170
});
171+
172+
test("dynamic segment params match word with dashes `(\\w-)+`", () => {
173+
expect(
174+
matchPath("/sitemap/:lang.xml", "/sitemap/blah.xml")?.params
175+
).toStrictEqual({ lang: "blah" });
176+
expect(
177+
matchPath("/sitemap/:lang.xml", "/sitemap/blah")?.params
178+
).toStrictEqual(undefined);
179+
expect(
180+
matchPath("/sitemap/:lang.:xml", "/sitemap/blah.:xml")?.params
181+
).toStrictEqual({ lang: "blah" });
182+
expect(
183+
matchPath("/sitemap/:lang.:xml", "/sitemap/blah.pdf")?.params
184+
).toStrictEqual(undefined);
185+
expect(
186+
matchPath("/sitemap/:lang?.xml", "/sitemap/.xml")?.params
187+
).toStrictEqual({ lang: undefined });
188+
});
171189
});
172190

173191
describe("path matching with a basename", () => {

packages/react-router/lib/router/utils.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Equal, Expect } from "../test-types";
12
import type { Location, Path, To } from "./history";
23
import { invariant, parsePath, warning } from "./history";
34

@@ -342,28 +343,39 @@ export type RouteManifest<R = AgnosticDataRouteObject> = Record<
342343
R | undefined
343344
>;
344345

346+
// prettier-ignore
347+
type Regex_az = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z"
348+
// prettier-ignore
349+
type Regez_AZ = "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J" | "K" | "L" | "M" | "N" | "O" | "P" | "Q" | "R" | "S" | "T" | "U" | "V" | "W" | "X" | "Y" | "Z"
350+
type Regex_09 = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
351+
type Regex_w = Regex_az | Regez_AZ | Regex_09 | "_";
352+
type ParamChar = Regex_w | "-";
353+
354+
// Emulates regex `+`
355+
type RegexMatchPlus<
356+
CharPattern extends string,
357+
T extends string
358+
> = T extends `${infer First}${infer Rest}`
359+
? First extends CharPattern
360+
? RegexMatchPlus<CharPattern, Rest> extends never
361+
? First
362+
: `${First}${RegexMatchPlus<CharPattern, Rest>}`
363+
: never
364+
: never;
365+
345366
// Recursive helper for finding path parameters in the absence of wildcards
346367
type _PathParam<Path extends string> =
347368
// split path into individual path segments
348369
Path extends `${infer L}/${infer R}`
349370
? _PathParam<L> | _PathParam<R>
350371
: // find params after `:`
351372
Path extends `:${infer Param}`
352-
? Param extends `${infer Optional}?`
353-
? Optional
354-
: Param
373+
? Param extends `${infer Optional}?${string}`
374+
? RegexMatchPlus<ParamChar, Optional>
375+
: RegexMatchPlus<ParamChar, Param>
355376
: // otherwise, there aren't any params present
356377
never;
357378

358-
/**
359-
* Examples:
360-
* "/a/b/*" -> "*"
361-
* ":a" -> "a"
362-
* "/a/:b" -> "b"
363-
* "/a/blahblahblah:b" -> "b"
364-
* "/:a/:b" -> "a" | "b"
365-
* "/:a/b/:c/*" -> "a" | "c" | "*"
366-
*/
367379
export type PathParam<Path extends string> =
368380
// check if path is just a wildcard
369381
Path extends "*" | "/*"
@@ -374,6 +386,18 @@ export type PathParam<Path extends string> =
374386
: // look for params in the absence of wildcards
375387
_PathParam<Path>;
376388

389+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
390+
type _tests = [
391+
Expect<Equal<PathParam<"/a/b/*">, "*">>,
392+
Expect<Equal<PathParam<":a">, "a">>,
393+
Expect<Equal<PathParam<"/a/:b">, "b">>,
394+
Expect<Equal<PathParam<"/a/blahblahblah:b">, never>>,
395+
Expect<Equal<PathParam<"/:a/:b">, "a" | "b">>,
396+
Expect<Equal<PathParam<"/:a/b/:c/*">, "a" | "c" | "*">>,
397+
Expect<Equal<PathParam<"/:lang.xml">, "lang">>,
398+
Expect<Equal<PathParam<"/:lang?.xml">, "lang">>
399+
];
400+
377401
// Attempt to parse the given string segment. If it fails, then just return the
378402
// plain string type as a default fallback. Otherwise, return the union of the
379403
// parsed string literals that were referenced as dynamic segments in the route.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export type Expect<T extends true> = T;
2+
3+
// prettier-ignore
4+
export type Equal<X, Y> =
5+
(<T>() => T extends X ? 1 : 2) extends
6+
(<T>() => T extends Y ? 1 : 2) ? true : false

packages/react-router/lib/types.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,8 @@ import type {
55
import type { DataWithResponseInit } from "./router/utils";
66
import type { AppLoadContext } from "./server-runtime/data";
77
import type { Serializable } from "./server-runtime/single-fetch";
8+
import type { Equal, Expect } from "./test-types";
89

9-
export type Expect<T extends true> = T;
10-
// prettier-ignore
11-
type Equal<X, Y> =
12-
(<T>() => T extends X ? 1 : 2) extends
13-
(<T>() => T extends Y ? 1 : 2) ? true : false
1410
type IsAny<T> = 0 extends 1 & T ? true : false;
1511
type IsDefined<T> = Equal<T, undefined> extends true ? false : true;
1612
type Fn = (...args: any[]) => unknown;

0 commit comments

Comments
 (0)