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
2 changes: 1 addition & 1 deletion packages/docs/src/pages/advanced/SSR.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Without SSR, `{children}` must be the only content of its parent element in the

When SSR is enabled, your client components run during the build process to generate HTML. This means they must work in a server-like environment where browser APIs are not available.

Components that directly access browser-only APIs will cause build errors:
Components that directly access browser-only APIs will cause build errors. This includes errors inside a `<Suspense>` boundary, which React would otherwise recover from by rendering that part on the client (see [`failOnRenderError`](/api/funstack-static#failonrendererror-optional)):

```tsx
"use client";
Expand Down
19 changes: 19 additions & 0 deletions packages/docs/src/pages/api/FunstackStatic.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,25 @@ funstackStatic({
});
```

### failOnRenderError (optional)

**Type:** `boolean`
**Default:** `true`

Whether `vite build` fails when React reports an error while rendering a page. Examples include a Server Component that throws, a function passed as a prop to a Client Component, a [deferred](/api/defer) component that throws, or a Client Component that throws during [SSR](/advanced/ssr).

React recovers from these errors without stopping the render. It puts an error into the RSC payload, or with SSR, leaves the affected `<Suspense>` boundary to be rendered on the client. A server could try the render again on a later request, but a static build has no later request, so the broken page would be deployed as is. That's why the build fails by default: the errors are logged, the build stops before writing the affected pages, and it ends with an error that lists each failure and where it happened.

Set this to `false` to log the errors and write the output anyway. This was the behavior before this option existed.

```typescript
funstackStatic({
root: "./src/root.tsx",
app: "./src/App.tsx",
failOnRenderError: false,
});
```

## Full Example

### Single-Entry
Expand Down
14 changes: 14 additions & 0 deletions packages/static/e2e/fixture-render-error/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "e2e-fixture-render-error",
"private": true,
"type": "module",
"devDependencies": {
"@funstack/static": "workspace:*",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"vite": "catalog:"
}
}
12 changes: 12 additions & 0 deletions packages/static/e2e/fixture-render-error/src/ClientPart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"use client";

export function ClientPart({ children }: { children?: unknown }) {
return <p>{typeof children === "function" ? children() : "client part"}</p>;
}

export function ThrowOnServer() {
if (typeof window === "undefined") {
throw new Error("ThrowOnServer: error thrown during SSR");
}
return <p>client only</p>;
}
11 changes: 11 additions & 0 deletions packages/static/e2e/fixture-render-error/src/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<title>Render Error Test</title>
</head>
<body>{children}</body>
</html>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Suspense } from "react";
import { defer } from "@funstack/static/server";

function Throws(): React.ReactNode {
throw new Error("Throws: error thrown in a deferred component");
}

export default function App() {
return (
<main>
<Suspense fallback={<p>loading</p>}>
{defer(<Throws />, { name: "Throws" })}
</Suspense>
</main>
);
}
15 changes: 15 additions & 0 deletions packages/static/e2e/fixture-render-error/src/scenarios/ok.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defer } from "@funstack/static/server";
import { ClientPart } from "../ClientPart";

function Deferred() {
return <p>deferred</p>;
}

export default function App() {
return (
<main>
<ClientPart />
{defer(<Deferred />, { name: "Deferred" })}
</main>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Suspense } from "react";
import { ClientPart } from "../ClientPart";

export default function App() {
return (
<main>
{/* With SSR, the boundary lets prerendering recover from the error by
client-rendering this part, so only onError reports it. */}
<Suspense fallback={<p>loading</p>}>
{/* Functions cannot be passed to Client Components (#194) */}
<ClientPart>{() => "x"}</ClientPart>
</Suspense>
</main>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Suspense } from "react";
import { ThrowOnServer } from "../ClientPart";

export default function App() {
return (
<main>
<Suspense fallback={<p>loading</p>}>
<ThrowOnServer />
</Suspense>
</main>
);
}
29 changes: 29 additions & 0 deletions packages/static/e2e/fixture-render-error/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import funstackStatic from "@funstack/static";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

// Which App to build and the plugin options are selected by environment
// variables, so that e2e/tests/render-error.spec.ts can build each case.
const scenario = process.env.RENDER_ERROR_SCENARIO ?? "ok";
const ssr = process.env.RENDER_ERROR_SSR === "true";
const failOnRenderError = process.env.RENDER_ERROR_FAIL !== "false";

// Separate output per variant so that builds can run in parallel.
const outDir = `dist/${scenario}-${ssr ? "ssr" : "csr"}${failOnRenderError ? "" : "-nofail"}`;

export default defineConfig({
plugins: [
funstackStatic({
root: "./src/root.tsx",
app: `./src/scenarios/${scenario}.tsx`,
ssr,
failOnRenderError,
publicOutDir: `${outDir}/public`,
}),
react(),
],
environments: {
rsc: { build: { outDir: `${outDir}/rsc` } },
ssr: { build: { outDir: `${outDir}/ssr` } },
},
});
5 changes: 5 additions & 0 deletions packages/static/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ export default defineConfig({
},
testMatch: /\/fs-routing\.spec\.ts$/,
},
{
// Runs `vite build` itself; needs no web server
name: "render-error",
testMatch: /\/render-error\.spec\.ts$/,
},
],

webServer: [
Expand Down
119 changes: 119 additions & 0 deletions packages/static/e2e/tests/render-error.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { execFile } from "node:child_process";
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { expect, test } from "@playwright/test";

const execFileAsync = promisify(execFile);

const fixtureDir = path.resolve(import.meta.dirname, "../fixture-render-error");

type Scenario = "ok" | "rsc-error" | "defer-error" | "ssr-error";

/**
* Runs `vite build` in the render-error fixture and returns its exit code,
* combined output and public output directory.
*/
async function build(
scenario: Scenario,
options: { ssr: boolean; failOnRenderError?: boolean },
) {
const failOnRenderError = options.failOnRenderError ?? true;
const outDir = path.join(
fixtureDir,
"dist",
`${scenario}-${options.ssr ? "ssr" : "csr"}${failOnRenderError ? "" : "-nofail"}`,
"public",
);
try {
const { stdout, stderr } = await execFileAsync("pnpm", ["vite", "build"], {
cwd: fixtureDir,
env: {
...process.env,
RENDER_ERROR_SCENARIO: scenario,
RENDER_ERROR_SSR: String(options.ssr),
RENDER_ERROR_FAIL: String(failOnRenderError),
},
});
return { exitCode: 0, output: stdout + stderr, outDir };
} catch (error) {
const { code, stdout, stderr } = error as {
code: number;
stdout: string;
stderr: string;
};
return { exitCode: code, output: stdout + stderr, outDir };
}
}

async function readRscPayloads(outDir: string): Promise<string[]> {
const dir = path.join(outDir, "funstack__", "fun__rsc-payload");
const files = await readdir(dir);
return Promise.all(
files.map((file) => readFile(path.join(dir, file), "utf8")),
);
}

test.describe("Build fails on render errors (#194)", () => {
// Each build takes a few seconds.
test.setTimeout(60000);

for (const ssr of [false, true]) {
test.describe(ssr ? "SSR on" : "SSR off", () => {
test("succeeds without render errors", async () => {
const result = await build("ok", { ssr });
expect(result.output).not.toContain("Error while rendering");
expect(result.exitCode).toBe(0);
});

test("fails when a Server Component render errors", async () => {
const result = await build("rsc-error", { ssr });
expect(result.exitCode).not.toBe(0);
expect(result.output).toContain(
'RSC payload of "index.html": Functions cannot be passed directly to Client Components',
);
// Nothing is written for the broken page
await expect(
readFile(path.join(result.outDir, "index.html")),
).rejects.toThrow();
});

test("fails when a deferred component errors", async () => {
const result = await build("defer-error", { ssr });
expect(result.exitCode).not.toBe(0);
expect(result.output).toContain(
'deferred component "Throws": Throws: error thrown in a deferred component',
);
});

test("emits the output with failOnRenderError: false", async () => {
const result = await build("rsc-error", {
ssr,
failOnRenderError: false,
});
expect(result.exitCode).toBe(0);
// The error is still logged
expect(result.output).toContain("Error while rendering");
const payloads = await readRscPayloads(result.outDir);
expect(payloads.some((payload) => /^\w+:E\{/m.test(payload))).toBe(
true,
);
if (ssr) {
const html = await readFile(
path.join(result.outDir, "index.html"),
"utf8",
);
expect(html).toContain("<!--$!-->");
}
});
});
}

test("fails when prerendering a Client Component errors", async () => {
const result = await build("ssr-error", { ssr: true });
expect(result.exitCode).not.toBe(0);
expect(result.output).toContain(
'HTML of "index.html": ThrowOnServer: error thrown during SSR',
);
});
});
19 changes: 17 additions & 2 deletions packages/static/src/build/buildApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ import { replaceIdsInContent } from "./idReplacement";
import { computeContentHash } from "./contentHash";
import { drainStream } from "../util/drainStream";
import { validateEntryPath, checkDuplicatePaths } from "./validateEntryPath";
import { createRenderErrorsError } from "./renderErrors";
import type { EntryBuildResult } from "../rsc/entry";

export async function buildApp(
builder: ViteBuilder,
context: MinimalPluginContextWithoutEnvironment,
options: { rscPayloadDir: string },
options: { rscPayloadDir: string; failOnRenderError: boolean },
) {
const { config } = builder;
// import server entry
Expand All @@ -35,7 +36,17 @@ export async function buildApp(
const base = normalizeBase(config.base);

async function doBuild() {
const { entries, deferRegistry } = await entry.build();
const { entries, deferRegistry, renderErrors } = await entry.build();

// React reports render errors through `onError` and recovers in the
// output (an error row in the RSC payload, a client-rendered boundary in
// the HTML), so they are collected as streams drain. Check before each
// write so broken output is not emitted.
const checkRenderErrors = () => {
if (options.failOnRenderError && renderErrors.length > 0) {
throw createRenderErrorsError(renderErrors);
}
};

// Validate all entry paths
const paths: string[] = [];
Expand All @@ -57,6 +68,7 @@ export async function buildApp(
options.rscPayloadDir,
context,
);
checkRenderErrors();

// Write each entry's HTML and RSC payload
for (const result of entries) {
Expand All @@ -67,6 +79,7 @@ export async function buildApp(
base,
options.rscPayloadDir,
context,
checkRenderErrors,
);
}

Expand Down Expand Up @@ -99,6 +112,7 @@ async function buildSingleEntry(
base: string,
rscPayloadDir: string,
context: MinimalPluginContextWithoutEnvironment,
checkRenderErrors: () => void,
) {
const { path: entryPath, html, appRsc } = result;

Expand All @@ -108,6 +122,7 @@ async function buildSingleEntry(
// Drain and process RSC payload: replace temp IDs with final hashed IDs
const rawAppRscContent = await drainStream(appRsc);
const appRscContent = replaceIdsInContent(rawAppRscContent, idMapping);
checkRenderErrors();

// Compute hash for this entry's RSC payload
const mainPayloadHash = await computeContentHash(appRscContent);
Expand Down
Loading
Loading