From 6d841eced7998048c148bf707f8f40e2b2fc6d10 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Mon, 24 Nov 2025 18:34:49 -0800 Subject: [PATCH 1/7] feat(rsc): add support for throwing redirect Response's at RSC render time --- .changeset/early-doors-obey.md | 6 + .../helpers/rsc-parcel/src/prerender.tsx | 3 +- .../helpers/rsc-vite/src/entry.ssr.tsx | 3 +- integration/rsc/rsc-test.ts | 32 +++- .../config/default-rsc-entries/entry.ssr.tsx | 3 +- packages/react-router/lib/components.tsx | 22 ++- .../lib/dom-export/hydrated-router.tsx | 12 +- packages/react-router/lib/dom/server.tsx | 11 +- packages/react-router/lib/errors.ts | 29 ++++ packages/react-router/lib/hooks.tsx | 65 ++++++-- packages/react-router/lib/rsc/browser.tsx | 4 +- packages/react-router/lib/rsc/server.rsc.ts | 39 ++++- packages/react-router/lib/rsc/server.ssr.tsx | 149 ++++++++++++++---- playground/rsc-parcel/src/entry.ssr.tsx | 3 +- playground/rsc-vite/src/entry.ssr.tsx | 3 +- 15 files changed, 321 insertions(+), 63 deletions(-) create mode 100644 .changeset/early-doors-obey.md create mode 100644 packages/react-router/lib/errors.ts diff --git a/.changeset/early-doors-obey.md b/.changeset/early-doors-obey.md new file mode 100644 index 0000000000..e9341261b0 --- /dev/null +++ b/.changeset/early-doors-obey.md @@ -0,0 +1,6 @@ +--- +"@react-router/dev": minor +"react-router": minor +--- + +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 3d962b3fec..89fc9841ae 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 6f47c70f85..4cf98b9723 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, fetchServer, 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-test.ts b/integration/rsc/rsc-test.ts index 9ddff47d05..3c16ed96e8 100644 --- a/integration/rsc/rsc-test.ts +++ b/integration/rsc/rsc-test.ts @@ -534,7 +534,12 @@ implementations.forEach((implementation) => { id: "action-transition-state", path: "action-transition-state", lazy: () => import("./routes/action-transition-state/home"), - } + }, + { + id: "render-redirect", + path: "/render-redirect/:id?", + lazy: () => import("./routes/render-redirect/home"), + }, ], }, ] satisfies RSCRouteConfig; @@ -1460,6 +1465,23 @@ 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"); + } + + return ( + <> +

{id || "home"}

+ Redirect + + ) + } + `, }, }); }); @@ -1738,6 +1760,14 @@ implementations.forEach((implementation) => { "An error occurred in the Server Components render.", ); }); + + test.only("Suppport throwing redirect Response from render", async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/render-redirect`); + await page.click("a"); + await expect(page.getByText("redirected")).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 5b15a77f71..7cdf8b9559 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, fetchServer, 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 fa2d6b03e5..c561bec394 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -400,6 +400,12 @@ export interface RouterProviderProps { * For more information, please see the [docs](https://reactrouter.com/explanation/react-transitions). */ unstable_useTransitions?: boolean; + + /** + * Control whether rsc specific behaviors are enabled. This includes + * `unstable_useTransitions` and redirects thrown at render time. + */ + unstable_rsc?: boolean; } /** @@ -432,6 +438,7 @@ export interface RouterProviderProps { * @param {RouterProviderProps.unstable_onError} props.unstable_onError n/a * @param {RouterProviderProps.router} props.router n/a * @param {RouterProviderProps.unstable_useTransitions} props.unstable_useTransitions n/a + * @param {RouterProviderProps.unstable_rsc} props.unstable_rsc n/a * @returns React element for the rendered router */ export function RouterProvider({ @@ -439,7 +446,10 @@ export function RouterProvider({ flushSync: reactDomFlushSyncImpl, unstable_onError, unstable_useTransitions, + unstable_rsc, }: RouterProviderProps): React.ReactElement { + unstable_useTransitions = unstable_useTransitions || unstable_rsc; + let [_state, setStateImpl] = React.useState(router.state); let [state, setOptimisticState] = useOptimisticSafe(_state); let [pendingState, setPendingState] = React.useState(); @@ -718,6 +728,7 @@ export function RouterProvider({ future={router.future} state={state} unstable_onError={unstable_onError} + unstable_rsc={unstable_rsc} /> @@ -764,13 +775,22 @@ function DataRoutes({ future, state, unstable_onError, + unstable_rsc, }: { routes: DataRouteObject[]; future: DataRouter["future"]; state: RouterState; unstable_onError: unstable_ClientOnErrorFunction | undefined; + unstable_rsc: boolean | undefined; }): React.ReactElement | null { - return useRoutesImpl(routes, undefined, state, unstable_onError, future); + return useRoutesImpl( + routes, + undefined, + state, + unstable_onError, + unstable_rsc, + future, + ); } /** diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 41437a02f3..f958737780 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -80,9 +80,11 @@ function initSsrInfo(): void { function createHydratedRouter({ getContext, unstable_instrumentations, + unstable_rsc, }: { getContext?: RouterInit["getContext"]; unstable_instrumentations?: unstable_ClientInstrumentation[]; + unstable_rsc?: boolean; }): DataRouter { initSsrInfo(); @@ -178,7 +180,7 @@ function createHydratedRouter({ unstable_instrumentations, mapRouteProperties, future: { - middleware: ssrInfo.context.future.v8_middleware, + unstable_rsc, }, dataStrategy: getTurboStreamSingleFetchDataStrategy( () => router, @@ -317,6 +319,13 @@ export interface HydratedRouterProps { * For more information, please see the [docs](https://reactrouter.com/explanation/react-transitions). */ unstable_useTransitions?: boolean; + + /** + * Control whether RSC specific behaviors are introduced. This currently + * enables the unstable_useTransitions flag, as well as the ability to handle + * thrown redirect responses during the render phase. + */ + unstable_rsc?: boolean; } /** @@ -336,6 +345,7 @@ export function HydratedRouter(props: HydratedRouterProps) { router = createHydratedRouter({ getContext: props.getContext, unstable_instrumentations: props.unstable_instrumentations, + unstable_rsc: props.unstable_rsc, }); } diff --git a/packages/react-router/lib/dom/server.tsx b/packages/react-router/lib/dom/server.tsx index 7653755af3..6482c962eb 100644 --- a/packages/react-router/lib/dom/server.tsx +++ b/packages/react-router/lib/dom/server.tsx @@ -232,12 +232,21 @@ function DataRoutes({ routes, future, state, + unstable_rsc, }: { routes: DataRouteObject[]; future: DataRouter["future"]; state: RouterState; + unstable_rsc?: boolean; }): React.ReactElement | null { - return useRoutesImpl(routes, undefined, state, undefined, future); + return useRoutesImpl( + routes, + undefined, + state, + undefined, + unstable_rsc, + future, + ); } function serializeErrors( diff --git a/packages/react-router/lib/errors.ts b/packages/react-router/lib/errors.ts new file mode 100644 index 0000000000..916df73c4e --- /dev/null +++ b/packages/react-router/lib/errors.ts @@ -0,0 +1,29 @@ +const ERROR_DIGEST_BASE = "REACT_ROUTER_ERROR"; +const ERROR_DIGEST_REDIRECT = "REDIRECT"; + +export function createRedirectErrorDigest(response: Response) { + return `${ERROR_DIGEST_BASE}:${ERROR_DIGEST_REDIRECT}:${JSON.stringify({ + status: response.status, + location: response.headers.get("Location"), + })}`; +} + +export function decodeRedirectErrorDigest( + digest: string, +): undefined | { status: number; location: string } { + if (digest.startsWith(`${ERROR_DIGEST_BASE}:${ERROR_DIGEST_REDIRECT}:{`)) { + try { + let parsed = JSON.parse(digest.slice(28)); + if ( + typeof parsed === "object" && + parsed && + "status" in parsed && + typeof parsed.status === "number" && + "location" in parsed && + typeof parsed.location === "string" + ) { + return parsed; + } + } catch {} + } +} diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 4f6ffb16cb..c3d336c396 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -58,6 +58,7 @@ import type { } from "./types/route-data"; import type { unstable_ClientOnErrorFunction } from "./components"; import type { RouteModules } from "./types/register"; +import { decodeRedirectErrorDigest } from "./errors"; /** * Resolves a URL against the current {@link Location}. @@ -758,6 +759,7 @@ export function useRoutesImpl( locationArg?: Partial | string, dataRouterState?: DataRouter["state"], unstable_onError?: unstable_ClientOnErrorFunction, + unstable_rsc?: boolean, future?: DataRouter["future"], ): React.ReactElement | null { invariant( @@ -912,6 +914,7 @@ export function useRoutesImpl( parentMatches, dataRouterState, unstable_onError, + unstable_rsc, future, ); @@ -991,6 +994,7 @@ type RenderErrorBoundaryProps = React.PropsWithChildren<{ component: React.ReactNode; routeContext: RouteContextObject; onError?: (error: unknown, errorInfo?: React.ErrorInfo) => void; + unstable_rsc?: boolean; }>; type RenderErrorBoundaryState = { @@ -1062,17 +1066,56 @@ export class RenderErrorBoundary extends React.Component< } render() { - return this.state.error !== undefined ? ( - - - - ) : ( - this.props.children - ); + let result = + this.state.error !== undefined ? ( + + + + ) : ( + this.props.children + ); + + if (this.props.unstable_rsc) { + return ( + {result} + ); + } + + return result; + } +} + +const errorRedirectPromises = new WeakMap>(); +function RSCErrorHandler({ + children, + error, +}: { + children: React.ReactNode; + error: unknown; +}) { + if ( + typeof error === "object" && + error && + "digest" in error && + typeof error.digest === "string" + ) { + let redirect = decodeRedirectErrorDigest(error.digest); + if (redirect) { + let promise = errorRedirectPromises.get(error); + if (!promise) { + // TODO: Handle external redirects? + promise = window.__reactRouterDataRouter!.navigate(redirect.location, { + replace: true, + }); + errorRedirectPromises.set(error, promise); + } + throw promise; + } } + return children; } interface RenderedRouteProps { @@ -1107,6 +1150,7 @@ export function _renderMatches( parentMatches: RouteMatch[] = [], dataRouterState: DataRouter["state"] | null = null, unstable_onError: unstable_ClientOnErrorFunction | null = null, + unstable_rsc: boolean | undefined = undefined, future: DataRouter["future"] | null = null, ): React.ReactElement | null { if (matches == null) { @@ -1275,6 +1319,7 @@ export function _renderMatches( error={error} children={getChildren()} routeContext={{ outlet: null, matches, isDataRoute: true }} + unstable_rsc={unstable_rsc} onError={onError} /> ) : ( diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx index 0fbc3448b6..e22681bfbc 100644 --- a/packages/react-router/lib/rsc/browser.tsx +++ b/packages/react-router/lib/rsc/browser.tsx @@ -844,7 +844,7 @@ export function RSCHydratedRouter({ @@ -1061,7 +1061,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 7662d29947..711b5c92c2 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -10,6 +10,7 @@ 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"; type DecodedPayload = Promise & { _deepestRenderedBoundaryId?: string | null; @@ -95,6 +96,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 { @@ -181,6 +186,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"); @@ -206,11 +212,42 @@ 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 (renderRedirect) { + headers.set("Location", renderRedirect.location); + return new Response(html, { + status: renderRedirect.status, + headers, + }); + } + if (!hydrate) { return new Response(html, { status: serverResponse.status, @@ -232,46 +269,90 @@ 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 (retryRedirect) { + headers.set("Location", retryRedirect.location); + return new Response(html, { + status: retryRedirect.status, + headers, + }); + } if (!hydrate) { return new Response(html, { diff --git a/playground/rsc-parcel/src/entry.ssr.tsx b/playground/rsc-parcel/src/entry.ssr.tsx index 1f29d629d3..fc2f878d09 100644 --- a/playground/rsc-parcel/src/entry.ssr.tsx +++ b/playground/rsc-parcel/src/entry.ssr.tsx @@ -21,12 +21,13 @@ app.use( request, fetchServer, 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 6f47c70f85..4cf98b9723 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, fetchServer, createFromReadableStream, - async renderHTML(getPayload) { + async renderHTML(getPayload, options) { const payload = getPayload(); return ReactDomServer.renderToReadableStream( , { + ...options, bootstrapScriptContent, signal: request.signal, formState: await payload.formState, From 89d22dfbbacd7ab61e39162845df993241d211c6 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Mon, 24 Nov 2025 18:46:40 -0800 Subject: [PATCH 2/7] remove .only --- integration/rsc/rsc-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/rsc/rsc-test.ts b/integration/rsc/rsc-test.ts index 3c16ed96e8..08cf42a55c 100644 --- a/integration/rsc/rsc-test.ts +++ b/integration/rsc/rsc-test.ts @@ -1761,7 +1761,7 @@ implementations.forEach((implementation) => { ); }); - test.only("Suppport throwing redirect Response from render", async ({ + test("Suppport throwing redirect Response from render", async ({ page, }) => { await page.goto(`http://localhost:${port}/render-redirect`); From 2df3cfb26e1c834e2fcddf8ad0ae7cd614706ab5 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Mon, 24 Nov 2025 20:59:26 -0800 Subject: [PATCH 3/7] handle no js cases and suspended thrown redirects --- integration/rsc/rsc-nojs-test.ts | 96 +++++++++++++++++++- integration/rsc/rsc-test.ts | 48 ++++++++++ packages/react-router/lib/hooks.tsx | 23 +++-- packages/react-router/lib/rsc/server.ssr.tsx | 37 +++++++- 4 files changed, 190 insertions(+), 14 deletions(-) diff --git a/integration/rsc/rsc-nojs-test.ts b/integration/rsc/rsc-nojs-test.ts index 0638a27e59..014f7f06d4 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,50 @@ 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"); + } + + return ( + <> +

{id || "home"}

+ Redirect + + ) + } + `, + "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"); + } + + return ( + <> +

{id || "home"}

+ Redirect + + ); + } + `, }, }); }); @@ -129,5 +199,27 @@ 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.click("a"); + await page.waitForURL( + `http://localhost:${port}/render-redirect/redirected`, + ); + await expect(page.getByText("redirected")).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(); + }); }); }); diff --git a/integration/rsc/rsc-test.ts b/integration/rsc/rsc-test.ts index 08cf42a55c..d5bec5d22e 100644 --- a/integration/rsc/rsc-test.ts +++ b/integration/rsc/rsc-test.ts @@ -535,6 +535,11 @@ implementations.forEach((implementation) => { 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?", @@ -1482,6 +1487,33 @@ implementations.forEach((implementation) => { ) } `, + "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"); + } + + return ( + <> +

{id || "home"}

+ Redirect + + ); + } + `, }, }); }); @@ -1765,7 +1797,23 @@ implementations.forEach((implementation) => { page, }) => { await page.goto(`http://localhost:${port}/render-redirect`); + await expect(page.getByText("home")).toBeAttached(); await page.click("a"); + await page.waitForURL( + `http://localhost:${port}/render-redirect/redirected`, + ); + await expect(page.getByText("redirected")).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.click("a"); + await page.waitForURL( + `http://localhost:${port}/render-redirect/lazy/redirected`, + ); await expect(page.getByText("redirected")).toBeAttached(); }); }); diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index c3d336c396..95687f030c 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -1088,7 +1088,7 @@ export class RenderErrorBoundary extends React.Component< } } -const errorRedirectPromises = new WeakMap>(); +const errorRedirectHandledMap = new WeakMap(); function RSCErrorHandler({ children, error, @@ -1104,15 +1104,22 @@ function RSCErrorHandler({ ) { let redirect = decodeRedirectErrorDigest(error.digest); if (redirect) { - let promise = errorRedirectPromises.get(error); - if (!promise) { + if ( + typeof window !== "undefined" && + window.__reactRouterDataRouter && + !errorRedirectHandledMap.get(error) + ) { // TODO: Handle external redirects? - promise = window.__reactRouterDataRouter!.navigate(redirect.location, { - replace: true, - }); - errorRedirectPromises.set(error, promise); + setTimeout(() => { + window.__reactRouterDataRouter!.navigate(redirect.location, { + replace: true, + }); + }, 0); + errorRedirectHandledMap.set(error, true); } - throw promise; + return ( + + ); } } return children; diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx index 711b5c92c2..7507c7eadf 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -11,6 +11,7 @@ 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; @@ -248,8 +249,20 @@ export async function routeRSCServerRequest({ }); } + const redirectTransform = new TransformStream({ + flush(controller) { + if (renderRedirect) { + controller.enqueue( + new TextEncoder().encode( + ``, + ), + ); + } + }, + }); + if (!hydrate) { - return new Response(html, { + return new Response(html.pipeThrough(redirectTransform), { status: serverResponse.status, headers, }); @@ -259,7 +272,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, @@ -354,8 +369,20 @@ export async function routeRSCServerRequest({ }); } + const retryRedirectTransform = new TransformStream({ + flush(controller) { + if (retryRedirect) { + controller.enqueue( + new TextEncoder().encode( + ``, + ), + ); + } + }, + }); + if (!hydrate) { - return new Response(html, { + return new Response(html.pipeThrough(retryRedirectTransform), { status: status, headers, }); @@ -365,7 +392,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, From ca1f3e7bae49652f51743050a2913183df07dbcc Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Tue, 2 Dec 2025 14:07:14 -0800 Subject: [PATCH 4/7] cleanup plumbing, add shared utility for to parsing --- .changeset/early-doors-obey.md | 4 +- integration/rsc/rsc-nojs-test.ts | 30 ++++++++- integration/rsc/rsc-test.ts | 34 +++++++++- packages/react-router/lib/components.tsx | 22 +------ .../lib/dom-export/hydrated-router.tsx | 14 +---- packages/react-router/lib/dom/lib.tsx | 40 ++---------- packages/react-router/lib/dom/server.tsx | 11 +--- packages/react-router/lib/errors.ts | 22 +++++-- packages/react-router/lib/hooks.tsx | 46 ++++++++------ packages/react-router/lib/router/utils.ts | 62 +++++++++++++++++++ packages/react-router/lib/rsc/browser.tsx | 1 - 11 files changed, 179 insertions(+), 107 deletions(-) diff --git a/.changeset/early-doors-obey.md b/.changeset/early-doors-obey.md index e9341261b0..2c3410f1ee 100644 --- a/.changeset/early-doors-obey.md +++ b/.changeset/early-doors-obey.md @@ -1,6 +1,6 @@ --- -"@react-router/dev": minor -"react-router": minor +"@react-router/dev": patch +"react-router": patch --- add support for throwing redirect Response's at RSC render time diff --git a/integration/rsc/rsc-nojs-test.ts b/integration/rsc/rsc-nojs-test.ts index 014f7f06d4..f7fd7ca3c6 100644 --- a/integration/rsc/rsc-nojs-test.ts +++ b/integration/rsc/rsc-nojs-test.ts @@ -111,10 +111,15 @@ implementations.forEach((implementation) => { throw redirect("/render-redirect/redirected"); } + if (id === "external") { + throw redirect("https://example.com/"); + } + return ( <>

{id || "home"}

Redirect + External ) } @@ -138,10 +143,15 @@ implementations.forEach((implementation) => { throw redirect("/render-redirect/lazy/redirected"); } + if (id === "external") { + throw redirect("https://example.com/"); + } + return ( <>

{id || "home"}

Redirect + External ); } @@ -205,13 +215,23 @@ implementations.forEach((implementation) => { }) => { await page.goto(`http://localhost:${port}/render-redirect`); await expect(page.getByText("home")).toBeAttached(); - await page.click("a"); + 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, }) => { @@ -221,5 +241,13 @@ implementations.forEach((implementation) => { ); 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/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 d5bec5d22e..cfdb45474e 100644 --- a/integration/rsc/rsc-test.ts +++ b/integration/rsc/rsc-test.ts @@ -1479,10 +1479,15 @@ implementations.forEach((implementation) => { throw redirect("/render-redirect/redirected"); } + if (id === "external") { + throw redirect("https://example.com/") + } + return ( <>

{id || "home"}

Redirect + External ) } @@ -1506,10 +1511,15 @@ implementations.forEach((implementation) => { throw redirect("/render-redirect/lazy/redirected"); } + if (id === "external") { + throw redirect("https://example.com/") + } + return ( <>

{id || "home"}

Redirect + External ); } @@ -1798,24 +1808,44 @@ implementations.forEach((implementation) => { }) => { await page.goto(`http://localhost:${port}/render-redirect`); await expect(page.getByText("home")).toBeAttached(); - await page.click("a"); + 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.click("a"); + 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/lib/components.tsx b/packages/react-router/lib/components.tsx index c561bec394..11bba4a490 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -54,6 +54,7 @@ import { FetchersContext, LocationContext, NavigationContext, + RSCRouterContext, RouteContext, ViewTransitionContext, } from "./context"; @@ -400,12 +401,6 @@ export interface RouterProviderProps { * For more information, please see the [docs](https://reactrouter.com/explanation/react-transitions). */ unstable_useTransitions?: boolean; - - /** - * Control whether rsc specific behaviors are enabled. This includes - * `unstable_useTransitions` and redirects thrown at render time. - */ - unstable_rsc?: boolean; } /** @@ -438,7 +433,6 @@ export interface RouterProviderProps { * @param {RouterProviderProps.unstable_onError} props.unstable_onError n/a * @param {RouterProviderProps.router} props.router n/a * @param {RouterProviderProps.unstable_useTransitions} props.unstable_useTransitions n/a - * @param {RouterProviderProps.unstable_rsc} props.unstable_rsc n/a * @returns React element for the rendered router */ export function RouterProvider({ @@ -446,8 +440,8 @@ export function RouterProvider({ flushSync: reactDomFlushSyncImpl, unstable_onError, unstable_useTransitions, - unstable_rsc, }: RouterProviderProps): React.ReactElement { + let unstable_rsc = React.useContext(RSCRouterContext); unstable_useTransitions = unstable_useTransitions || unstable_rsc; let [_state, setStateImpl] = React.useState(router.state); @@ -728,7 +722,6 @@ export function RouterProvider({ future={router.future} state={state} unstable_onError={unstable_onError} - unstable_rsc={unstable_rsc} /> @@ -775,22 +768,13 @@ function DataRoutes({ future, state, unstable_onError, - unstable_rsc, }: { routes: DataRouteObject[]; future: DataRouter["future"]; state: RouterState; unstable_onError: unstable_ClientOnErrorFunction | undefined; - unstable_rsc: boolean | undefined; }): React.ReactElement | null { - return useRoutesImpl( - routes, - undefined, - state, - unstable_onError, - unstable_rsc, - future, - ); + return useRoutesImpl(routes, undefined, state, unstable_onError, future); } /** diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index f958737780..e2b360c723 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -80,11 +80,9 @@ function initSsrInfo(): void { function createHydratedRouter({ getContext, unstable_instrumentations, - unstable_rsc, }: { getContext?: RouterInit["getContext"]; unstable_instrumentations?: unstable_ClientInstrumentation[]; - unstable_rsc?: boolean; }): DataRouter { initSsrInfo(); @@ -179,9 +177,7 @@ function createHydratedRouter({ hydrationRouteProperties, unstable_instrumentations, mapRouteProperties, - future: { - unstable_rsc, - }, + future: {}, dataStrategy: getTurboStreamSingleFetchDataStrategy( () => router, ssrInfo.manifest, @@ -319,13 +315,6 @@ export interface HydratedRouterProps { * For more information, please see the [docs](https://reactrouter.com/explanation/react-transitions). */ unstable_useTransitions?: boolean; - - /** - * Control whether RSC specific behaviors are introduced. This currently - * enables the unstable_useTransitions flag, as well as the ability to handle - * thrown redirect responses during the render phase. - */ - unstable_rsc?: boolean; } /** @@ -345,7 +334,6 @@ export function HydratedRouter(props: HydratedRouterProps) { router = createHydratedRouter({ getContext: props.getContext, unstable_instrumentations: props.unstable_instrumentations, - unstable_rsc: props.unstable_rsc, }); } diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 54b87d058b..cee6848fc0 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"; @@ -1412,39 +1413,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 }); @@ -1476,8 +1446,8 @@ export const Link = React.forwardRef( | string, dataRouterState?: DataRouter["state"], unstable_onError?: unstable_ClientOnErrorFunction, - unstable_rsc?: boolean, future?: DataRouter["future"], ): React.ReactElement | null { invariant( @@ -914,7 +916,6 @@ export function useRoutesImpl( parentMatches, dataRouterState, unstable_onError, - unstable_rsc, future, ); @@ -994,7 +995,6 @@ type RenderErrorBoundaryProps = React.PropsWithChildren<{ component: React.ReactNode; routeContext: RouteContextObject; onError?: (error: unknown, errorInfo?: React.ErrorInfo) => void; - unstable_rsc?: boolean; }>; type RenderErrorBoundaryState = { @@ -1016,6 +1016,9 @@ export class RenderErrorBoundary extends React.Component< }; } + static contextType = RSCRouterContext; + declare context: React.ContextType; + static getDerivedStateFromError(error: any) { return { error: error }; } @@ -1078,7 +1081,7 @@ export class RenderErrorBoundary extends React.Component< this.props.children ); - if (this.props.unstable_rsc) { + if (this.context) { return ( {result} ); @@ -1096,6 +1099,9 @@ function RSCErrorHandler({ children: React.ReactNode; error: unknown; }) { + let { basename } = React.useContext(NavigationContext); + let navigate = useNavigate(); + if ( typeof error === "object" && error && @@ -1104,21 +1110,27 @@ function RSCErrorHandler({ ) { let redirect = decodeRedirectErrorDigest(error.digest); if (redirect) { - if ( - typeof window !== "undefined" && - window.__reactRouterDataRouter && - !errorRedirectHandledMap.get(error) - ) { - // TODO: Handle external redirects? - setTimeout(() => { - window.__reactRouterDataRouter!.navigate(redirect.location, { - replace: true, - }); - }, 0); + 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 ( - + ); } } @@ -1157,7 +1169,6 @@ export function _renderMatches( parentMatches: RouteMatch[] = [], dataRouterState: DataRouter["state"] | null = null, unstable_onError: unstable_ClientOnErrorFunction | null = null, - unstable_rsc: boolean | undefined = undefined, future: DataRouter["future"] | null = null, ): React.ReactElement | null { if (matches == null) { @@ -1326,7 +1337,6 @@ export function _renderMatches( error={error} children={getChildren()} routeContext={{ outlet: null, matches, isDataRoute: true }} - unstable_rsc={unstable_rsc} onError={onError} /> ) : ( diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index b670d47781..7fb6f923df 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -2058,3 +2058,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 e22681bfbc..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({ From d6fd85bcead7553f1d3ac15499ef7075dc8dd33f Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Tue, 2 Dec 2025 14:18:00 -0800 Subject: [PATCH 5/7] remove declare field --- packages/react-router/lib/hooks.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 927ba33d88..1207aa9e00 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -1017,7 +1017,6 @@ export class RenderErrorBoundary extends React.Component< } static contextType = RSCRouterContext; - declare context: React.ContextType; static getDerivedStateFromError(error: any) { return { error: error }; From dcbe438a5f41c62b9eddf794b28c5f722da0c2bd Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Tue, 2 Dec 2025 15:37:51 -0800 Subject: [PATCH 6/7] allow undefined transition config to fallthrough --- packages/react-router/lib/components.tsx | 6 +++--- packages/react-router/lib/dom-export/hydrated-router.tsx | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 10e1709f3c..e66723773f 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -54,9 +54,9 @@ import { FetchersContext, LocationContext, NavigationContext, - RSCRouterContext, RouteContext, ViewTransitionContext, + useIsRSCRouterContext, } from "./context"; import { _renderMatches, @@ -465,8 +465,8 @@ export function RouterProvider({ unstable_onError, unstable_useTransitions, }: RouterProviderProps): React.ReactElement { - let unstable_rsc = React.useContext(RSCRouterContext); - unstable_useTransitions = unstable_useTransitions || unstable_rsc; + let unstable_rsc = useIsRSCRouterContext(); + unstable_useTransitions = unstable_rsc || unstable_useTransitions; let [_state, setStateImpl] = React.useState(router.state); let [state, setOptimisticState] = useOptimisticSafe(_state); diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 1bcb4c5a29..42d2fc0dbf 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -177,7 +177,9 @@ function createHydratedRouter({ hydrationRouteProperties, unstable_instrumentations, mapRouteProperties, - future: {}, + future: { + middleware: ssrInfo.context.future.v8_middleware, + }, dataStrategy: getTurboStreamSingleFetchDataStrategy( () => router, ssrInfo.manifest, From 7d4c3b8e83aa80968ca243bef4bbae8bb1988d35 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Tue, 2 Dec 2025 20:58:03 -0800 Subject: [PATCH 7/7] disable firefox test, playwright is broken for this use-case --- integration/rsc/rsc-nojs-test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/integration/rsc/rsc-nojs-test.ts b/integration/rsc/rsc-nojs-test.ts index f7fd7ca3c6..5bb15307b9 100644 --- a/integration/rsc/rsc-nojs-test.ts +++ b/integration/rsc/rsc-nojs-test.ts @@ -244,7 +244,12 @@ implementations.forEach((implementation) => { 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();