Skip to content

Commit 850bf9a

Browse files
authored
fix: handle rsc external redirects (#14400)
1 parent 40e3966 commit 850bf9a

File tree

3 files changed

+177
-2
lines changed

3 files changed

+177
-2
lines changed

.changeset/breezy-planes-roll.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
handle external redirects in from server actions

integration/rsc/rsc-test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,17 @@ implementations.forEach((implementation) => {
435435
}
436436
]
437437
},
438+
{
439+
id: "throw-external-redirect-server-action",
440+
path: "throw-external-redirect-server-action",
441+
children: [
442+
{
443+
id: "throw-external-redirect-server-action.home",
444+
index: true,
445+
lazy: () => import("./routes/throw-external-redirect-server-action/home"),
446+
}
447+
]
448+
},
438449
{
439450
id: "side-effect-redirect-server-action",
440451
path: "side-effect-redirect-server-action",
@@ -446,6 +457,17 @@ implementations.forEach((implementation) => {
446457
}
447458
]
448459
},
460+
{
461+
id: "side-effect-external-redirect-server-action",
462+
path: "side-effect-external-redirect-server-action",
463+
children: [
464+
{
465+
id: "side-effect-external-redirect-server-action.home",
466+
index: true,
467+
lazy: () => import("./routes/side-effect-external-redirect-server-action/home"),
468+
}
469+
]
470+
},
449471
{
450472
id: "server-function-reference",
451473
path: "server-function-reference",
@@ -986,6 +1008,82 @@ implementations.forEach((implementation) => {
9861008
);
9871009
}
9881010
`,
1011+
"src/routes/throw-external-redirect-server-action/home.actions.ts": js`
1012+
"use server";
1013+
import { redirect } from "react-router";
1014+
1015+
export async function redirectAction(formData: FormData) {
1016+
// Throw a redirect to an external URL
1017+
throw redirect("https://example.com/");
1018+
}
1019+
`,
1020+
"src/routes/throw-external-redirect-server-action/home.client.tsx": js`
1021+
"use client";
1022+
1023+
import { useState } from "react";
1024+
1025+
export function Counter() {
1026+
const [count, setCount] = useState(0);
1027+
return <button type="button" onClick={() => setCount(c => c + 1)} data-count>Count: {count}</button>;
1028+
}
1029+
`,
1030+
"src/routes/throw-external-redirect-server-action/home.tsx": js`
1031+
import { redirectAction } from "./home.actions";
1032+
import { Counter } from "./home.client";
1033+
1034+
export default function HomeRoute(props) {
1035+
return (
1036+
<div>
1037+
<form action={redirectAction}>
1038+
<button type="submit" data-submit>
1039+
Redirect via Server Function
1040+
</button>
1041+
</form>
1042+
<Counter />
1043+
</div>
1044+
);
1045+
}
1046+
`,
1047+
"src/routes/side-effect-external-redirect-server-action/home.actions.ts": js`
1048+
"use server";
1049+
import { redirect } from "react-router";
1050+
1051+
export async function redirectAction() {
1052+
// Perform a side-effect redirect to an external URL
1053+
redirect("https://example.com/", { headers: { "x-test": "test" } });
1054+
return "redirected";
1055+
}
1056+
`,
1057+
"src/routes/side-effect-external-redirect-server-action/home.client.tsx": js`
1058+
"use client";
1059+
import { useState } from "react";
1060+
1061+
export function Counter() {
1062+
const [count, setCount] = useState(0);
1063+
return <button type="button" onClick={() => setCount(c => c + 1)} data-count>Count: {count}</button>;
1064+
}
1065+
`,
1066+
"src/routes/side-effect-external-redirect-server-action/home.tsx": js`
1067+
"use client";
1068+
import {useActionState} from "react";
1069+
import { redirectAction } from "./home.actions";
1070+
import { Counter } from "./home.client";
1071+
1072+
export default function HomeRoute(props) {
1073+
const [state, action] = useActionState(redirectAction, null);
1074+
return (
1075+
<div>
1076+
<form action={action}>
1077+
<button type="submit" data-submit>
1078+
Redirect via Server Function
1079+
</button>
1080+
</form>
1081+
{state && <div data-testid="state">{state}</div>}
1082+
<Counter />
1083+
</div>
1084+
);
1085+
}
1086+
`,
9891087

9901088
"src/routes/server-function-reference/home.actions.ts": js`
9911089
"use server";
@@ -1736,6 +1834,33 @@ implementations.forEach((implementation) => {
17361834
validateRSCHtml(await page.content());
17371835
});
17381836

1837+
test("Supports React Server Functions thrown external redirects", async ({
1838+
page,
1839+
}) => {
1840+
// Test is expected to fail currently — skip running it
1841+
// test.skip(true, "Known failing test for external redirect behavior");
1842+
1843+
await page.goto(
1844+
`http://localhost:${port}/throw-external-redirect-server-action/`,
1845+
);
1846+
1847+
// Verify initial server render
1848+
await page.waitForSelector("[data-count]");
1849+
expect(await page.locator("[data-count]").textContent()).toBe(
1850+
"Count: 0",
1851+
);
1852+
await page.click("[data-count]");
1853+
expect(await page.locator("[data-count]").textContent()).toBe(
1854+
"Count: 1",
1855+
);
1856+
1857+
// Submit the form to trigger server function redirect to external URL
1858+
await page.click("[data-submit]");
1859+
1860+
// We expect the browser to navigate to the external site (example.com)
1861+
await expect(page).toHaveURL(`https://example.com/`);
1862+
});
1863+
17391864
test("Supports React Server Functions side-effect redirects", async ({
17401865
page,
17411866
}) => {
@@ -1789,6 +1914,46 @@ implementations.forEach((implementation) => {
17891914
validateRSCHtml(await page.content());
17901915
});
17911916

1917+
test("Supports React Server Functions side-effect external redirects", async ({
1918+
page,
1919+
}) => {
1920+
// Test is expected to fail currently — skip running it
1921+
test.skip(implementation.name === "parcel", "Not working in parcel?");
1922+
1923+
await page.goto(
1924+
`http://localhost:${port}/side-effect-external-redirect-server-action`,
1925+
);
1926+
1927+
// Verify initial server render
1928+
await page.waitForSelector("[data-count]");
1929+
expect(await page.locator("[data-count]").textContent()).toBe(
1930+
"Count: 0",
1931+
);
1932+
await page.click("[data-count]");
1933+
expect(await page.locator("[data-count]").textContent()).toBe(
1934+
"Count: 1",
1935+
);
1936+
1937+
const responseHeadersPromise = new Promise<Record<string, string>>(
1938+
(resolve) => {
1939+
page.addListener("response", (response) => {
1940+
if (response.request().method() === "POST") {
1941+
resolve(response.headers());
1942+
}
1943+
});
1944+
},
1945+
);
1946+
1947+
// Submit the form to trigger server function redirect to external URL
1948+
await page.click("[data-submit]");
1949+
1950+
// We expect the browser to navigate to the external site (example.com)
1951+
await expect(page).toHaveURL(`https://example.com/`);
1952+
1953+
// Optionally assert that the server sent the header
1954+
expect((await responseHeadersPromise)["x-test"]).toBe("test");
1955+
});
1956+
17921957
test("Supports React Server Function References", async ({ page }) => {
17931958
await page.goto(`http://localhost:${port}/server-function-reference`);
17941959

packages/react-router/lib/rsc/browser.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ export function createCallServer({
140140
Promise.resolve(payloadPromise)
141141
.then(async (payload) => {
142142
if (payload.type === "redirect") {
143-
if (payload.reload) {
143+
if (payload.reload || isExternalLocation(payload.location)) {
144144
window.location.href = payload.location;
145145
return () => {};
146146
}
@@ -163,7 +163,7 @@ export function createCallServer({
163163
globalVar.__routerActionID <= actionId
164164
) {
165165
if (rerender.type === "redirect") {
166-
if (rerender.reload) {
166+
if (rerender.reload || isExternalLocation(rerender.location)) {
167167
window.location.href = rerender.location;
168168
return;
169169
}
@@ -1047,3 +1047,8 @@ function debounce(callback: (...args: unknown[]) => unknown, wait: number) {
10471047
timeoutId = window.setTimeout(() => callback(...args), wait);
10481048
};
10491049
}
1050+
1051+
function isExternalLocation(location: string) {
1052+
const newLocation = new URL(location, window.location.href);
1053+
return newLocation.origin !== window.location.origin;
1054+
}

0 commit comments

Comments
 (0)