Skip to content

Commit d82b943

Browse files
committed
fix typegen for optional dynamic params
1 parent a7ab88a commit d82b943

File tree

3 files changed

+72
-41
lines changed

3 files changed

+72
-41
lines changed

.changeset/witty-buttons-shake.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@react-router/dev": patch
3+
---
4+
5+
Fix href types for optional dynamic params
6+
7+
7.6.1 introduced fixes for `href` when using optional static segments,
8+
but those fixes caused regressions with how optional dynamic params worked in 7.6.0:
9+
10+
```ts
11+
// 7.6.0
12+
href("/users/:id?"); //
13+
href("/users/:id?", { id: 1 }); //
14+
15+
// 7.6.1
16+
href("/users/:id?"); //
17+
href("/users/:id?", { id: 1 }); //
18+
```
19+
20+
Now, optional static segments are expanded into different paths for `href`, but optional dynamic params are not.
21+
This way `href` can unambiguously refer to an exact URL path, all while keeping the number of path options to a minimum.
22+
23+
```ts
24+
// 7.6.2
25+
26+
// path: /users/:id?/edit?
27+
href("
28+
// ^ suggestions when cursor is here:
29+
//
30+
// /users/:id?
31+
// /users/:id?/edit
32+
```
33+
34+
Additionally, you can pass `params` from component props without needing to narrow them manually:
35+
36+
```ts
37+
declare const params: { id?: number };
38+
39+
// 7.6.0
40+
href("/users/:id?", params);
41+
42+
// 7.6.1
43+
href("/users/:id?", params); //
44+
"id" in params ? href("/users/:id", params) : href("/users"); // works... but is annoying
45+
46+
// 7.6.2
47+
href("/users/:id?", params); // restores convenience of 7.6.0
48+
```

integration/typegen-test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -537,11 +537,11 @@ test.describe("typegen", () => {
537537
href("/required-param/:req")
538538
href("/required-param/:req", { req: "hello" })
539539
540+
// @ts-expect-error
540541
href("/optional-param")
541-
href("/optional-param/:opt", { opt: "hello" })
542542
// @ts-expect-error
543+
href("/optional-param/:opt", { opt: "hello" })
543544
href("/optional-param/:opt?")
544-
// @ts-expect-error
545545
href("/optional-param/:opt?", { opt: "hello" })
546546
547547
href("/leading-and-trailing-slash")

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

Lines changed: 22 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ export function generateRoutes(ctx: Context): Array<VirtualFile> {
7777
// pages
7878
const fullpath = Route.fullpath(lineage);
7979
if (!fullpath) continue;
80-
const pages = explodeOptionalSegments(fullpath);
80+
81+
const pages = expand(fullpath);
8182
pages.forEach((page) => allPages.add(page));
8283

8384
// routePages
@@ -348,48 +349,30 @@ function paramsType(path: string) {
348349
);
349350
}
350351

351-
// https://github.com/remix-run/react-router/blob/7a7f4b11ca8b26889ad328ba0ee5a749b0c6939e/packages/react-router/lib/router/utils.ts#L894C1-L937C2
352-
function explodeOptionalSegments(path: string): string[] {
353-
let segments = path.split("/");
354-
if (segments.length === 0) return [];
355-
356-
let [first, ...rest] = segments;
352+
function expand(fullpath: string): Set<string> {
353+
function recurse(segments: Array<string>, index: number): Array<string> {
354+
if (index === segments.length) return [""];
355+
const segment = segments[index];
357356

358-
// Optional path segments are denoted by a trailing `?`
359-
let isOptional = first.endsWith("?");
360-
// Compute the corresponding required segment: `foo?` -> `foo`
361-
let required = first.replace(/\?$/, "");
357+
const isOptional = segment.endsWith("?");
358+
const isDynamic = segment.startsWith(":");
359+
const required = segment.replace(/\?$/, "");
362360

363-
if (rest.length === 0) {
364-
// Interpret empty string as omitting an optional segment
365-
// `["one", "", "three"]` corresponds to omitting `:two` from `/one/:two?/three` -> `/one/three`
366-
return isOptional ? [required, ""] : [required];
367-
}
361+
const keep = !isOptional || isDynamic;
362+
const kept = isDynamic ? segment : required;
368363

369-
let restExploded = explodeOptionalSegments(rest.join("/"));
364+
const withoutSegment = recurse(segments, index + 1);
365+
const withSegment = withoutSegment.map((rest) => [kept, rest].join("/"));
370366

371-
let result: string[] = [];
372-
373-
// All child paths with the prefix. Do this for all children before the
374-
// optional version for all children, so we get consistent ordering where the
375-
// parent optional aspect is preferred as required. Otherwise, we can get
376-
// child sections interspersed where deeper optional segments are higher than
377-
// parent optional segments, where for example, /:two would explode _earlier_
378-
// then /:one. By always including the parent as required _for all children_
379-
// first, we avoid this issue
380-
result.push(
381-
...restExploded.map((subpath) =>
382-
subpath === "" ? required : [required, subpath].join("/")
383-
)
384-
);
385-
386-
// Then, if this is an optional value, add all child versions without
387-
if (isOptional) {
388-
result.push(...restExploded);
367+
if (keep) return withSegment;
368+
return [...withoutSegment, ...withSegment];
389369
}
390370

391-
// for absolute paths, ensure `/` instead of empty segment
392-
return result.map((exploded) =>
393-
path.startsWith("/") && exploded === "" ? "/" : exploded
394-
);
371+
const segments = fullpath.split("/");
372+
const expanded = new Set<string>();
373+
for (let result of recurse(segments, 0)) {
374+
if (result !== "/") result = result.replace(/\/$/, "");
375+
expanded.add(result);
376+
}
377+
return expanded;
395378
}

0 commit comments

Comments
 (0)