Skip to content

Fix usage of prerender option with serverBundles #13082

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 21, 2025
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
5 changes: 5 additions & 0 deletions .changeset/dry-impalas-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-router/dev": patch
---

Fix usage of `prerender` option when `serverBundles` option has been configured or provided by a preset, e.g. `vercelPreset` from `@vercel/react-router`
54 changes: 54 additions & 0 deletions integration/vite-prerender-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,60 @@ test.describe("Prerendering", () => {
expect(html).toMatch('<p data-loader-data="true">About Loader Data</p>');
});

test("Prerenders a static array of routes with server bundles", async () => {
fixture = await createFixture({
prerender: true,
files: {
...files,
"react-router.config.ts": js`
let counter = 1;
export default {
serverBundles: () => "server" + counter++,
async prerender() {
await new Promise(r => setTimeout(r, 1));
return ['/', '/about'];
},
}
`,
"vite.config.ts": js`
import { defineConfig } from "vite";
import { reactRouter } from "@react-router/dev/vite";

export default defineConfig({
build: { manifest: true },
plugins: [
reactRouter()
],
});
`,
},
});
appFixture = await createAppFixture(fixture);

let clientDir = path.join(fixture.projectDir, "build", "client");
expect(listAllFiles(clientDir).sort()).toEqual([
"_root.data",
"about.data",
"about/index.html",
"favicon.ico",
"index.html",
]);

let res = await fixture.requestDocument("/");
let html = await res.text();
expect(html).toMatch("<title>Index Title: Index Loader Data</title>");
expect(html).toMatch("<h1>Root</h1>");
expect(html).toMatch('<h2 data-route="true">Index</h2>');
expect(html).toMatch('<p data-loader-data="true">Index Loader Data</p>');

res = await fixture.requestDocument("/about");
html = await res.text();
expect(html).toMatch("<title>About Title: About Loader Data</title>");
expect(html).toMatch("<h1>Root</h1>");
expect(html).toMatch('<h2 data-route="true">About</h2>');
expect(html).toMatch('<p data-loader-data="true">About Loader Data</p>');
});

test("Prerenders a dynamic array of routes based on the static routes", async () => {
fixture = await createFixture({
files: {
Expand Down
47 changes: 39 additions & 8 deletions packages/react-router-dev/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,28 @@ const isRouteVirtualModule = (id: string): boolean => {
return isRouteEntryModuleId(id) || isRouteChunkModuleId(id);
};

const isServerBuildVirtualModuleId = (id: string): boolean => {
return id.split("?")[0] === virtual.serverBuild.id;
};

const getServerBuildFile = (viteManifest: Vite.Manifest): string => {
let serverBuildIds = Object.keys(viteManifest).filter(
isServerBuildVirtualModuleId
);

invariant(
serverBuildIds.length <= 1,
"Multiple server build files found in manifest"
);

invariant(
serverBuildIds.length === 1,
"Server build file not found in manifest"
);

return viteManifest[serverBuildIds[0]].file;
};

export type ServerBundleBuildConfig = {
routes: RouteManifest;
serverBundleId: string;
Expand Down Expand Up @@ -1518,7 +1540,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
viteConfig,
ctx.reactRouterConfig,
serverBuildDirectory,
ssrViteManifest[virtual.serverBuild.id].file,
getServerBuildFile(ssrViteManifest),
clientBuildDirectory
);
}
Expand All @@ -1531,7 +1553,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
viteConfig,
ctx.reactRouterConfig,
serverBuildDirectory,
ssrViteManifest[virtual.serverBuild.id].file,
getServerBuildFile(ssrViteManifest),
clientBuildDirectory
);
}
Expand Down Expand Up @@ -2406,19 +2428,28 @@ async function handlePrerender(
serverBuildPath
);

let routes = createPrerenderRoutes(build.routes);
let routes = createPrerenderRoutes(reactRouterConfig.routes);
for (let path of build.prerender) {
let matches = matchRoutes(routes, `/${path}/`.replace(/^\/\/+/, "/"));
if (!matches) {
throw new Error(
`Unable to prerender path because it does not match any routes: ${path}`
);
}
}

let buildRoutes = createPrerenderRoutes(build.routes);
let headers = {
// Header that can be used in the loader to know if you're running at
// build time or runtime
"X-React-Router-Prerender": "yes",
};
for (let path of build.prerender) {
// Ensure we have a leading slash for matching
let matches = matchRoutes(routes, `/${path}/`.replace(/^\/\/+/, "/"));
invariant(
matches,
`Unable to prerender path because it does not match any routes: ${path}`
);
let matches = matchRoutes(buildRoutes, `/${path}/`.replace(/^\/\/+/, "/"));
if (!matches) {
continue;
Copy link
Member Author

@markdalgleish markdalgleish Feb 21, 2025

Choose a reason for hiding this comment

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

It's valid for an individual server bundle not to contain a matching route. Instead, we now perform validation earlier as a separate step, checking the routes coming from react-router.config.ts which contains everything, not just the potential subset in this server build.

}
// When prerendering a resource route, we don't want to pass along the
// `.data` file since we want to prerender the raw Response returned from
// the loader. Presumably this is for routes where a file extension is
Expand Down