diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index c2bb6d5319..03591c834c 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -18,9 +18,7 @@ import { import { createRequestInit } from "./data"; import type { AssetsManifest, EntryContext } from "./entry"; import { escapeHtml } from "./markup"; -import type { RouteModule, RouteModules } from "./routeModules"; import invariant from "./invariant"; -import type { EntryRoute } from "./routes"; export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect"); @@ -32,15 +30,22 @@ export type SingleFetchRedirectResult = { replace: boolean; }; +// Shared/serializable type used by both turbo-stream and RSC implementations +type DecodedSingleFetchResults = + | { routes: { [key: string]: SingleFetchResult } } + | { redirect: SingleFetchRedirectResult }; + +// This and SingleFetchResults are only used over the wire, and are converted to +// DecodedSingleFetchResults in `fetchAndDecode`. This way turbo-stream/RSC +// can use the same `unwrapSingleFetchResult` implementation. export type SingleFetchResult = | { data: unknown } | { error: unknown } | SingleFetchRedirectResult; -export type SingleFetchResults = { - [key: string]: SingleFetchResult; - [SingleFetchRedirectSymbol]?: SingleFetchRedirectResult; -}; +export type SingleFetchResults = + | { [key: string]: SingleFetchResult } + | { [SingleFetchRedirectSymbol]: SingleFetchRedirectResult }; interface StreamTransferProps { context: EntryContext; @@ -50,6 +55,16 @@ interface StreamTransferProps { nonce?: string; } +// Some status codes are not permitted to have bodies, so we want to just +// treat those as "no data" instead of throwing an exception: +// https://datatracker.ietf.org/doc/html/rfc9110#name-informational-1xx +// https://datatracker.ietf.org/doc/html/rfc9110#name-204-no-content +// https://datatracker.ietf.org/doc/html/rfc9110#name-205-reset-content +// +// Note: 304 is not included here because the browser should fill those responses +// with the cached body content. +export const NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205]); + // StreamTransfer recursively renders down chunks of the `serverHandoffStream` // into the client-side `streamController` export function StreamTransfer({ @@ -245,12 +260,13 @@ async function singleFetchActionStrategy( let result = await handler(async () => { let url = singleFetchUrl(request.url, basename); let init = await createRequestInit(request); - let { data, status } = await fetchAndDecode(url, init); - actionStatus = status; - return unwrapSingleFetchResult( - data as SingleFetchResult, + let { data, status } = await fetchAndDecode( + url, + init, actionMatch!.route.id ); + actionStatus = status; + return unwrapSingleFetchResult(data, actionMatch!.route.id); }); return result; }); @@ -308,23 +324,17 @@ async function singleFetchLoaderNavigationStrategy( router: DataRouter, basename: string | undefined ) { - // Track which routes need a server load - in case we need to tack on a - // `_routes` param + // Track which routes need a server load for use in a `_routes` param let routesParams = new Set<string>(); - // We only add `_routes` when one or more routes opts out of a load via - // `shouldRevalidate` or `clientLoader` + // Only add `_routes` when at least 1 route opts out via `shouldRevalidate`/`clientLoader` let foundOptOutRoute = false; - // Deferreds for each route so we can be sure they've all loaded via - // `match.resolve()`, and a singular promise that can tell us all routes - // have been resolved + // Deferreds per-route so we can be sure they've all loaded via `match.resolve()` let routeDfds = matches.map(() => createDeferred<void>()); - let routesLoadedPromise = Promise.all(routeDfds.map((d) => d.promise)); - // Deferred that we'll use for the call to the server that each match can - // await and parse out it's specific result - let singleFetchDfd = createDeferred<SingleFetchResults>(); + // Deferred we'll use for the singleular call to the server + let singleFetchDfd = createDeferred<DecodedSingleFetchResults>(); // Base URL and RequestInit for calls to the server let url = stripIndexParam(singleFetchUrl(request.url, basename)); @@ -339,6 +349,7 @@ async function singleFetchLoaderNavigationStrategy( routeDfds[i].resolve(); let manifestRoute = manifest.routes[m.route.id]; + invariant(manifestRoute, "No manifest route found for dataStrategy"); let defaultShouldRevalidate = !m.unstable_shouldRevalidateArgs || @@ -347,8 +358,7 @@ async function singleFetchLoaderNavigationStrategy( let shouldCall = m.unstable_shouldCallHandler(defaultShouldRevalidate); if (!shouldCall) { - // If this route opted out of revalidation, we don't want to include - // it in the single fetch .data request + // If this route opted out, don't include in the .data request foundOptOutRoute ||= m.unstable_shouldRevalidateArgs != null && // This is a revalidation, manifestRoute?.hasLoader === true && // for a route with a server loader, @@ -358,7 +368,7 @@ async function singleFetchLoaderNavigationStrategy( // When a route has a client loader, it opts out of the singular call and // calls it's server loader via `serverLoader()` using a `?_routes` param - if (manifestRoute && manifestRoute.hasClientLoader) { + if (manifestRoute.hasClientLoader) { if (manifestRoute.hasLoader) { foundOptOutRoute = true; } @@ -385,7 +395,7 @@ async function singleFetchLoaderNavigationStrategy( try { let result = await handler(async () => { let data = await singleFetchDfd.promise; - return unwrapSingleFetchResults(data, m.route.id); + return unwrapSingleFetchResult(data, m.route.id); }); results[m.route.id] = { type: "data", @@ -402,7 +412,7 @@ async function singleFetchLoaderNavigationStrategy( ); // Wait for all routes to resolve above before we make the HTTP call - await routesLoadedPromise; + await Promise.all(routeDfds.map((d) => d.promise)); // We can skip the server call: // - On initial hydration - only clientLoaders can pass through via `clientLoader.hydrate` @@ -417,24 +427,18 @@ async function singleFetchLoaderNavigationStrategy( ) { singleFetchDfd.resolve({}); } else { - try { - // When one or more routes have opted out, we add a _routes param to - // limit the loaders to those that have a server loader and did not - // opt out - if (ssr && foundOptOutRoute && routesParams.size > 0) { - url.searchParams.set( - "_routes", - matches - .filter((m) => routesParams.has(m.route.id)) - .map((m) => m.route.id) - .join(",") - ); - } + // When routes have opted out, add a `_routes` param to filter server loaders + // Skipped in `ssr:false` because we expect to be loading static `.data` files + if (ssr && foundOptOutRoute && routesParams.size > 0) { + let routes = [...routesParams.keys()].join(","); + url.searchParams.set("_routes", routes); + } + try { let data = await fetchAndDecode(url, init); - singleFetchDfd.resolve(data.data as SingleFetchResults); + singleFetchDfd.resolve(data.data); } catch (e) { - singleFetchDfd.reject(e as Error); + singleFetchDfd.reject(e); } } @@ -471,7 +475,7 @@ function fetchSingleLoader( let singleLoaderUrl = new URL(url); singleLoaderUrl.searchParams.set("_routes", routeId); let { data } = await fetchAndDecode(singleLoaderUrl, init); - return unwrapSingleFetchResults(data as SingleFetchResults, routeId); + return unwrapSingleFetchResult(data, routeId); }); } @@ -520,8 +524,9 @@ export function singleFetchUrl( async function fetchAndDecode( url: URL, - init: RequestInit -): Promise<{ status: number; data: unknown }> { + init: RequestInit, + routeId?: string +): Promise<{ status: number; data: DecodedSingleFetchResults }> { let res = await fetch(url, init); // If this 404'd without hitting the running server (most likely in a @@ -530,27 +535,39 @@ async function fetchAndDecode( throw new ErrorResponseImpl(404, "Not Found", true); } - // some status codes are not permitted to have bodies, so we want to just - // treat those as "no data" instead of throwing an exception. - // 304 is not included here because the browser should fill those responses - // with the cached body content. - const NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205]); if (NO_BODY_STATUS_CODES.has(res.status)) { - if (!init.method || init.method === "GET") { - // SingleFetchResults can just have no routeId keys which will result - // in no data for all routes - return { status: res.status, data: {} }; - } else { - // SingleFetchResult is for a singular route and can specify no data - return { status: res.status, data: { data: undefined } }; + let routes: { [key: string]: SingleFetchResult } = {}; + if (routeId) { + routes[routeId] = { data: undefined }; } + return { + status: res.status, + data: { routes }, + }; } invariant(res.body, "No response body to decode"); try { let decoded = await decodeViaTurboStream(res.body, window); - return { status: res.status, data: decoded.value }; + let data: DecodedSingleFetchResults; + if (!init.method || init.method === "GET") { + let typed = decoded.value as SingleFetchResults; + if (SingleFetchRedirectSymbol in typed) { + data = { redirect: typed[SingleFetchRedirectSymbol] }; + } else { + data = { routes: typed }; + } + } else { + let typed = decoded.value as SingleFetchResult; + invariant(routeId, "No routeId found for single fetch call decoding"); + if ("redirect" in typed) { + data = { redirect: typed }; + } else { + data = { routes: { [routeId]: typed } }; + } + } + return { status: res.status, data }; } catch (e) { // Can't clone after consuming the body via turbo-stream so we can't // include the body here. In an ideal world we'd look for a turbo-stream @@ -617,37 +634,34 @@ export function decodeViaTurboStream( }); } -function unwrapSingleFetchResults( - results: SingleFetchResults, +function unwrapSingleFetchResult( + result: DecodedSingleFetchResults, routeId: string ) { - let redirect = results[SingleFetchRedirectSymbol]; - if (redirect) { - return unwrapSingleFetchResult(redirect, routeId); + if ("redirect" in result) { + let { + redirect: location, + revalidate, + reload, + replace, + status, + } = result.redirect; + throw redirect(location, { + status, + headers: { + // Three R's of redirecting (lol Veep) + ...(revalidate ? { "X-Remix-Revalidate": "yes" } : null), + ...(reload ? { "X-Remix-Reload-Document": "yes" } : null), + ...(replace ? { "X-Remix-Replace": "yes" } : null), + }, + }); } - return results[routeId] !== undefined - ? unwrapSingleFetchResult(results[routeId], routeId) - : null; -} - -function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) { - if ("error" in result) { - throw result.error; - } else if ("redirect" in result) { - let headers: Record<string, string> = {}; - if (result.revalidate) { - headers["X-Remix-Revalidate"] = "yes"; - } - if (result.reload) { - headers["X-Remix-Reload-Document"] = "yes"; - } - if (result.replace) { - headers["X-Remix-Replace"] = "yes"; - } - throw redirect(result.redirect, { status: result.status, headers }); - } else if ("data" in result) { - return result.data; + let routeResult = result.routes[routeId]; + if ("error" in routeResult) { + throw routeResult.error; + } else if ("data" in routeResult) { + return routeResult.data; } else { throw new Error(`No response found for routeId "${routeId}"`); } @@ -655,7 +669,7 @@ function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) { function createDeferred<T = unknown>() { let resolve: (val?: any) => Promise<void>; - let reject: (error?: Error) => Promise<void>; + let reject: (error?: unknown) => Promise<void>; let promise = new Promise<T>((res, rej) => { resolve = async (val: T) => { res(val); @@ -663,7 +677,7 @@ function createDeferred<T = unknown>() { await promise; } catch (e) {} }; - reject = async (error?: Error) => { + reject = async (error?: unknown) => { rej(error); try { await promise; diff --git a/packages/react-router/lib/server-runtime/routes.ts b/packages/react-router/lib/server-runtime/routes.ts index e7bcf80978..ab22c9c2e5 100644 --- a/packages/react-router/lib/server-runtime/routes.ts +++ b/packages/react-router/lib/server-runtime/routes.ts @@ -2,7 +2,6 @@ import type { AgnosticDataRouteObject, LoaderFunctionArgs as RRLoaderFunctionArgs, ActionFunctionArgs as RRActionFunctionArgs, - RedirectFunction, RouteManifest, unstable_MiddlewareFunction, } from "../router/utils"; diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 84c0bac3c9..5cba00bcd4 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -24,18 +24,21 @@ import type { ServerRoute } from "./routes"; import { createStaticHandlerDataRoutes, createRoutes } from "./routes"; import { createServerHandoffString } from "./serverHandoff"; import { getDevServerHooks } from "./dev"; -import type { SingleFetchResult, SingleFetchResults } from "./single-fetch"; import { encodeViaTurboStream, getSingleFetchRedirect, singleFetchAction, singleFetchLoaders, - SingleFetchRedirectSymbol, SINGLE_FETCH_REDIRECT_STATUS, - NO_BODY_STATUS_CODES, + SERVER_NO_BODY_STATUS_CODES, } from "./single-fetch"; import { getDocumentHeaders } from "./headers"; import type { EntryRoute } from "../dom/ssr/routes"; +import type { + SingleFetchResult, + SingleFetchResults, +} from "../dom/ssr/single-fetch"; +import { SingleFetchRedirectSymbol } from "../dom/ssr/single-fetch"; import type { MiddlewareEnabled } from "../types/future"; export type RequestHandler = ( @@ -448,7 +451,7 @@ async function handleDocumentRequest( let headers = getDocumentHeaders(build, context); // Skip response body for unsupported status codes - if (NO_BODY_STATUS_CODES.has(context.statusCode)) { + if (SERVER_NO_BODY_STATUS_CODES.has(context.statusCode)) { return new Response(null, { status: context.statusCode, headers }); } diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index 145b09f4bc..21c2d84538 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -18,27 +18,16 @@ import type { SingleFetchResult, SingleFetchResults, } from "../dom/ssr/single-fetch"; -import { SingleFetchRedirectSymbol } from "../dom/ssr/single-fetch"; +import { + NO_BODY_STATUS_CODES, + SingleFetchRedirectSymbol, +} from "../dom/ssr/single-fetch"; import type { AppLoadContext } from "./data"; import { sanitizeError, sanitizeErrors } from "./errors"; import { ServerMode } from "./mode"; import { getDocumentHeaders } from "./headers"; import type { ServerBuild } from "./build"; -export type { SingleFetchResult, SingleFetchResults }; -export { SingleFetchRedirectSymbol }; - -// Do not include a response body if the status code is one of these, -// otherwise `undici` will throw an error when constructing the Response: -// https://github.com/nodejs/undici/blob/bd98a6303e45d5e0d44192a93731b1defdb415f3/lib/web/fetch/response.js#L522-L528 -// -// Specs: -// https://datatracker.ietf.org/doc/html/rfc9110#name-informational-1xx -// https://datatracker.ietf.org/doc/html/rfc9110#name-204-no-content -// https://datatracker.ietf.org/doc/html/rfc9110#name-205-reset-content -// https://datatracker.ietf.org/doc/html/rfc9110#name-304-not-modified -export const NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205, 304]); - // We can't use a 3xx status or else the `fetch()` would follow the redirect. // We need to communicate the redirect back as data so we can act on it in the // client side router. We use a 202 to avoid any automatic caching we might @@ -46,6 +35,14 @@ export const NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205, 304]); // the user control cache behavior via Cache-Control export const SINGLE_FETCH_REDIRECT_STATUS = 202; +// Add 304 for server side - that is not included in the client side logic +// because the browser should fill those responses with the cached data +// https://datatracker.ietf.org/doc/html/rfc9110#name-304-not-modified +export const SERVER_NO_BODY_STATUS_CODES = new Set([ + ...NO_BODY_STATUS_CODES, + 304, +]); + export async function singleFetchAction( build: ServerBuild, serverMode: ServerMode, @@ -280,7 +277,7 @@ function generateSingleFetchResponse( resultHeaders.set("X-Remix-Response", "yes"); // Skip response body for unsupported status codes - if (NO_BODY_STATUS_CODES.has(status)) { + if (SERVER_NO_BODY_STATUS_CODES.has(status)) { return new Response(null, { status, headers: resultHeaders }); }