Skip to content

Commit c923ded

Browse files
authored
Properly handle redirects in prerendered pages (#13365)
1 parent bd50e07 commit c923ded

File tree

4 files changed

+107
-10
lines changed

4 files changed

+107
-10
lines changed

.changeset/smart-ligers-lay.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+
Fix prerendering when a loader returns a redirect

integration/vite-prerender-test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ let files = {
5454
<Link to="/">Home</Link><br/>
5555
<Link to="/about">About</Link><br/>
5656
<Link to="/not-found">Not Found</Link><br/>
57+
<Link to="/redirect">Redirect</Link><br/>
5758
</nav>
5859
{children}
5960
<Scripts />
@@ -2347,5 +2348,47 @@ test.describe("Prerendering", () => {
23472348
await page.waitForSelector("[data-error]:has-text('404 Not Found')");
23482349
expect(requests).toEqual(["/not-found.data"]);
23492350
});
2351+
2352+
test("Handles redirects in prerendered pages", async ({ page }) => {
2353+
fixture = await createFixture({
2354+
prerender: true,
2355+
files: {
2356+
...files,
2357+
"react-router.config.ts": reactRouterConfig({
2358+
ssr: false, // turn off fog of war since we're serving with a static server
2359+
prerender: true,
2360+
}),
2361+
"app/routes/redirect.tsx": js`
2362+
import { redirect } from "react-router"
2363+
export function loader() {
2364+
return redirect('/target', 301);
2365+
}
2366+
export default function Component() {
2367+
<h1>Nope</h1>
2368+
}
2369+
`,
2370+
"app/routes/target.tsx": js`
2371+
export default function Component() {
2372+
return <h1 id="target">Target</h1>
2373+
}
2374+
`,
2375+
},
2376+
});
2377+
2378+
appFixture = await createAppFixture(fixture);
2379+
2380+
// Document loads
2381+
let requests = captureRequests(page);
2382+
let app = new PlaywrightFixture(appFixture, page);
2383+
await app.goto("/redirect");
2384+
await page.waitForSelector("#target");
2385+
expect(requests).toEqual([]);
2386+
2387+
// Client side navigations
2388+
await app.goto("/", true);
2389+
app.clickLink("/redirect");
2390+
await page.waitForSelector("#target");
2391+
expect(requests).toEqual(["/redirect.data"]);
2392+
});
23502393
});
23512394
});

packages/react-router-dev/vite/plugin.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2761,7 +2761,8 @@ async function prerenderData(
27612761
let response = await handler(request);
27622762
let data = await response.text();
27632763

2764-
if (response.status !== 200) {
2764+
// 202 is used for `.data` redirects
2765+
if (response.status !== 200 && response.status !== 202) {
27652766
throw new Error(
27662767
`Prerender (data): Received a ${response.status} status code from ` +
27672768
`\`entry.server.tsx\` while prerendering the \`${prerenderPath}\` ` +
@@ -2780,6 +2781,8 @@ async function prerenderData(
27802781
return data;
27812782
}
27822783

2784+
let redirectStatusCodes = new Set([301, 302, 303, 307, 308]);
2785+
27832786
async function prerenderRoute(
27842787
handler: RequestHandler,
27852788
prerenderPath: string,
@@ -2796,7 +2799,29 @@ async function prerenderRoute(
27962799
let response = await handler(request);
27972800
let html = await response.text();
27982801

2799-
if (response.status !== 200) {
2802+
if (redirectStatusCodes.has(response.status)) {
2803+
// This isn't ideal but gets the job done as a fallback if the user can't
2804+
// implement proper redirects via .htaccess or something else. This is the
2805+
// approach used by Astro as well so there's some precedent.
2806+
// https://github.com/withastro/roadmap/issues/466
2807+
// https://github.com/withastro/astro/blob/main/packages/astro/src/core/routing/3xx.ts
2808+
let location = response.headers.get("Location");
2809+
// A short delay causes Google to interpret the redirect as temporary.
2810+
// https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh
2811+
let delay = response.status === 302 ? 2 : 0;
2812+
html = `<!doctype html>
2813+
<head>
2814+
<title>Redirecting to: ${location}</title>
2815+
<meta http-equiv="refresh" content="${delay};url=${location}">
2816+
<meta name="robots" content="noindex">
2817+
</head>
2818+
<body>
2819+
<a href="${location}">
2820+
Redirecting from <code>${normalizedPath}</code> to <code>${location}</code>
2821+
</a>
2822+
</body>
2823+
</html>`;
2824+
} else if (response.status !== 200) {
28002825
throw new Error(
28012826
`Prerender (html): Received a ${response.status} status code from ` +
28022827
`\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` ` +

packages/react-router/lib/server-runtime/routes.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@ import type {
22
AgnosticDataRouteObject,
33
LoaderFunctionArgs as RRLoaderFunctionArgs,
44
ActionFunctionArgs as RRActionFunctionArgs,
5+
RedirectFunction,
56
RouteManifest,
67
unstable_MiddlewareFunction,
78
} from "../router/utils";
9+
import { redirectDocument, replace, redirect } from "../router/utils";
810
import { callRouteHandler } from "./data";
911
import type { FutureConfig } from "../dom/ssr/entry";
1012
import type { Route } from "../dom/ssr/routes";
1113
import type {
1214
SingleFetchResult,
1315
SingleFetchResults,
1416
} from "../dom/ssr/single-fetch";
15-
import { decodeViaTurboStream } from "../dom/ssr/single-fetch";
17+
import {
18+
SingleFetchRedirectSymbol,
19+
decodeViaTurboStream,
20+
} from "../dom/ssr/single-fetch";
1621
import invariant from "./invariant";
1722
import type { ServerRouteModule } from "../dom/ssr/routeModules";
1823

@@ -99,13 +104,31 @@ export function createStaticHandlerDataRoutes(
99104
});
100105
let decoded = await decodeViaTurboStream(stream, global);
101106
let data = decoded.value as SingleFetchResults;
102-
invariant(
103-
data && route.id in data,
104-
"Unable to decode prerendered data"
105-
);
106-
let result = data[route.id] as SingleFetchResult;
107-
invariant("data" in result, "Unable to process prerendered data");
108-
return result.data;
107+
108+
// If the loader returned a `.data` redirect, re-throw a normal
109+
// Response here to trigger a document level SSG redirect
110+
if (data && SingleFetchRedirectSymbol in data) {
111+
let result = data[SingleFetchRedirectSymbol]!;
112+
let init = { status: result.status };
113+
if (result.reload) {
114+
throw redirectDocument(result.redirect, init);
115+
} else if (result.replace) {
116+
throw replace(result.redirect, init);
117+
} else {
118+
throw redirect(result.redirect, init);
119+
}
120+
} else {
121+
invariant(
122+
data && route.id in data,
123+
"Unable to decode prerendered data"
124+
);
125+
let result = data[route.id] as SingleFetchResult;
126+
invariant(
127+
"data" in result,
128+
"Unable to process prerendered data"
129+
);
130+
return result.data;
131+
}
109132
}
110133
let val = await callRouteHandler(route.module.loader!, args);
111134
return val;

0 commit comments

Comments
 (0)