Skip to content

Commit 9849f77

Browse files
committed
wip: type-safe href
1 parent 1923f4b commit 9849f77

File tree

4 files changed

+151
-0
lines changed

4 files changed

+151
-0
lines changed

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import fs from "node:fs";
22

3+
import ts from "dedent";
34
import * as Path from "pathe";
45
import pc from "picocolors";
56
import type vite from "vite";
67

78
import { createConfigLoader } from "../config/config";
9+
import * as Babel from "../vite/babel";
810

911
import { generate } from "./generate";
1012
import type { Context } from "./context";
1113
import { getTypesDir, getTypesPath } from "./paths";
14+
import type { RouteManifest, RouteManifestEntry } from "../config/routes";
1215

1316
export async function run(rootDirectory: string) {
1417
const ctx = await createContext({ rootDirectory, watch: false });
@@ -81,4 +84,91 @@ async function writeAll(ctx: Context): Promise<void> {
8184
fs.mkdirSync(Path.dirname(typesPath), { recursive: true });
8285
fs.writeFileSync(typesPath, content);
8386
});
87+
88+
const registerPath = Path.join(typegenDir, "+register.ts");
89+
fs.writeFileSync(registerPath, register(ctx));
90+
}
91+
92+
function register(ctx: Context) {
93+
const register = ts`
94+
import "react-router";
95+
96+
declare module "react-router" {
97+
interface Register {
98+
params: Params;
99+
}
100+
}
101+
`;
102+
103+
const { t } = Babel;
104+
105+
const typeParams = t.tsTypeAliasDeclaration(
106+
t.identifier("Params"),
107+
null,
108+
t.tsTypeLiteral(
109+
Object.values(ctx.config.routes)
110+
.map((route) => {
111+
// filter out pathless (layout) routes
112+
if (route.id !== "root" && !route.path) return undefined;
113+
114+
const lineage = getRouteLineage(ctx.config.routes, route);
115+
const fullpath =
116+
route.id === "root"
117+
? "/"
118+
: lineage
119+
.map((route) => route.path)
120+
.filter((path) => path !== undefined)
121+
.join("/");
122+
const params = parseParams(fullpath);
123+
return t.tsPropertySignature(
124+
t.stringLiteral(fullpath),
125+
t.tsTypeAnnotation(
126+
t.tsTypeLiteral(
127+
Object.entries(params).map(([param, isRequired]) => {
128+
const property = t.tsPropertySignature(
129+
t.stringLiteral(param),
130+
t.tsTypeAnnotation(t.tsStringKeyword())
131+
);
132+
property.optional = !isRequired;
133+
return property;
134+
})
135+
)
136+
)
137+
);
138+
})
139+
.filter((x): x is Babel.Babel.TSPropertySignature => x !== undefined)
140+
)
141+
);
142+
143+
return [register, Babel.generate(typeParams).code].join("\n\n");
144+
}
145+
146+
function parseParams(fullpath: string) {
147+
const result: Record<string, boolean> = {};
148+
149+
let segments = fullpath.split("/");
150+
segments.forEach((segment) => {
151+
const match = segment.match(/^:([\w-]+)(\?)?/);
152+
if (!match) return;
153+
const param = match[1];
154+
const isRequired = match[2] === undefined;
155+
156+
result[param] ||= isRequired;
157+
return;
158+
});
159+
160+
const hasSplat = segments.at(-1) === "*";
161+
if (hasSplat) result["*"] = true;
162+
return result;
163+
}
164+
165+
function getRouteLineage(routes: RouteManifest, route: RouteManifestEntry) {
166+
const result: RouteManifestEntry[] = [];
167+
while (route) {
168+
result.push(route);
169+
if (!route.parentId) break;
170+
route = routes[route.parentId];
171+
}
172+
result.reverse();
173+
return result;
84174
}

packages/react-router/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,9 @@ export type {
263263
FlashSessionData,
264264
} from "./lib/server-runtime/sessions";
265265

266+
export type { Register } from "./lib/types/register";
267+
export { href } from "./lib/href";
268+
266269
///////////////////////////////////////////////////////////////////////////////
267270
// DANGER! PLEASE READ ME!
268271
// We provide these exports as an escape hatch in the event that you need any

packages/react-router/lib/href.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { Register } from "./types/register";
2+
import type { Equal } from "./types/utils";
3+
4+
type AnyParams = Record<string, Record<string, string | undefined>>;
5+
type Params = Register extends {
6+
params: infer RegisteredParams extends AnyParams;
7+
}
8+
? RegisteredParams
9+
: AnyParams;
10+
11+
type HrefArgs = { [K in keyof Params]: ToHrefArgs<Params[K]> };
12+
13+
// prettier-ignore
14+
type ToHrefArgs<T> =
15+
// path without params -> no `params` arg
16+
Equal<T, {}> extends true ? [] :
17+
// path with only optional params -> optional `params` arg
18+
Partial<T> extends T ? [T] | [] :
19+
// otherwise, require `params` arg
20+
[T];
21+
22+
/**
23+
Returns a resolved URL path for the specified route.
24+
25+
```tsx
26+
const h = href("/:lang?/about", { lang: "en" })
27+
// -> `/en/about`
28+
29+
<Link to={href("/products/:id", { id: "abc123" })} />
30+
```
31+
*/
32+
export function href<Path extends keyof HrefArgs>(
33+
path: Path,
34+
...args: HrefArgs[Path]
35+
): string {
36+
let params = args[0];
37+
return path
38+
.split("/")
39+
.map((segment) => {
40+
const match = segment.match(/^:([\w-]+)(\?)?/);
41+
if (!match) return segment;
42+
const param = match[1];
43+
if (params === undefined) {
44+
throw Error(`Path '${path}' requires params but none were provided`);
45+
}
46+
return params[param];
47+
})
48+
.join("/");
49+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Apps can use this interface to "register" app-wide types for React Router via interface declaration merging and module augmentation.
3+
* React Router should handle this for you via type generation.
4+
*
5+
* For more on declaration merging and module augmentation, see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation .
6+
*/
7+
export interface Register {
8+
// params
9+
}

0 commit comments

Comments
 (0)