Skip to content

Commit c0209c9

Browse files
committed
Add RSC eager route discovery
1 parent b3da1e9 commit c0209c9

File tree

3 files changed

+208
-60
lines changed

3 files changed

+208
-60
lines changed

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

Lines changed: 144 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -188,27 +188,11 @@ function createRouterFromPayload({
188188
undefined,
189189
false
190190
),
191-
async patchRoutesOnNavigation({ patch, path, signal }) {
192-
let response = await fetch(`${path}.manifest`, { signal });
193-
if (!response.body || response.status < 200 || response.status >= 300) {
194-
throw new Error("Unable to fetch new route matches from the server");
191+
async patchRoutesOnNavigation({ path, signal }) {
192+
if (discoveredPaths.has(path)) {
193+
return;
195194
}
196-
197-
let payload = await decode(response.body);
198-
if (payload.type !== "manifest") {
199-
throw new Error("Failed to patch routes on navigation");
200-
}
201-
202-
// Without the `allowElementMutations` flag, this will no-op if the route
203-
// already exists so we can just call it for all returned matches
204-
payload.matches.forEach((match, i) =>
205-
patch(payload.matches[i - 1]?.id ?? null, [
206-
createRouteFromServerManifest(match),
207-
])
208-
);
209-
payload.patches.forEach((p) => {
210-
patch(p.parentId ?? null, [createRouteFromServerManifest(p)]);
211-
});
195+
await fetchAndApplyManifestPatches([path], decode, signal);
212196
},
213197
// FIXME: Pass `build.ssr` and `build.basename` into this function
214198
dataStrategy: getRSCSingleFetchDataStrategy(
@@ -389,9 +373,11 @@ function getFetchAndDecodeViaRSC(
389373
export function RSCHydratedRouter({
390374
decode,
391375
payload,
376+
routeDiscovery = "eager",
392377
}: {
393378
decode: DecodeServerResponseFunction;
394379
payload: ServerPayload;
380+
routeDiscovery?: "eager" | "lazy";
395381
}) {
396382
if (payload.type !== "render") throw new Error("Invalid payload type");
397383

@@ -410,6 +396,75 @@ export function RSCHydratedRouter({
410396
}
411397
}, []);
412398

399+
React.useEffect(() => {
400+
if (
401+
routeDiscovery === "lazy" ||
402+
// @ts-expect-error - TS doesn't know about this yet
403+
window.navigator?.connection?.saveData === true
404+
) {
405+
return;
406+
}
407+
408+
// Register a link href for patching
409+
function registerElement(el: Element) {
410+
let path =
411+
el.tagName === "FORM"
412+
? el.getAttribute("action")
413+
: el.getAttribute("href");
414+
if (!path) {
415+
return;
416+
}
417+
// optimization: use the already-parsed pathname from links
418+
let pathname =
419+
el.tagName === "A"
420+
? (el as HTMLAnchorElement).pathname
421+
: new URL(path, window.location.origin).pathname;
422+
if (!discoveredPaths.has(pathname)) {
423+
nextPaths.add(pathname);
424+
}
425+
}
426+
427+
// Register and fetch patches for all initially-rendered links/forms
428+
async function fetchPatches() {
429+
// re-check/update registered links
430+
document
431+
.querySelectorAll("a[data-discover], form[data-discover]")
432+
.forEach(registerElement);
433+
434+
let paths = Array.from(nextPaths.keys()).filter((path) => {
435+
if (discoveredPaths.has(path)) {
436+
nextPaths.delete(path);
437+
return false;
438+
}
439+
return true;
440+
});
441+
442+
if (paths.length === 0) {
443+
return;
444+
}
445+
446+
try {
447+
await fetchAndApplyManifestPatches(paths, decode);
448+
} catch (e) {
449+
console.error("Failed to fetch manifest patches", e);
450+
}
451+
}
452+
453+
let debouncedFetchPatches = debounce(fetchPatches, 100);
454+
455+
// scan and fetch initial links
456+
fetchPatches();
457+
458+
let observer = new MutationObserver(() => debouncedFetchPatches());
459+
460+
observer.observe(document.documentElement, {
461+
subtree: true,
462+
childList: true,
463+
attributes: true,
464+
attributeFilter: ["data-discover", "href", "action"],
465+
});
466+
}, [routeDiscovery, decode]);
467+
413468
const frameworkContext: FrameworkContextObject = {
414469
future: {
415470
// These flags have no runtime impact so can always be false. If we add
@@ -556,3 +611,72 @@ function preventInvalidServerHandlerCall(
556611
throw new ErrorResponseImpl(400, "Bad Request", new Error(msg), true);
557612
}
558613
}
614+
615+
// Currently rendered links that may need prefetching
616+
const nextPaths = new Set<string>();
617+
618+
// FIFO queue of previously discovered routes to prevent re-calling on
619+
// subsequent navigations to the same path
620+
const discoveredPathsMaxSize = 1000;
621+
const discoveredPaths = new Set<string>();
622+
623+
// 7.5k to come in under the ~8k limit for most browsers
624+
// https://stackoverflow.com/a/417184
625+
const URL_LIMIT = 7680;
626+
627+
async function fetchAndApplyManifestPatches(
628+
paths: string[],
629+
decode: DecodeServerResponseFunction,
630+
signal?: AbortSignal
631+
) {
632+
let basename = (window.__router.basename ?? "").replace(/^\/|\/$/g, "");
633+
let url = new URL(`${basename}/.manifest`, window.location.origin);
634+
paths.sort().forEach((path) => url.searchParams.append("p", path));
635+
636+
// If the URL is nearing the ~8k limit on GET requests, skip this optimization
637+
// step and just let discovery happen on link click. We also wipe out the
638+
// nextPaths Set here so we can start filling it with fresh links
639+
if (url.toString().length > URL_LIMIT) {
640+
nextPaths.clear();
641+
return;
642+
}
643+
644+
let response = await fetch(url, { signal });
645+
if (!response.body || response.status < 200 || response.status >= 300) {
646+
throw new Error("Unable to fetch new route matches from the server");
647+
}
648+
649+
let payload = await decode(response.body);
650+
if (payload.type !== "manifest") {
651+
throw new Error("Failed to patch routes");
652+
}
653+
654+
// Track discovered paths so we don't have to fetch them again
655+
paths.forEach((p) => addToFifoQueue(p, discoveredPaths));
656+
657+
// Without the `allowElementMutations` flag, this will no-op if the route
658+
// already exists so we can just call it for all returned patches
659+
payload.patches.forEach((p) => {
660+
window.__router.patchRoutes(p.parentId ?? null, [
661+
createRouteFromServerManifest(p),
662+
]);
663+
});
664+
}
665+
666+
function addToFifoQueue(path: string, queue: Set<string>) {
667+
if (queue.size >= discoveredPathsMaxSize) {
668+
let first = queue.values().next().value;
669+
queue.delete(first);
670+
}
671+
queue.add(path);
672+
}
673+
674+
// Thanks Josh!
675+
// https://www.joshwcomeau.com/snippets/javascript/debounce/
676+
function debounce(callback: (...args: unknown[]) => unknown, wait: number) {
677+
let timeoutId: number | undefined;
678+
return (...args: unknown[]) => {
679+
window.clearTimeout(timeoutId);
680+
timeoutId = window.setTimeout(() => callback(...args), wait);
681+
};
682+
}

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

Lines changed: 63 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,7 @@ export type ServerRenderPayload = {
101101

102102
export type ServerManifestPayload = {
103103
type: "manifest";
104-
// Current rendered matches
105-
matches: RenderedRoute[];
106-
// Additional routes we should patch into the router for subsequent navigations.
107-
// Mostly a collection of pathless/index routes that may be needed for complete
108-
// matching on upward navigations.
104+
// Routes we should patch into the router for subsequent navigations.
109105
patches: RenderedRoute[];
110106
};
111107

@@ -190,19 +186,36 @@ async function generateManifestResponse(
190186
generateResponse: (match: ServerMatch) => Response
191187
) {
192188
let url = new URL(request.url);
193-
let matches = matchRoutes(routes, url.pathname.replace(/\.manifest$/, ""));
189+
let pathnameParams = url.searchParams.getAll("p");
190+
let pathnames = pathnameParams.length
191+
? pathnameParams
192+
: [url.pathname.replace(/\.manifest$/, "")];
193+
let routeIds = new Set<string>();
194+
let matchedRoutes = pathnames
195+
.flatMap((pathname) => {
196+
let pathnameMatches = matchRoutes(routes, pathname);
197+
return (
198+
pathnameMatches?.map((m, i) => ({
199+
...m.route,
200+
parentId: pathnameMatches[i - 1]?.route.id,
201+
})) ?? []
202+
);
203+
})
204+
.filter((route) => {
205+
if (!routeIds.has(route.id)) {
206+
routeIds.add(route.id);
207+
return true;
208+
}
209+
return false;
210+
});
194211
let payload: ServerManifestPayload = {
195212
type: "manifest",
196-
matches: await Promise.all(
197-
matches?.map((m, i) =>
198-
getManifestRoute(m.route, matches[i - 1]?.route.id)
199-
) ?? []
200-
),
201-
patches: await getAdditionalRoutePatches(
202-
url.pathname,
203-
routes,
204-
matches?.map((m) => m.route.id) ?? []
205-
),
213+
patches: (
214+
await Promise.all([
215+
...matchedRoutes.map((route) => getManifestRoute(route)),
216+
getAdditionalRoutePatches(pathnames, routes, Array.from(routeIds)),
217+
])
218+
).flat(1),
206219
};
207220

208221
return generateResponse({
@@ -529,7 +542,7 @@ async function getRenderPayload(
529542

530543
let patchesPromise = !isDataRequest
531544
? getAdditionalRoutePatches(
532-
staticContext.location.pathname,
545+
[staticContext.location.pathname],
533546
routes,
534547
staticContext.matches.map((m) => m.route.id)
535548
)
@@ -650,8 +663,7 @@ async function getServerRouteMatch(
650663
}
651664

652665
async function getManifestRoute(
653-
route: ServerRouteObject,
654-
parentId: string | undefined
666+
route: ServerRouteObject & { parentId: string | undefined }
655667
): Promise<RenderedRoute> {
656668
await explodeLazyRoute(route);
657669

@@ -675,7 +687,7 @@ async function getManifestRoute(
675687
errorElement,
676688
hasLoader: !!route.loader,
677689
id: route.id,
678-
parentId,
690+
parentId: route.parentId,
679691
path: route.path,
680692
index: "index" in route ? route.index : undefined,
681693
links: route.links,
@@ -694,41 +706,52 @@ async function explodeLazyRoute(route: ServerRouteObject) {
694706
}
695707

696708
async function getAdditionalRoutePatches(
697-
pathname: string,
709+
pathnames: string[],
698710
routes: ServerRouteObject[],
699711
matchedRouteIds: string[]
700712
): Promise<RenderedRoute[]> {
701713
let patchRouteMatches = new Map<
702714
string,
703715
ServerRouteObject & { parentId: string | undefined }
704716
>();
705-
let segments = pathname.split("/").filter(Boolean);
706-
let paths: string[] = ["/"];
717+
let matchedPaths = new Set<string>();
707718

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

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

718-
paths.forEach((path) => {
719-
let matches = matchRoutes(routes, path) || [];
720-
matches.forEach((m, i) =>
721-
patchRouteMatches.set(m.route.id, {
722-
...m.route,
723-
parentId: matches[i - 1]?.route.id,
724-
})
725-
);
726-
});
726+
// Traverse each path for our parents and match in case they have pathless/index
727+
// children we need to include in the initial manifest
728+
while (segments.length > 0) {
729+
paths.push(`/${segments.join("/")}`);
730+
segments.pop();
731+
}
732+
733+
paths.forEach((path) => {
734+
if (matchedPaths.has(path)) {
735+
return;
736+
}
737+
matchedPaths.add(path);
738+
let matches = matchRoutes(routes, path) || [];
739+
matches.forEach((m, i) => {
740+
if (patchRouteMatches.get(m.route.id)) {
741+
return;
742+
}
743+
patchRouteMatches.set(m.route.id, {
744+
...m.route,
745+
parentId: matches[i - 1]?.route.id,
746+
});
747+
});
748+
});
749+
}
727750

728751
let patches = await Promise.all(
729752
[...patchRouteMatches.values()]
730753
.filter((route) => !matchedRouteIds.some((id) => id === route.id))
731-
.map((route) => getManifestRoute(route, route.parentId))
754+
.map((route) => getManifestRoute(route))
732755
);
733756
return patches;
734757
}

playground/rsc-parcel/src/entry.browser.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ createFromReadableStream(getServerStream(), { assets: "manifest" }).then(
3232
decode={createFromReadableStream}
3333
// @ts-expect-error
3434
payload={payload}
35+
routeDiscovery="eager"
3536
/>
3637
</React.StrictMode>
3738
);

0 commit comments

Comments
 (0)