Skip to content

Commit 1b5ba36

Browse files
committed
fix href optionals
1 parent 9ed17cd commit 1b5ba36

File tree

2 files changed

+71
-9
lines changed

2 files changed

+71
-9
lines changed

integration/typegen-test.ts

Lines changed: 17 additions & 3 deletions
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,11 +116,12 @@ 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
`,
123123
});
124+
console.log({ cwd });
124125
const proc = typecheck(cwd);
125126
expect(proc.stdout.toString()).toBe("");
126127
expect(proc.stderr.toString()).toBe("");
@@ -497,13 +498,17 @@ test.describe("typegen", () => {
497498
import { type RouteConfig, route } from "@react-router/dev/routes";
498499
499500
export default [
501+
route("optional-static/opt?", "routes/optional-static.tsx"),
500502
route("no-params", "routes/no-params.tsx"),
501503
route("required-param/:req", "routes/required-param.tsx"),
502504
route("optional-param/:opt?", "routes/optional-param.tsx"),
503505
route("/leading-and-trailing-slash/", "routes/leading-and-trailing-slash.tsx"),
504506
route("some-other-route", "routes/some-other-route.tsx"),
505507
] satisfies RouteConfig;
506508
`,
509+
"app/routes/optional-static.tsx": tsx`
510+
export default function Component() {}
511+
`,
507512
"app/routes/no-params.tsx": tsx`
508513
export default function Component() {}
509514
`,
@@ -522,13 +527,22 @@ test.describe("typegen", () => {
522527
// @ts-expect-error
523528
href("/does-not-exist")
524529
530+
href("/optional-static")
531+
href("/optional-static/opt")
532+
// @ts-expect-error
533+
href("/optional-static/opt?")
534+
525535
href("/no-params")
526536
527537
// @ts-expect-error
528538
href("/required-param/:req")
529539
href("/required-param/:req", { req: "hello" })
530540
541+
href("/optional-param")
542+
href("/optional-param/:opt", { opt: "hello" })
543+
// @ts-expect-error
531544
href("/optional-param/:opt?")
545+
// @ts-expect-error
532546
href("/optional-param/:opt?", { opt: "hello" })
533547
534548
href("/leading-and-trailing-slash")

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

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { Context } from "./context";
77
import * as Params from "./params";
88
import * as Route from "./route";
99
import type { RouteManifestEntry } from "../config/routes";
10+
import exp from "constants";
1011

1112
export type VirtualFile = { filename: string; content: string };
1213

@@ -59,7 +60,7 @@ export function generateRoutes(ctx: Context): Array<VirtualFile> {
5960
// precompute
6061
const fileToRoutes = new Map<string, Set<string>>();
6162
const lineages = new Map<string, Array<RouteManifestEntry>>();
62-
const pages = new Set<string>();
63+
const allPages = new Set<string>();
6364
const routeToPages = new Map<string, Set<string>>();
6465
for (const route of Object.values(ctx.config.routes)) {
6566
// fileToRoutes
@@ -75,9 +76,10 @@ export function generateRoutes(ctx: Context): Array<VirtualFile> {
7576
lineages.set(route.id, lineage);
7677

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

8284
// routePages
8385
lineage.forEach(({ id }) => {
@@ -86,7 +88,7 @@ export function generateRoutes(ctx: Context): Array<VirtualFile> {
8688
routePages = new Set<string>();
8789
routeToPages.set(id, routePages);
8890
}
89-
routePages.add(page);
91+
pages.forEach((page) => routePages.add(page));
9092
});
9193
}
9294

@@ -107,7 +109,7 @@ export function generateRoutes(ctx: Context): Array<VirtualFile> {
107109
}
108110
` +
109111
"\n\n" +
110-
Babel.generate(pagesType(pages)).code +
112+
Babel.generate(pagesType(allPages)).code +
111113
"\n\n" +
112114
Babel.generate(routeFilesType({ fileToRoutes, routeToPages })).code,
113115
};
@@ -346,3 +348,49 @@ function paramsType(path: string) {
346348
})
347349
);
348350
}
351+
352+
// https://github.com/remix-run/react-router/blob/7a7f4b11ca8b26889ad328ba0ee5a749b0c6939e/packages/react-router/lib/router/utils.ts#L894C1-L937C2
353+
function explodeOptionalSegments(path: string): string[] {
354+
let segments = path.split("/");
355+
if (segments.length === 0) return [];
356+
357+
let [first, ...rest] = segments;
358+
359+
// Optional path segments are denoted by a trailing `?`
360+
let isOptional = first.endsWith("?");
361+
// Compute the corresponding required segment: `foo?` -> `foo`
362+
let required = first.replace(/\?$/, "");
363+
364+
if (rest.length === 0) {
365+
// Interpret empty string as omitting an optional segment
366+
// `["one", "", "three"]` corresponds to omitting `:two` from `/one/:two?/three` -> `/one/three`
367+
return isOptional ? [required, ""] : [required];
368+
}
369+
370+
let restExploded = explodeOptionalSegments(rest.join("/"));
371+
372+
let result: string[] = [];
373+
374+
// All child paths with the prefix. Do this for all children before the
375+
// optional version for all children, so we get consistent ordering where the
376+
// parent optional aspect is preferred as required. Otherwise, we can get
377+
// child sections interspersed where deeper optional segments are higher than
378+
// parent optional segments, where for example, /:two would explode _earlier_
379+
// then /:one. By always including the parent as required _for all children_
380+
// first, we avoid this issue
381+
result.push(
382+
...restExploded.map((subpath) =>
383+
subpath === "" ? required : [required, subpath].join("/")
384+
)
385+
);
386+
387+
// Then, if this is an optional value, add all child versions without
388+
if (isOptional) {
389+
result.push(...restExploded);
390+
}
391+
392+
// for absolute paths, ensure `/` instead of empty segment
393+
return result.map((exploded) =>
394+
path.startsWith("/") && exploded === "" ? "/" : exploded
395+
);
396+
}

0 commit comments

Comments
 (0)