Skip to content
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

Add support for prerendering #11539

Merged
merged 12 commits into from
May 9, 2024
32 changes: 32 additions & 0 deletions .changeset/prerendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"react-router": minor
---

- Add support for `prerender` config in the React Router vite plugin, to support existing SSG use-cases
- You can use the `prerender` config to pre-render your `.html` and `.data` files at build time and then serve them statically at runtime (either from a running server or a CDN)
- `prerender` can either be an array of string paths, or a function (sync or async) that returns an array of strings so that you can dynamically generate the paths by talking to your CMS, etc.

```ts
export default defineConfig({
plugins: [
reactRouter({
// Single fetch is required for prerendering (which will be the default in v7)
future: {
unstable_singleFetch: true,
},
async prerender() {
let slugs = await fakeGetSlugsFromCms();
// Prerender these paths into `.html` files at build time, and `.data`
// files if they have loaders
return ["/", "/about", ...slugs.map((slug) => `/product/${slug}`)];
},
}),
tsconfigPaths(),
],
});

async function fakeGetSlugsFromCms() {
await new Promise((r) => setTimeout(r, 1000));
return ["shirt", "hat"];
}
```
7 changes: 6 additions & 1 deletion integration/error-boundary-v2-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,12 @@ test.describe("single fetch", () => {
let app = new PlaywrightFixture(appFixture, page);
await app.goto("/parent");
await app.clickLink("/parent/child-with-boundary");
await waitForAndAssert(page, app, "#child-error", "CDN Error");
await waitForAndAssert(
page,
app,
"#child-error",
"Unable to decode turbo-stream response"
Copy link
Contributor Author

Choose a reason for hiding this comment

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

See below, but we can't surface this underlying CDN error any longer with pre-rendering :/

);
});
});

Expand Down
5 changes: 3 additions & 2 deletions integration/vite-presets-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const files = {
let isDeepFrozen = (obj: any) =>
Object.isFrozen(obj) &&
Object.keys(obj).every(
prop => typeof obj[prop] !== 'object' || isDeepFrozen(obj[prop])
prop => typeof obj[prop] !== 'object' || obj[prop] === null || isDeepFrozen(obj[prop])
);

export default {
Expand Down Expand Up @@ -49,7 +49,7 @@ const files = {
return {};
},
},

// Ensure preset config takes lower precedence than user config
{
name: "test-preset",
Expand Down Expand Up @@ -214,6 +214,7 @@ test("Vite / presets", async () => {
"buildEnd",
"future",
"manifest",
"prerender",
"publicPath",
"routes",
"serverBuildFile",
Expand Down
17 changes: 10 additions & 7 deletions packages/react-router/lib/dom/ssr/single-fetch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,16 +294,19 @@ export function singleFetchUrl(reqUrl: URL | string) {

async function fetchAndDecode(url: URL, init?: RequestInit) {
let res = await fetch(url, init);
if (res.headers.get("Content-Type")?.includes("text/x-turbo")) {
invariant(res.body, "No response body to decode");
invariant(res.body, "No response body to decode");
try {
let decoded = await decodeViaTurboStream(res.body, window);
return { status: res.status, data: decoded.value };
} catch (e) {
// Can't clone after consuming the body via turbo-stream so we can't
// include the body here. In an ideal world we'd look for a turbo-stream
// content type here, or even X-Remix-Response but then folks can't
// statically deploy their prerendered .data files to a CDN unless they can
// tell that CDN to add special headers to those certain files - which is a
// bit restrictive.
throw new Error("Unable to decode turbo-stream response");
}
Comment on lines +309 to 316
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is why we can't surface the res.text() of CDN errors


// If we didn't get back a turbo-stream response, then we never reached the
// Remix server and likely this is a network error - just expose up the
// response body as an Error
throw new Error(await res.text());
}

// Note: If you change this function please change the corresponding
Expand Down
32 changes: 27 additions & 5 deletions packages/remix-dev/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ export type VitePluginConfig = {
* Defaults to `false`.
*/
manifest?: boolean;
/**
* An array of URLs to prerender to HTML files at build time. Can also be a
* function returning an array to dynamically generate URLs.
*/
prerender?: Array<string> | (() => Array<string> | Promise<Array<string>>);
/**
* An array of React Router plugin config presets to ease integration with
* other platforms and tools.
Expand Down Expand Up @@ -195,6 +200,10 @@ export type ResolvedVitePluginConfig = Readonly<{
* Defaults to `false`.
*/
manifest: boolean;
/**
* An array of URLs to prerender to HTML files at build time.
*/
prerender: Array<string> | null;
/**
* Derived from Vite's `base` config
* */
Expand Down Expand Up @@ -370,6 +379,7 @@ export async function resolveReactRouterConfig({
ignoredRouteFiles,
manifest,
routes: userRoutesFunction,
prerender: prerenderConfig,
serverBuildFile,
serverBundles,
serverModuleFormat,
Expand All @@ -379,10 +389,8 @@ export async function resolveReactRouterConfig({
...mergeReactRouterConfig(...presets, reactRouterUserConfig),
};

let isSpaMode = !ssr;

// Log warning for incompatible vite config flags
if (isSpaMode && serverBundles) {
if (!ssr && serverBundles) {
console.warn(
colors.yellow(
colors.bold("⚠️ SPA Mode: ") +
Expand All @@ -393,6 +401,20 @@ export async function resolveReactRouterConfig({
serverBundles = undefined;
}

let prerender: Array<string> | null = null;
if (prerenderConfig) {
if (Array.isArray(prerenderConfig)) {
prerender = prerenderConfig;
} else if (typeof prerenderConfig === "function") {
prerender = await prerenderConfig();
} else {
throw new Error(
"The `prerender` config must be an array of string paths, or a function " +
"returning an array of string paths"
);
}
}

let appDirectory = path.resolve(rootDirectory, userAppDirectory || "app");
let buildDirectory = path.resolve(rootDirectory, userBuildDirectory);
let publicPath = viteUserConfig.base ?? "/";
Expand Down Expand Up @@ -445,6 +467,7 @@ export async function resolveReactRouterConfig({
buildEnd,
future,
manifest,
prerender,
publicPath,
routes,
serverBuildFile,
Expand All @@ -468,7 +491,6 @@ export async function resolveEntryFiles({
reactRouterConfig: ResolvedVitePluginConfig;
}) {
let { appDirectory, future } = reactRouterConfig;
let isSpaMode = !reactRouterConfig.ssr;

let defaultsDirectory = path.resolve(__dirname, "config", "defaults");

Expand All @@ -481,7 +503,7 @@ export async function resolveEntryFiles({
let pkgJson = await PackageJson.load(rootDirectory);
let deps = pkgJson.content.dependencies ?? {};

if (isSpaMode && future?.unstable_singleFetch != true) {
if (!reactRouterConfig.ssr && future?.unstable_singleFetch !== true) {
// This is a super-simple default since we don't need streaming in SPA Mode.
// We can include this in a remix-spa template, but right now `npx remix reveal`
// will still expose the streaming template since that command doesn't have
Expand Down
Loading