diff --git a/.changeset/smart-ligers-lay.md b/.changeset/smart-ligers-lay.md new file mode 100644 index 0000000000..ca831ce832 --- /dev/null +++ b/.changeset/smart-ligers-lay.md @@ -0,0 +1,6 @@ +--- +"@react-router/dev": patch +"react-router": patch +--- + +Fix prerendering when a loader returns a redirect diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index c6b0c6d18c..afa014be4a 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -54,6 +54,7 @@ let files = { Home
About
Not Found
+ Redirect
{children} @@ -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() { +

Nope

+ } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

Target

+ } + `, + }, + }); + + 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"]); + }); }); }); diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 96b51e1711..543e03ec7b 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -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}\` ` + @@ -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, @@ -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 = ` + +Redirecting to: ${location} + + + + + + Redirecting from ${normalizedPath} to ${location} + + +`; + } else if (response.status !== 200) { throw new Error( `Prerender (html): Received a ${response.status} status code from ` + `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` ` + diff --git a/packages/react-router/lib/server-runtime/routes.ts b/packages/react-router/lib/server-runtime/routes.ts index a49682f572..e7bcf80978 100644 --- a/packages/react-router/lib/server-runtime/routes.ts +++ b/packages/react-router/lib/server-runtime/routes.ts @@ -2,9 +2,11 @@ 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"; @@ -12,7 +14,10 @@ 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"; @@ -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;