diff --git a/.changeset/rsc-reload-document.md b/.changeset/rsc-reload-document.md new file mode 100644 index 0000000000..c7cb90d2db --- /dev/null +++ b/.changeset/rsc-reload-document.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Add versioned document recovery for RSC navigations, route discovery, and server actions. diff --git a/contributors.yml b/contributors.yml index 84a0c345e2..c95e4e6c54 100644 --- a/contributors.yml +++ b/contributors.yml @@ -14,6 +14,7 @@ - adriananin - adrienharnay - afzalsayed96 +- agcty - AhmadMayo - Ajayff4 - akamfoad diff --git a/packages/react-router-dev/config/default-rsc-entries/entry.rsc.tsx b/packages/react-router-dev/config/default-rsc-entries/entry.rsc.tsx index b06cca5505..0bdd6c2e92 100644 --- a/packages/react-router-dev/config/default-rsc-entries/entry.rsc.tsx +++ b/packages/react-router-dev/config/default-rsc-entries/entry.rsc.tsx @@ -14,6 +14,7 @@ import { // Import the routes generated by routes.ts import routes from "virtual:react-router/unstable_rsc/routes"; import routeDiscovery from "virtual:react-router/unstable_rsc/route-discovery"; +import version from "virtual:react-router/unstable_rsc/version"; import basename from "virtual:react-router/unstable_rsc/basename"; import unstable_reactRouterServeConfig from "virtual:react-router/unstable_rsc/react-router-serve-config"; @@ -38,6 +39,7 @@ export function fetchServer( routes, // The route discovery configuration. routeDiscovery, + version, // Encode the match with the React Server implementation. generateResponse(match, options) { return new Response(renderToReadableStream(match.payload, options), { diff --git a/packages/react-router-dev/rsc-types.d.ts b/packages/react-router-dev/rsc-types.d.ts index 58223e68de..09d9b2e79b 100644 --- a/packages/react-router-dev/rsc-types.d.ts +++ b/packages/react-router-dev/rsc-types.d.ts @@ -35,4 +35,9 @@ declare module "virtual:react-router/unstable_rsc/route-discovery" { export default routeDiscovery; } +declare module "virtual:react-router/unstable_rsc/version" { + const version: string; + export default version; +} + declare module "virtual:react-router/unstable_rsc/inject-hmr-runtime" {} diff --git a/packages/react-router-dev/vite/rsc/plugin.ts b/packages/react-router-dev/vite/rsc/plugin.ts index fa9878f1eb..a6193a3ed8 100644 --- a/packages/react-router-dev/vite/rsc/plugin.ts +++ b/packages/react-router-dev/vite/rsc/plugin.ts @@ -54,6 +54,9 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { let config: ResolvedReactRouterConfig; let rootRouteFile: string; + let rscVersion = `${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2)}`; function updateConfig(newConfig: ResolvedReactRouterConfig) { config = newConfig; rootRouteFile = Path.resolve( @@ -566,6 +569,19 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { } }, }, + { + name: "react-router/rsc/virtual-version", + resolveId(id) { + if (id === virtual.version.id) { + return virtual.version.resolvedId; + } + }, + load(id) { + if (id === virtual.version.resolvedId) { + return `export default ${JSON.stringify(rscVersion)};`; + } + }, + }, { name: "react-router/rsc/hmr/inject-runtime", enforce: "pre", @@ -776,6 +792,7 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { const virtual = { routeConfig: create("unstable_rsc/routes"), routeDiscovery: create("unstable_rsc/route-discovery"), + version: create("unstable_rsc/version"), injectHmrRuntime: create("unstable_rsc/inject-hmr-runtime"), basename: create("unstable_rsc/basename"), reactRouterServeConfig: create("unstable_rsc/react-router-serve-config"), diff --git a/packages/react-router/__tests__/rsc/browser-test.ts b/packages/react-router/__tests__/rsc/browser-test.ts new file mode 100644 index 0000000000..4799897a5a --- /dev/null +++ b/packages/react-router/__tests__/rsc/browser-test.ts @@ -0,0 +1,116 @@ +import { + claimRSCReloadDocumentAttempt, + clearRSCReloadDocumentAttempt, + getRSCManifestReloadDocumentTarget, + getRSCReloadDocumentTarget, + isRSCReloadDocumentResponse, +} from "../../lib/rsc/browser"; + +describe("RSC document reload recovery", () => { + beforeEach(() => { + window.history.replaceState(null, "", "/current?tab=one#section"); + // @ts-expect-error test-only router globals + window.__reactRouterDataRouter = undefined; + // @ts-expect-error test-only router globals + window.__reactRouterRSCVersion = undefined; + clearRSCReloadDocumentAttempt(); + }); + + it("recognizes reload-document responses", () => { + expect( + isRSCReloadDocumentResponse( + new Response(null, { + status: 204, + headers: { "X-Remix-Reload-Document": "true" }, + }), + ), + ).toBe(true); + + expect( + isRSCReloadDocumentResponse( + new Response(null, { + status: 200, + headers: { "X-Remix-Reload-Document": "true" }, + }), + ), + ).toBe(false); + }); + + it("reloads GET RSC responses at the document request target", () => { + // @ts-expect-error partial router global for this focused unit test + window.__reactRouterDataRouter = { + state: { + navigation: { + location: { + pathname: "/target", + search: "?panel=notes", + hash: "#source", + }, + }, + }, + }; + + expect( + getRSCReloadDocumentTarget( + new Request("http://localhost/target?panel=notes"), + ), + ).toBe("/target?panel=notes#source"); + }); + + it("reloads GET fetcher RSC responses at the current document", () => { + expect( + getRSCReloadDocumentTarget( + new Request("http://localhost/target?panel=notes"), + "fetcher", + ), + ).toBe("http://localhost/current?tab=one#section"); + }); + + it("reloads mutation RSC responses at the current document", () => { + expect( + getRSCReloadDocumentTarget( + new Request("http://localhost/target", { method: "POST" }), + ), + ).toBe("http://localhost/current?tab=one#section"); + }); + + it("reloads manifest mismatches at the pending navigation target", () => { + // @ts-expect-error partial router global for this focused unit test + window.__reactRouterDataRouter = { + state: { + navigation: { + location: { + pathname: "/target", + search: "?panel=notes", + hash: "#source", + }, + }, + }, + }; + + expect(getRSCManifestReloadDocumentTarget("/target")).toBe( + "/target?panel=notes#source", + ); + }); + + it("reloads fetcher manifest mismatches at the current document", () => { + expect(getRSCManifestReloadDocumentTarget("/target", "fetcher")).toBe( + "http://localhost/current?tab=one#section", + ); + }); + + it("bounds reload attempts by target URL and client version", () => { + // @ts-expect-error test-only router globals + window.__reactRouterRSCVersion = "version-a"; + expect(claimRSCReloadDocumentAttempt("/target")).toBe(true); + expect(claimRSCReloadDocumentAttempt("/target")).toBe(false); + + // @ts-expect-error test-only router globals + window.__reactRouterRSCVersion = "version-b"; + expect(claimRSCReloadDocumentAttempt("/target")).toBe(true); + expect(claimRSCReloadDocumentAttempt("/other")).toBe(true); + + clearRSCReloadDocumentAttempt(); + expect(claimRSCReloadDocumentAttempt("/target")).toBe(true); + }); +}); diff --git a/packages/react-router/__tests__/rsc/server-test.ts b/packages/react-router/__tests__/rsc/server-test.ts new file mode 100644 index 0000000000..064dc24e66 --- /dev/null +++ b/packages/react-router/__tests__/rsc/server-test.ts @@ -0,0 +1,96 @@ +import { + unstable_matchRSCServerRequest as matchRSCServerRequest, + type unstable_RSCMatch as RSCMatch, +} from "../../index-react-server"; +import { unstable_routeRSCServerRequest as routeRSCServerRequest } from "../../index"; + +describe("RSC server version recovery", () => { + it("returns a reload document response for stale manifest requests", async () => { + let response = await matchRSCServerRequest({ + createTemporaryReferenceSet: () => undefined, + request: new Request("http://localhost/target.manifest?version=old"), + routes: [], + version: "new", + generateResponse, + }); + + expect(response.status).toBe(204); + expect(response.headers.get("X-Remix-Reload-Document")).toBe("true"); + }); + + it("returns a reload document response for stale configured manifest requests", async () => { + let response = await matchRSCServerRequest({ + createTemporaryReferenceSet: () => undefined, + request: new Request( + "http://localhost/__routes?paths=/target&version=old", + ), + routeDiscovery: { mode: "lazy", manifestPath: "/__routes" }, + routes: [], + version: "new", + generateResponse, + }); + + expect(response.status).toBe(204); + expect(response.headers.get("X-Remix-Reload-Document")).toBe("true"); + }); + + it("returns a reload document response for stale RSC data requests", async () => { + let response = await matchRSCServerRequest({ + createTemporaryReferenceSet: () => undefined, + request: new Request("http://localhost/target.rsc", { + headers: { "X-React-Router-RSC-Version": "old" }, + }), + routes: [], + version: "new", + generateResponse, + }); + + expect(response.status).toBe(204); + expect(response.headers.get("X-Remix-Reload-Document")).toBe("true"); + }); + + it("returns a reload document response for stale server action requests", async () => { + let response = await matchRSCServerRequest({ + createTemporaryReferenceSet: () => undefined, + request: new Request("http://localhost/current", { + method: "POST", + headers: { + "rsc-action-id": "action", + "X-React-Router-RSC-Version": "old", + }, + }), + routes: [], + version: "new", + generateResponse, + }); + + expect(response.status).toBe(204); + expect(response.headers.get("X-Remix-Reload-Document")).toBe("true"); + }); + + it("passes through configured manifest responses from the SSR router", async () => { + let response = await routeRSCServerRequest({ + request: new Request( + "http://localhost/__routes?paths=/target&version=new", + ), + serverResponse: new Response("manifest", { + headers: { "React-Router-RSC-Manifest": "true" }, + }), + createFromReadableStream: async () => { + throw new Error("Should not decode manifest responses"); + }, + renderHTML() { + throw new Error("Should not render manifest responses"); + }, + }); + + expect(await response.text()).toBe("manifest"); + }); +}); + +function generateResponse(match: RSCMatch) { + return new Response(JSON.stringify(match.payload), { + status: match.statusCode, + headers: match.headers, + }); +} diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx index 7d1b7656dd..0d01674c49 100644 --- a/packages/react-router/lib/rsc/browser.tsx +++ b/packages/react-router/lib/rsc/browser.tsx @@ -5,7 +5,7 @@ import { RouterProvider } from "../components"; import { RSCRouterContext } from "../context"; import { FrameworkContext, setIsHydrated } from "../dom/ssr/components"; import type { FrameworkContextObject } from "../dom/ssr/entry"; -import { createBrowserHistory, invariant } from "../router/history"; +import { createBrowserHistory, createPath, invariant } from "../router/history"; import type { Router as DataRouter, RouterInit } from "../router/router"; import { createRouter, @@ -24,7 +24,7 @@ import type { DataStrategyFunctionArgs, RouterContextProvider, } from "../router/utils"; -import { ErrorResponseImpl, createContext } from "../router/utils"; +import { ErrorResponseImpl, createContext, joinPaths } from "../router/utils"; import type { DecodedSingleFetchResults, FetchAndDecodeFunction, @@ -66,8 +66,12 @@ type WindowWithRouterGlobals = Window & __reactRouterDataRouter: DataRouter; __routerInitialized: boolean; __routerActionID: number; + __reactRouterRSCVersion?: string; }; +const RSC_VERSION_HEADER = "X-React-Router-RSC-Version"; +const RSC_RELOAD_DOCUMENT_STORAGE_KEY = "react-router-rsc-reload-document"; + /** * Create a React `callServer` implementation for React Router. * @@ -123,23 +127,32 @@ export function createCallServer({ (globalVar.__routerActionID ??= 0) + 1); const temporaryReferences = createTemporaryReferenceSet(); - const payloadPromise = fetchImplementation( - new Request(location.href, { - body: await encodeReply(args, { temporaryReferences }), - method: "POST", - headers: { - Accept: "text/x-component", - "rsc-action-id": id, - }, - }), - ).then((response) => { - if (!response.body) { - throw new Error("No response body"); - } - return createFromReadableStream(response.body, { - temporaryReferences, - }) as Promise; + let version = getRSCVersion(); + const request = new Request(location.href, { + body: await encodeReply(args, { temporaryReferences }), + method: "POST", + headers: { + Accept: "text/x-component", + "rsc-action-id": id, + ...(version ? { [RSC_VERSION_HEADER]: version } : {}), + }, }); + const payloadPromise = fetchImplementation(request).then( + async (response) => { + if (isRSCReloadDocumentResponse(response)) { + await reloadRSCResponseDocument(getRSCReloadDocumentTarget(request)); + } + + clearRSCReloadDocumentAttempt(); + + if (!response.body) { + throw new Error("No response body"); + } + return createFromReadableStream(response.body, { + temporaryReferences, + }) as Promise; + }, + ); React.startTransition(() => // @ts-expect-error - Needs React 19 types @@ -249,14 +262,16 @@ function createRouterFromPayload({ } { const globalVar = window as WindowWithRouterGlobals; + if (payload.type !== "render") throw new Error("Invalid payload type"); + + globalVar.__reactRouterRSCVersion = payload.version; + if (globalVar.__reactRouterDataRouter && globalVar.__reactRouterRouteModules) return { router: globalVar.__reactRouterDataRouter, routeModules: globalVar.__reactRouterRouteModules, }; - if (payload.type !== "render") throw new Error("Invalid payload type"); - globalVar.__reactRouterRouteModules = globalVar.__reactRouterRouteModules ?? {}; populateRSCRouteModules(globalVar.__reactRouterRouteModules, payload.matches); @@ -301,7 +316,7 @@ function createRouterFromPayload({ basename: payload.basename, isSpaMode: false, }), - async patchRoutesOnNavigation({ path, signal }) { + async patchRoutesOnNavigation({ path, signal, fetcherKey }) { if (payload.routeDiscovery.mode === "initial") { if (!applyPatchesPromise) { applyPatchesPromise = (async () => { @@ -333,7 +348,10 @@ function createRouterFromPayload({ [path], createFromReadableStream, fetchImplementation, + payload.routeDiscovery.manifestPath, + payload.basename, signal, + getRSCManifestReloadDocumentTarget(path, fetcherKey), ); }, // FIXME: Pass `build.ssr` into this function @@ -557,9 +575,17 @@ function getFetchAndDecodeViaRSC( } let res = await fetchImplementation( - new Request(url, await createRequestInit(request)), + createRSCSubRequest(url, await createRequestInit(request)), ); + if (isRSCReloadDocumentResponse(res)) { + await reloadRSCResponseDocument( + getRSCReloadDocumentTarget(request, args.fetcherKey), + ); + } + + clearRSCReloadDocumentAttempt(); + // If this error'd without hitting the running server, then bubble a normal // `ErrorResponse` and don't try to decode the body with `turbo-stream`. // @@ -706,17 +732,18 @@ export function RSCHydratedRouter({ }: RSCHydratedRouterProps) { if (payload.type !== "render") throw new Error("Invalid payload type"); - let { routeDiscovery } = payload; + let renderPayload = payload; + let { basename, routeDiscovery } = renderPayload; let { router, routeModules } = React.useMemo( () => createRouterFromPayload({ - payload, + payload: renderPayload, fetchImplementation, getContext, createFromReadableStream, }), - [createFromReadableStream, payload, fetchImplementation, getContext], + [createFromReadableStream, renderPayload, fetchImplementation, getContext], ); React.useEffect(() => { @@ -770,6 +797,7 @@ export function RSCHydratedRouter({ ) { return; } + let manifestPath = routeDiscovery.manifestPath; // Register a link href for patching function registerElement(el: Element) { @@ -814,6 +842,8 @@ export function RSCHydratedRouter({ paths, createFromReadableStream, fetchImplementation, + manifestPath, + basename, ); } catch (e) { console.error("Failed to fetch manifest patches", e); @@ -833,7 +863,7 @@ export function RSCHydratedRouter({ attributes: true, attributeFilter: ["data-discover", "href", "action"], }); - }, [routeDiscovery, createFromReadableStream, fetchImplementation]); + }, [basename, routeDiscovery, createFromReadableStream, fetchImplementation]); const frameworkContext: FrameworkContextObject = { future: { @@ -856,12 +886,11 @@ export function RSCHydratedRouter({ }, }, routeDiscovery: - payload.routeDiscovery.mode === "initial" + routeDiscovery.mode === "initial" ? { mode: "initial", manifestPath: defaultManifestPath } : { mode: "lazy", - manifestPath: - payload.routeDiscovery.manifestPath || defaultManifestPath, + manifestPath: routeDiscovery.manifestPath || defaultManifestPath, }, routeModules, }; @@ -1014,33 +1043,46 @@ const nextPaths = new Set(); const discoveredPathsMaxSize = 1000; const discoveredPaths = new Set(); -function getManifestUrl(paths: string[]): URL | null { +function getManifestUrl( + paths: string[], + manifestPath: string | undefined, + basename: string | undefined, +): URL | null { if (paths.length === 0) { return null; } - if (paths.length === 1) { - return new URL(`${paths[0]}.manifest`, window.location.origin); - } - - const globalVar = window as WindowWithRouterGlobals; - let basename = (globalVar.__reactRouterDataRouter.basename ?? "").replace( - /^\/|\/$/g, - "", + let version = getRSCVersion(); + let url = new URL( + getRouteDiscoveryManifestPath(manifestPath, basename), + window.location.origin, ); - let url = new URL(`${basename}/.manifest`, window.location.origin); url.searchParams.set("paths", paths.sort().join(",")); + if (version) url.searchParams.set("version", version); return url; } +function getRouteDiscoveryManifestPath( + manifestPath: string | undefined, + basename: string | undefined, +): string { + let resolvedManifestPath = manifestPath || defaultManifestPath; + return basename == null + ? resolvedManifestPath + : joinPaths([basename, resolvedManifestPath]); +} + async function fetchAndApplyManifestPatches( paths: string[], createFromReadableStream: BrowserCreateFromReadableStreamFunction, fetchImplementation: (request: Request) => Promise, + manifestPath?: string, + basename?: string, signal?: AbortSignal, + reloadDocumentPath?: string, ) { - let url = getManifestUrl(paths); + let url = getManifestUrl(paths, manifestPath, basename); if (url == null) { return; } @@ -1053,7 +1095,22 @@ async function fetchAndApplyManifestPatches( return; } - let response = await fetchImplementation(new Request(url, { signal })); + let response = await fetchImplementation( + createRSCSubRequest(url, { signal }), + ); + if (isRSCReloadDocumentResponse(response)) { + if (reloadDocumentPath) { + await reloadRSCResponseDocument(reloadDocumentPath); + } + console.warn( + "Detected an RSC manifest version mismatch during eager route discovery. " + + "The next navigation to an undiscovered route will reload the document.", + ); + return; + } + + clearRSCReloadDocumentAttempt(); + if (!response.body || response.status < 200 || response.status >= 300) { throw new Error("Unable to fetch new route matches from the server"); } @@ -1090,6 +1147,102 @@ function addToFifoQueue(path: string, queue: Set) { queue.add(path); } +export function isRSCReloadDocumentResponse(response: Response): boolean { + return ( + response.status === 204 && response.headers.has("X-Remix-Reload-Document") + ); +} + +export function getRSCReloadDocumentTarget( + request: Request, + fetcherKey?: string | null, +): string { + if (request.method === "GET" && !fetcherKey) { + let navigation = (window as WindowWithRouterGlobals).__reactRouterDataRouter + ?.state.navigation; + if (navigation?.location) { + return createPath(navigation.location); + } + return new URL(request.url, window.location.href).toString(); + } + + return window.location.href; +} + +export function getRSCManifestReloadDocumentTarget( + path: string, + fetcherKey?: string, +): string { + if (fetcherKey) { + return window.location.href; + } + + let navigation = (window as WindowWithRouterGlobals).__reactRouterDataRouter + ?.state.navigation; + if (navigation?.location) { + return createPath(navigation.location); + } + + return path; +} + +export function claimRSCReloadDocumentAttempt(target: string): boolean { + let href = new URL(target, window.location.href).toString(); + let version = getRSCVersion() ?? ""; + let attemptKey = `${version}:${href}`; + try { + if ( + sessionStorage.getItem(RSC_RELOAD_DOCUMENT_STORAGE_KEY) === attemptKey + ) { + return false; + } + sessionStorage.setItem(RSC_RELOAD_DOCUMENT_STORAGE_KEY, attemptKey); + } catch { + // If sessionStorage is unavailable, still attempt the reload. + } + return true; +} + +export function clearRSCReloadDocumentAttempt(): void { + try { + sessionStorage.removeItem(RSC_RELOAD_DOCUMENT_STORAGE_KEY); + } catch { + // Session storage unavailable + } +} + +async function reloadRSCResponseDocument(target: string): Promise { + let href = new URL(target, window.location.href).toString(); + if (!claimRSCReloadDocumentAttempt(href)) { + console.error("Unable to reload document due to RSC version mismatch."); + throw new Error("Unable to reload document due to RSC version mismatch."); + } + + window.location.assign(href); + console.warn("Detected RSC version mismatch, reloading..."); + + // Stall here and let the browser reload, avoiding an ErrorBoundary flash. + return await new Promise(() => { + // Intentionally never resolves. + }); +} + +function createRSCSubRequest( + input: RequestInfo | URL, + init?: RequestInit, +): Request { + let request = new Request(input, init); + let version = getRSCVersion(); + if (version) { + request.headers.set(RSC_VERSION_HEADER, version); + } + return request; +} + +function getRSCVersion(): string | undefined { + return (window as WindowWithRouterGlobals).__reactRouterRSCVersion; +} + // Thanks Josh! // https://www.joshwcomeau.com/snippets/javascript/debounce/ function debounce(callback: (...args: unknown[]) => unknown, wait: number) { diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index 69a6dab548..0c9916e3e0 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -29,6 +29,7 @@ import { type TrackedPromise, isAbsoluteUrl, isRouteErrorResponse, + joinPaths, matchRoutes, prependBasename, convertRouteMatchToUiMatch, @@ -69,6 +70,10 @@ import { } from "../errors"; import { getNormalizedPath } from "../server-runtime/urls"; +const defaultManifestPath = "/__manifest"; +const RSC_MANIFEST_RESPONSE_HEADER = "React-Router-RSC-Manifest"; +const RSC_VERSION_HEADER = "X-React-Router-RSC-Version"; + const Outlet: typeof OutletType = UNTYPED_Outlet; const WithComponentProps: typeof WithComponentPropsType = UNSAFE_WithComponentProps; @@ -247,6 +252,7 @@ export type RSCRenderPayload = { loaderData: Record; location: Location; routeDiscovery: RouteDiscovery; + version?: string; matches: RSCRouteMatch[]; // Additional routes we should patch into the router for subsequent navigations. // Mostly a collection of pathless/index routes that may be needed for complete @@ -259,6 +265,7 @@ export type RSCRenderPayload = { export type RSCManifestPayload = { type: "manifest"; + version?: string; // Routes we should patch into the router for subsequent navigations. patches: Promise; }; @@ -383,6 +390,7 @@ export type RouteDiscovery = * [`loader`](../../start/data/route-object#loader)s and [middleware](../../how-to/middleware). * @param opts.routeDiscovery The route discovery configuration, used to determine how the router should discover new routes during navigations. * @param opts.routes Your {@link unstable_RSCRouteConfigEntry | route definitions}. + * @param opts.version An optional build version identifier used to detect stale RSC clients and trigger document recovery. * @returns A [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) * that contains the [RSC](https://react.dev/reference/rsc/server-components) * data for hydration. @@ -400,6 +408,7 @@ export async function matchRSCServerRequest({ onError, request, routes, + version, generateResponse, }: { allowedActionOrigins?: string[]; @@ -414,6 +423,7 @@ export async function matchRSCServerRequest({ request: Request; routes: RSCRouteConfigEntry[]; routeDiscovery?: RouteDiscovery; + version?: string; generateResponse: ( match: RSCMatch, { @@ -456,7 +466,13 @@ export async function matchRSCServerRequest({ const temporaryReferences = createTemporaryReferenceSet(); const requestUrl = new URL(request.url); - if (isManifestRequest(requestUrl)) { + if ( + isRSCVersionMismatch(request, requestUrl, version, basename, routeDiscovery) + ) { + return getRSCReloadDocumentResponse(); + } + + if (isManifestRequest(requestUrl, basename, routeDiscovery)) { let response = await generateManifestResponse( routes, basename, @@ -464,6 +480,7 @@ export async function matchRSCServerRequest({ generateResponse, temporaryReferences, routeDiscovery, + version, ); return response; } @@ -511,6 +528,7 @@ export async function matchRSCServerRequest({ temporaryReferences, allowedActionOrigins, routeDiscovery, + version, ); // The front end uses this to know whether a 4xx/5xx status came from app code // or never reached the origin server @@ -531,10 +549,12 @@ async function generateManifestResponse( ) => Response, temporaryReferences: unknown, routeDiscovery: RouteDiscovery | undefined, + version: string | undefined, ) { if (routeDiscovery?.mode === "initial") { let payload: RSCManifestPayload = { type: "manifest", + version, patches: getAllRoutePatches(routes, basename), }; return generateResponse( @@ -542,6 +562,7 @@ async function generateManifestResponse( statusCode: 200, headers: new Headers({ "Content-Type": "text/x-component", + [RSC_MANIFEST_RESPONSE_HEADER]: "true", Vary: "Content-Type", }), payload, @@ -554,7 +575,9 @@ async function generateManifestResponse( let pathParam = url.searchParams.get("paths"); let pathnames = pathParam ? pathParam.split(",").filter(Boolean) - : [url.pathname.replace(/\.manifest$/, "")]; + : url.pathname.endsWith(".manifest") + ? [url.pathname.replace(/\.manifest$/, "")] + : []; let routeIds = new Set(); let matchedRoutes = pathnames .flatMap((pathname) => { @@ -575,6 +598,7 @@ async function generateManifestResponse( }); let payload: RSCManifestPayload = { type: "manifest", + version, patches: Promise.all([ ...matchedRoutes.map((route) => getManifestRoute(route)), getAdditionalRoutePatches( @@ -591,6 +615,7 @@ async function generateManifestResponse( statusCode: 200, headers: new Headers({ "Content-Type": "text/x-component", + [RSC_MANIFEST_RESPONSE_HEADER]: "true", }), payload, }, @@ -807,6 +832,7 @@ async function generateRenderResponse( temporaryReferences: unknown, allowedActionOrigins: string[] | undefined, routeDiscovery: RouteDiscovery | undefined, + version: string | undefined, ): Promise { // If this is a RR submission, we just want the `actionData` but don't want // to call any loaders or render any components back in the response - that @@ -944,6 +970,7 @@ async function generateRenderResponse( skipRevalidation, ctx.redirect?.headers, routeDiscovery, + version, ); }, }), @@ -1042,6 +1069,7 @@ async function generateStaticContextResponse( skipRevalidation: boolean, sideEffectRedirectHeaders: Headers | undefined, routeDiscovery: RouteDiscovery | undefined, + version: string | undefined, ): Promise { statusCode = staticContext.statusCode ?? statusCode; @@ -1097,6 +1125,7 @@ async function generateStaticContextResponse( loaderData: staticContext.loaderData, location: staticContext.location, formState, + version, }; const renderPayloadPromise = () => @@ -1471,8 +1500,53 @@ export function isReactServerRequest(url: URL) { return url.pathname.endsWith(".rsc"); } -export function isManifestRequest(url: URL) { - return url.pathname.endsWith(".manifest"); +export function isManifestRequest( + url: URL, + basename?: string, + routeDiscovery?: RouteDiscovery, +) { + return ( + url.pathname.endsWith(".manifest") || + url.pathname === getRouteDiscoveryManifestPath(routeDiscovery, basename) + ); +} + +function isRSCVersionMismatch( + request: Request, + url: URL, + version: string | undefined, + basename: string | undefined, + routeDiscovery: RouteDiscovery | undefined, +): boolean { + if (!version) { + return false; + } + + let requestVersion = isManifestRequest(url, basename, routeDiscovery) + ? url.searchParams.get("version") + : request.headers.get(RSC_VERSION_HEADER); + + return requestVersion != null && requestVersion !== version; +} + +function getRouteDiscoveryManifestPath( + routeDiscovery: RouteDiscovery | undefined, + basename: string | undefined, +): string { + let manifestPath = + routeDiscovery?.mode === "lazy" + ? routeDiscovery.manifestPath || defaultManifestPath + : defaultManifestPath; + return basename == null ? manifestPath : joinPaths([basename, manifestPath]); +} + +function getRSCReloadDocumentResponse(): Response { + return new Response(null, { + status: 204, + headers: { + "X-Remix-Reload-Document": "true", + }, + }); } function defaultOnError(error: unknown) { diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx index 2952f38501..444266a4fc 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -17,6 +17,7 @@ import { import { escapeHtml } from "../dom/ssr/markup"; const defaultManifestPath = "/__manifest"; +const RSC_MANIFEST_RESPONSE_HEADER = "React-Router-RSC-Manifest"; type DecodedPayload = Promise & { _deepestRenderedBoundaryId?: string | null; @@ -109,10 +110,15 @@ export async function routeRSCServerRequest({ }): Promise { const url = new URL(request.url); const isDataRequest = isReactServerRequest(url); + const isReloadDocumentResponse = + serverResponse.status === 204 && + serverResponse.headers.has("X-Remix-Reload-Document"); const respondWithRSCPayload = isDataRequest || isManifestRequest(url) || - request.headers.has("rsc-action-id"); + request.headers.has("rsc-action-id") || + serverResponse.headers.get(RSC_MANIFEST_RESPONSE_HEADER) === "true" || + isReloadDocumentResponse; if ( respondWithRSCPayload ||