Skip to content

Add RSC eager route discovery #13698

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

Merged
merged 1 commit into from
May 28, 2025
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
164 changes: 144 additions & 20 deletions packages/react-router/lib/rsc/browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,27 +188,11 @@ function createRouterFromPayload({
undefined,
false
),
async patchRoutesOnNavigation({ patch, path, signal }) {
let response = await fetch(`${path}.manifest`, { signal });
if (!response.body || response.status < 200 || response.status >= 300) {
throw new Error("Unable to fetch new route matches from the server");
async patchRoutesOnNavigation({ path, signal }) {
if (discoveredPaths.has(path)) {
return;
}

let payload = await decode(response.body);
if (payload.type !== "manifest") {
throw new Error("Failed to patch routes on navigation");
}

// Without the `allowElementMutations` flag, this will no-op if the route
// already exists so we can just call it for all returned matches
payload.matches.forEach((match, i) =>
patch(payload.matches[i - 1]?.id ?? null, [
createRouteFromServerManifest(match),
])
);
payload.patches.forEach((p) => {
patch(p.parentId ?? null, [createRouteFromServerManifest(p)]);
});
await fetchAndApplyManifestPatches([path], decode, signal);
},
// FIXME: Pass `build.ssr` and `build.basename` into this function
dataStrategy: getRSCSingleFetchDataStrategy(
Expand Down Expand Up @@ -389,9 +373,11 @@ function getFetchAndDecodeViaRSC(
export function RSCHydratedRouter({
decode,
payload,
routeDiscovery = "eager",
}: {
decode: DecodeServerResponseFunction;
payload: ServerPayload;
routeDiscovery?: "eager" | "lazy";
}) {
if (payload.type !== "render") throw new Error("Invalid payload type");

Expand All @@ -410,6 +396,75 @@ export function RSCHydratedRouter({
}
}, []);

React.useEffect(() => {
if (
routeDiscovery === "lazy" ||
// @ts-expect-error - TS doesn't know about this yet
window.navigator?.connection?.saveData === true
) {
return;
}

// Register a link href for patching
function registerElement(el: Element) {
let path =
el.tagName === "FORM"
? el.getAttribute("action")
: el.getAttribute("href");
if (!path) {
return;
}
// optimization: use the already-parsed pathname from links
let pathname =
el.tagName === "A"
? (el as HTMLAnchorElement).pathname
: new URL(path, window.location.origin).pathname;
if (!discoveredPaths.has(pathname)) {
nextPaths.add(pathname);
}
}

// Register and fetch patches for all initially-rendered links/forms
async function fetchPatches() {
// re-check/update registered links
document
.querySelectorAll("a[data-discover], form[data-discover]")
.forEach(registerElement);

let paths = Array.from(nextPaths.keys()).filter((path) => {
if (discoveredPaths.has(path)) {
nextPaths.delete(path);
return false;
}
return true;
});

if (paths.length === 0) {
return;
}

try {
await fetchAndApplyManifestPatches(paths, decode);
} catch (e) {
console.error("Failed to fetch manifest patches", e);
}
}

let debouncedFetchPatches = debounce(fetchPatches, 100);

// scan and fetch initial links
fetchPatches();

let observer = new MutationObserver(() => debouncedFetchPatches());

observer.observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["data-discover", "href", "action"],
});
}, [routeDiscovery, decode]);

const frameworkContext: FrameworkContextObject = {
future: {
// These flags have no runtime impact so can always be false. If we add
Expand Down Expand Up @@ -556,3 +611,72 @@ function preventInvalidServerHandlerCall(
throw new ErrorResponseImpl(400, "Bad Request", new Error(msg), true);
}
}

// Currently rendered links that may need prefetching
const nextPaths = new Set<string>();

// FIFO queue of previously discovered routes to prevent re-calling on
// subsequent navigations to the same path
const discoveredPathsMaxSize = 1000;
const discoveredPaths = new Set<string>();

// 7.5k to come in under the ~8k limit for most browsers
// https://stackoverflow.com/a/417184
const URL_LIMIT = 7680;

async function fetchAndApplyManifestPatches(
paths: string[],
decode: DecodeServerResponseFunction,
signal?: AbortSignal
) {
let basename = (window.__router.basename ?? "").replace(/^\/|\/$/g, "");
let url = new URL(`${basename}/.manifest`, window.location.origin);
paths.sort().forEach((path) => url.searchParams.append("p", path));

// If the URL is nearing the ~8k limit on GET requests, skip this optimization
// step and just let discovery happen on link click. We also wipe out the
// nextPaths Set here so we can start filling it with fresh links
if (url.toString().length > URL_LIMIT) {
nextPaths.clear();
return;
}

let response = await fetch(url, { signal });
if (!response.body || response.status < 200 || response.status >= 300) {
throw new Error("Unable to fetch new route matches from the server");
}

let payload = await decode(response.body);
if (payload.type !== "manifest") {
throw new Error("Failed to patch routes");
}

// Track discovered paths so we don't have to fetch them again
paths.forEach((p) => addToFifoQueue(p, discoveredPaths));

// Without the `allowElementMutations` flag, this will no-op if the route
// already exists so we can just call it for all returned patches
payload.patches.forEach((p) => {
window.__router.patchRoutes(p.parentId ?? null, [
createRouteFromServerManifest(p),
]);
});
}

function addToFifoQueue(path: string, queue: Set<string>) {
if (queue.size >= discoveredPathsMaxSize) {
let first = queue.values().next().value;
queue.delete(first);
}
queue.add(path);
}

// Thanks Josh!
// https://www.joshwcomeau.com/snippets/javascript/debounce/
function debounce(callback: (...args: unknown[]) => unknown, wait: number) {
let timeoutId: number | undefined;
return (...args: unknown[]) => {
window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => callback(...args), wait);
};
}
103 changes: 63 additions & 40 deletions packages/react-router/lib/rsc/server.rsc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,7 @@ export type ServerRenderPayload = {

export type ServerManifestPayload = {
type: "manifest";
// Current rendered matches
matches: RenderedRoute[];
// Additional routes we should patch into the router for subsequent navigations.
// Mostly a collection of pathless/index routes that may be needed for complete
// matching on upward navigations.
// Routes we should patch into the router for subsequent navigations.
patches: RenderedRoute[];
};

Expand Down Expand Up @@ -190,19 +186,36 @@ async function generateManifestResponse(
generateResponse: (match: ServerMatch) => Response
) {
let url = new URL(request.url);
let matches = matchRoutes(routes, url.pathname.replace(/\.manifest$/, ""));
let pathnameParams = url.searchParams.getAll("p");
let pathnames = pathnameParams.length
? pathnameParams
: [url.pathname.replace(/\.manifest$/, "")];
let routeIds = new Set<string>();
let matchedRoutes = pathnames
.flatMap((pathname) => {
let pathnameMatches = matchRoutes(routes, pathname);
return (
pathnameMatches?.map((m, i) => ({
...m.route,
parentId: pathnameMatches[i - 1]?.route.id,
})) ?? []
);
})
.filter((route) => {
if (!routeIds.has(route.id)) {
routeIds.add(route.id);
return true;
}
return false;
});
let payload: ServerManifestPayload = {
type: "manifest",
matches: await Promise.all(
matches?.map((m, i) =>
getManifestRoute(m.route, matches[i - 1]?.route.id)
) ?? []
),
patches: await getAdditionalRoutePatches(
url.pathname,
routes,
matches?.map((m) => m.route.id) ?? []
),
patches: (
await Promise.all([
...matchedRoutes.map((route) => getManifestRoute(route)),
getAdditionalRoutePatches(pathnames, routes, Array.from(routeIds)),
])
).flat(1),
};

return generateResponse({
Expand Down Expand Up @@ -529,7 +542,7 @@ async function getRenderPayload(

let patchesPromise = !isDataRequest
? getAdditionalRoutePatches(
staticContext.location.pathname,
[staticContext.location.pathname],
routes,
staticContext.matches.map((m) => m.route.id)
)
Expand Down Expand Up @@ -650,8 +663,7 @@ async function getServerRouteMatch(
}

async function getManifestRoute(
route: ServerRouteObject,
parentId: string | undefined
route: ServerRouteObject & { parentId: string | undefined }
): Promise<RenderedRoute> {
await explodeLazyRoute(route);

Expand All @@ -675,7 +687,7 @@ async function getManifestRoute(
errorElement,
hasLoader: !!route.loader,
id: route.id,
parentId,
parentId: route.parentId,
path: route.path,
index: "index" in route ? route.index : undefined,
links: route.links,
Expand All @@ -694,41 +706,52 @@ async function explodeLazyRoute(route: ServerRouteObject) {
}

async function getAdditionalRoutePatches(
pathname: string,
pathnames: string[],
routes: ServerRouteObject[],
matchedRouteIds: string[]
): Promise<RenderedRoute[]> {
let patchRouteMatches = new Map<
string,
ServerRouteObject & { parentId: string | undefined }
>();
let segments = pathname.split("/").filter(Boolean);
let paths: string[] = ["/"];
let matchedPaths = new Set<string>();

// We've already matched to the last segment
segments.pop();
for (const pathname of pathnames) {
let segments = pathname.split("/").filter(Boolean);
let paths: string[] = ["/"];

// Traverse each path for our parents and match in case they have pathless/index
// children we need to include in the initial manifest
while (segments.length > 0) {
paths.push(`/${segments.join("/")}`);
// We've already matched to the last segment
segments.pop();
}

paths.forEach((path) => {
let matches = matchRoutes(routes, path) || [];
matches.forEach((m, i) =>
patchRouteMatches.set(m.route.id, {
...m.route,
parentId: matches[i - 1]?.route.id,
})
);
});
// Traverse each path for our parents and match in case they have pathless/index
// children we need to include in the initial manifest
while (segments.length > 0) {
paths.push(`/${segments.join("/")}`);
segments.pop();
}

paths.forEach((path) => {
if (matchedPaths.has(path)) {
return;
}
matchedPaths.add(path);
let matches = matchRoutes(routes, path) || [];
matches.forEach((m, i) => {
if (patchRouteMatches.get(m.route.id)) {
return;
}
patchRouteMatches.set(m.route.id, {
...m.route,
parentId: matches[i - 1]?.route.id,
});
});
});
}

let patches = await Promise.all(
[...patchRouteMatches.values()]
.filter((route) => !matchedRouteIds.some((id) => id === route.id))
.map((route) => getManifestRoute(route, route.parentId))
.map((route) => getManifestRoute(route))
);
return patches;
}
Expand Down
1 change: 1 addition & 0 deletions playground/rsc-parcel/src/entry.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ createFromReadableStream(getServerStream(), { assets: "manifest" }).then(
decode={createFromReadableStream}
// @ts-expect-error
payload={payload}
routeDiscovery="eager"
/>
</React.StrictMode>
);
Expand Down