Skip to content

fix: ensure consistency in generatePath/compilePath for partial splats #9238

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

Closed
Closed
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
6 changes: 6 additions & 0 deletions .changeset/angry-pens-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"react-router": patch
"@remix-run/router": patch
---

fix: Permit partial splat route segment matching (i.e., `/prefix-*`) (#9238)
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ jobs:
- name: Build
run: yarn build

- name: Lint
run: yarn lint

- name: Test
run: yarn test

Expand Down
46 changes: 16 additions & 30 deletions packages/react-router/__tests__/matchPath-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,38 +298,24 @@ describe("matchPath *", () => {
});
});

describe("matchPath warnings", () => {
let consoleWarn: jest.SpyInstance<void, any>;
beforeEach(() => {
consoleWarn = jest.spyOn(console, "warn").mockImplementation();
});

afterEach(() => {
consoleWarn.mockRestore();
});

describe("when the pattern has a trailing *", () => {
it("issues a warning and matches the remaining portion of the pathname", () => {
expect(matchPath("/files*", "/files/mj.jpg")).toMatchObject({
params: { "*": "mj.jpg" },
pathname: "/files/mj.jpg",
pathnameBase: "/files",
});
expect(consoleWarn).toHaveBeenCalledTimes(1);
describe("when the pattern has a trailing *", () => {
it("matches the remaining portion of the pathname", () => {
expect(matchPath("/files*", "/files/mj.jpg")).toMatchObject({
params: { "*": "/mj.jpg" },
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously trimmed leading slashes are now included since we support partial splat matching

pathname: "/files/mj.jpg",
pathnameBase: "/files",
});

expect(matchPath("/files*", "/files/")).toMatchObject({
params: { "*": "" },
pathname: "/files/",
pathnameBase: "/files",
});
expect(consoleWarn).toHaveBeenCalledTimes(2);
expect(matchPath("/files*", "/files/")).toMatchObject({
params: { "*": "/" },
pathname: "/files/",
pathnameBase: "/files",
});

expect(matchPath("/files*", "/files")).toMatchObject({
params: { "*": "" },
pathname: "/files",
pathnameBase: "/files",
});
expect(consoleWarn).toHaveBeenCalledTimes(3);
expect(matchPath("/files*", "/files")).toMatchObject({
params: { "*": "" },
pathname: "/files",
pathnameBase: "/files",
});
});
});
129 changes: 128 additions & 1 deletion packages/react-router/__tests__/path-matching-test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import type { RouteObject } from "react-router";
import { matchRoutes } from "react-router";

function pickPaths(routes: RouteObject[], pathname: string): string[] | null {
function pickPaths(routes: RouteObject[], pathname: string) {
let matches = matchRoutes(routes, pathname);
return matches && matches.map((match) => match.route.path || "");
}

function pickPathsAndParams(routes: RouteObject[], pathname: string) {
let matches = matchRoutes(routes, pathname);
return (
matches &&
matches.map((match) => ({ path: match.route.path, params: match.params }))
);
}

describe("path matching", () => {
test("root vs. dynamic", () => {
let routes = [{ path: "/" }, { path: ":id" }];
Expand Down Expand Up @@ -267,4 +275,123 @@ describe("path matching with splats", () => {
pathnameBase: "/courses",
});
});

test("supports partial path matching with named parameters", () => {
let routes = [{ path: "/prefix:id" }];
expect(matchRoutes(routes, "/prefixabc")).toMatchInlineSnapshot(`
Array [
Object {
"params": Object {
"id": "abc",
},
"pathname": "/prefixabc",
"pathnameBase": "/prefixabc",
"route": Object {
"path": "/prefix:id",
},
},
]
`);
expect(matchRoutes(routes, "/prefix/abc")).toMatchInlineSnapshot(`null`);
});

test("supports partial path matching with splat parameters", () => {
let routes = [{ path: "/prefix*" }];
expect(matchRoutes(routes, "/prefix/abc")).toMatchInlineSnapshot(`
Array [
Object {
"params": Object {
"*": "/abc",
},
"pathname": "/prefix/abc",
"pathnameBase": "/prefix",
"route": Object {
"path": "/prefix*",
},
},
]
`);
expect(matchRoutes(routes, "/prefixabc")).toMatchInlineSnapshot(`
Array [
Object {
"params": Object {
"*": "abc",
},
"pathname": "/prefixabc",
"pathnameBase": "/prefix",
"route": Object {
"path": "/prefix*",
},
},
]
`);
});
});

describe("route scoring", () => {
test("splat routes versus dynamic routes", () => {
let routes = [
{ path: "nested/prefix-:param/static/prefix-*" }, // Score 43
{ path: "nested/prefix-:param/static/*" }, // Score 33
{ path: "nested/prefix-:param/static" }, // Score 34
{ path: "nested/prefix-:param/*" }, // Score 22
{ path: "nested/:param/static" }, // Score 28
{ path: "nested/static" }, // Score 24
{ path: "nested/prefix-:param" }, // Score 23
{ path: "nested/prefix-*" }, // Score 22
{ path: "nested/:param" }, // Score 17
{ path: "static" }, // Score 13
{ path: "prefix-:param" }, // Score 12
{ path: "prefix-*" }, // Score 11
{ path: ":param" }, // Score 6
{ path: "*" }, // Score 1
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is my attempt to pressure test all the potential combinations of partial/full splat/dynamic params and assert that we get the expected matches/params

];

// Matches are defined as [A, B, C], as in:
// "URL A should match path B with params C"
let matches: Array<[string, string, Record<string, string>]> = [
[
"/nested/prefix-foo/static/prefix-bar/baz",
"nested/prefix-:param/static/prefix-*",
{ param: "foo", "*": "bar/baz" },
],
[
"/nested/prefix-foo/static/bar/baz",
"nested/prefix-:param/static/*",
{ param: "foo", "*": "bar/baz" },
],
[
"/nested/prefix-foo/static/bar",
"nested/prefix-:param/static/*",
{ param: "foo", "*": "bar" },
],
[
"/nested/prefix-foo/static",
"nested/prefix-:param/static",
{ param: "foo" },
],
[
"/nested/prefix-foo/bar",
"nested/prefix-:param/*",
{ param: "foo", "*": "bar" },
],
["/nested/foo/static", "nested/:param/static", { param: "foo" }],
["/nested/static", "nested/static", {}],
["/nested/prefix-foo", "nested/prefix-:param", { param: "foo" }],
["/nested/foo", "nested/:param", { param: "foo" }],
["/static", "static", {}],
["/prefix-foo", "prefix-:param", { param: "foo" }],
["/prefix-foo/bar", "prefix-*", { "*": "foo/bar" }],
["/foo", ":param", { param: "foo" }],
["/foo/bar/baz", "*", { "*": "foo/bar/baz" }],
];

// Ensure order agnostic by testing route definitions forward + backwards
[...matches, ...matches.reverse()].forEach(([url, path, params]) =>
expect({
url,
matches: pickPathsAndParams(routes, url),
}).toEqual({ url, matches: [{ path, params }] })
);
});
});
70 changes: 39 additions & 31 deletions packages/router/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,36 +374,47 @@ function rankRouteBranches(branches: RouteBranch[]): void {
}

const paramRe = /^:\w+$/;
const dynamicSegmentValue = 3;
const indexRouteValue = 2;
const partialParamRe = /:\w+$/;
const splatRe = /^\*$/;
const partialSplatRe = /\*$/;

const staticSegmentValue = 10; /* /static */
const partialDynamicSegmentValue = 9; /* /prefix-:param */
const partialSplatValue = 8; /* /prefix-* */
const dynamicSegmentValue = 3; /* /:param */
const indexRouteValue = 2; /* / */
const emptySegmentValue = 1;
const staticSegmentValue = 10;
const splatPenalty = -2;
const isSplat = (s: string) => s === "*";
const splatPenalty = -2; /* /* */

function computeScore(path: string, index: boolean | undefined): number {
let segments = path.split("/");
let initialScore = segments.length;
if (segments.some(isSplat)) {
Copy link
Contributor Author

@brophdawg11 brophdawg11 Sep 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

splat scoring moved inside the below loop to avoid multiple traversals of segments

initialScore += splatPenalty;
}
let score = segments.length;

if (index) {
initialScore += indexRouteValue;
score += indexRouteValue;
}

return segments
.filter((s) => !isSplat(s))
.reduce(
(score, segment) =>
score +
(paramRe.test(segment)
? dynamicSegmentValue
: segment === ""
? emptySegmentValue
: staticSegmentValue),
initialScore
);
// Penalties apply globally, so only subtract them once
let splatPenaltyApplied = false;

segments.forEach((segment) => {
if (splatRe.test(segment)) {
score += splatPenaltyApplied ? 0 : splatPenalty;
splatPenaltyApplied = true;
} else if (partialSplatRe.test(segment)) {
score += partialSplatValue;
} else if (paramRe.test(segment)) {
score += dynamicSegmentValue;
} else if (partialParamRe.test(segment)) {
score += partialDynamicSegmentValue;
} else if (segment === "") {
score += emptySegmentValue;
} else {
score += staticSegmentValue;
}
});

return score;
}

function compareIndexes(a: number[], b: number[]): number {
Expand Down Expand Up @@ -608,14 +619,6 @@ function compilePath(
caseSensitive = false,
end = true
): [RegExp, string[]] {
warning(
path === "*" || !path.endsWith("*") || path.endsWith("/*"),
`Route path "${path}" will be treated as if it were ` +
`"${path.replace(/\*$/, "/*")}" because the \`*\` character must ` +
`always follow a \`/\` in the pattern. To get rid of this warning, ` +
`please change the route path to "${path.replace(/\*$/, "/*")}".`
);

let paramNames: string[] = [];
let regexpSource =
"^" +
Expand All @@ -628,12 +631,17 @@ function compilePath(
return "([^\\/]+)";
});

if (path.endsWith("*")) {
if (path === "*" || path.endsWith("/*")) {
// Full splat segment
paramNames.push("*");
regexpSource +=
path === "*" || path === "/*"
? "(.*)$" // Already matched the initial /, just match the rest
: "(?:\\/(.+)|\\/*)$"; // Don't include the / in params["*"]
} else if (path.endsWith("*")) {
// Partial splat segment
paramNames.push("*");
regexpSource += "(.*)$";
} else {
regexpSource += end
? "\\/*$" // When matching to the end, ignore trailing slashes
Expand Down