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();