Skip to content

Commit e64853c

Browse files
authored
feat(rsc): add support for throwing redirect Response's at RSC render time (#14596)
1 parent dac4129 commit e64853c

File tree

16 files changed

+612
-100
lines changed

16 files changed

+612
-100
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": patch
3+
"react-router": patch
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
serverResponse,
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-nojs-test.ts

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ import getPort from "get-port";
33

44
import { implementations, js, setupRscTest, validateRSCHtml } from "./utils";
55

6-
test.use({ javaScriptEnabled: false });
7-
86
implementations.forEach((implementation) => {
97
test.describe(`RSC nojs (${implementation.name})`, () => {
108
let port: number;
@@ -20,6 +18,34 @@ implementations.forEach((implementation) => {
2018
implementation,
2119
port,
2220
files: {
21+
"src/routes.ts": js`
22+
import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router";
23+
24+
export const routes = [
25+
{
26+
id: "root",
27+
path: "",
28+
lazy: () => import("./routes/root"),
29+
children: [
30+
{
31+
id: "home",
32+
index: true,
33+
lazy: () => import("./routes/home"),
34+
},
35+
{
36+
id: "render-redirect-lazy",
37+
path: "/render-redirect/lazy/:id?",
38+
lazy: () => import("./routes/render-redirect/lazy"),
39+
},
40+
{
41+
id: "render-redirect",
42+
path: "/render-redirect/:id?",
43+
lazy: () => import("./routes/render-redirect/home"),
44+
},
45+
],
46+
},
47+
] satisfies RSCRouteConfig;
48+
`,
2349
"src/routes/home.actions.ts": js`
2450
"use server";
2551
import { redirect } from "react-router";
@@ -76,6 +102,60 @@ implementations.forEach((implementation) => {
76102
);
77103
}
78104
`,
105+
106+
"src/routes/render-redirect/home.tsx": js`
107+
import { Link, redirect } from "react-router";
108+
109+
export default function RenderRedirect({ params: { id } }) {
110+
if (id === "redirect") {
111+
throw redirect("/render-redirect/redirected");
112+
}
113+
114+
if (id === "external") {
115+
throw redirect("https://example.com/");
116+
}
117+
118+
return (
119+
<>
120+
<h1>{id || "home"}</h1>
121+
<Link to="/render-redirect/redirect">Redirect</Link>
122+
<Link to="/render-redirect/external">External</Link>
123+
</>
124+
)
125+
}
126+
`,
127+
"src/routes/render-redirect/lazy.tsx": js`
128+
import { Suspense } from "react";
129+
import { Link, redirect } from "react-router";
130+
131+
export default function RenderRedirect({ params: { id } }) {
132+
return (
133+
<Suspense fallback={<p>Loading...</p>}>
134+
<Lazy id={id} />
135+
</Suspense>
136+
);
137+
}
138+
139+
async function Lazy({ id }) {
140+
await new Promise((r) => setTimeout(r, 0));
141+
142+
if (id === "redirect") {
143+
throw redirect("/render-redirect/lazy/redirected");
144+
}
145+
146+
if (id === "external") {
147+
throw redirect("https://example.com/");
148+
}
149+
150+
return (
151+
<>
152+
<h1>{id || "home"}</h1>
153+
<Link to="/render-redirect/lazy/redirect">Redirect</Link>
154+
<Link to="/render-redirect/external">External</Link>
155+
</>
156+
);
157+
}
158+
`,
79159
},
80160
});
81161
});
@@ -129,5 +209,50 @@ implementations.forEach((implementation) => {
129209
// Ensure this is using RSC
130210
validateRSCHtml(await page.content());
131211
});
212+
213+
test("Suppport throwing redirect Response from render", async ({
214+
page,
215+
}) => {
216+
await page.goto(`http://localhost:${port}/render-redirect`);
217+
await expect(page.getByText("home")).toBeAttached();
218+
await page.getByText("Redirect").click();
219+
await page.waitForURL(
220+
`http://localhost:${port}/render-redirect/redirected`,
221+
);
222+
await expect(page.getByText("redirected")).toBeAttached();
223+
});
224+
225+
test("Suppport throwing external redirect Response from render", async ({
226+
page,
227+
}) => {
228+
await page.goto(`http://localhost:${port}/render-redirect`);
229+
await expect(page.getByText("home")).toBeAttached();
230+
await page.getByText("External").click();
231+
await page.waitForURL(`https://example.com/`);
232+
await expect(page.getByText("Example Domain")).toBeAttached();
233+
});
234+
235+
test("Suppport throwing redirect Response from suspended render", async ({
236+
page,
237+
}) => {
238+
await page.goto(`http://localhost:${port}/render-redirect/lazy/redirect`);
239+
await page.waitForURL(
240+
`http://localhost:${port}/render-redirect/lazy/redirected`,
241+
);
242+
await expect(page.getByText("redirected")).toBeAttached();
243+
});
244+
245+
test("Suppport throwing external redirect Response from suspended render", async ({
246+
page,
247+
browserName,
248+
}) => {
249+
test.skip(
250+
browserName === "firefox",
251+
"Playwright doesn't like external meta redirects for tests. It times out waiting for the URL even though it navigates.",
252+
);
253+
await page.goto(`http://localhost:${port}/render-redirect/lazy/external`);
254+
await page.waitForURL(`https://example.com/`);
255+
await expect(page.getByText("Example Domain")).toBeAttached();
256+
});
132257
});
133258
});

integration/rsc/rsc-test.ts

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,17 @@ 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-lazy",
540+
path: "/render-redirect/lazy/:id?",
541+
lazy: () => import("./routes/render-redirect/lazy"),
542+
},
543+
{
544+
id: "render-redirect",
545+
path: "/render-redirect/:id?",
546+
lazy: () => import("./routes/render-redirect/home"),
547+
},
538548
],
539549
},
540550
] satisfies RSCRouteConfig;
@@ -1460,6 +1470,60 @@ implementations.forEach((implementation) => {
14601470
);
14611471
}
14621472
`,
1473+
1474+
"src/routes/render-redirect/home.tsx": js`
1475+
import { Link, redirect } from "react-router";
1476+
1477+
export default function RenderRedirect({ params: { id } }) {
1478+
if (id === "redirect") {
1479+
throw redirect("/render-redirect/redirected");
1480+
}
1481+
1482+
if (id === "external") {
1483+
throw redirect("https://example.com/")
1484+
}
1485+
1486+
return (
1487+
<>
1488+
<h1>{id || "home"}</h1>
1489+
<Link to="/render-redirect/redirect">Redirect</Link>
1490+
<Link to="/render-redirect/external">External</Link>
1491+
</>
1492+
)
1493+
}
1494+
`,
1495+
"src/routes/render-redirect/lazy.tsx": js`
1496+
import { Suspense } from "react";
1497+
import { Link, redirect } from "react-router";
1498+
1499+
export default function RenderRedirect({ params: { id } }) {
1500+
return (
1501+
<Suspense fallback={<p>Loading...</p>}>
1502+
<Lazy id={id} />
1503+
</Suspense>
1504+
);
1505+
}
1506+
1507+
async function Lazy({ id }) {
1508+
await new Promise((r) => setTimeout(r, 0));
1509+
1510+
if (id === "redirect") {
1511+
throw redirect("/render-redirect/lazy/redirected");
1512+
}
1513+
1514+
if (id === "external") {
1515+
throw redirect("https://example.com/")
1516+
}
1517+
1518+
return (
1519+
<>
1520+
<h1>{id || "home"}</h1>
1521+
<Link to="/render-redirect/lazy/redirect">Redirect</Link>
1522+
<Link to="/render-redirect/external">External</Link>
1523+
</>
1524+
);
1525+
}
1526+
`,
14631527
},
14641528
});
14651529
});
@@ -1738,6 +1802,50 @@ implementations.forEach((implementation) => {
17381802
"An error occurred in the Server Components render.",
17391803
);
17401804
});
1805+
1806+
test("Suppport throwing redirect Response from render", async ({
1807+
page,
1808+
}) => {
1809+
await page.goto(`http://localhost:${port}/render-redirect`);
1810+
await expect(page.getByText("home")).toBeAttached();
1811+
await page.getByText("Redirect").click();
1812+
await page.waitForURL(
1813+
`http://localhost:${port}/render-redirect/redirected`,
1814+
);
1815+
await expect(page.getByText("redirected")).toBeAttached();
1816+
});
1817+
1818+
test("Suppport throwing external redirect Response from render", async ({
1819+
page,
1820+
}) => {
1821+
await page.goto(`http://localhost:${port}/render-redirect`);
1822+
await expect(page.getByText("home")).toBeAttached();
1823+
await page.getByText("External").click();
1824+
await page.waitForURL(`https://example.com/`);
1825+
await expect(page.getByText("Example Domain")).toBeAttached();
1826+
});
1827+
1828+
test("Suppport throwing redirect Response from suspended render", async ({
1829+
page,
1830+
}) => {
1831+
await page.goto(`http://localhost:${port}/render-redirect/lazy`);
1832+
await expect(page.getByText("home")).toBeAttached();
1833+
await page.getByText("Redirect").click();
1834+
await page.waitForURL(
1835+
`http://localhost:${port}/render-redirect/lazy/redirected`,
1836+
);
1837+
await expect(page.getByText("redirected")).toBeAttached();
1838+
});
1839+
1840+
test("Suppport throwing external redirect Response from suspended render", async ({
1841+
page,
1842+
}) => {
1843+
await page.goto(`http://localhost:${port}/render-redirect/lazy`);
1844+
await expect(page.getByText("home")).toBeAttached();
1845+
await page.getByText("External").click();
1846+
await page.waitForURL(`https://example.com/`);
1847+
await expect(page.getByText("Example Domain")).toBeAttached();
1848+
});
17411849
});
17421850

17431851
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
serverResponse,
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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
NavigationContext,
5757
RouteContext,
5858
ViewTransitionContext,
59+
useIsRSCRouterContext,
5960
} from "./context";
6061
import {
6162
_renderMatches,
@@ -465,6 +466,9 @@ export function RouterProvider({
465466
unstable_onError,
466467
unstable_useTransitions,
467468
}: RouterProviderProps): React.ReactElement {
469+
let unstable_rsc = useIsRSCRouterContext();
470+
unstable_useTransitions = unstable_rsc || unstable_useTransitions;
471+
468472
let [_state, setStateImpl] = React.useState(router.state);
469473
let [state, setOptimisticState] = useOptimisticSafe(_state);
470474
let [pendingState, setPendingState] = React.useState<RouterState>();

0 commit comments

Comments
 (0)