diff --git a/.changeset/early-doors-obey.md b/.changeset/early-doors-obey.md new file mode 100644 index 0000000000..2c3410f1ee --- /dev/null +++ b/.changeset/early-doors-obey.md @@ -0,0 +1,6 @@ +--- +"@react-router/dev": patch +"react-router": patch +--- + +add support for throwing redirect Response's at RSC render time diff --git a/integration/helpers/rsc-parcel/src/prerender.tsx b/integration/helpers/rsc-parcel/src/prerender.tsx index c0819d1e34..727588dcb1 100644 --- a/integration/helpers/rsc-parcel/src/prerender.tsx +++ b/integration/helpers/rsc-parcel/src/prerender.tsx @@ -20,12 +20,13 @@ export async function prerender( // Provide the React Server touchpoints. createFromReadableStream, // Render the router to HTML. - async renderHTML(getPayload) { + async renderHTML(getPayload, options) { const payload = getPayload(); return await renderHTMLToReadableStream( , { + ...options, bootstrapScriptContent, formState: await payload.formState, }, diff --git a/integration/helpers/rsc-vite/src/entry.ssr.tsx b/integration/helpers/rsc-vite/src/entry.ssr.tsx index 5a2cf52529..d4a00d2bb2 100644 --- a/integration/helpers/rsc-vite/src/entry.ssr.tsx +++ b/integration/helpers/rsc-vite/src/entry.ssr.tsx @@ -16,12 +16,13 @@ export default async function handler( request, serverResponse, createFromReadableStream, - async renderHTML(getPayload) { + async renderHTML(getPayload, options) { const payload = getPayload(); return ReactDomServer.renderToReadableStream( , { + ...options, bootstrapScriptContent, signal: request.signal, formState: await payload.formState, diff --git a/integration/rsc/rsc-nojs-test.ts b/integration/rsc/rsc-nojs-test.ts index 0638a27e59..5bb15307b9 100644 --- a/integration/rsc/rsc-nojs-test.ts +++ b/integration/rsc/rsc-nojs-test.ts @@ -3,8 +3,6 @@ import getPort from "get-port"; import { implementations, js, setupRscTest, validateRSCHtml } from "./utils"; -test.use({ javaScriptEnabled: false }); - implementations.forEach((implementation) => { test.describe(`RSC nojs (${implementation.name})`, () => { let port: number; @@ -20,6 +18,34 @@ implementations.forEach((implementation) => { implementation, port, files: { + "src/routes.ts": js` + import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + + export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + { + id: "render-redirect-lazy", + path: "/render-redirect/lazy/:id?", + lazy: () => import("./routes/render-redirect/lazy"), + }, + { + id: "render-redirect", + path: "/render-redirect/:id?", + lazy: () => import("./routes/render-redirect/home"), + }, + ], + }, + ] satisfies RSCRouteConfig; + `, "src/routes/home.actions.ts": js` "use server"; import { redirect } from "react-router"; @@ -76,6 +102,60 @@ implementations.forEach((implementation) => { ); } `, + + "src/routes/render-redirect/home.tsx": js` + import { Link, redirect } from "react-router"; + + export default function RenderRedirect({ params: { id } }) { + if (id === "redirect") { + throw redirect("/render-redirect/redirected"); + } + + if (id === "external") { + throw redirect("https://example.com/"); + } + + return ( + <> +

{id || "home"}

+ Redirect + External + + ) + } + `, + "src/routes/render-redirect/lazy.tsx": js` + import { Suspense } from "react"; + import { Link, redirect } from "react-router"; + + export default function RenderRedirect({ params: { id } }) { + return ( + Loading...

}> + +
+ ); + } + + async function Lazy({ id }) { + await new Promise((r) => setTimeout(r, 0)); + + if (id === "redirect") { + throw redirect("/render-redirect/lazy/redirected"); + } + + if (id === "external") { + throw redirect("https://example.com/"); + } + + return ( + <> +

{id || "home"}

+ Redirect + External + + ); + } + `, }, }); }); @@ -129,5 +209,50 @@ implementations.forEach((implementation) => { // Ensure this is using RSC validateRSCHtml(await page.content()); }); + + test("Suppport throwing redirect Response from render", async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/render-redirect`); + await expect(page.getByText("home")).toBeAttached(); + await page.getByText("Redirect").click(); + await page.waitForURL( + `http://localhost:${port}/render-redirect/redirected`, + ); + await expect(page.getByText("redirected")).toBeAttached(); + }); + + test("Suppport throwing external redirect Response from render", async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/render-redirect`); + await expect(page.getByText("home")).toBeAttached(); + await page.getByText("External").click(); + await page.waitForURL(`https://example.com/`); + await expect(page.getByText("Example Domain")).toBeAttached(); + }); + + test("Suppport throwing redirect Response from suspended render", async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/render-redirect/lazy/redirect`); + await page.waitForURL( + `http://localhost:${port}/render-redirect/lazy/redirected`, + ); + await expect(page.getByText("redirected")).toBeAttached(); + }); + + test("Suppport throwing external redirect Response from suspended render", async ({ + page, + browserName, + }) => { + test.skip( + browserName === "firefox", + "Playwright doesn't like external meta redirects for tests. It times out waiting for the URL even though it navigates.", + ); + await page.goto(`http://localhost:${port}/render-redirect/lazy/external`); + await page.waitForURL(`https://example.com/`); + await expect(page.getByText("Example Domain")).toBeAttached(); + }); }); }); diff --git a/integration/rsc/rsc-test.ts b/integration/rsc/rsc-test.ts index 9ddff47d05..cfdb45474e 100644 --- a/integration/rsc/rsc-test.ts +++ b/integration/rsc/rsc-test.ts @@ -534,7 +534,17 @@ implementations.forEach((implementation) => { id: "action-transition-state", path: "action-transition-state", lazy: () => import("./routes/action-transition-state/home"), - } + }, + { + id: "render-redirect-lazy", + path: "/render-redirect/lazy/:id?", + lazy: () => import("./routes/render-redirect/lazy"), + }, + { + id: "render-redirect", + path: "/render-redirect/:id?", + lazy: () => import("./routes/render-redirect/home"), + }, ], }, ] satisfies RSCRouteConfig; @@ -1460,6 +1470,60 @@ implementations.forEach((implementation) => { ); } `, + + "src/routes/render-redirect/home.tsx": js` + import { Link, redirect } from "react-router"; + + export default function RenderRedirect({ params: { id } }) { + if (id === "redirect") { + throw redirect("/render-redirect/redirected"); + } + + if (id === "external") { + throw redirect("https://example.com/") + } + + return ( + <> +

{id || "home"}

+ Redirect + External + + ) + } + `, + "src/routes/render-redirect/lazy.tsx": js` + import { Suspense } from "react"; + import { Link, redirect } from "react-router"; + + export default function RenderRedirect({ params: { id } }) { + return ( + Loading...

}> + +
+ ); + } + + async function Lazy({ id }) { + await new Promise((r) => setTimeout(r, 0)); + + if (id === "redirect") { + throw redirect("/render-redirect/lazy/redirected"); + } + + if (id === "external") { + throw redirect("https://example.com/") + } + + return ( + <> +

{id || "home"}

+ Redirect + External + + ); + } + `, }, }); }); @@ -1738,6 +1802,50 @@ implementations.forEach((implementation) => { "An error occurred in the Server Components render.", ); }); + + test("Suppport throwing redirect Response from render", async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/render-redirect`); + await expect(page.getByText("home")).toBeAttached(); + await page.getByText("Redirect").click(); + await page.waitForURL( + `http://localhost:${port}/render-redirect/redirected`, + ); + await expect(page.getByText("redirected")).toBeAttached(); + }); + + test("Suppport throwing external redirect Response from render", async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/render-redirect`); + await expect(page.getByText("home")).toBeAttached(); + await page.getByText("External").click(); + await page.waitForURL(`https://example.com/`); + await expect(page.getByText("Example Domain")).toBeAttached(); + }); + + test("Suppport throwing redirect Response from suspended render", async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/render-redirect/lazy`); + await expect(page.getByText("home")).toBeAttached(); + await page.getByText("Redirect").click(); + await page.waitForURL( + `http://localhost:${port}/render-redirect/lazy/redirected`, + ); + await expect(page.getByText("redirected")).toBeAttached(); + }); + + test("Suppport throwing external redirect Response from suspended render", async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/render-redirect/lazy`); + await expect(page.getByText("home")).toBeAttached(); + await page.getByText("External").click(); + await page.waitForURL(`https://example.com/`); + await expect(page.getByText("Example Domain")).toBeAttached(); + }); }); test.describe("Server Actions", () => { diff --git a/packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx b/packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx index 25cc769323..838a9bad23 100644 --- a/packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx +++ b/packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx @@ -17,12 +17,13 @@ export default async function handler( request, serverResponse, createFromReadableStream, - async renderHTML(getPayload) { + async renderHTML(getPayload, options) { const payload = getPayload(); return ReactDomServer.renderToReadableStream( , { + ...options, bootstrapScriptContent, signal: request.signal, formState: await payload.formState, diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 8ed10893e8..e66723773f 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -56,6 +56,7 @@ import { NavigationContext, RouteContext, ViewTransitionContext, + useIsRSCRouterContext, } from "./context"; import { _renderMatches, @@ -464,6 +465,9 @@ export function RouterProvider({ unstable_onError, unstable_useTransitions, }: RouterProviderProps): React.ReactElement { + let unstable_rsc = useIsRSCRouterContext(); + unstable_useTransitions = unstable_rsc || unstable_useTransitions; + let [_state, setStateImpl] = React.useState(router.state); let [state, setOptimisticState] = useOptimisticSafe(_state); let [pendingState, setPendingState] = React.useState(); diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 6d192efaf7..40c78fe785 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -36,6 +36,7 @@ import { ErrorResponseImpl, joinPaths, matchPath, + parseToInfo, stripBasename, } from "../router/utils"; @@ -1255,39 +1256,8 @@ export const Link = React.forwardRef( React.useContext(NavigationContext); let isAbsolute = typeof to === "string" && ABSOLUTE_URL_REGEX.test(to); - // Rendered into for absolute URLs - let absoluteHref; - let isExternal = false; - - if (typeof to === "string" && isAbsolute) { - // Render the absolute href server- and client-side - absoluteHref = to; - - // Only check for external origins client-side - if (isBrowser) { - try { - let currentUrl = new URL(window.location.href); - let targetUrl = to.startsWith("//") - ? new URL(currentUrl.protocol + to) - : new URL(to); - let path = stripBasename(targetUrl.pathname, basename); - - if (targetUrl.origin === currentUrl.origin && path != null) { - // Strip the protocol/origin/basename for same-origin absolute URLs - to = path + targetUrl.search + targetUrl.hash; - } else { - isExternal = true; - } - } catch (e) { - // We can't do external URL detection without a valid URL - warning( - false, - ` contains an invalid URL which will probably break ` + - `when clicked - please update to a valid URL path.`, - ); - } - } - } + let parsed = parseToInfo(to, basename); + to = parsed.to; // Rendered into for relative URLs let href = useHref(to, { relative }); @@ -1319,8 +1289,8 @@ export const Link = React.forwardRef( - + + + ) : ( + this.props.children + ); + + if (this.context) { + return ( + {result} + ); + } + + return result; + } +} + +const errorRedirectHandledMap = new WeakMap(); +function RSCErrorHandler({ + children, + error, +}: { + children: React.ReactNode; + error: unknown; +}) { + let { basename } = React.useContext(NavigationContext); + let navigate = useNavigate(); + + if ( + typeof error === "object" && + error && + "digest" in error && + typeof error.digest === "string" + ) { + let redirect = decodeRedirectErrorDigest(error.digest); + if (redirect) { + let parsed = parseToInfo(redirect.location, basename); + + if (isBrowser && !errorRedirectHandledMap.get(error)) { + errorRedirectHandledMap.set(error, true); + + if (parsed.isExternal || redirect.reloadDocument) { + window.location.href = parsed.absoluteURL || parsed.to; + } else { + // @ts-expect-error - Needs React 19 types + React.startTransition(() => { + return navigate(parsed.to, { + replace: redirect.replace, + }); + }); + } + } + return ( + - - ) : ( - this.props.children - ); + ); + } } + return children; } interface RenderedRouteProps { diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index 0ec29fdc74..74046d529c 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -2068,3 +2068,65 @@ export function getRoutePattern(matches: AgnosticRouteMatch[]) { .replace(/\/\/*/g, "/") || "/" ); } + +export const isBrowser = + typeof window !== "undefined" && + typeof window.document !== "undefined" && + typeof window.document.createElement !== "undefined"; + +export type ParsedLocationInfo = + | { + absoluteURL: string; + isExternal: boolean; + to: string; + } + | { + absoluteURL: undefined; + isExternal: false; + to: T; + }; +export function parseToInfo( + _to: T, + basename: string, +): ParsedLocationInfo { + let to = _to as string; + if (typeof to !== "string" || !ABSOLUTE_URL_REGEX.test(to)) { + return { + absoluteURL: undefined, + isExternal: false, + to, + }; + } + + let absoluteURL = to; + let isExternal = false; + if (isBrowser) { + try { + let currentUrl = new URL(window.location.href); + let targetUrl = to.startsWith("//") + ? new URL(currentUrl.protocol + to) + : new URL(to); + let path = stripBasename(targetUrl.pathname, basename); + + if (targetUrl.origin === currentUrl.origin && path != null) { + // Strip the protocol/origin/basename for same-origin absolute URLs + to = path + targetUrl.search + targetUrl.hash; + } else { + isExternal = true; + } + } catch (e) { + // We can't do external URL detection without a valid URL + warning( + false, + ` contains an invalid URL which will probably break ` + + `when clicked - please update to a valid URL path.`, + ); + } + } + + return { + absoluteURL, + isExternal, + to, + }; +} diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx index 0fbc3448b6..462ef03097 100644 --- a/packages/react-router/lib/rsc/browser.tsx +++ b/packages/react-router/lib/rsc/browser.tsx @@ -844,7 +844,6 @@ export function RSCHydratedRouter({ @@ -1061,7 +1060,7 @@ async function fetchAndApplyManifestPatches( function addToFifoQueue(path: string, queue: Set) { if (queue.size >= discoveredPathsMaxSize) { let first = queue.values().next().value; - queue.delete(first); + if (typeof first === "string") queue.delete(first); } queue.add(path); } diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index 04bea99916..405c0c89b4 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -60,6 +60,9 @@ import type { ErrorBoundaryProps, HydrateFallbackProps, } from "../components"; + +import { createRedirectErrorDigest } from "../errors"; + const Outlet: typeof OutletType = UNTYPED_Outlet; const WithComponentProps: typeof WithComponentPropsType = UNSAFE_WithComponentProps; @@ -278,7 +281,7 @@ export type DecodeFormStateFunction = ( export type DecodeReplyFunction = ( reply: FormData | string, - { temporaryReferences }: { temporaryReferences: unknown }, + options: { temporaryReferences: unknown }, ) => Promise; export type LoadServerActionFunction = (id: string) => Promise; @@ -379,8 +382,10 @@ export async function matchRSCServerRequest({ generateResponse: ( match: RSCMatch, { + onError, temporaryReferences, }: { + onError(error: unknown): string | undefined; temporaryReferences: unknown; }, ) => Response; @@ -467,7 +472,10 @@ async function generateManifestResponse( request: Request, generateResponse: ( match: RSCMatch, - { temporaryReferences }: { temporaryReferences: unknown }, + options: { + onError(error: unknown): string | undefined; + temporaryReferences: unknown; + }, ) => Response, temporaryReferences: unknown, ) { @@ -518,7 +526,7 @@ async function generateManifestResponse( }), payload, }, - { temporaryReferences }, + { temporaryReferences, onError: defaultOnError }, ); } @@ -722,7 +730,10 @@ async function generateRenderResponse( onError: ((error: unknown) => void) | undefined, generateResponse: ( match: RSCMatch, - { temporaryReferences }: { temporaryReferences: unknown }, + options: { + onError(error: unknown): string | undefined; + temporaryReferences: unknown; + }, ) => Response, temporaryReferences: unknown, ): Promise { @@ -876,7 +887,10 @@ function generateRedirectResponse( isDataRequest: boolean, generateResponse: ( match: RSCMatch, - { temporaryReferences }: { temporaryReferences: unknown }, + options: { + onError(error: unknown): string | undefined; + temporaryReferences: unknown; + }, ) => Response, temporaryReferences: unknown, sideEffectRedirectHeaders: Headers | undefined, @@ -919,7 +933,7 @@ function generateRedirectResponse( headers, payload, }, - { temporaryReferences }, + { temporaryReferences, onError: defaultOnError }, ); } @@ -928,7 +942,10 @@ async function generateStaticContextResponse( basename: string | undefined, generateResponse: ( match: RSCMatch, - { temporaryReferences }: { temporaryReferences: unknown }, + options: { + onError(error: unknown): string | undefined; + temporaryReferences: unknown; + }, ) => Response, statusCode: number, routeIdsToLoad: string[] | null, @@ -1034,7 +1051,7 @@ async function generateStaticContextResponse( headers, payload, }, - { temporaryReferences }, + { temporaryReferences, onError: defaultOnError }, ); } @@ -1335,6 +1352,12 @@ export function isManifestRequest(url: URL) { return url.pathname.endsWith(".manifest"); } +function defaultOnError(error: unknown) { + if (isRedirectResponse(error)) { + return createRedirectErrorDigest(error); + } +} + function isClientReference(x: any) { try { return x.$$typeof === Symbol.for("react.client.reference"); diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx index d86de8cdda..230dfe59c8 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -10,6 +10,8 @@ import { shouldHydrateRouteLoader } from "../dom/ssr/routes"; import type { RSCPayload } from "./server.rsc"; import { createRSCRouteModules } from "./route-modules"; import { isRouteErrorResponse } from "../router/utils"; +import { decodeRedirectErrorDigest } from "../errors"; +import { escapeHtml } from "../dom/ssr/markup"; type DecodedPayload = Promise & { _deepestRenderedBoundaryId?: string | null; @@ -93,6 +95,10 @@ export async function routeRSCServerRequest({ createFromReadableStream: SSRCreateFromReadableStreamFunction; renderHTML: ( getPayload: () => DecodedPayload, + options: { + onError(error: unknown): string | undefined; + onHeaders(headers: Headers): void; + }, ) => ReadableStream | Promise>; hydrate?: boolean; }): Promise { @@ -177,6 +183,7 @@ export async function routeRSCServerRequest({ }) as DecodedPayload; }; + let renderRedirect: { status: number; location: string } | undefined; try { if (!detectRedirectResponse.body) { throw new Error("Failed to clone server response"); @@ -202,13 +209,56 @@ export async function routeRSCServerRequest({ }); } - const html = await renderHTML(getPayload); + let reactHeaders = new Headers(); + let html = await renderHTML(getPayload, { + onError(error: unknown) { + if ( + typeof error === "object" && + error && + "digest" in error && + typeof error.digest === "string" + ) { + renderRedirect = decodeRedirectErrorDigest(error.digest); + if (renderRedirect) { + return error.digest; + } + } + }, + onHeaders(headers) { + for (const [key, value] of headers) { + reactHeaders.append(key, value); + } + }, + }); - const headers = new Headers(serverResponse.headers); + const headers = new Headers(reactHeaders); + for (const [key, value] of serverResponse.headers) { + headers.append(key, value); + } headers.set("Content-Type", "text/html; charset=utf-8"); - if (!hydrate) { + if (renderRedirect) { + headers.set("Location", renderRedirect.location); return new Response(html, { + status: renderRedirect.status, + headers, + }); + } + + const redirectTransform = new TransformStream({ + flush(controller) { + if (renderRedirect) { + controller.enqueue( + new TextEncoder().encode( + ``, + ), + ); + } + }, + }); + + if (!hydrate) { + return new Response(html.pipeThrough(redirectTransform), { status: serverResponse.status, headers, }); @@ -218,7 +268,9 @@ export async function routeRSCServerRequest({ throw new Error("Failed to clone server response"); } - const body = html.pipeThrough(injectRSCPayload(serverResponseB.body)); + const body = html + .pipeThrough(injectRSCPayload(serverResponseB.body)) + .pipeThrough(redirectTransform); return new Response(body, { status: serverResponse.status, headers, @@ -228,49 +280,105 @@ export async function routeRSCServerRequest({ return reason; } + if (renderRedirect) { + return new Response(`Redirect: ${renderRedirect.location}`, { + status: renderRedirect.status, + headers: { + Location: renderRedirect.location, + }, + }); + } + try { const status = isRouteErrorResponse(reason) ? reason.status : 500; - const html = await renderHTML(() => { - const decoded = Promise.resolve( - createFromReadableStream(createStream()), - ) as Promise; - - const payloadPromise = decoded.then((payload) => - Object.assign(payload, { - status, - errors: deepestRenderedBoundaryId - ? { - [deepestRenderedBoundaryId]: reason, - } - : {}, - }), - ); - - return Object.defineProperties(payloadPromise, { - _deepestRenderedBoundaryId: { - get() { - return deepestRenderedBoundaryId; + let retryRedirect: { status: number; location: string } | undefined; + let reactHeaders = new Headers(); + const html = await renderHTML( + () => { + const decoded = Promise.resolve( + createFromReadableStream(createStream()), + ) as Promise; + + const payloadPromise = decoded.then((payload) => + Object.assign(payload, { + status, + errors: deepestRenderedBoundaryId + ? { + [deepestRenderedBoundaryId]: reason, + } + : {}, + }), + ); + + return Object.defineProperties(payloadPromise, { + _deepestRenderedBoundaryId: { + get() { + return deepestRenderedBoundaryId; + }, + set(boundaryId: string | null) { + deepestRenderedBoundaryId = boundaryId; + }, }, - set(boundaryId: string | null) { - deepestRenderedBoundaryId = boundaryId; + formState: { + get() { + return payloadPromise.then((payload) => + payload.type === "render" ? payload.formState : undefined, + ); + }, }, + }) as unknown as DecodedPayload; + }, + { + onError(error: unknown) { + if ( + typeof error === "object" && + error && + "digest" in error && + typeof error.digest === "string" + ) { + retryRedirect = decodeRedirectErrorDigest(error.digest); + if (retryRedirect) { + return error.digest; + } + } }, - formState: { - get() { - return payloadPromise.then((payload) => - payload.type === "render" ? payload.formState : undefined, - ); - }, + onHeaders(headers) { + for (const [key, value] of headers) { + reactHeaders.append(key, value); + } }, - }) as unknown as DecodedPayload; - }); + }, + ); - const headers = new Headers(serverResponse.headers); - headers.set("Content-Type", "text/html"); + const headers = new Headers(reactHeaders); + for (const [key, value] of serverResponse.headers) { + headers.append(key, value); + } + headers.set("Content-Type", "text/html; charset=utf-8"); - if (!hydrate) { + if (retryRedirect) { + headers.set("Location", retryRedirect.location); return new Response(html, { + status: retryRedirect.status, + headers, + }); + } + + const retryRedirectTransform = new TransformStream({ + flush(controller) { + if (retryRedirect) { + controller.enqueue( + new TextEncoder().encode( + ``, + ), + ); + } + }, + }); + + if (!hydrate) { + return new Response(html.pipeThrough(retryRedirectTransform), { status: status, headers, }); @@ -280,7 +388,9 @@ export async function routeRSCServerRequest({ throw new Error("Failed to clone server response"); } - const body = html.pipeThrough(injectRSCPayload(serverResponseB.body)); + const body = html + .pipeThrough(injectRSCPayload(serverResponseB.body)) + .pipeThrough(retryRedirectTransform); return new Response(body, { status, headers, diff --git a/playground/rsc-parcel/src/entry.ssr.tsx b/playground/rsc-parcel/src/entry.ssr.tsx index 29e8503537..fc048ff64b 100644 --- a/playground/rsc-parcel/src/entry.ssr.tsx +++ b/playground/rsc-parcel/src/entry.ssr.tsx @@ -21,12 +21,13 @@ app.use( request, serverResponse: await fetchServer(request), createFromReadableStream, - async renderHTML(getPayload) { + async renderHTML(getPayload, options) { const payload = getPayload(); return await renderHTMLToReadableStream( , { + ...options, bootstrapScriptContent: ( fetchServer as unknown as { bootstrapScript: string } ).bootstrapScript, diff --git a/playground/rsc-vite/src/entry.ssr.tsx b/playground/rsc-vite/src/entry.ssr.tsx index 5a2cf52529..d4a00d2bb2 100644 --- a/playground/rsc-vite/src/entry.ssr.tsx +++ b/playground/rsc-vite/src/entry.ssr.tsx @@ -16,12 +16,13 @@ export default async function handler( request, serverResponse, createFromReadableStream, - async renderHTML(getPayload) { + async renderHTML(getPayload, options) { const payload = getPayload(); return ReactDomServer.renderToReadableStream( , { + ...options, bootstrapScriptContent, signal: request.signal, formState: await payload.formState,