Skip to content

Commit 677d6c8

Browse files
Support optional path segments in matchPath (#10768)
Co-authored-by: Matt Brophy <[email protected]>
1 parent 908a40a commit 677d6c8

File tree

5 files changed

+73
-16
lines changed

5 files changed

+73
-16
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@remix-run/router": minor
3+
---
4+
5+
Add support for optional path segments in `matchPath`

contributors.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
- ianflynnwork
8989
- IbraRouisDev
9090
- igniscyan
91+
- imjordanxd
9192
- infoxicator
9293
- IsaiStormBlesed
9394
- Isammoc

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@
110110
},
111111
"filesize": {
112112
"packages/router/dist/router.umd.min.js": {
113-
"none": "48.3 kB"
113+
"none": "48.7 kB"
114114
},
115115
"packages/react-router/dist/react-router.production.min.js": {
116116
"none": "13.9 kB"

packages/react-router/__tests__/matchPath-test.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,53 @@ describe("matchPath", () => {
245245
});
246246
});
247247

248+
describe("matchPath optional segments", () => {
249+
it("should match when optional segment is provided", () => {
250+
const match = matchPath("/:lang?/user/:id", "/en/user/123");
251+
expect(match).toMatchObject({ params: { lang: "en", id: "123" } });
252+
});
253+
254+
it("should match when optional segment is *not* provided", () => {
255+
const match = matchPath("/:lang?/user/:id", "/user/123");
256+
expect(match).toMatchObject({ params: { lang: undefined, id: "123" } });
257+
});
258+
259+
it("should match when middle optional segment is provided", () => {
260+
const match = matchPath("/user/:lang?/:id", "/user/en/123");
261+
expect(match).toMatchObject({ params: { lang: "en", id: "123" } });
262+
});
263+
264+
it("should match when middle optional segment is *not* provided", () => {
265+
const match = matchPath("/user/:lang?/:id", "/user/123");
266+
expect(match).toMatchObject({ params: { lang: undefined, id: "123" } });
267+
});
268+
269+
it("should match when end optional segment is provided", () => {
270+
const match = matchPath("/user/:id/:lang?", "/user/123/en");
271+
expect(match).toMatchObject({ params: { lang: "en", id: "123" } });
272+
});
273+
274+
it("should match when end optional segment is *not* provided", () => {
275+
const match = matchPath("/user/:id/:lang?", "/user/123");
276+
expect(match).toMatchObject({ params: { lang: undefined, id: "123" } });
277+
});
278+
279+
it("should match multiple optional segments and none are provided", () => {
280+
const match = matchPath("/:lang?/user/:id?", "/user");
281+
expect(match).toMatchObject({ params: { lang: undefined, id: undefined } });
282+
});
283+
284+
it("should match multiple optional segments and one is provided", () => {
285+
const match = matchPath("/:lang?/user/:id?", "/en/user");
286+
expect(match).toMatchObject({ params: { lang: "en", id: undefined } });
287+
});
288+
289+
it("should match multiple optional segments and all are provided", () => {
290+
const match = matchPath("/:lang?/user/:id?", "/en/user/123");
291+
expect(match).toMatchObject({ params: { lang: "en", id: "123" } });
292+
});
293+
});
294+
248295
describe("matchPath *", () => {
249296
it("matches the root URL", () => {
250297
expect(matchPath("*", "/")).toMatchObject({

packages/router/utils.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -903,7 +903,7 @@ export function matchPath<
903903
pattern = { path: pattern, caseSensitive: false, end: true };
904904
}
905905

906-
let [matcher, paramNames] = compilePath(
906+
let [matcher, compiledParams] = compilePath(
907907
pattern.path,
908908
pattern.caseSensitive,
909909
pattern.end
@@ -915,8 +915,8 @@ export function matchPath<
915915
let matchedPathname = match[0];
916916
let pathnameBase = matchedPathname.replace(/(.)\/+$/, "$1");
917917
let captureGroups = match.slice(1);
918-
let params: Params = paramNames.reduce<Mutable<Params>>(
919-
(memo, paramName, index) => {
918+
let params: Params = compiledParams.reduce<Mutable<Params>>(
919+
(memo, { paramName, isOptional }, index) => {
920920
// We need to compute the pathnameBase here using the raw splat value
921921
// instead of using params["*"] later because it will be decoded then
922922
if (paramName === "*") {
@@ -926,10 +926,12 @@ export function matchPath<
926926
.replace(/(.)\/+$/, "$1");
927927
}
928928

929-
memo[paramName] = safelyDecodeURIComponent(
930-
captureGroups[index] || "",
931-
paramName
932-
);
929+
const value = captureGroups[index];
930+
if (isOptional && !value) {
931+
memo[paramName] = undefined;
932+
} else {
933+
memo[paramName] = safelyDecodeURIComponent(value || "", paramName);
934+
}
933935
return memo;
934936
},
935937
{}
@@ -943,11 +945,13 @@ export function matchPath<
943945
};
944946
}
945947

948+
type CompiledPathParam = { paramName: string; isOptional?: boolean };
949+
946950
function compilePath(
947951
path: string,
948952
caseSensitive = false,
949953
end = true
950-
): [RegExp, string[]] {
954+
): [RegExp, CompiledPathParam[]] {
951955
warning(
952956
path === "*" || !path.endsWith("*") || path.endsWith("/*"),
953957
`Route path "${path}" will be treated as if it were ` +
@@ -956,20 +960,20 @@ function compilePath(
956960
`please change the route path to "${path.replace(/\*$/, "/*")}".`
957961
);
958962

959-
let paramNames: string[] = [];
963+
let params: CompiledPathParam[] = [];
960964
let regexpSource =
961965
"^" +
962966
path
963967
.replace(/\/*\*?$/, "") // Ignore trailing / and /*, we'll handle it below
964968
.replace(/^\/*/, "/") // Make sure it has a leading /
965-
.replace(/[\\.*+^$?{}|()[\]]/g, "\\$&") // Escape special regex chars
966-
.replace(/\/:(\w+)/g, (_: string, paramName: string) => {
967-
paramNames.push(paramName);
968-
return "/([^\\/]+)";
969+
.replace(/[\\.*+^${}|()[\]]/g, "\\$&") // Escape special regex chars
970+
.replace(/\/:(\w+)(\?)?/g, (_: string, paramName: string, isOptional) => {
971+
params.push({ paramName, isOptional: isOptional != null });
972+
return isOptional ? "/?([^\\/]+)?" : "/([^\\/]+)";
969973
});
970974

971975
if (path.endsWith("*")) {
972-
paramNames.push("*");
976+
params.push({ paramName: "*" });
973977
regexpSource +=
974978
path === "*" || path === "/*"
975979
? "(.*)$" // Already matched the initial /, just match the rest
@@ -992,7 +996,7 @@ function compilePath(
992996

993997
let matcher = new RegExp(regexpSource, caseSensitive ? undefined : "i");
994998

995-
return [matcher, paramNames];
999+
return [matcher, params];
9961000
}
9971001

9981002
function safelyDecodeURI(value: string) {

0 commit comments

Comments
 (0)