Skip to content

Commit 09e6020

Browse files
authored
Optimize route matching internals (#15186)
1 parent 8ef9b16 commit 09e6020

10 files changed

Lines changed: 196 additions & 64 deletions

File tree

integration/fog-of-war-test.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -715,7 +715,9 @@ test.describe("Fog of War", () => {
715715
expect(await app.getHtml("#parent")).toMatch(`Parent`);
716716
expect(await app.getHtml("#child2")).toMatch(`Child 2`);
717717
expect(manifestRequests).toEqual([
718-
expect.stringMatching(/\/__manifest\?paths=%2Fparent%2Fchild2&version=/),
718+
expect.stringMatching(
719+
/\/__manifest\?paths=%2Fparent%2C%2Fparent%2Fchild2&version=/,
720+
),
719721
]);
720722
});
721723

@@ -1065,7 +1067,7 @@ test.describe("Fog of War", () => {
10651067
await page.waitForSelector("#splat");
10661068
expect(await app.getHtml("#splat")).toMatch("Splat: b/c");
10671069
expect(manifestRequests).toEqual([
1068-
expect.stringMatching(/\/__manifest\?paths=%2Fb%2Fc&version=/),
1070+
expect.stringMatching(/\/__manifest\?paths=%2Fb%2C%2Fb%2Fc&version=/),
10691071
]);
10701072
});
10711073

@@ -1137,7 +1139,9 @@ test.describe("Fog of War", () => {
11371139
await app.clickLink("/not/a/path");
11381140
await page.waitForSelector("#error");
11391141
expect(manifestRequests).toEqual([
1140-
expect.stringMatching(/\/__manifest\?paths=%2Fnot%2Fa%2Fpath&version=/),
1142+
expect.stringMatching(
1143+
/\/__manifest\?paths=%2Fnot%2C%2Fnot%2Fa%2C%2Fnot%2Fa%2Fpath&version=/,
1144+
),
11411145
]);
11421146
manifestRequests = [];
11431147

@@ -1449,7 +1453,9 @@ test.describe("Fog of War", () => {
14491453
// Wait for eager discovery to kick off
14501454
await new Promise((r) => setTimeout(r, 500));
14511455
expect(manifestRequests).toEqual([
1452-
expect.stringMatching(/\/custom-manifest\?paths=%2Fa%2Fb&version=/),
1456+
expect.stringMatching(
1457+
/\/custom-manifest\?paths=%2Fa%2C%2Fa%2Fb&version=/,
1458+
),
14531459
]);
14541460

14551461
expect(wrongManifestRequests).toEqual([]);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Precompute route branch matchers to avoid recompiling route path regexes during matching
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { getPathsWithAncestors } from "../../../lib/dom/ssr/fog-of-war";
2+
3+
describe("fog of war", () => {
4+
describe("getPathsWithAncestors", () => {
5+
test("adds parent paths", () => {
6+
expect(getPathsWithAncestors(["/a/b/c"])).toEqual([
7+
"/a",
8+
"/a/b",
9+
"/a/b/c",
10+
]);
11+
});
12+
13+
test("dedupes shared parent paths", () => {
14+
expect(getPathsWithAncestors(["/a/b", "/a/c"])).toEqual([
15+
"/a",
16+
"/a/b",
17+
"/a/c",
18+
]);
19+
});
20+
21+
test("normalizes paths without leading slashes", () => {
22+
expect(getPathsWithAncestors(["a/b"])).toEqual(["/a", "/a/b"]);
23+
});
24+
});
25+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {
2+
matchRSCServerRequest,
3+
type RSCMatch,
4+
type RSCRouteConfigEntry,
5+
} from "../../lib/rsc/server.rsc";
6+
import { URL_LIMIT } from "../../lib/dom/ssr/fog-of-war";
7+
8+
describe("RSC server", () => {
9+
describe("manifest requests", () => {
10+
test("rejects manifest requests over the URL limit", async () => {
11+
let path = `/${"a".repeat(URL_LIMIT)}.manifest`;
12+
13+
let { response, match } = await matchManifestRequest(
14+
new Request(`https://remix.run${path}`),
15+
[],
16+
);
17+
18+
expect(response.status).toBe(400);
19+
expect(match).toBeUndefined();
20+
});
21+
});
22+
});
23+
24+
async function matchManifestRequest(
25+
request: Request,
26+
routes: RSCRouteConfigEntry[],
27+
) {
28+
let match: RSCMatch | undefined;
29+
let response = await matchRSCServerRequest({
30+
createTemporaryReferenceSet: () => ({}),
31+
request,
32+
routes,
33+
generateResponse(nextMatch) {
34+
match = nextMatch;
35+
return new Response(null, {
36+
status: nextMatch.statusCode,
37+
headers: nextMatch.headers,
38+
});
39+
},
40+
});
41+
42+
return { response, match };
43+
}

packages/react-router/__tests__/server-runtime/server-test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createContext, type StaticHandlerContext } from "react-router";
66

77
import { createRequestHandler } from "../../lib/server-runtime/server";
88
import { ServerMode } from "../../lib/server-runtime/mode";
9+
import { URL_LIMIT } from "../../lib/dom/ssr/fog-of-war";
910
import { mockServerBuild } from "./utils";
1011

1112
function spyConsole() {
@@ -2133,7 +2134,7 @@ describe("shared server runtime", () => {
21332134
let handler = createRequestHandler(build, ServerMode.Test);
21342135

21352136
let request = new Request(
2136-
`${baseUrl}/__manifest?paths=%2Fa%2Fb&version=${build.assets.version}`,
2137+
`${baseUrl}/__manifest?paths=%2Fa,%2Fa%2Fb&version=${build.assets.version}`,
21372138
);
21382139

21392140
let result = await handler(request);
@@ -2165,6 +2166,24 @@ describe("shared server runtime", () => {
21652166
});
21662167
});
21672168

2169+
test("rejects manifest requests over the URL limit", async () => {
2170+
let build = mockServerBuild({
2171+
root: {
2172+
default: {},
2173+
},
2174+
});
2175+
let handler = createRequestHandler(build, ServerMode.Test);
2176+
2177+
let request = new Request(
2178+
`${baseUrl}/__manifest?paths=${encodeURIComponent(
2179+
`/${"a".repeat(URL_LIMIT)}`,
2180+
)}&version=${build.assets.version}`,
2181+
);
2182+
2183+
let result = await handler(request);
2184+
expect(result.status).toBe(400);
2185+
});
2186+
21682187
test("disabled when route discovery is disabled", async () => {
21692188
let build = mockServerBuild(
21702189
{

packages/react-router/lib/dom/ssr/fog-of-war.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,31 @@ const discoveredPaths = new Set<string>();
2525
// https://stackoverflow.com/a/417184
2626
export const URL_LIMIT = 7680;
2727

28+
export function getPathsWithAncestors(paths: string[]): string[] {
29+
let result = new Set<string>();
30+
31+
paths.forEach((path) => {
32+
if (!path.startsWith("/")) {
33+
path = `/${path}`;
34+
}
35+
// In addition to the requested path, we need to include patches for each
36+
// ancestor path so that we pick up any pathless/index routes below ancestor
37+
// segments. So if we get a request for `/parent/child`, we need to look for
38+
// a match on `/parent` so that if a `parent._index` route exists we return
39+
// it and it's available for client side matching if the user routes back up
40+
// to `/parent`. This is the same thing we do on initial load in <Scripts>
41+
// via `getPartialManifest()`.
42+
for (let i = 1; i < path.length; i++) {
43+
if (path[i] === "/") {
44+
result.add(path.slice(0, i));
45+
}
46+
}
47+
result.add(path);
48+
});
49+
50+
return Array.from(result);
51+
}
52+
2853
export function isFogOfWarEnabled(
2954
routeDiscovery: ServerBuild["routeDiscovery"],
3055
ssr: boolean,
@@ -228,6 +253,8 @@ export async function fetchAndApplyManifestPatches(
228253
patchRoutes: DataRouter["patchRoutes"],
229254
signal?: AbortSignal,
230255
): Promise<void> {
256+
paths = getPathsWithAncestors(paths);
257+
231258
// NOTE: Intentionally using a standalone `URLSearchParams` instance
232259
// instead of mutating `url.searchParams`, which is *significantly* slower:
233260
// https://issues.chromium.org/issues/331406951

packages/react-router/lib/router/utils.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,6 +1105,8 @@ interface RouteMeta<RouteObjectType extends RouteObject = RouteObject> {
11051105
caseSensitive: boolean;
11061106
childrenIndex: number;
11071107
route: RouteObjectType;
1108+
matcher?: RegExp;
1109+
compiledParams?: CompiledPathParam[];
11081110
}
11091111

11101112
/**
@@ -1205,9 +1207,21 @@ function flattenRoutes<RouteObjectType extends RouteObject = RouteObject>(
12051207
branches.push({
12061208
path,
12071209
score: computeScore(path, route.index),
1208-
routesMeta,
1210+
routesMeta: routesMeta.map((meta, i) => {
1211+
let [matcher, params] = compilePath(
1212+
meta.relativePath,
1213+
meta.caseSensitive,
1214+
i === routesMeta.length - 1,
1215+
);
1216+
return {
1217+
...meta,
1218+
matcher,
1219+
compiledParams: params,
1220+
} satisfies RouteMeta<RouteObjectType>;
1221+
}),
12091222
});
12101223
};
1224+
12111225
routes.forEach((route, index) => {
12121226
// coarse-grain check for optional params
12131227
if (route.path === "" || !route.path?.includes("?")) {
@@ -1360,10 +1374,21 @@ function matchRouteBranch<
13601374
matchedPathname === "/"
13611375
? pathname
13621376
: pathname.slice(matchedPathname.length) || "/";
1363-
let match = matchPath(
1364-
{ path: meta.relativePath, caseSensitive: meta.caseSensitive, end },
1365-
remainingPathname,
1366-
);
1377+
let pattern = {
1378+
path: meta.relativePath,
1379+
caseSensitive: meta.caseSensitive,
1380+
end,
1381+
};
1382+
let match =
1383+
// Use precomputed matcher if it exists
1384+
meta.matcher && meta.compiledParams
1385+
? matchPathImpl(
1386+
pattern,
1387+
remainingPathname,
1388+
meta.matcher,
1389+
meta.compiledParams,
1390+
)
1391+
: matchPath(pattern, remainingPathname);
13671392

13681393
let route = meta.route;
13691394

@@ -1546,6 +1571,15 @@ export function matchPath<Path extends string>(
15461571
pattern.end,
15471572
);
15481573

1574+
return matchPathImpl(pattern, pathname, matcher, compiledParams);
1575+
}
1576+
1577+
function matchPathImpl<Path extends string>(
1578+
pattern: PathPattern<Path>,
1579+
pathname: string,
1580+
matcher: RegExp,
1581+
compiledParams: CompiledPathParam[],
1582+
): PathMatch<ParamParseKey<Path>> | null {
15491583
let match = pathname.match(matcher);
15501584
if (!match) return null;
15511585

packages/react-router/lib/rsc/browser.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import {
4444
import { RSCRouterGlobalErrorBoundary } from "./errorBoundaries";
4545
import type { RouteModules } from "../dom/ssr/routeModules";
4646
import { populateRSCRouteModules } from "./route-modules";
47-
import { URL_LIMIT } from "../dom/ssr/fog-of-war";
47+
import { URL_LIMIT, getPathsWithAncestors } from "../dom/ssr/fog-of-war";
4848

4949
const defaultManifestPath = "/__manifest";
5050

@@ -1044,6 +1044,8 @@ async function fetchAndApplyManifestPatches(
10441044
fetchImplementation: (request: Request) => Promise<Response>,
10451045
signal?: AbortSignal,
10461046
) {
1047+
paths = getPathsWithAncestors(paths);
1048+
10471049
let url = getManifestUrl(paths);
10481050
if (url == null) {
10491051
return;

packages/react-router/lib/rsc/server.rsc.ts

Lines changed: 19 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
} from "../router/utils";
4040
import { getDocumentHeadersImpl } from "../server-runtime/headers";
4141
import { SINGLE_FETCH_REDIRECT_STATUS } from "../dom/ssr/single-fetch";
42+
import { URL_LIMIT, getPathsWithAncestors } from "../dom/ssr/fog-of-war";
4243
import { throwIfPotentialCSRFAttack } from "../actions";
4344
import invariant from "../server-runtime/invariant";
4445

@@ -532,6 +533,14 @@ async function generateManifestResponse(
532533
temporaryReferences: unknown,
533534
routeDiscovery: RouteDiscovery | undefined,
534535
) {
536+
let url = new URL(request.url);
537+
if (url.toString().length > URL_LIMIT) {
538+
return new Response(null, {
539+
statusText: "Bad Request",
540+
status: 400,
541+
});
542+
}
543+
535544
if (routeDiscovery?.mode === "initial") {
536545
let payload: RSCManifestPayload = {
537546
type: "manifest",
@@ -550,7 +559,6 @@ async function generateManifestResponse(
550559
);
551560
}
552561

553-
let url = new URL(request.url);
554562
let pathParam = url.searchParams.get("paths");
555563
let pathnames = pathParam
556564
? pathParam.split(",").filter(Boolean)
@@ -1193,7 +1201,7 @@ async function getRenderPayload(
11931201
),
11941202
)
11951203
: getAdditionalRoutePatches(
1196-
[staticContext.location.pathname],
1204+
getPathsWithAncestors([staticContext.location.pathname]),
11971205
routes,
11981206
basename,
11991207
staticContext.matches.map((m) => m.route.id),
@@ -1428,33 +1436,18 @@ async function getAdditionalRoutePatches(
14281436
let matchedPaths = new Set<string>();
14291437

14301438
for (const pathname of pathnames) {
1431-
let segments = pathname.split("/").filter(Boolean);
1432-
let paths: string[] = ["/"];
1433-
1434-
// We've already matched to the last segment
1435-
segments.pop();
1436-
1437-
// Traverse each path for our parents and match in case they have pathless/index
1438-
// children we need to include in the initial manifest
1439-
while (segments.length > 0) {
1440-
paths.push(`/${segments.join("/")}`);
1441-
segments.pop();
1439+
if (matchedPaths.has(pathname)) {
1440+
continue;
14421441
}
1443-
1444-
paths.forEach((path) => {
1445-
if (matchedPaths.has(path)) {
1442+
matchedPaths.add(pathname);
1443+
let matches = matchRoutes(routes, pathname, basename) || [];
1444+
matches.forEach((m, i) => {
1445+
if (patchRouteMatches.get(m.route.id)) {
14461446
return;
14471447
}
1448-
matchedPaths.add(path);
1449-
let matches = matchRoutes(routes, path, basename) || [];
1450-
matches.forEach((m, i) => {
1451-
if (patchRouteMatches.get(m.route.id)) {
1452-
return;
1453-
}
1454-
patchRouteMatches.set(m.route.id, {
1455-
...m.route,
1456-
parentId: matches[i - 1]?.route.id,
1457-
});
1448+
patchRouteMatches.set(m.route.id, {
1449+
...m.route,
1450+
parentId: matches[i - 1]?.route.id,
14581451
});
14591452
});
14601453
}

0 commit comments

Comments
 (0)