From 0abe993cd7c2f7a6ac558b2e3ec6425bbb82996b Mon Sep 17 00:00:00 2001 From: Brooks Lybrand Date: Fri, 9 Oct 2026 10:47:00 -0400 Subject: [PATCH 1/3] Fix RSC external redirect tests after example.com page change (#15600) Co-authored-by: Matt Brophy (cherry picked from commit 35101bda183cf07136b4c2df2bcdf4a31d5d1f4a) --- integration/rsc/rsc-nojs-test.ts | 6 ++---- integration/rsc/rsc-test.ts | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/integration/rsc/rsc-nojs-test.ts b/integration/rsc/rsc-nojs-test.ts index 8f6d8babed..3768954a87 100644 --- a/integration/rsc/rsc-nojs-test.ts +++ b/integration/rsc/rsc-nojs-test.ts @@ -240,8 +240,7 @@ implementations.forEach((implementation) => { await page.goto(`http://localhost:${port}/render-redirect`); await expect(page.getByText("home")).toBeAttached(); await page.getByText("External").click(); - await page.waitForURL(`https://example.com/`); - await expect(page.getByText("Example Domain")).toBeAttached(); + await expect(page).toHaveURL(`https://example.com/`); }); test("Handles unsupported protocol redirect Responses from render", async ({ @@ -273,8 +272,7 @@ implementations.forEach((implementation) => { "Playwright doesn't like external redirects for tests. It times out waiting for the URL even though it navigates.", ); await page.goto(`http://localhost:${port}/render-redirect/lazy/external`); - await page.waitForURL(`https://example.com/`); - await expect(page.getByText("Example Domain")).toBeAttached(); + await expect(page).toHaveURL(`https://example.com/`); }); test("Handles unsupported protocol redirect Responses from suspended render", async ({ diff --git a/integration/rsc/rsc-test.ts b/integration/rsc/rsc-test.ts index ed074b4737..d0a7009a96 100644 --- a/integration/rsc/rsc-test.ts +++ b/integration/rsc/rsc-test.ts @@ -1873,8 +1873,7 @@ implementations.forEach((implementation) => { await page.goto(`http://localhost:${port}/render-redirect`); await expect(page.getByText("home")).toBeAttached(); await page.getByText("External").click(); - await page.waitForURL(`https://example.com/`); - await expect(page.getByText("Example Domain")).toBeAttached(); + await expect(page).toHaveURL(`https://example.com/`); }); test("Handles unsupported protocol redirect Responses from render", async ({ @@ -1912,8 +1911,7 @@ implementations.forEach((implementation) => { await page.goto(`http://localhost:${port}/render-redirect/lazy`); await expect(page.getByText("home")).toBeAttached(); await page.getByText("External").click(); - await page.waitForURL(`https://example.com/`); - await expect(page.getByText("Example Domain")).toBeAttached(); + await expect(page).toHaveURL(`https://example.com/`); }); test("Handles unsupported protocol redirect Responses from suspended render", async ({ From 38ffbf24f1b66d9c8b4c119a6c7155f5b3f46bf1 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 27 Oct 2026 12:26:01 -0400 Subject: [PATCH 2/3] Bound SSR route manifest processing (cherry picked from commit 91a7312936f4bf0fbb854d9193f6efe38ce76b28) --- integration/fog-of-war-test.ts | 41 +++++++++++++ .../patch.bound-ssr-manifest-expansion.md | 1 + .../__tests__/dom/ssr/fog-of-war-test.ts | 61 ++++++++++++++++++- .../react-router/lib/dom/ssr/fog-of-war.ts | 21 +++++++ packages/react-router/lib/router/router.ts | 9 +++ .../react-router/lib/server-runtime/server.ts | 23 ++++++- 6 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 packages/react-router/.changes/patch.bound-ssr-manifest-expansion.md diff --git a/integration/fog-of-war-test.ts b/integration/fog-of-war-test.ts index 03b29b05fb..44b2eb1b36 100644 --- a/integration/fog-of-war-test.ts +++ b/integration/fog-of-war-test.ts @@ -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

Sample page

; + } + `, + }, + }); + 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; diff --git a/packages/react-router/.changes/patch.bound-ssr-manifest-expansion.md b/packages/react-router/.changes/patch.bound-ssr-manifest-expansion.md new file mode 100644 index 0000000000..5141afac78 --- /dev/null +++ b/packages/react-router/.changes/patch.bound-ssr-manifest-expansion.md @@ -0,0 +1 @@ +Bound route manifest processing during server rendering 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 diff --git a/packages/react-router/__tests__/dom/ssr/fog-of-war-test.ts b/packages/react-router/__tests__/dom/ssr/fog-of-war-test.ts index d86c511e29..565a10a6d3 100644 --- a/packages/react-router/__tests__/dom/ssr/fog-of-war-test.ts +++ b/packages/react-router/__tests__/dom/ssr/fog-of-war-test.ts @@ -1,6 +1,65 @@ -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, + ); + }); + + test("ignores empty pathname segments", () => { + let pathname = samplePath(10).replaceAll("/", "///"); + expect(shouldUseInitialRouteDiscovery(pathname, 50_001)).toBe(false); + }); + }); + describe("getPathsWithAncestors", () => { test("adds parent paths", () => { expect(getPathsWithAncestors(["/a/b/c"])).toEqual([ diff --git a/packages/react-router/lib/dom/ssr/fog-of-war.ts b/packages/react-router/lib/dom/ssr/fog-of-war.ts index 265a4606a2..b1fd8dd205 100644 --- a/packages/react-router/lib/dom/ssr/fog-of-war.ts +++ b/packages/react-router/lib/dom/ssr/fog-of-war.ts @@ -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("/").filter(Boolean).length; + 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, diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 610b1d4167..55fec05896 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -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 @@ -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 @@ -5029,6 +5037,7 @@ export function createStaticHandler( return { dataRoutes, + _matchCost: matchCost, _internalRouteBranches: routeBranches, query, queryRoute, diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 00bd4ff98d..f4c974d273 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -40,7 +40,12 @@ import { import { getDocumentHeaders } from "./headers"; import type { EntryRoute } from "../dom/ssr/routes"; import type { MiddlewareEnabled } from "../types/future"; -import { URL_LIMIT, getManifestPath } from "../dom/ssr/fog-of-war"; +import { + URL_LIMIT, + getManifestPath, + isFogOfWarEnabled, + shouldUseInitialRouteDiscovery, +} from "../dom/ssr/fog-of-war"; import type { InstrumentRequestHandlerFunction } from "../router/instrumentation"; import { instrumentHandler } from "../router/instrumentation"; import { throwIfPotentialCSRFAttack } from "../actions"; @@ -534,10 +539,22 @@ async function handleDocumentRequest( actionData: context.actionData, errors: context.errors, }; + let routeDiscovery = build.routeDiscovery; + if ( + isFogOfWarEnabled(routeDiscovery, build.ssr) && + shouldUseInitialRouteDiscovery( + context.location.pathname, + staticHandler._matchCost, + ) + ) { + // Keep this response and its client router on the existing full-manifest + // path without changing discovery for other requests using this build. + routeDiscovery = { ...routeDiscovery, mode: "initial" }; + } let baseServerHandoff: ServerHandoff = { basename: build.basename, future: build.future, - routeDiscovery: build.routeDiscovery, + routeDiscovery, ssr: build.ssr, isSpaMode, }; @@ -560,7 +577,7 @@ async function handleDocumentRequest( renderMeta: {}, future: build.future, ssr: build.ssr, - routeDiscovery: build.routeDiscovery, + routeDiscovery, isSpaMode, serializeError: (err) => serializeError(err, serverMode), }; From 748d07691b470eb2c0c8cd0ccae8cb4ead1e4d89 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 27 Oct 2026 13:02:39 -0400 Subject: [PATCH 3/3] Bound RSC route manifest processing (cherry picked from commit d3d9a6151c6f0a47af87d463056bed1abb069139) --- integration/rsc/route-discovery-test.ts | 84 +++++++++++++++++++ .../patch.bound-ssr-manifest-expansion.md | 2 +- .../__tests__/dom/ssr/fog-of-war-test.ts | 5 -- .../react-router/__tests__/rsc/server-test.ts | 53 ++++++++++++ .../react-router/lib/dom/ssr/fog-of-war.ts | 2 +- packages/react-router/lib/rsc/server.rsc.ts | 47 +++++++---- 6 files changed, 171 insertions(+), 22 deletions(-) create mode 100644 integration/rsc/route-discovery-test.ts diff --git a/integration/rsc/route-discovery-test.ts b/integration/rsc/route-discovery-test.ts new file mode 100644 index 0000000000..166925975c --- /dev/null +++ b/integration/rsc/route-discovery-test.ts @@ -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 <> +

Sample

+ Sample + Other + ; + } + `, + "src/routes/other.tsx": js` + export default function Other() { + return

Other

; + } + `, + }, + }); + + 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(); + } + }); +} diff --git a/packages/react-router/.changes/patch.bound-ssr-manifest-expansion.md b/packages/react-router/.changes/patch.bound-ssr-manifest-expansion.md index 5141afac78..4002fbaedd 100644 --- a/packages/react-router/.changes/patch.bound-ssr-manifest-expansion.md +++ b/packages/react-router/.changes/patch.bound-ssr-manifest-expansion.md @@ -1 +1 @@ -Bound route manifest processing during server rendering 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 +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 diff --git a/packages/react-router/__tests__/dom/ssr/fog-of-war-test.ts b/packages/react-router/__tests__/dom/ssr/fog-of-war-test.ts index 565a10a6d3..cb07fd180a 100644 --- a/packages/react-router/__tests__/dom/ssr/fog-of-war-test.ts +++ b/packages/react-router/__tests__/dom/ssr/fog-of-war-test.ts @@ -53,11 +53,6 @@ describe("fog of war", () => { false, ); }); - - test("ignores empty pathname segments", () => { - let pathname = samplePath(10).replaceAll("/", "///"); - expect(shouldUseInitialRouteDiscovery(pathname, 50_001)).toBe(false); - }); }); describe("getPathsWithAncestors", () => { diff --git a/packages/react-router/__tests__/rsc/server-test.ts b/packages/react-router/__tests__/rsc/server-test.ts index fdf4d2ab32..2761786d0e 100644 --- a/packages/react-router/__tests__/rsc/server-test.ts +++ b/packages/react-router/__tests__/rsc/server-test.ts @@ -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`; diff --git a/packages/react-router/lib/dom/ssr/fog-of-war.ts b/packages/react-router/lib/dom/ssr/fog-of-war.ts index b1fd8dd205..545acbe60f 100644 --- a/packages/react-router/lib/dom/ssr/fog-of-war.ts +++ b/packages/react-router/lib/dom/ssr/fog-of-war.ts @@ -64,7 +64,7 @@ export function shouldUseInitialRouteDiscovery( pathname: string, matchCost = 0, ): boolean { - let segmentCount = pathname.split("/").filter(Boolean).length; + 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 diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index 834b27aa7d..a1af25480a 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -39,7 +39,11 @@ import { } from "../router/utils"; import { getDocumentHeadersImpl } from "../server-runtime/headers"; import { SINGLE_FETCH_REDIRECT_STATUS } from "../dom/ssr/single-fetch"; -import { URL_LIMIT, getPathsWithAncestors } from "../dom/ssr/fog-of-war"; +import { + URL_LIMIT, + getPathsWithAncestors, + shouldUseInitialRouteDiscovery, +} from "../dom/ssr/fog-of-war"; import { throwIfPotentialCSRFAttack } from "../actions"; import invariant from "../server-runtime/invariant"; @@ -958,6 +962,7 @@ async function generateRenderResponse( skipRevalidation, ctx.redirect?.headers, routeDiscovery, + staticHandler._matchCost, ); }, }), @@ -1056,6 +1061,7 @@ async function generateStaticContextResponse( skipRevalidation: boolean, sideEffectRedirectHeaders: Headers | undefined, routeDiscovery: RouteDiscovery | undefined, + matchCost: number | undefined, ): Promise { statusCode = staticContext.statusCode ?? statusCode; @@ -1122,6 +1128,7 @@ async function generateStaticContextResponse( isDataRequest, staticContext, routeDiscovery, + matchCost, ); let payload: RSCRenderPayload | RSCActionPayload; @@ -1164,6 +1171,7 @@ async function getRenderPayload( isDataRequest: boolean, staticContext: StaticHandlerContext, routeDiscovery: RouteDiscovery | undefined, + matchCost: number | undefined, ) { // Figure out how deep we want to render server components based on any // triggered error boundaries and/or `routeIdsToLoad` @@ -1198,23 +1206,32 @@ async function getRenderPayload( }), ); - let patches = - routeDiscovery?.mode === "initial" && !isDataRequest - ? getAllRoutePatches(routes, basename).then((patches) => - patches.filter( - (patch) => - !staticContext.matches.some((m) => m.route.id === patch.id), - ), - ) - : getAdditionalRoutePatches( - getPathsWithAncestors([staticContext.location.pathname]), - routes, - basename, - staticContext.matches.map((m) => m.route.id), - ); + let pathname = staticContext.location.pathname; + let useInitialRouteDiscovery = + (routeDiscovery?.mode === "initial" && !isDataRequest) || + shouldUseInitialRouteDiscovery(pathname, matchCost); + let patches: Promise; + if (useInitialRouteDiscovery) { + patches = getAllRoutePatches(routes, basename).then((patches) => + patches.filter( + (patch) => !staticContext.matches.some((m) => m.route.id === patch.id), + ), + ); + } else { + patches = getAdditionalRoutePatches( + // The full pathname is last and its matches are already in the payload. + getPathsWithAncestors([pathname]).slice(0, -1), + routes, + basename, + staticContext.matches.map((m) => m.route.id), + ); + } return { ...baseRenderPayload, + routeDiscovery: useInitialRouteDiscovery + ? ({ mode: "initial" } satisfies RouteDiscovery) + : baseRenderPayload.routeDiscovery, matches: await matchesPromise, patches, };