Skip to content

Commit 43c8b06

Browse files
committed
fix href optionals
1 parent 9ed17cd commit 43c8b06

File tree

2 files changed

+69
-9
lines changed

2 files changed

+69
-9
lines changed

integration/typegen-test.ts

+16-3
Original file line numberDiff line numberDiff line change
@@ -99,15 +99,15 @@ test.describe("typegen", () => {
9999
import type { Expect, Equal } from "../expect-type"
100100
import type { Route } from "./+types/only-optional"
101101
export function loader({ params }: Route.LoaderArgs) {
102-
type Test = Expect<Equal<typeof params, { id?: string }>>
102+
type Test = Expect<Equal<typeof params.id, string | undefined>>
103103
return null
104104
}
105105
`,
106106
"app/routes/optional-then-required.tsx": tsx`
107107
import type { Expect, Equal } from "../expect-type"
108108
import type { Route } from "./+types/optional-then-required"
109109
export function loader({ params }: Route.LoaderArgs) {
110-
type Test = Expect<Equal<typeof params, { id: string }>>
110+
type Test = Expect<Equal<typeof params.id, string>>
111111
return null
112112
}
113113
`,
@@ -116,7 +116,7 @@ test.describe("typegen", () => {
116116
import type { Route } from "./+types/required-then-optional"
117117
118118
export function loader({ params }: Route.LoaderArgs) {
119-
type Test = Expect<Equal<typeof params, { id: string }>>
119+
type Test = Expect<Equal<typeof params.id, string>>
120120
return null
121121
}
122122
`,
@@ -497,13 +497,17 @@ test.describe("typegen", () => {
497497
import { type RouteConfig, route } from "@react-router/dev/routes";
498498
499499
export default [
500+
route("optional-static/opt?", "routes/optional-static.tsx"),
500501
route("no-params", "routes/no-params.tsx"),
501502
route("required-param/:req", "routes/required-param.tsx"),
502503
route("optional-param/:opt?", "routes/optional-param.tsx"),
503504
route("/leading-and-trailing-slash/", "routes/leading-and-trailing-slash.tsx"),
504505
route("some-other-route", "routes/some-other-route.tsx"),
505506
] satisfies RouteConfig;
506507
`,
508+
"app/routes/optional-static.tsx": tsx`
509+
export default function Component() {}
510+
`,
507511
"app/routes/no-params.tsx": tsx`
508512
export default function Component() {}
509513
`,
@@ -522,13 +526,22 @@ test.describe("typegen", () => {
522526
// @ts-expect-error
523527
href("/does-not-exist")
524528
529+
href("/optional-static")
530+
href("/optional-static/opt")
531+
// @ts-expect-error
532+
href("/optional-static/opt?")
533+
525534
href("/no-params")
526535
527536
// @ts-expect-error
528537
href("/required-param/:req")
529538
href("/required-param/:req", { req: "hello" })
530539
540+
href("/optional-param")
541+
href("/optional-param/:opt", { opt: "hello" })
542+
// @ts-expect-error
531543
href("/optional-param/:opt?")
544+
// @ts-expect-error
532545
href("/optional-param/:opt?", { opt: "hello" })
533546
534547
href("/leading-and-trailing-slash")

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

+53-6
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function generateRoutes(ctx: Context): Array<VirtualFile> {
5959
// precompute
6060
const fileToRoutes = new Map<string, Set<string>>();
6161
const lineages = new Map<string, Array<RouteManifestEntry>>();
62-
const pages = new Set<string>();
62+
const allPages = new Set<string>();
6363
const routeToPages = new Map<string, Set<string>>();
6464
for (const route of Object.values(ctx.config.routes)) {
6565
// fileToRoutes
@@ -75,9 +75,10 @@ export function generateRoutes(ctx: Context): Array<VirtualFile> {
7575
lineages.set(route.id, lineage);
7676

7777
// pages
78-
const page = Route.fullpath(lineage);
79-
if (!page) continue;
80-
pages.add(page);
78+
const fullpath = Route.fullpath(lineage);
79+
if (!fullpath) continue;
80+
const pages = explodeOptionalSegments(fullpath);
81+
pages.forEach((page) => allPages.add(page));
8182

8283
// routePages
8384
lineage.forEach(({ id }) => {
@@ -86,7 +87,7 @@ export function generateRoutes(ctx: Context): Array<VirtualFile> {
8687
routePages = new Set<string>();
8788
routeToPages.set(id, routePages);
8889
}
89-
routePages.add(page);
90+
pages.forEach((page) => routePages.add(page));
9091
});
9192
}
9293

@@ -107,7 +108,7 @@ export function generateRoutes(ctx: Context): Array<VirtualFile> {
107108
}
108109
` +
109110
"\n\n" +
110-
Babel.generate(pagesType(pages)).code +
111+
Babel.generate(pagesType(allPages)).code +
111112
"\n\n" +
112113
Babel.generate(routeFilesType({ fileToRoutes, routeToPages })).code,
113114
};
@@ -346,3 +347,49 @@ function paramsType(path: string) {
346347
})
347348
);
348349
}
350+
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;
357+
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(/\?$/, "");
362+
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+
}
368+
369+
let restExploded = explodeOptionalSegments(rest.join("/"));
370+
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);
389+
}
390+
391+
// for absolute paths, ensure `/` instead of empty segment
392+
return result.map((exploded) =>
393+
path.startsWith("/") && exploded === "" ? "/" : exploded
394+
);
395+
}

0 commit comments

Comments
 (0)