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. diff --git a/packages/react-router/__tests__/href-test.ts b/packages/react-router/__tests__/href-test.ts index b3b14ed8c6..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", () => { @@ -26,4 +36,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"); + }); }); diff --git a/packages/react-router/lib/href.ts b/packages/react-router/lib/href.ts index 4b395cdc29..d7f594b783 100644 --- a/packages/react-router/lib/href.ts +++ b/packages/react-router/lib/href.ts @@ -27,26 +27,28 @@ export function href( ...args: Args[Path] ): string { let params = args[0]; - return path - .split("/") - .map((segment) => { - if (segment === "*") { - return params ? params["*"] : undefined; - } + 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; + }, + ); - const match = segment.match(/^:([\w-]+)(\?)?/); - if (!match) return segment; - const param = match[1]; - const value = params ? params[param] : undefined; + 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. + if (params && params["*"] != null) { + result += "/" + params["*"]; + } + } - const isRequired = match[2] === undefined; - if (isRequired && value === undefined) { - throw Error( - `Path '${path}' requires param '${param}' but it was not provided`, - ); - } - return value; - }) - .filter((segment) => segment !== undefined) - .join("/"); + return result || "/"; }