diff --git a/package.json b/package.json index a875cd94cf..a36de485fe 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "47.2 kB" + "none": "47.34 kB" }, "packages/react-router/dist/react-router.production.min.js": { "none": "13.8 kB" diff --git a/packages/react-router/__tests__/matchPath-test.tsx b/packages/react-router/__tests__/matchPath-test.tsx index 337734e0e4..5d4a95b0aa 100644 --- a/packages/react-router/__tests__/matchPath-test.tsx +++ b/packages/react-router/__tests__/matchPath-test.tsx @@ -245,6 +245,53 @@ describe("matchPath", () => { }); }); +describe("matchPath optional segments", () => { + it("should match when optional segment is provided", () => { + const match = matchPath("/:lang?/user/:id", "/en/user/123"); + expect(match).toMatchObject({ params: { lang: "en", id: "123" } }); + }); + + it("should match when optional segment is *not* provided", () => { + const match = matchPath("/:lang?/user/:id", "/user/123"); + expect(match).toMatchObject({ params: { lang: undefined, id: "123" } }); + }); + + it("should match when middle optional segment is provided", () => { + const match = matchPath("/user/:lang?/:id", "/user/en/123"); + expect(match).toMatchObject({ params: { lang: "en", id: "123" } }); + }); + + it("should match when middle optional segment is *not* provided", () => { + const match = matchPath("/user/:lang?/:id", "/user/123"); + expect(match).toMatchObject({ params: { lang: undefined, id: "123" } }); + }); + + it("should match when end optional segment is provided", () => { + const match = matchPath("/user/:id/:lang?", "/user/123/en"); + expect(match).toMatchObject({ params: { lang: "en", id: "123" } }); + }); + + it("should match when end optional segment is *not* provided", () => { + const match = matchPath("/user/:id/:lang?", "/user/123"); + expect(match).toMatchObject({ params: { lang: undefined, id: "123" } }); + }); + + it("should match multiple optional segments and none are provided", () => { + const match = matchPath("/:lang?/user/:id?", "/user"); + expect(match).toMatchObject({ params: { lang: undefined, id: undefined } }); + }); + + it("should match multiple optional segments and one is provided", () => { + const match = matchPath("/:lang?/user/:id?", "/en/user"); + expect(match).toMatchObject({ params: { lang: "en", id: undefined } }); + }); + + it("should match multiple optional segments and all are provided", () => { + const match = matchPath("/:lang?/user/:id?", "/en/user/123"); + expect(match).toMatchObject({ params: { lang: "en", id: "123" } }); + }); +}); + describe("matchPath *", () => { it("matches the root URL", () => { expect(matchPath("*", "/")).toMatchObject({ diff --git a/packages/router/utils.ts b/packages/router/utils.ts index 9830d3078d..ab8d2391bc 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -878,7 +878,7 @@ export function matchPath< let pathnameBase = matchedPathname.replace(/(.)\/+$/, "$1"); let captureGroups = match.slice(1); let params: Params = paramNames.reduce>( - (memo, paramName, index) => { + (memo, { paramName, isOptional }, index) => { // We need to compute the pathnameBase here using the raw splat value // instead of using params["*"] later because it will be decoded then if (paramName === "*") { @@ -888,10 +888,15 @@ export function matchPath< .replace(/(.)\/+$/, "$1"); } - memo[paramName] = safelyDecodeURIComponent( - captureGroups[index] || "", - paramName - ); + const value = captureGroups[index]; + if (isOptional && !value) { + memo[paramName] = undefined; + } else { + memo[paramName] = safelyDecodeURIComponent( + value || "", + paramName + ); + } return memo; }, {} @@ -909,7 +914,7 @@ function compilePath( path: string, caseSensitive = false, end = true -): [RegExp, string[]] { +): [RegExp, { paramName: string, isOptional: boolean }[]] { warning( path === "*" || !path.endsWith("*") || path.endsWith("/*"), `Route path "${path}" will be treated as if it were ` + @@ -918,20 +923,21 @@ function compilePath( `please change the route path to "${path.replace(/\*$/, "/*")}".` ); - let paramNames: string[] = []; + let paramNames: { paramName: string, isOptional: boolean }[] = []; let regexpSource = "^" + path .replace(/\/*\*?$/, "") // Ignore trailing / and /*, we'll handle it below .replace(/^\/*/, "/") // Make sure it has a leading / - .replace(/[\\.*+^$?{}|()[\]]/g, "\\$&") // Escape special regex chars - .replace(/\/:(\w+)/g, (_: string, paramName: string) => { - paramNames.push(paramName); - return "/([^\\/]+)"; + .replace(/[\\.*+^${}|()[\]]/g, "\\$&") // Escape special regex chars + .replace(/\/:(\w+)(\?)?/g, (_: string, paramName: string, ...rest) => { + const isOptional = rest[0] != null; + paramNames.push({ paramName, isOptional }); + return isOptional ? "/?([^\\/]+)?" : "/([^\\/]+)"; }); if (path.endsWith("*")) { - paramNames.push("*"); + paramNames.push({ paramName: "*", isOptional: false }); regexpSource += path === "*" || path === "/*" ? "(.*)$" // Already matched the initial /, just match the rest