Skip to content

Commit 6d841ec

Browse files
committed
feat(rsc): add support for throwing redirect Response's at RSC render time
1 parent 1dbf8e3 commit 6d841ec

File tree

15 files changed

+321
-63
lines changed

15 files changed

+321
-63
lines changed

.changeset/early-doors-obey.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@react-router/dev": minor
3+
"react-router": minor
4+
---
5+
6+
add support for throwing redirect Response's at RSC render time

integration/helpers/rsc-parcel/src/prerender.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@ export async function prerender(
2020
// Provide the React Server touchpoints.
2121
createFromReadableStream,
2222
// Render the router to HTML.
23-
async renderHTML(getPayload) {
23+
async renderHTML(getPayload, options) {
2424
const payload = getPayload();
2525

2626
return await renderHTMLToReadableStream(
2727
<RSCStaticRouter getPayload={getPayload} />,
2828
{
29+
...options,
2930
bootstrapScriptContent,
3031
formState: await payload.formState,
3132
},

integration/helpers/rsc-vite/src/entry.ssr.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ export default async function handler(
1616
request,
1717
fetchServer,
1818
createFromReadableStream,
19-
async renderHTML(getPayload) {
19+
async renderHTML(getPayload, options) {
2020
const payload = getPayload();
2121

2222
return ReactDomServer.renderToReadableStream(
2323
<RSCStaticRouter getPayload={getPayload} />,
2424
{
25+
...options,
2526
bootstrapScriptContent,
2627
signal: request.signal,
2728
formState: await payload.formState,

integration/rsc/rsc-test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,12 @@ implementations.forEach((implementation) => {
534534
id: "action-transition-state",
535535
path: "action-transition-state",
536536
lazy: () => import("./routes/action-transition-state/home"),
537-
}
537+
},
538+
{
539+
id: "render-redirect",
540+
path: "/render-redirect/:id?",
541+
lazy: () => import("./routes/render-redirect/home"),
542+
},
538543
],
539544
},
540545
] satisfies RSCRouteConfig;
@@ -1460,6 +1465,23 @@ implementations.forEach((implementation) => {
14601465
);
14611466
}
14621467
`,
1468+
1469+
"src/routes/render-redirect/home.tsx": js`
1470+
import { Link, redirect } from "react-router";
1471+
1472+
export default function RenderRedirect({ params: { id } }) {
1473+
if (id === "redirect") {
1474+
throw redirect("/render-redirect/redirected");
1475+
}
1476+
1477+
return (
1478+
<>
1479+
<h1>{id || "home"}</h1>
1480+
<Link to="/render-redirect/redirect">Redirect</Link>
1481+
</>
1482+
)
1483+
}
1484+
`,
14631485
},
14641486
});
14651487
});
@@ -1738,6 +1760,14 @@ implementations.forEach((implementation) => {
17381760
"An error occurred in the Server Components render.",
17391761
);
17401762
});
1763+
1764+
test.only("Suppport throwing redirect Response from render", async ({
1765+
page,
1766+
}) => {
1767+
await page.goto(`http://localhost:${port}/render-redirect`);
1768+
await page.click("a");
1769+
await expect(page.getByText("redirected")).toBeAttached();
1770+
});
17411771
});
17421772

17431773
test.describe("Server Actions", () => {

packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,13 @@ export default async function handler(
1717
request,
1818
fetchServer,
1919
createFromReadableStream,
20-
async renderHTML(getPayload) {
20+
async renderHTML(getPayload, options) {
2121
const payload = getPayload();
2222

2323
return ReactDomServer.renderToReadableStream(
2424
<RSCStaticRouter getPayload={getPayload} />,
2525
{
26+
...options,
2627
bootstrapScriptContent,
2728
signal: request.signal,
2829
formState: await payload.formState,

packages/react-router/lib/components.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,12 @@ export interface RouterProviderProps {
400400
* For more information, please see the [docs](https://reactrouter.com/explanation/react-transitions).
401401
*/
402402
unstable_useTransitions?: boolean;
403+
404+
/**
405+
* Control whether rsc specific behaviors are enabled. This includes
406+
* `unstable_useTransitions` and redirects thrown at render time.
407+
*/
408+
unstable_rsc?: boolean;
403409
}
404410

405411
/**
@@ -432,14 +438,18 @@ export interface RouterProviderProps {
432438
* @param {RouterProviderProps.unstable_onError} props.unstable_onError n/a
433439
* @param {RouterProviderProps.router} props.router n/a
434440
* @param {RouterProviderProps.unstable_useTransitions} props.unstable_useTransitions n/a
441+
* @param {RouterProviderProps.unstable_rsc} props.unstable_rsc n/a
435442
* @returns React element for the rendered router
436443
*/
437444
export function RouterProvider({
438445
router,
439446
flushSync: reactDomFlushSyncImpl,
440447
unstable_onError,
441448
unstable_useTransitions,
449+
unstable_rsc,
442450
}: RouterProviderProps): React.ReactElement {
451+
unstable_useTransitions = unstable_useTransitions || unstable_rsc;
452+
443453
let [_state, setStateImpl] = React.useState(router.state);
444454
let [state, setOptimisticState] = useOptimisticSafe(_state);
445455
let [pendingState, setPendingState] = React.useState<RouterState>();
@@ -718,6 +728,7 @@ export function RouterProvider({
718728
future={router.future}
719729
state={state}
720730
unstable_onError={unstable_onError}
731+
unstable_rsc={unstable_rsc}
721732
/>
722733
</Router>
723734
</ViewTransitionContext.Provider>
@@ -764,13 +775,22 @@ function DataRoutes({
764775
future,
765776
state,
766777
unstable_onError,
778+
unstable_rsc,
767779
}: {
768780
routes: DataRouteObject[];
769781
future: DataRouter["future"];
770782
state: RouterState;
771783
unstable_onError: unstable_ClientOnErrorFunction | undefined;
784+
unstable_rsc: boolean | undefined;
772785
}): React.ReactElement | null {
773-
return useRoutesImpl(routes, undefined, state, unstable_onError, future);
786+
return useRoutesImpl(
787+
routes,
788+
undefined,
789+
state,
790+
unstable_onError,
791+
unstable_rsc,
792+
future,
793+
);
774794
}
775795

776796
/**

packages/react-router/lib/dom-export/hydrated-router.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,11 @@ function initSsrInfo(): void {
8080
function createHydratedRouter({
8181
getContext,
8282
unstable_instrumentations,
83+
unstable_rsc,
8384
}: {
8485
getContext?: RouterInit["getContext"];
8586
unstable_instrumentations?: unstable_ClientInstrumentation[];
87+
unstable_rsc?: boolean;
8688
}): DataRouter {
8789
initSsrInfo();
8890

@@ -178,7 +180,7 @@ function createHydratedRouter({
178180
unstable_instrumentations,
179181
mapRouteProperties,
180182
future: {
181-
middleware: ssrInfo.context.future.v8_middleware,
183+
unstable_rsc,
182184
},
183185
dataStrategy: getTurboStreamSingleFetchDataStrategy(
184186
() => router,
@@ -317,6 +319,13 @@ export interface HydratedRouterProps {
317319
* For more information, please see the [docs](https://reactrouter.com/explanation/react-transitions).
318320
*/
319321
unstable_useTransitions?: boolean;
322+
323+
/**
324+
* Control whether RSC specific behaviors are introduced. This currently
325+
* enables the unstable_useTransitions flag, as well as the ability to handle
326+
* thrown redirect responses during the render phase.
327+
*/
328+
unstable_rsc?: boolean;
320329
}
321330

322331
/**
@@ -336,6 +345,7 @@ export function HydratedRouter(props: HydratedRouterProps) {
336345
router = createHydratedRouter({
337346
getContext: props.getContext,
338347
unstable_instrumentations: props.unstable_instrumentations,
348+
unstable_rsc: props.unstable_rsc,
339349
});
340350
}
341351

packages/react-router/lib/dom/server.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,12 +232,21 @@ function DataRoutes({
232232
routes,
233233
future,
234234
state,
235+
unstable_rsc,
235236
}: {
236237
routes: DataRouteObject[];
237238
future: DataRouter["future"];
238239
state: RouterState;
240+
unstable_rsc?: boolean;
239241
}): React.ReactElement | null {
240-
return useRoutesImpl(routes, undefined, state, undefined, future);
242+
return useRoutesImpl(
243+
routes,
244+
undefined,
245+
state,
246+
undefined,
247+
unstable_rsc,
248+
future,
249+
);
241250
}
242251

243252
function serializeErrors(
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
const ERROR_DIGEST_BASE = "REACT_ROUTER_ERROR";
2+
const ERROR_DIGEST_REDIRECT = "REDIRECT";
3+
4+
export function createRedirectErrorDigest(response: Response) {
5+
return `${ERROR_DIGEST_BASE}:${ERROR_DIGEST_REDIRECT}:${JSON.stringify({
6+
status: response.status,
7+
location: response.headers.get("Location"),
8+
})}`;
9+
}
10+
11+
export function decodeRedirectErrorDigest(
12+
digest: string,
13+
): undefined | { status: number; location: string } {
14+
if (digest.startsWith(`${ERROR_DIGEST_BASE}:${ERROR_DIGEST_REDIRECT}:{`)) {
15+
try {
16+
let parsed = JSON.parse(digest.slice(28));
17+
if (
18+
typeof parsed === "object" &&
19+
parsed &&
20+
"status" in parsed &&
21+
typeof parsed.status === "number" &&
22+
"location" in parsed &&
23+
typeof parsed.location === "string"
24+
) {
25+
return parsed;
26+
}
27+
} catch {}
28+
}
29+
}

packages/react-router/lib/hooks.tsx

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import type {
5858
} from "./types/route-data";
5959
import type { unstable_ClientOnErrorFunction } from "./components";
6060
import type { RouteModules } from "./types/register";
61+
import { decodeRedirectErrorDigest } from "./errors";
6162

6263
/**
6364
* Resolves a URL against the current {@link Location}.
@@ -758,6 +759,7 @@ export function useRoutesImpl(
758759
locationArg?: Partial<Location> | string,
759760
dataRouterState?: DataRouter["state"],
760761
unstable_onError?: unstable_ClientOnErrorFunction,
762+
unstable_rsc?: boolean,
761763
future?: DataRouter["future"],
762764
): React.ReactElement | null {
763765
invariant(
@@ -912,6 +914,7 @@ export function useRoutesImpl(
912914
parentMatches,
913915
dataRouterState,
914916
unstable_onError,
917+
unstable_rsc,
915918
future,
916919
);
917920

@@ -991,6 +994,7 @@ type RenderErrorBoundaryProps = React.PropsWithChildren<{
991994
component: React.ReactNode;
992995
routeContext: RouteContextObject;
993996
onError?: (error: unknown, errorInfo?: React.ErrorInfo) => void;
997+
unstable_rsc?: boolean;
994998
}>;
995999

9961000
type RenderErrorBoundaryState = {
@@ -1062,17 +1066,56 @@ export class RenderErrorBoundary extends React.Component<
10621066
}
10631067

10641068
render() {
1065-
return this.state.error !== undefined ? (
1066-
<RouteContext.Provider value={this.props.routeContext}>
1067-
<RouteErrorContext.Provider
1068-
value={this.state.error}
1069-
children={this.props.component}
1070-
/>
1071-
</RouteContext.Provider>
1072-
) : (
1073-
this.props.children
1074-
);
1069+
let result =
1070+
this.state.error !== undefined ? (
1071+
<RouteContext.Provider value={this.props.routeContext}>
1072+
<RouteErrorContext.Provider
1073+
value={this.state.error}
1074+
children={this.props.component}
1075+
/>
1076+
</RouteContext.Provider>
1077+
) : (
1078+
this.props.children
1079+
);
1080+
1081+
if (this.props.unstable_rsc) {
1082+
return (
1083+
<RSCErrorHandler error={this.state.error}>{result}</RSCErrorHandler>
1084+
);
1085+
}
1086+
1087+
return result;
1088+
}
1089+
}
1090+
1091+
const errorRedirectPromises = new WeakMap<any, Promise<void>>();
1092+
function RSCErrorHandler({
1093+
children,
1094+
error,
1095+
}: {
1096+
children: React.ReactNode;
1097+
error: unknown;
1098+
}) {
1099+
if (
1100+
typeof error === "object" &&
1101+
error &&
1102+
"digest" in error &&
1103+
typeof error.digest === "string"
1104+
) {
1105+
let redirect = decodeRedirectErrorDigest(error.digest);
1106+
if (redirect) {
1107+
let promise = errorRedirectPromises.get(error);
1108+
if (!promise) {
1109+
// TODO: Handle external redirects?
1110+
promise = window.__reactRouterDataRouter!.navigate(redirect.location, {
1111+
replace: true,
1112+
});
1113+
errorRedirectPromises.set(error, promise);
1114+
}
1115+
throw promise;
1116+
}
10751117
}
1118+
return children;
10761119
}
10771120

10781121
interface RenderedRouteProps {
@@ -1107,6 +1150,7 @@ export function _renderMatches(
11071150
parentMatches: RouteMatch[] = [],
11081151
dataRouterState: DataRouter["state"] | null = null,
11091152
unstable_onError: unstable_ClientOnErrorFunction | null = null,
1153+
unstable_rsc: boolean | undefined = undefined,
11101154
future: DataRouter["future"] | null = null,
11111155
): React.ReactElement | null {
11121156
if (matches == null) {
@@ -1275,6 +1319,7 @@ export function _renderMatches(
12751319
error={error}
12761320
children={getChildren()}
12771321
routeContext={{ outlet: null, matches, isDataRoute: true }}
1322+
unstable_rsc={unstable_rsc}
12781323
onError={onError}
12791324
/>
12801325
) : (

0 commit comments

Comments
 (0)