From e2130f538e1db2e862b9c7fd27a5462b55c3cea5 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Sun, 28 Sep 2025 20:03:03 -0700 Subject: [PATCH 1/2] fix: handle rsc external redirects --- .changeset/breezy-planes-roll.md | 5 + integration/rsc/rsc-test.ts | 165 ++++++++++++++++++++ packages/react-router/lib/rsc/server.rsc.ts | 5 +- 3 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 .changeset/breezy-planes-roll.md diff --git a/.changeset/breezy-planes-roll.md b/.changeset/breezy-planes-roll.md new file mode 100644 index 0000000000..6d1bb70e6a --- /dev/null +++ b/.changeset/breezy-planes-roll.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +handle external redirects in from server actions diff --git a/integration/rsc/rsc-test.ts b/integration/rsc/rsc-test.ts index 7ddafe7421..9ddff47d05 100644 --- a/integration/rsc/rsc-test.ts +++ b/integration/rsc/rsc-test.ts @@ -435,6 +435,17 @@ implementations.forEach((implementation) => { } ] }, + { + id: "throw-external-redirect-server-action", + path: "throw-external-redirect-server-action", + children: [ + { + id: "throw-external-redirect-server-action.home", + index: true, + lazy: () => import("./routes/throw-external-redirect-server-action/home"), + } + ] + }, { id: "side-effect-redirect-server-action", path: "side-effect-redirect-server-action", @@ -446,6 +457,17 @@ implementations.forEach((implementation) => { } ] }, + { + id: "side-effect-external-redirect-server-action", + path: "side-effect-external-redirect-server-action", + children: [ + { + id: "side-effect-external-redirect-server-action.home", + index: true, + lazy: () => import("./routes/side-effect-external-redirect-server-action/home"), + } + ] + }, { id: "server-function-reference", path: "server-function-reference", @@ -986,6 +1008,82 @@ implementations.forEach((implementation) => { ); } `, + "src/routes/throw-external-redirect-server-action/home.actions.ts": js` + "use server"; + import { redirect } from "react-router"; + + export async function redirectAction(formData: FormData) { + // Throw a redirect to an external URL + throw redirect("https://example.com/"); + } + `, + "src/routes/throw-external-redirect-server-action/home.client.tsx": js` + "use client"; + + import { useState } from "react"; + + export function Counter() { + const [count, setCount] = useState(0); + return ; + } + `, + "src/routes/throw-external-redirect-server-action/home.tsx": js` + import { redirectAction } from "./home.actions"; + import { Counter } from "./home.client"; + + export default function HomeRoute(props) { + return ( +
+
+ +
+ +
+ ); + } + `, + "src/routes/side-effect-external-redirect-server-action/home.actions.ts": js` + "use server"; + import { redirect } from "react-router"; + + export async function redirectAction() { + // Perform a side-effect redirect to an external URL + redirect("https://example.com/", { headers: { "x-test": "test" } }); + return "redirected"; + } + `, + "src/routes/side-effect-external-redirect-server-action/home.client.tsx": js` + "use client"; + import { useState } from "react"; + + export function Counter() { + const [count, setCount] = useState(0); + return ; + } + `, + "src/routes/side-effect-external-redirect-server-action/home.tsx": js` + "use client"; + import {useActionState} from "react"; + import { redirectAction } from "./home.actions"; + import { Counter } from "./home.client"; + + export default function HomeRoute(props) { + const [state, action] = useActionState(redirectAction, null); + return ( +
+
+ +
+ {state &&
{state}
} + +
+ ); + } + `, "src/routes/server-function-reference/home.actions.ts": js` "use server"; @@ -1736,6 +1834,33 @@ implementations.forEach((implementation) => { validateRSCHtml(await page.content()); }); + test("Supports React Server Functions thrown external redirects", async ({ + page, + }) => { + // Test is expected to fail currently — skip running it + // test.skip(true, "Known failing test for external redirect behavior"); + + await page.goto( + `http://localhost:${port}/throw-external-redirect-server-action/`, + ); + + // Verify initial server render + await page.waitForSelector("[data-count]"); + expect(await page.locator("[data-count]").textContent()).toBe( + "Count: 0", + ); + await page.click("[data-count]"); + expect(await page.locator("[data-count]").textContent()).toBe( + "Count: 1", + ); + + // Submit the form to trigger server function redirect to external URL + await page.click("[data-submit]"); + + // We expect the browser to navigate to the external site (example.com) + await expect(page).toHaveURL(`https://example.com/`); + }); + test("Supports React Server Functions side-effect redirects", async ({ page, }) => { @@ -1789,6 +1914,46 @@ implementations.forEach((implementation) => { validateRSCHtml(await page.content()); }); + test("Supports React Server Functions side-effect external redirects", async ({ + page, + }) => { + // Test is expected to fail currently — skip running it + test.skip(implementation.name === "parcel", "Not working in parcel?"); + + await page.goto( + `http://localhost:${port}/side-effect-external-redirect-server-action`, + ); + + // Verify initial server render + await page.waitForSelector("[data-count]"); + expect(await page.locator("[data-count]").textContent()).toBe( + "Count: 0", + ); + await page.click("[data-count]"); + expect(await page.locator("[data-count]").textContent()).toBe( + "Count: 1", + ); + + const responseHeadersPromise = new Promise>( + (resolve) => { + page.addListener("response", (response) => { + if (response.request().method() === "POST") { + resolve(response.headers()); + } + }); + }, + ); + + // Submit the form to trigger server function redirect to external URL + await page.click("[data-submit]"); + + // We expect the browser to navigate to the external site (example.com) + await expect(page).toHaveURL(`https://example.com/`); + + // Optionally assert that the server sent the header + expect((await responseHeadersPromise)["x-test"]).toBe("test"); + }); + test("Supports React Server Function References", async ({ page }) => { await page.goto(`http://localhost:${port}/server-function-reference`); diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index c911c7641a..9c6f298c4b 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -887,10 +887,13 @@ function generateRedirectResponse( redirect = stripBasename(redirect, basename) || redirect; } + const isExternal = isAbsoluteUrl(redirect); + let payload: RSCRedirectPayload = { type: "redirect", location: redirect, - reload: response.headers.get("X-Remix-Reload-Document") === "true", + reload: + isExternal || response.headers.get("X-Remix-Reload-Document") === "true", replace: response.headers.get("X-Remix-Replace") === "true", status: response.status, actionResult, From ec0227194328d2a36fbd31804932b214fdde0ccc Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Mon, 29 Sep 2025 11:06:56 -0700 Subject: [PATCH 2/2] allow spa redirects to same origin --- packages/react-router/lib/rsc/browser.tsx | 9 +++++++-- packages/react-router/lib/rsc/server.rsc.ts | 5 +---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx index 9b9974bc70..5046e28179 100644 --- a/packages/react-router/lib/rsc/browser.tsx +++ b/packages/react-router/lib/rsc/browser.tsx @@ -140,7 +140,7 @@ export function createCallServer({ Promise.resolve(payloadPromise) .then(async (payload) => { if (payload.type === "redirect") { - if (payload.reload) { + if (payload.reload || isExternalLocation(payload.location)) { window.location.href = payload.location; return () => {}; } @@ -163,7 +163,7 @@ export function createCallServer({ globalVar.__routerActionID <= actionId ) { if (rerender.type === "redirect") { - if (rerender.reload) { + if (rerender.reload || isExternalLocation(rerender.location)) { window.location.href = rerender.location; return; } @@ -1047,3 +1047,8 @@ function debounce(callback: (...args: unknown[]) => unknown, wait: number) { timeoutId = window.setTimeout(() => callback(...args), wait); }; } + +function isExternalLocation(location: string) { + const newLocation = new URL(location, window.location.href); + return newLocation.origin !== window.location.origin; +} diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index 9c6f298c4b..c911c7641a 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -887,13 +887,10 @@ function generateRedirectResponse( redirect = stripBasename(redirect, basename) || redirect; } - const isExternal = isAbsoluteUrl(redirect); - let payload: RSCRedirectPayload = { type: "redirect", location: redirect, - reload: - isExternal || response.headers.get("X-Remix-Reload-Document") === "true", + reload: response.headers.get("X-Remix-Reload-Document") === "true", replace: response.headers.get("X-Remix-Replace") === "true", status: response.status, actionResult,