From b1f2af62ecf036c34f61d39911a15389887b036b Mon Sep 17 00:00:00 2001 From: Nathan Brown Date: Thu, 12 Jun 2025 10:12:42 -0700 Subject: [PATCH 1/4] Add href() test for period in page url --- packages/react-router/__tests__/href-test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/react-router/__tests__/href-test.ts b/packages/react-router/__tests__/href-test.ts index b3b14ed8c6..5873ce31c5 100644 --- a/packages/react-router/__tests__/href-test.ts +++ b/packages/react-router/__tests__/href-test.ts @@ -26,4 +26,8 @@ describe("href", () => { `Path '/a/:b' requires param 'b' but it was not provided`, ); }); + + it("works with periods", () => { + expect(href("/a/:b.zip", { b: "hello" })).toBe("/a/hello.zip"); + }); }); From 606ea464f62d78a93949ee830f93fe3b93b548a8 Mon Sep 17 00:00:00 2001 From: Nathan Brown Date: Thu, 12 Jun 2025 14:19:11 -0700 Subject: [PATCH 2/4] Update href() with new implementation --- packages/react-router/__tests__/href-test.ts | 10 ++++++ packages/react-router/lib/href.ts | 37 ++++++++++---------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/packages/react-router/__tests__/href-test.ts b/packages/react-router/__tests__/href-test.ts index 5873ce31c5..e674abbaa7 100644 --- a/packages/react-router/__tests__/href-test.ts +++ b/packages/react-router/__tests__/href-test.ts @@ -9,16 +9,26 @@ describe("href", () => { expect(href("/a/:b", { b: "hello", z: "ignored" })).toBe("/a/hello"); expect(href("/a/:b?", { b: "hello", z: "ignored" })).toBe("/a/hello"); expect(href("/a/:b?")).toBe("/a"); + expect(href("/:b?")).toBe("/"); + expect(href("/a/:e-z", { "e-z": "hello" })).toBe("/a/hello"); }); it("works with repeated params", () => { expect(href("/a/:b?/:b/:b?/:b", { b: "hello" })).toBe( "/a/hello/hello/hello/hello", ); + expect(href("/a/:c?/:b/:c?/:b", { b: "hello" })).toBe("/a/hello/hello"); }); it("works with splats", () => { expect(href("/a/*", { "*": "b/c" })).toBe("/a/b/c"); + expect(href("/a/*", {})).toBe("/a"); + }); + + it("works with malformed splats", () => { + // this is how packages\react-router\lib\router\utils.ts: compilePath() will handle these routes. + expect(href("/a/z*", { "*": "b/c" })).toBe("/a/z/b/c"); + expect(href("/a/z*", {})).toBe("/a/z"); }); it("throws when required params are missing", () => { diff --git a/packages/react-router/lib/href.ts b/packages/react-router/lib/href.ts index 4b395cdc29..2ba491ea4a 100644 --- a/packages/react-router/lib/href.ts +++ b/packages/react-router/lib/href.ts @@ -27,26 +27,27 @@ export function href( ...args: Args[Path] ): string { let params = args[0]; - return path - .split("/") - .map((segment) => { - if (segment === "*") { - return params ? params["*"] : undefined; - } - - const match = segment.match(/^:([\w-]+)(\?)?/); - if (!match) return segment; - const param = match[1]; + let result = path.replace( + /\/:([\w-]+)(\?)?/g, // same regex as in .\router\utils.ts: compilePath(). + (_: string, param: string, isOptional) => { const value = params ? params[param] : undefined; - - const isRequired = match[2] === undefined; - if (isRequired && value === undefined) { - throw Error( + if (isOptional == null && value == null) { + throw new Error( `Path '${path}' requires param '${param}' but it was not provided`, ); } - return value; - }) - .filter((segment) => segment !== undefined) - .join("/"); + return value == null ? "" : "/" + value; + }, + ); + + if (result.endsWith("*")) { + // treat trailing splat the same way as compilePath, and force it to be as if it were `/*`. + // `react-router typegen` will not generate the params for a malformed splat, causing a type error, but we can still do the correct thing here. + result = result.slice(0, result.endsWith("/*") ? -2 : -1); + if (params && params["*"] != null) { + result += "/" + params["*"]; + } + } + + return result || "/"; } From 138d40347eb1e43fb716436462d88b6d299e7313 Mon Sep 17 00:00:00 2001 From: Nathan Brown Date: Thu, 12 Jun 2025 17:10:54 -0700 Subject: [PATCH 3/4] Add changeset --- .changeset/slow-readers-thank.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/slow-readers-thank.md diff --git a/.changeset/slow-readers-thank.md b/.changeset/slow-readers-thank.md new file mode 100644 index 0000000000..22b04b1e64 --- /dev/null +++ b/.changeset/slow-readers-thank.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +`href()` now correctly processes routes that have an extension after the parameter or are a single optional parameter. From 179b5260d3231ab40011f7c8c3f380ed62c22a0f Mon Sep 17 00:00:00 2001 From: Nathan Brown Date: Fri, 13 Jun 2025 09:30:26 -0700 Subject: [PATCH 4/4] In href(), remove need for slice and ignore trailing slashes from input --- packages/react-router/lib/href.ts | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/react-router/lib/href.ts b/packages/react-router/lib/href.ts index 2ba491ea4a..d7f594b783 100644 --- a/packages/react-router/lib/href.ts +++ b/packages/react-router/lib/href.ts @@ -27,23 +27,24 @@ export function href( ...args: Args[Path] ): string { let params = args[0]; - let result = path.replace( - /\/:([\w-]+)(\?)?/g, // same regex as in .\router\utils.ts: compilePath(). - (_: string, param: string, isOptional) => { - const value = params ? params[param] : undefined; - if (isOptional == null && value == null) { - throw new Error( - `Path '${path}' requires param '${param}' but it was not provided`, - ); - } - return value == null ? "" : "/" + value; - }, - ); + let result = path + .replace(/\/*\*?$/, "") // Ignore trailing / and /*, we'll handle it below + .replace( + /\/:([\w-]+)(\?)?/g, // same regex as in .\router\utils.ts: compilePath(). + (_: string, param: string, isOptional) => { + const value = params ? params[param] : undefined; + if (isOptional == null && value == null) { + throw new Error( + `Path '${path}' requires param '${param}' but it was not provided`, + ); + } + return value == null ? "" : "/" + value; + }, + ); - if (result.endsWith("*")) { + if (path.endsWith("*")) { // treat trailing splat the same way as compilePath, and force it to be as if it were `/*`. // `react-router typegen` will not generate the params for a malformed splat, causing a type error, but we can still do the correct thing here. - result = result.slice(0, result.endsWith("/*") ? -2 : -1); if (params && params["*"] != null) { result += "/" + params["*"]; }