From 3cc6ae738acd2741243643e1f8b1aa8e656c1b8d Mon Sep 17 00:00:00 2001 From: Jordan Thomson Date: Wed, 9 Aug 2023 00:32:03 +1000 Subject: [PATCH 1/4] Added support for optional segments in `matchPath` and added tests --- .../react-router/__tests__/matchPath-test.tsx | 47 +++++++++++++++++++ packages/router/utils.ts | 30 +++++++----- 2 files changed, 65 insertions(+), 12 deletions(-) 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 5b50f6d761..d87c419b5b 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -916,7 +916,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 === "*") { @@ -926,10 +926,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; }, {} @@ -947,7 +952,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 ` + @@ -956,20 +961,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 From 25c1dc530b287c82d6ccf8e4b0b4c405e2ead348 Mon Sep 17 00:00:00 2001 From: Jordan Thomson Date: Wed, 9 Aug 2023 10:48:25 +1000 Subject: [PATCH 2/4] updated `contributors.yml` --- contributors.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/contributors.yml b/contributors.yml index 6e46af091c..7009e14ead 100644 --- a/contributors.yml +++ b/contributors.yml @@ -88,6 +88,7 @@ - ianflynnwork - IbraRouisDev - igniscyan +- imjordanxd - infoxicator - IsaiStormBlesed - Isammoc From 2d0b110cc10eac502d64789fea9b36239af719bd Mon Sep 17 00:00:00 2001 From: Jordan Thomson Date: Wed, 9 Aug 2023 16:30:22 +1000 Subject: [PATCH 3/4] added changeset & updated bundle size --- .changeset/support-optional-path-segments-in-match-path.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/support-optional-path-segments-in-match-path.md diff --git a/.changeset/support-optional-path-segments-in-match-path.md b/.changeset/support-optional-path-segments-in-match-path.md new file mode 100644 index 0000000000..fa5072636e --- /dev/null +++ b/.changeset/support-optional-path-segments-in-match-path.md @@ -0,0 +1,6 @@ +--- +"react-router": patch +"@remix-run/router": patch +--- + +Added support for optional path segments in `matchPath` diff --git a/package.json b/package.json index f1746f97c7..9a3add99b2 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "48.3 kB" + "none": "48.7 kB" }, "packages/react-router/dist/react-router.production.min.js": { "none": "13.9 kB" From 309214826d83e7e0fa6ee0c13dc24075fb51895a Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 19 Oct 2023 10:10:17 -0400 Subject: [PATCH 4/4] Minor cleanup --- ...rt-optional-path-segments-in-match-path.md | 5 ++-- packages/router/utils.ts | 24 +++++++++---------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/.changeset/support-optional-path-segments-in-match-path.md b/.changeset/support-optional-path-segments-in-match-path.md index fa5072636e..68da9bc890 100644 --- a/.changeset/support-optional-path-segments-in-match-path.md +++ b/.changeset/support-optional-path-segments-in-match-path.md @@ -1,6 +1,5 @@ --- -"react-router": patch -"@remix-run/router": patch +"@remix-run/router": minor --- -Added support for optional path segments in `matchPath` +Add support for optional path segments in `matchPath` diff --git a/packages/router/utils.ts b/packages/router/utils.ts index d87c419b5b..51f9759125 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -903,7 +903,7 @@ export function matchPath< pattern = { path: pattern, caseSensitive: false, end: true }; } - let [matcher, paramNames] = compilePath( + let [matcher, compiledParams] = compilePath( pattern.path, pattern.caseSensitive, pattern.end @@ -915,7 +915,7 @@ export function matchPath< let matchedPathname = match[0]; let pathnameBase = matchedPathname.replace(/(.)\/+$/, "$1"); let captureGroups = match.slice(1); - let params: Params = paramNames.reduce>( + let params: Params = compiledParams.reduce>( (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 @@ -930,10 +930,7 @@ export function matchPath< if (isOptional && !value) { memo[paramName] = undefined; } else { - memo[paramName] = safelyDecodeURIComponent( - value || "", - paramName - ); + memo[paramName] = safelyDecodeURIComponent(value || "", paramName); } return memo; }, @@ -948,11 +945,13 @@ export function matchPath< }; } +type CompiledPathParam = { paramName: string; isOptional?: boolean }; + function compilePath( path: string, caseSensitive = false, end = true -): [RegExp, { paramName: string, isOptional: boolean }[]] { +): [RegExp, CompiledPathParam[]] { warning( path === "*" || !path.endsWith("*") || path.endsWith("/*"), `Route path "${path}" will be treated as if it were ` + @@ -961,21 +960,20 @@ function compilePath( `please change the route path to "${path.replace(/\*$/, "/*")}".` ); - let paramNames: { paramName: string, isOptional: boolean }[] = []; + let params: CompiledPathParam[] = []; 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, ...rest) => { - const isOptional = rest[0] != null; - paramNames.push({ paramName, isOptional }); + .replace(/\/:(\w+)(\?)?/g, (_: string, paramName: string, isOptional) => { + params.push({ paramName, isOptional: isOptional != null }); return isOptional ? "/?([^\\/]+)?" : "/([^\\/]+)"; }); if (path.endsWith("*")) { - paramNames.push({ paramName: "*", isOptional: false }); + params.push({ paramName: "*" }); regexpSource += path === "*" || path === "/*" ? "(.*)$" // Already matched the initial /, just match the rest @@ -998,7 +996,7 @@ function compilePath( let matcher = new RegExp(regexpSource, caseSensitive ? undefined : "i"); - return [matcher, paramNames]; + return [matcher, params]; } function safelyDecodeURI(value: string) {