Skip to content

Commit eec2ec2

Browse files
committed
Add a new dataRedirect utility for external redirects on .data reqeusts
1 parent b8cf1b6 commit eec2ec2

File tree

6 files changed

+132
-4
lines changed

6 files changed

+132
-4
lines changed

.changeset/new-hornets-run.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": minor
3+
---
4+
5+
Add a new `dataRedirect` utility for performing redirects on `.data` requests outside of the React Router handler

integration/helpers/vite.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export const EXPRESS_SERVER = (args: {
104104
port: number;
105105
base?: string;
106106
loadContext?: Record<string, unknown>;
107+
customLogic: string;
107108
}) =>
108109
String.raw`
109110
import { createRequestHandler } from "@react-router/express";
@@ -130,6 +131,8 @@ export const EXPRESS_SERVER = (args: {
130131
}
131132
app.use(express.static("build/client", { maxAge: "1h" }));
132133
134+
${args?.customLogic || ""}
135+
133136
app.all(
134137
"*",
135138
createRequestHandler({

integration/single-fetch-test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@ import {
1010
js,
1111
} from "./helpers/create-fixture.js";
1212
import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
13-
import { reactRouterConfig } from "./helpers/vite.js";
13+
import {
14+
EXPRESS_SERVER,
15+
createProject,
16+
customDev,
17+
reactRouterConfig,
18+
viteConfig,
19+
} from "./helpers/vite.js";
20+
import getPort from "get-port";
1421

1522
const ISO_DATE = "2024-03-12T12:00:00.000Z";
1623

@@ -1464,6 +1471,67 @@ test.describe("single-fetch", () => {
14641471
expect(await app.getHtml("#target")).toContain("Target");
14651472
});
14661473

1474+
test("processes redirects returned outside of react router", async ({
1475+
page,
1476+
}) => {
1477+
let port = await getPort();
1478+
let cwd = await createProject({
1479+
"vite.config.js": await viteConfig.basic({ port }),
1480+
"server.mjs": EXPRESS_SERVER({
1481+
port,
1482+
customLogic: js`
1483+
app.use(async (req, res, next) => {
1484+
if (req.url === "/page.data") {
1485+
let { dataRedirect } = await import("react-router");
1486+
let response = dataRedirect("/target");
1487+
res.statusMessage = response.statusText;
1488+
res.status(response.status);
1489+
for (let [key, value] of response.headers.entries()) {
1490+
res.append(key, value);
1491+
}
1492+
res.end();
1493+
} else {
1494+
next();
1495+
}
1496+
});
1497+
`,
1498+
}),
1499+
"app/routes/_index.tsx": js`
1500+
import { Link } from "react-router";
1501+
export default function Component() {
1502+
return <Link to="/page">Go to /page</Link>
1503+
}
1504+
`,
1505+
"app/routes/page.tsx": js`
1506+
export function loader() {
1507+
return null
1508+
}
1509+
export default function Component() {
1510+
return <p>Should not see me</p>
1511+
}
1512+
`,
1513+
"app/routes/target.tsx": js`
1514+
export default function Component() {
1515+
return <h1 id="target">Target</h1>
1516+
}
1517+
`,
1518+
});
1519+
let stop = await customDev({ cwd, port });
1520+
1521+
try {
1522+
await page.goto(`http://localhost:${port}/`, {
1523+
waitUntil: "networkidle",
1524+
});
1525+
let link = page.locator('a[href="/page"]');
1526+
await expect(link).toHaveText("Go to /page");
1527+
await link.click();
1528+
await page.waitForSelector("#target");
1529+
await expect(page.locator("#target")).toHaveText("Target");
1530+
} finally {
1531+
stop();
1532+
}
1533+
});
1534+
14671535
test("processes thrown loader errors", async ({ page }) => {
14681536
let fixture = await createFixture({
14691537
files: {

packages/react-router/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export {
6565
} from "./lib/router/router";
6666
export {
6767
data,
68+
dataRedirect,
6869
generatePath,
6970
isRouteErrorResponse,
7071
matchPath,

packages/react-router/lib/dom/ssr/single-fetch.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { escapeHtml } from "./markup";
2121
import type { RouteModule, RouteModules } from "./routeModules";
2222
import invariant from "./invariant";
2323
import type { EntryRoute } from "./routes";
24+
import { SINGLE_FETCH_REDIRECT_STATUS } from "../../server-runtime/single-fetch";
2425

2526
export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect");
2627

@@ -550,6 +551,25 @@ async function fetchAndDecode(
550551
throw new ErrorResponseImpl(404, "Not Found", true);
551552
}
552553

554+
// Handle non-RR redirects (i.e., from express middleware via `dataRedirect`)
555+
if (res.status === 204) {
556+
let data: SingleFetchRedirectResult = {
557+
redirect: res.headers.get("X-Remix-Redirect")!,
558+
status: Number(res.headers.get("X-Remix-Status") || "302"),
559+
revalidate: res.headers.get("X-Remix-Revalidate") === "true",
560+
reload: res.headers.get("X-Remix-Reload-Document") === "true",
561+
replace: res.headers.get("X-Remix-Replace") === "true",
562+
};
563+
if (!init.method || init.method === "GET") {
564+
return {
565+
status: SINGLE_FETCH_REDIRECT_STATUS,
566+
data: { [SingleFetchRedirectSymbol]: data },
567+
};
568+
} else {
569+
return { status: SINGLE_FETCH_REDIRECT_STATUS, data };
570+
}
571+
}
572+
553573
// some status codes are not permitted to have bodies, so we want to just
554574
// treat those as "no data" instead of throwing an exception.
555575
// 304 is not included here because the browser should fill those responses
@@ -657,13 +677,13 @@ function unwrapSingleFetchResult(result: SingleFetchResult, routeId: string) {
657677
} else if ("redirect" in result) {
658678
let headers: Record<string, string> = {};
659679
if (result.revalidate) {
660-
headers["X-Remix-Revalidate"] = "yes";
680+
headers["X-Remix-Revalidate"] = "true";
661681
}
662682
if (result.reload) {
663-
headers["X-Remix-Reload-Document"] = "yes";
683+
headers["X-Remix-Reload-Document"] = "true";
664684
}
665685
if (result.replace) {
666-
headers["X-Remix-Replace"] = "yes";
686+
headers["X-Remix-Replace"] = "true";
667687
}
668688
throw redirect(result.redirect, { status: result.status, headers });
669689
} else if ("data" in result) {

packages/react-router/lib/router/utils.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1605,6 +1605,37 @@ export const replace: RedirectFunction = (url, init) => {
16051605
return response;
16061606
};
16071607

1608+
/**
1609+
* A "soft" redirect response that can be returned from outside of React Router
1610+
* in response to a `.data` request. Options are available to set the status
1611+
* and toggle the equivalent behaviors of the traditional redirect utilities
1612+
* ()`redirect`/`replace`/`redirectDocument`)
1613+
*
1614+
* @category Utils
1615+
*/
1616+
export function dataRedirect(
1617+
url: string,
1618+
{
1619+
status,
1620+
replace,
1621+
revalidate,
1622+
reload,
1623+
}: {
1624+
status?: number;
1625+
replace?: boolean;
1626+
revalidate?: boolean;
1627+
reload?: boolean;
1628+
} = {}
1629+
) {
1630+
let headers = new Headers();
1631+
headers.set("X-Remix-Redirect", url);
1632+
if (status) headers.set("X-Remix-Status", String(status));
1633+
if (replace) headers.set("X-Remix-Replace", "true");
1634+
if (revalidate) headers.set("X-Remix-Revalidate", "true");
1635+
if (reload) headers.set("X-Remix-Reload-Document", "true");
1636+
return new Response(null, { status: 204, headers });
1637+
}
1638+
16081639
export type ErrorResponse = {
16091640
status: number;
16101641
statusText: string;

0 commit comments

Comments
 (0)