Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support optional path segments in matchPath #10768

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/support-optional-path-segments-in-match-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@remix-run/router": minor
---

Add support for optional path segments in `matchPath`
1 change: 1 addition & 0 deletions contributors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
- ianflynnwork
- IbraRouisDev
- igniscyan
- imjordanxd
- infoxicator
- IsaiStormBlesed
- Isammoc
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
47 changes: 47 additions & 0 deletions packages/react-router/__tests__/matchPath-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
34 changes: 19 additions & 15 deletions packages/router/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -915,8 +915,8 @@ export function matchPath<
let matchedPathname = match[0];
let pathnameBase = matchedPathname.replace(/(.)\/+$/, "$1");
let captureGroups = match.slice(1);
let params: Params = paramNames.reduce<Mutable<Params>>(
(memo, paramName, index) => {
let params: Params = compiledParams.reduce<Mutable<Params>>(
(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 === "*") {
Expand All @@ -926,10 +926,12 @@ 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;
},
{}
Expand All @@ -943,11 +945,13 @@ export function matchPath<
};
}

type CompiledPathParam = { paramName: string; isOptional?: boolean };

function compilePath(
path: string,
caseSensitive = false,
end = true
): [RegExp, string[]] {
): [RegExp, CompiledPathParam[]] {
warning(
path === "*" || !path.endsWith("*") || path.endsWith("/*"),
`Route path "${path}" will be treated as if it were ` +
Expand All @@ -956,20 +960,20 @@ function compilePath(
`please change the route path to "${path.replace(/\*$/, "/*")}".`
);

let paramNames: string[] = [];
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) => {
paramNames.push(paramName);
return "/([^\\/]+)";
.replace(/[\\.*+^${}|()[\]]/g, "\\$&") // Escape special regex chars
.replace(/\/:(\w+)(\?)?/g, (_: string, paramName: string, isOptional) => {
params.push({ paramName, isOptional: isOptional != null });
return isOptional ? "/?([^\\/]+)?" : "/([^\\/]+)";
});

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

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

return [matcher, paramNames];
return [matcher, params];
}

function safelyDecodeURI(value: string) {
Expand Down