Skip to content

Commit 52358da

Browse files
authored
Refactor dataStrategy for easier RSC abstraction (#13344)
1 parent aae4b2c commit 52358da

File tree

4 files changed

+120
-107
lines changed

4 files changed

+120
-107
lines changed

packages/react-router/lib/dom/ssr/single-fetch.tsx

Lines changed: 100 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,7 @@ import {
1818
import { createRequestInit } from "./data";
1919
import type { AssetsManifest, EntryContext } from "./entry";
2020
import { escapeHtml } from "./markup";
21-
import type { RouteModule, RouteModules } from "./routeModules";
2221
import invariant from "./invariant";
23-
import type { EntryRoute } from "./routes";
2422

2523
export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect");
2624

@@ -32,15 +30,22 @@ export type SingleFetchRedirectResult = {
3230
replace: boolean;
3331
};
3432

33+
// Shared/serializable type used by both turbo-stream and RSC implementations
34+
type DecodedSingleFetchResults =
35+
| { routes: { [key: string]: SingleFetchResult } }
36+
| { redirect: SingleFetchRedirectResult };
37+
38+
// This and SingleFetchResults are only used over the wire, and are converted to
39+
// DecodedSingleFetchResults in `fetchAndDecode`. This way turbo-stream/RSC
40+
// can use the same `unwrapSingleFetchResult` implementation.
3541
export type SingleFetchResult =
3642
| { data: unknown }
3743
| { error: unknown }
3844
| SingleFetchRedirectResult;
3945

40-
export type SingleFetchResults = {
41-
[key: string]: SingleFetchResult;
42-
[SingleFetchRedirectSymbol]?: SingleFetchRedirectResult;
43-
};
46+
export type SingleFetchResults =
47+
| { [key: string]: SingleFetchResult }
48+
| { [SingleFetchRedirectSymbol]: SingleFetchRedirectResult };
4449

4550
interface StreamTransferProps {
4651
context: EntryContext;
@@ -50,6 +55,16 @@ interface StreamTransferProps {
5055
nonce?: string;
5156
}
5257

58+
// Some status codes are not permitted to have bodies, so we want to just
59+
// treat those as "no data" instead of throwing an exception:
60+
// https://datatracker.ietf.org/doc/html/rfc9110#name-informational-1xx
61+
// https://datatracker.ietf.org/doc/html/rfc9110#name-204-no-content
62+
// https://datatracker.ietf.org/doc/html/rfc9110#name-205-reset-content
63+
//
64+
// Note: 304 is not included here because the browser should fill those responses
65+
// with the cached body content.
66+
export const NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205]);
67+
5368
// StreamTransfer recursively renders down chunks of the `serverHandoffStream`
5469
// into the client-side `streamController`
5570
export function StreamTransfer({
@@ -245,12 +260,13 @@ async function singleFetchActionStrategy(
245260
let result = await handler(async () => {
246261
let url = singleFetchUrl(request.url, basename);
247262
let init = await createRequestInit(request);
248-
let { data, status } = await fetchAndDecode(url, init);
249-
actionStatus = status;
250-
return unwrapSingleFetchResult(
251-
data as SingleFetchResult,
263+
let { data, status } = await fetchAndDecode(
264+
url,
265+
init,
252266
actionMatch!.route.id
253267
);
268+
actionStatus = status;
269+
return unwrapSingleFetchResult(data, actionMatch!.route.id);
254270
});
255271
return result;
256272
});
@@ -308,23 +324,17 @@ async function singleFetchLoaderNavigationStrategy(
308324
router: DataRouter,
309325
basename: string | undefined
310326
) {
311-
// Track which routes need a server load - in case we need to tack on a
312-
// `_routes` param
327+
// Track which routes need a server load for use in a `_routes` param
313328
let routesParams = new Set<string>();
314329

315-
// We only add `_routes` when one or more routes opts out of a load via
316-
// `shouldRevalidate` or `clientLoader`
330+
// Only add `_routes` when at least 1 route opts out via `shouldRevalidate`/`clientLoader`
317331
let foundOptOutRoute = false;
318332

319-
// Deferreds for each route so we can be sure they've all loaded via
320-
// `match.resolve()`, and a singular promise that can tell us all routes
321-
// have been resolved
333+
// Deferreds per-route so we can be sure they've all loaded via `match.resolve()`
322334
let routeDfds = matches.map(() => createDeferred<void>());
323-
let routesLoadedPromise = Promise.all(routeDfds.map((d) => d.promise));
324335

325-
// Deferred that we'll use for the call to the server that each match can
326-
// await and parse out it's specific result
327-
let singleFetchDfd = createDeferred<SingleFetchResults>();
336+
// Deferred we'll use for the singleular call to the server
337+
let singleFetchDfd = createDeferred<DecodedSingleFetchResults>();
328338

329339
// Base URL and RequestInit for calls to the server
330340
let url = stripIndexParam(singleFetchUrl(request.url, basename));
@@ -339,6 +349,7 @@ async function singleFetchLoaderNavigationStrategy(
339349
routeDfds[i].resolve();
340350

341351
let manifestRoute = manifest.routes[m.route.id];
352+
invariant(manifestRoute, "No manifest route found for dataStrategy");
342353

343354
let defaultShouldRevalidate =
344355
!m.unstable_shouldRevalidateArgs ||
@@ -347,8 +358,7 @@ async function singleFetchLoaderNavigationStrategy(
347358
let shouldCall = m.unstable_shouldCallHandler(defaultShouldRevalidate);
348359

349360
if (!shouldCall) {
350-
// If this route opted out of revalidation, we don't want to include
351-
// it in the single fetch .data request
361+
// If this route opted out, don't include in the .data request
352362
foundOptOutRoute ||=
353363
m.unstable_shouldRevalidateArgs != null && // This is a revalidation,
354364
manifestRoute?.hasLoader === true && // for a route with a server loader,
@@ -358,7 +368,7 @@ async function singleFetchLoaderNavigationStrategy(
358368

359369
// When a route has a client loader, it opts out of the singular call and
360370
// calls it's server loader via `serverLoader()` using a `?_routes` param
361-
if (manifestRoute && manifestRoute.hasClientLoader) {
371+
if (manifestRoute.hasClientLoader) {
362372
if (manifestRoute.hasLoader) {
363373
foundOptOutRoute = true;
364374
}
@@ -385,7 +395,7 @@ async function singleFetchLoaderNavigationStrategy(
385395
try {
386396
let result = await handler(async () => {
387397
let data = await singleFetchDfd.promise;
388-
return unwrapSingleFetchResults(data, m.route.id);
398+
return unwrapSingleFetchResult(data, m.route.id);
389399
});
390400
results[m.route.id] = {
391401
type: "data",
@@ -402,7 +412,7 @@ async function singleFetchLoaderNavigationStrategy(
402412
);
403413

404414
// Wait for all routes to resolve above before we make the HTTP call
405-
await routesLoadedPromise;
415+
await Promise.all(routeDfds.map((d) => d.promise));
406416

407417
// We can skip the server call:
408418
// - On initial hydration - only clientLoaders can pass through via `clientLoader.hydrate`
@@ -417,24 +427,18 @@ async function singleFetchLoaderNavigationStrategy(
417427
) {
418428
singleFetchDfd.resolve({});
419429
} else {
420-
try {
421-
// When one or more routes have opted out, we add a _routes param to
422-
// limit the loaders to those that have a server loader and did not
423-
// opt out
424-
if (ssr && foundOptOutRoute && routesParams.size > 0) {
425-
url.searchParams.set(
426-
"_routes",
427-
matches
428-
.filter((m) => routesParams.has(m.route.id))
429-
.map((m) => m.route.id)
430-
.join(",")
431-
);
432-
}
430+
// When routes have opted out, add a `_routes` param to filter server loaders
431+
// Skipped in `ssr:false` because we expect to be loading static `.data` files
432+
if (ssr && foundOptOutRoute && routesParams.size > 0) {
433+
let routes = [...routesParams.keys()].join(",");
434+
url.searchParams.set("_routes", routes);
435+
}
433436

437+
try {
434438
let data = await fetchAndDecode(url, init);
435-
singleFetchDfd.resolve(data.data as SingleFetchResults);
439+
singleFetchDfd.resolve(data.data);
436440
} catch (e) {
437-
singleFetchDfd.reject(e as Error);
441+
singleFetchDfd.reject(e);
438442
}
439443
}
440444

@@ -471,7 +475,7 @@ function fetchSingleLoader(
471475
let singleLoaderUrl = new URL(url);
472476
singleLoaderUrl.searchParams.set("_routes", routeId);
473477
let { data } = await fetchAndDecode(singleLoaderUrl, init);
474-
return unwrapSingleFetchResults(data as SingleFetchResults, routeId);
478+
return unwrapSingleFetchResult(data, routeId);
475479
});
476480
}
477481

@@ -520,8 +524,9 @@ export function singleFetchUrl(
520524

521525
async function fetchAndDecode(
522526
url: URL,
523-
init: RequestInit
524-
): Promise<{ status: number; data: unknown }> {
527+
init: RequestInit,
528+
routeId?: string
529+
): Promise<{ status: number; data: DecodedSingleFetchResults }> {
525530
let res = await fetch(url, init);
526531

527532
// If this 404'd without hitting the running server (most likely in a
@@ -530,27 +535,39 @@ async function fetchAndDecode(
530535
throw new ErrorResponseImpl(404, "Not Found", true);
531536
}
532537

533-
// some status codes are not permitted to have bodies, so we want to just
534-
// treat those as "no data" instead of throwing an exception.
535-
// 304 is not included here because the browser should fill those responses
536-
// with the cached body content.
537-
const NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205]);
538538
if (NO_BODY_STATUS_CODES.has(res.status)) {
539-
if (!init.method || init.method === "GET") {
540-
// SingleFetchResults can just have no routeId keys which will result
541-
// in no data for all routes
542-
return { status: res.status, data: {} };
543-
} else {
544-
// SingleFetchResult is for a singular route and can specify no data
545-
return { status: res.status, data: { data: undefined } };
539+
let routes: { [key: string]: SingleFetchResult } = {};
540+
if (routeId) {
541+
routes[routeId] = { data: undefined };
546542
}
543+
return {
544+
status: res.status,
545+
data: { routes },
546+
};
547547
}
548548

549549
invariant(res.body, "No response body to decode");
550550

551551
try {
552552
let decoded = await decodeViaTurboStream(res.body, window);
553-
return { status: res.status, data: decoded.value };
553+
let data: DecodedSingleFetchResults;
554+
if (!init.method || init.method === "GET") {
555+
let typed = decoded.value as SingleFetchResults;
556+
if (SingleFetchRedirectSymbol in typed) {
557+
data = { redirect: typed[SingleFetchRedirectSymbol] };
558+
} else {
559+
data = { routes: typed };
560+
}
561+
} else {
562+
let typed = decoded.value as SingleFetchResult;
563+
invariant(routeId, "No routeId found for single fetch call decoding");
564+
if ("redirect" in typed) {
565+
data = { redirect: typed };
566+
} else {
567+
data = { routes: { [routeId]: typed } };
568+
}
569+
}
570+
return { status: res.status, data };
554571
} catch (e) {
555572
// Can't clone after consuming the body via turbo-stream so we can't
556573
// include the body here. In an ideal world we'd look for a turbo-stream
@@ -617,53 +634,50 @@ export function decodeViaTurboStream(
617634
});
618635
}
619636

620-
function unwrapSingleFetchResults(
621-
results: SingleFetchResults,
637+
function unwrapSingleFetchResult(
638+
result: DecodedSingleFetchResults,
622639
routeId: string
623640
) {
624-
let redirect = results[SingleFetchRedirectSymbol];
625-
if (redirect) {
626-
return unwrapSingleFetchResult(redirect, routeId);
641+
if ("redirect" in result) {
642+
let {
643+
redirect: location,
644+
revalidate,
645+
reload,
646+
replace,
647+
status,
648+
} = result.redirect;
649+
throw redirect(location, {
650+
status,
651+
headers: {
652+
// Three R's of redirecting (lol Veep)
653+
...(revalidate ? { "X-Remix-Revalidate": "yes" } : null),
654+
...(reload ? { "X-Remix-Reload-Document": "yes" } : null),
655+
...(replace ? { "X-Remix-Replace": "yes" } : null),
656+
},
657+
});
627658
}
628659

629-
return results[routeId] !== undefined
630-
? unwrapSingleFetchResult(results[routeId], routeId)
631-
: null;
632-
}
633-
634-
function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) {
635-
if ("error" in result) {
636-
throw result.error;
637-
} else if ("redirect" in result) {
638-
let headers: Record<string, string> = {};
639-
if (result.revalidate) {
640-
headers["X-Remix-Revalidate"] = "yes";
641-
}
642-
if (result.reload) {
643-
headers["X-Remix-Reload-Document"] = "yes";
644-
}
645-
if (result.replace) {
646-
headers["X-Remix-Replace"] = "yes";
647-
}
648-
throw redirect(result.redirect, { status: result.status, headers });
649-
} else if ("data" in result) {
650-
return result.data;
660+
let routeResult = result.routes[routeId];
661+
if ("error" in routeResult) {
662+
throw routeResult.error;
663+
} else if ("data" in routeResult) {
664+
return routeResult.data;
651665
} else {
652666
throw new Error(`No response found for routeId "${routeId}"`);
653667
}
654668
}
655669

656670
function createDeferred<T = unknown>() {
657671
let resolve: (val?: any) => Promise<void>;
658-
let reject: (error?: Error) => Promise<void>;
672+
let reject: (error?: unknown) => Promise<void>;
659673
let promise = new Promise<T>((res, rej) => {
660674
resolve = async (val: T) => {
661675
res(val);
662676
try {
663677
await promise;
664678
} catch (e) {}
665679
};
666-
reject = async (error?: Error) => {
680+
reject = async (error?: unknown) => {
667681
rej(error);
668682
try {
669683
await promise;

packages/react-router/lib/server-runtime/routes.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type {
22
AgnosticDataRouteObject,
33
LoaderFunctionArgs as RRLoaderFunctionArgs,
44
ActionFunctionArgs as RRActionFunctionArgs,
5-
RedirectFunction,
65
RouteManifest,
76
unstable_MiddlewareFunction,
87
} from "../router/utils";

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,21 @@ import type { ServerRoute } from "./routes";
2424
import { createStaticHandlerDataRoutes, createRoutes } from "./routes";
2525
import { createServerHandoffString } from "./serverHandoff";
2626
import { getDevServerHooks } from "./dev";
27-
import type { SingleFetchResult, SingleFetchResults } from "./single-fetch";
2827
import {
2928
encodeViaTurboStream,
3029
getSingleFetchRedirect,
3130
singleFetchAction,
3231
singleFetchLoaders,
33-
SingleFetchRedirectSymbol,
3432
SINGLE_FETCH_REDIRECT_STATUS,
35-
NO_BODY_STATUS_CODES,
33+
SERVER_NO_BODY_STATUS_CODES,
3634
} from "./single-fetch";
3735
import { getDocumentHeaders } from "./headers";
3836
import type { EntryRoute } from "../dom/ssr/routes";
37+
import type {
38+
SingleFetchResult,
39+
SingleFetchResults,
40+
} from "../dom/ssr/single-fetch";
41+
import { SingleFetchRedirectSymbol } from "../dom/ssr/single-fetch";
3942
import type { MiddlewareEnabled } from "../types/future";
4043

4144
export type RequestHandler = (
@@ -448,7 +451,7 @@ async function handleDocumentRequest(
448451
let headers = getDocumentHeaders(build, context);
449452

450453
// Skip response body for unsupported status codes
451-
if (NO_BODY_STATUS_CODES.has(context.statusCode)) {
454+
if (SERVER_NO_BODY_STATUS_CODES.has(context.statusCode)) {
452455
return new Response(null, { status: context.statusCode, headers });
453456
}
454457

0 commit comments

Comments
 (0)