Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/smart-ligers-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@react-router/dev": patch
"react-router": patch
---

Fix prerendering when a loader returns a redirect
43 changes: 43 additions & 0 deletions integration/vite-prerender-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ let files = {
<Link to="/">Home</Link><br/>
<Link to="/about">About</Link><br/>
<Link to="/not-found">Not Found</Link><br/>
<Link to="/redirect">Redirect</Link><br/>
</nav>
{children}
<Scripts />
Expand Down Expand Up @@ -2347,5 +2348,47 @@ test.describe("Prerendering", () => {
await page.waitForSelector("[data-error]:has-text('404 Not Found')");
expect(requests).toEqual(["/not-found.data"]);
});

test("Handles redirects in prerendered pages", async ({ page }) => {
fixture = await createFixture({
prerender: true,
files: {
...files,
"react-router.config.ts": reactRouterConfig({
ssr: false, // turn off fog of war since we're serving with a static server
prerender: true,
}),
"app/routes/redirect.tsx": js`
import { redirect } from "react-router"
export function loader() {
return redirect('/target', 301);
}
export default function Component() {
<h1>Nope</h1>
}
`,
"app/routes/target.tsx": js`
export default function Component() {
return <h1 id="target">Target</h1>
}
`,
},
});

appFixture = await createAppFixture(fixture);

// Document loads
let requests = captureRequests(page);
let app = new PlaywrightFixture(appFixture, page);
await app.goto("/redirect");
await page.waitForSelector("#target");
expect(requests).toEqual([]);

// Client side navigations
await app.goto("/", true);
app.clickLink("/redirect");
await page.waitForSelector("#target");
expect(requests).toEqual(["/redirect.data"]);
});
});
});
29 changes: 27 additions & 2 deletions packages/react-router-dev/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2761,7 +2761,8 @@ async function prerenderData(
let response = await handler(request);
let data = await response.text();

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

let redirectStatusCodes = new Set([301, 302, 303, 307, 308]);

async function prerenderRoute(
handler: RequestHandler,
prerenderPath: string,
Expand All @@ -2796,7 +2799,29 @@ async function prerenderRoute(
let response = await handler(request);
let html = await response.text();

if (response.status !== 200) {
if (redirectStatusCodes.has(response.status)) {
// This isn't ideal but gets the job done as a fallback if the user can't
// implement proper redirects via .htaccess or something else. This is the
// approach used by Astro as well so there's some precedent.
// https://github.com/withastro/roadmap/issues/466
// https://github.com/withastro/astro/blob/main/packages/astro/src/core/routing/3xx.ts
let location = response.headers.get("Location");
// A short delay causes Google to interpret the redirect as temporary.
// https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh
let delay = response.status === 302 ? 2 : 0;
html = `<!doctype html>
<head>
<title>Redirecting to: ${location}</title>
<meta http-equiv="refresh" content="${delay};url=${location}">
<meta name="robots" content="noindex">
</head>
<body>
<a href="${location}">
Redirecting from <code>${normalizedPath}</code> to <code>${location}</code>
</a>
</body>
</html>`;
Comment on lines +2802 to +2823
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a prerendered document redirects we fall back on an http-equiv redirect

} else if (response.status !== 200) {
throw new Error(
`Prerender (html): Received a ${response.status} status code from ` +
`\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` ` +
Expand Down
39 changes: 31 additions & 8 deletions packages/react-router/lib/server-runtime/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@ import type {
AgnosticDataRouteObject,
LoaderFunctionArgs as RRLoaderFunctionArgs,
ActionFunctionArgs as RRActionFunctionArgs,
RedirectFunction,
RouteManifest,
unstable_MiddlewareFunction,
} from "../router/utils";
import { redirectDocument, replace, redirect } from "../router/utils";
import { callRouteHandler } from "./data";
import type { FutureConfig } from "../dom/ssr/entry";
import type { Route } from "../dom/ssr/routes";
import type {
SingleFetchResult,
SingleFetchResults,
} from "../dom/ssr/single-fetch";
import { decodeViaTurboStream } from "../dom/ssr/single-fetch";
import {
SingleFetchRedirectSymbol,
decodeViaTurboStream,
} from "../dom/ssr/single-fetch";
import invariant from "./invariant";
import type { ServerRouteModule } from "../dom/ssr/routeModules";

Expand Down Expand Up @@ -99,13 +104,31 @@ export function createStaticHandlerDataRoutes(
});
let decoded = await decodeViaTurboStream(stream, global);
let data = decoded.value as SingleFetchResults;
invariant(
data && route.id in data,
"Unable to decode prerendered data"
);
let result = data[route.id] as SingleFetchResult;
invariant("data" in result, "Unable to process prerendered data");
return result.data;

// If the loader returned a `.data` redirect, re-throw a normal
// Response here to trigger a document level SSG redirect
if (data && SingleFetchRedirectSymbol in data) {
let result = data[SingleFetchRedirectSymbol]!;
let init = { status: result.status };
if (result.reload) {
throw redirectDocument(result.redirect, init);
} else if (result.replace) {
throw replace(result.redirect, init);
} else {
throw redirect(result.redirect, init);
}
} else {
invariant(
data && route.id in data,
"Unable to decode prerendered data"
);
let result = data[route.id] as SingleFetchResult;
invariant(
"data" in result,
"Unable to process prerendered data"
);
return result.data;
}
}
let val = await callRouteHandler(route.module.loader!, args);
return val;
Expand Down
Loading