Skip to content
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
41 changes: 41 additions & 0 deletions integration/fog-of-war-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,47 @@ function getFiles() {
};
}

test("switches to initial discovery above the pathname work limit", async ({
page,
}) => {
let fixture = await createFixture({
files: {
...getFiles(),
"app/routes/$.tsx": js`
export default function Splat() {
return <h1>Sample page</h1>;
}
`,
},
});
let appFixture = await createAppFixture(fixture);
let app = new PlaywrightFixture(appFixture, page);

try {
let response = await app.goto(
`/${Array.from({ length: 60 }, (_, i) => i % 10).join("/")}`,
);
expect(response.status()).toBe(200);
expect(
await page.evaluate(
() => (window as any).__reactRouterContext.routeDiscovery.mode,
),
).toBe("lazy");

response = await app.goto(
`/${Array.from({ length: 70 }, (_, i) => i % 10).join("/")}`,
);
expect(response.status()).toBe(200);
expect(
await page.evaluate(
() => (window as any).__reactRouterContext.routeDiscovery.mode,
),
).toBe("initial");
} finally {
await appFixture.close();
}
});

test.describe("Fog of War", () => {
let oldConsoleError: typeof console.error;

Expand Down
84 changes: 84 additions & 0 deletions integration/rsc/route-discovery-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { test, expect } from "@playwright/test";
import getPort from "get-port";

import { implementations, js, setupRscTest } from "./utils";

for (let implementation of implementations) {
test(`RSC (${implementation.name}) bounds route discovery for documents and data`, async ({
page,
}) => {
let pathname = `/${Array.from({ length: 70 }, (_, i) => i % 10).join("/")}`;
let port = await getPort();
let stop = await setupRscTest({
implementation,
port,
files: {
"src/routes.ts": js`
export const routes = [{
id: "root",
path: "",
lazy: () => import("./routes/root"),
children: [
{ id: "other", path: "other", lazy: () => import("./routes/other") },
{ id: "splat", path: "*", lazy: () => import("./routes/sample") },
],
}];
`,
"src/routes/sample.tsx": js`
import { Link } from "react-router";
export default function Sample() {
return <>
<h1>Sample</h1>
<Link to=${JSON.stringify(pathname)} discover="none">Sample</Link>
<Link to="/other" discover="none">Other</Link>
</>;
}
`,
"src/routes/other.tsx": js`
export default function Other() {
return <h1>Other</h1>;
}
`,
},
});

try {
let manifestRequests: string[] = [];
page.on("request", (request) => {
if (new URL(request.url()).pathname.endsWith(".manifest")) {
manifestRequests.push(request.url());
}
});

let response = await page.goto(`http://localhost:${port}${pathname}`);
expect(response?.status()).toBe(200);
await expect(page.getByRole("heading", { name: "Sample" })).toBeVisible();
await page.waitForFunction(() => (window as any).__routerInitialized);
let otherResponse = page.waitForResponse(
(response) => new URL(response.url()).pathname === "/other.rsc",
);
await page.getByRole("link", { name: "Other" }).click();
expect((await otherResponse).status()).toBe(200);
await expect(page.getByRole("heading", { name: "Other" })).toBeVisible();
expect(manifestRequests).toEqual([]);

// Start with the splat already discovered so navigation can request data.
await page.goto(`http://localhost:${port}/sample`);
await page.waitForFunction(() => (window as any).__routerInitialized);
let dataResponse = page.waitForResponse(
(response) => new URL(response.url()).pathname === `${pathname}.rsc`,
);
await page.getByRole("link", { name: "Sample" }).click();
let data = await dataResponse;
expect(data.status()).toBe(200);
let payload = await data.text();
expect(payload).toContain('"routeDiscovery":{"mode":"initial"}');
expect(payload).toContain('"id":"other"');
await expect(page.getByRole("heading", { name: "Sample" })).toBeVisible();
await page.getByRole("link", { name: "Other" }).click();
await expect(page.getByRole("heading", { name: "Other" })).toBeVisible();
} finally {
stop();
}
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bound route manifest processing during server rendering and RSC document/data requests by falling back to initial route discovery for paths with expensive ancestor expansion, accounting for the number and depth of candidate branches when using regex matching
56 changes: 55 additions & 1 deletion packages/react-router/__tests__/dom/ssr/fog-of-war-test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,60 @@
import { getPathsWithAncestors } from "../../../lib/dom/ssr/fog-of-war";
import {
getPathsWithAncestors,
shouldUseInitialRouteDiscovery,
URL_LIMIT,
} from "../../../lib/dom/ssr/fog-of-war";

function samplePath(segments: number, segmentLength = 1): string {
return `/${Array.from({ length: segments }, (_, i) =>
String(i % 10).repeat(segmentLength),
).join("/")}`;
}

describe("fog of war", () => {
describe("shouldUseInitialRouteDiscovery", () => {
test("keeps the match-cost budget inactive through ten segments", () => {
for (let segments of [0, 1, 10]) {
expect(
shouldUseInitialRouteDiscovery(samplePath(segments), 50_001),
).toBe(false);
}
expect(shouldUseInitialRouteDiscovery(samplePath(11), 50_001)).toBe(true);
});

test("permits exactly the match-cost budget", () => {
expect(shouldUseInitialRouteDiscovery(samplePath(20), 2500)).toBe(false);
expect(shouldUseInitialRouteDiscovery(samplePath(20), 2501)).toBe(true);
});

test("bounds pathname work without a match cost", () => {
expect(shouldUseInitialRouteDiscovery(samplePath(62))).toBe(false);
expect(shouldUseInitialRouteDiscovery(samplePath(63))).toBe(true);
});

test("bounds pathname work even for shallow paths", () => {
for (let matchCost of [undefined, 50_001]) {
expect(
shouldUseInitialRouteDiscovery(
`${samplePath(1, URL_LIMIT - 4)}/x`,
matchCost,
),
).toBe(false);
expect(
shouldUseInitialRouteDiscovery(
`${samplePath(1, URL_LIMIT - 3)}/x`,
matchCost,
),
).toBe(true);
}
});

test("permits a single segment without ancestor work", () => {
expect(shouldUseInitialRouteDiscovery(samplePath(1, URL_LIMIT))).toBe(
false,
);
});
});

describe("getPathsWithAncestors", () => {
test("adds parent paths", () => {
expect(getPathsWithAncestors(["/a/b/c"])).toEqual([
Expand Down
53 changes: 53 additions & 0 deletions packages/react-router/__tests__/rsc/server-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,59 @@ import {
import { URL_LIMIT } from "../../lib/dom/ssr/fog-of-war";

describe("RSC server", () => {
test("switches to initial discovery above the pathname work limit", async () => {
let routeDiscovery = Object.freeze({ mode: "lazy" } as const);
let routes: RSCRouteConfigEntry[] = [
{
id: "root",
path: "",
Component: () => null,
children: [
{ id: "other", path: "other" },
{ id: "splat", path: "*", Component: () => null },
],
},
];

async function getPayload(pathname: string) {
let match: RSCMatch | undefined;
let response = await matchRSCServerRequest({
createTemporaryReferenceSet: () => ({}),
request: new Request(`https://remix.run${pathname}`),
routes,
routeDiscovery,
generateResponse(nextMatch) {
match = nextMatch;
return new Response(null, { status: nextMatch.statusCode });
},
});
expect(response.status).toBe(200);
if (match?.payload.type !== "render") {
throw new Error("Expected a render payload");
}
return match.payload;
}

let pathname = `/${Array.from({ length: 60 }, (_, i) => i % 10).join("/")}`;
let payload = await getPayload(pathname);
expect(payload.routeDiscovery.mode).toBe("lazy");
expect(await payload.patches).toEqual([]);

payload = await getPayload(`${pathname}.rsc`);
expect(payload.routeDiscovery.mode).toBe("lazy");
expect(await payload.patches).toEqual([]);

pathname = `/${Array.from({ length: 70 }, (_, i) => i % 10).join("/")}`;
payload = await getPayload(pathname);
expect(payload.routeDiscovery.mode).toBe("initial");
expect((await payload.patches).map((patch) => patch.id)).toEqual(["other"]);

payload = await getPayload(`${pathname}.rsc`);
expect(payload.routeDiscovery.mode).toBe("initial");
expect((await payload.patches).map((patch) => patch.id)).toEqual(["other"]);
expect(routeDiscovery.mode).toBe("lazy");
});

describe("manifest requests", () => {
test("rejects manifest requests over the URL limit", async () => {
let path = `/${"a".repeat(URL_LIMIT)}.manifest`;
Expand Down
21 changes: 21 additions & 0 deletions packages/react-router/lib/dom/ssr/fog-of-war.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,27 @@ export function isFogOfWarEnabled(
return routeDiscovery.mode === "lazy" && ssr === true;
}

const MANIFEST_MATCH_COST_LIMIT = 50_000;
const MANIFEST_MATCH_COST_SEGMENT_THRESHOLD = 10;

export function shouldUseInitialRouteDiscovery(
pathname: string,
matchCost = 0,
): boolean {
let segmentCount = pathname.split("/").length - 1;
let ancestorCount = Math.max(segmentCount - 1, 0);

// Each ancestor is shorter than the incoming pathname. Use an upper bound on
// their cumulative length, plus the root path, to avoid expanding every prefix.
// Regex matching scans candidate branches. Bound its estimated metadata
// visits for deep paths while keeping typical shallow paths lazy.
return (
ancestorCount * pathname.length + 1 > URL_LIMIT ||
(segmentCount > MANIFEST_MATCH_COST_SEGMENT_THRESHOLD &&
segmentCount * matchCost > MANIFEST_MATCH_COST_LIMIT)
);
}

export function getPartialManifest(
{ sri, ...manifest }: AssetsManifest,
router: DataRouter,
Expand Down
9 changes: 9 additions & 0 deletions packages/react-router/lib/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,8 @@ export interface StaticHandler {
* The set of data routes managed by this handler
*/
dataRoutes: DataRouteObject[];
/** @private Estimated route metadata visits per regex match. */
readonly _matchCost?: number;
/**
* @private
* PRIVATE - DO NOT USE
Expand Down Expand Up @@ -4112,6 +4114,12 @@ export function createStaticHandler(
// Pre-compute flattened/ranked route branches when the flag is enabled.
// Skipped in development mode because routes can be added dynamically (HMR).
let routeBranches = flattenAndRankRoutes(dataRoutes);
// Upper bound on metadata visited per match, including optional segment
// expansion and pathless layouts within each candidate branch.
let matchCost = routeBranches.reduce(
(total, branch) => total + branch.routesMeta.length,
0,
);

/**
* The query() method is intended for document requests, in which we want to
Expand Down Expand Up @@ -5029,6 +5037,7 @@ export function createStaticHandler(

return {
dataRoutes,
_matchCost: matchCost,
_internalRouteBranches: routeBranches,
query,
queryRoute,
Expand Down
Loading
Loading