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 packages/docs/src/pages/FAQ.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# FAQ

## I'm seeing a ``The Root component renders other content next to `{children}` `` error

With `ssr: false` (the default), the App is mounted into the parent element of `{children}` in your Root component, and React removes all other content from that element when mounting. This error means your Root component renders content that is silently removed this way in production builds.

Make `{children}` the only content of its parent element in the Root component (for example, wrap it in a dedicated `<div>`). See [How It Works](/learn/how-it-works#keep-children-alone-in-its-parent-element) for details.

## I'm seeing a ``<link rel=preload> must have a valid `as` value`` warning

This is a bug in React itself. Please wait for [the fix](https://github.com/facebook/react/pull/34760) to be released.
1 change: 1 addition & 0 deletions packages/docs/src/pages/GettingStarted.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ The Root component:
- is responsible for defining the shell HTML structure of your app
- is a server component
- **CANNOT** import client components; you could, but they are fully rendered into static HTML and never hydrated
- must render `{children}` as the only content of its parent element (unless SSR is enabled); see [How It Works](/learn/how-it-works#keep-children-alone-in-its-parent-element)

### 3. Create Your App Component

Expand Down
4 changes: 4 additions & 0 deletions packages/docs/src/pages/advanced/SSR.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ This improves perceived performance, especially on:

The browser can start painting content as soon as the HTML arrives, while JavaScript loads in the background. Once loaded, React hydrates the existing HTML to make it interactive.

### No Root Layout Constraint

Without SSR, `{children}` must be the only content of its parent element in the Root component ([details](/learn/how-it-works#keep-children-alone-in-its-parent-element)). With SSR enabled, the whole document is hydrated instead of mounting the App into a container element, so this constraint does not apply.

## Cons

### Client Components Must Be SSR-Capable
Expand Down
38 changes: 38 additions & 0 deletions packages/docs/src/pages/learn/HowItWorks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,44 @@ The Root component is special in two ways:

The Root entrypoint is a FUNSTACK Static counterpart to the `index.html` file in traditional SPAs. It allows you to still leverage some of the benefits of server components for defining the HTML shell of your application.

### Keep `{children}` Alone in Its Parent Element

With `ssr: false` (the default), the App is mounted on the client into the parent element of `{children}`. React clears all existing content of that element when mounting, so any other content the Root component renders **in the same element** is removed from the page the moment the App mounts:

```tsx
// ❌ BAD: <header> and <footer> are removed when the App mounts
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<header>My Site</header>
{children}
<footer>All rights reserved.</footer>
</body>
</html>
);
}
```

To avoid this, make `{children}` the only content of its parent element. Static content is safe anywhere else:

```tsx
// ✅ GOOD: {children} is the only content of its parent <div>
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<header>My Site</header>
<div>{children}</div>
<footer>All rights reserved.</footer>
</body>
</html>
);
}
```

FUNSTACK Static reports a console error, both in development and in production builds, when it detects content that would be removed by the mount. If you want such content to survive, you can also move it into the App component, or enable [`ssr: true`](/advanced/ssr) — with SSR the whole document is hydrated and this constraint does not apply.

## Server-Side Rendering

By default, FUNSTACK Static only renders the Root shell to HTML. The App component is rendered client-side from its RSC payload. This behavior keeps the initial HTML small and fast to deliver.
Expand Down
5 changes: 5 additions & 0 deletions packages/static/e2e/fixture-multi-entry/src/entries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,10 @@ export default function getEntries(): EntryDefinition[] {
root: () => import("./root"),
app: () => import("./pages/HmrTest"),
},
{
path: "destructive.html",
root: () => import("./root-destructive"),
app: () => import("./pages/Destructive"),
},
];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function Destructive() {
return (
<main>
<h1>Destructive Page</h1>
<p data-testid="page-id">destructive</p>
</main>
);
}
21 changes: 21 additions & 0 deletions packages/static/e2e/fixture-multi-entry/src/root-destructive.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// A Root that violates the "keep {children} alone in its parent element"
// constraint: the <header> shares <body> with the app mount point, so the
// production client mount destroys it and a console error is reported.
export default function RootDestructive({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<title>Destructive Mount Fixture</title>
</head>
<body>
<header data-testid="doomed-header">Static Header</header>
{children}
</body>
</html>
);
}
42 changes: 42 additions & 0 deletions packages/static/e2e/tests-dev/multi-entry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,48 @@ test.describe("Multi-entry page rendering (dev server)", () => {
});
});

test.describe("Destructive mount detection (dev server)", () => {
test("warns about Root content that a production mount would destroy", async ({
page,
}) => {
const consoleErrors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push(msg.text());
}
});

await page.goto("/destructive");
await expect(page.locator("h1")).toHaveText("Destructive Page");

// In dev the full tree (including Root) is rendered by React, so the
// content survives — the console error is the only signal of the problem
await expect(page.getByTestId("doomed-header")).toBeVisible();

const warning = consoleErrors.find((m) => m.includes("[@funstack/static]"));
expect(warning).toBeDefined();
expect(warning).toContain("<header>");
});

test("does not warn when {children} is alone in its parent", async ({
page,
}) => {
const consoleErrors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push(msg.text());
}
});

await page.goto("/");
await expect(page.locator("h1")).toHaveText("Home Page");

expect(
consoleErrors.filter((m) => m.includes("[@funstack/static]")),
).toEqual([]);
});
});

test.describe("Multi-entry HMR (dev server)", () => {
const hmrPagePath = fileURLToPath(
new URL("../fixture-multi-entry/src/pages/HmrTest.tsx", import.meta.url),
Expand Down
41 changes: 41 additions & 0 deletions packages/static/e2e/tests/multi-entry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,44 @@ test.describe("Multi-entry page rendering", () => {
expect(errors).toEqual([]);
});
});

test.describe("Destructive mount detection", () => {
test("warns when Root content shares the mount container", async ({
page,
}) => {
const consoleErrors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push(msg.text());
}
});

await page.goto("/destructive");
await expect(page.locator("h1")).toHaveText("Destructive Page");

// The <header> shares <body> with the mount point, so mounting removed it
await expect(page.getByTestId("doomed-header")).toHaveCount(0);

const warning = consoleErrors.find((m) => m.includes("[@funstack/static]"));
expect(warning).toBeDefined();
expect(warning).toContain("<header>");
});

test("does not warn when {children} is alone in its parent", async ({
page,
}) => {
const consoleErrors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push(msg.text());
}
});

await page.goto("/");
await expect(page.locator("h1")).toHaveText("Home Page");

expect(
consoleErrors.filter((m) => m.includes("[@funstack/static]")),
).toEqual([]);
});
});
2 changes: 2 additions & 0 deletions packages/static/skills/funstack-static-knowledge/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export default defineConfig({

**Entrypoint.** Here, the `root` option points to the Root component of your application which is responsible for the HTML shell of your application. The `app` option points to the main App component which is the entrypoint for your application's UI.

**Root layout constraint.** Unless the `ssr` option is enabled, the Root component must render `{children}` as the only content of its parent element. The App is mounted into that parent element on the client, which removes any other content from it. Wrap `{children}` in a dedicated element (e.g. `<div>{children}</div>`) if the Root renders other content next to it.

**Server and Client Components.** The entrypoint components (Root and App) are **server components**. FUNSTACK Static follows React's conventions for Server and Client Components; the entrypoint is executed as a Server module. Modules marked with the `"use client"` directive are executed as Client modules. Server modules can import both Server and Client modules, while Client modules can only import other Client modules.

**Server Actions.** Note that Server Actions (`"use server"`) are **NOT** supported in FUNSTACK Static, as there is no server runtime deployed.
Expand Down
24 changes: 21 additions & 3 deletions packages/static/src/client/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { GlobalErrorBoundary } from "./error-boundary";
import type { RscPayload } from "../rsc/entry";
import { devMainRscPath } from "../rsc/request";
import { appClientManifestVar, type AppClientManifest } from "./globals";
import { findAppMarker, warnIfDestructiveMount } from "./mount-validation";
import { withBasePath } from "../util/basePath";

import { ssr as ssrEnabled } from "virtual:funstack/config";
Expand Down Expand Up @@ -59,7 +60,15 @@ async function devMain() {
} else if (ssrEnabled) {
hydrateRoot(document, browserRoot);
} else {
// SSR off: Root shell is static HTML, mount App client-side
// SSR off: Root shell is static HTML, mount App client-side.
// The served shell has the same shape as the production build
// (marker span inside its container), so validate it before React
// replaces the document — this surfaces in dev the content that a
// production mount would destroy.
const appMarker = findAppMarker();
if (appMarker) {
warnIfDestructiveMount(appMarker);
}
createRoot(document).render(browserRoot);
}

Expand Down Expand Up @@ -94,8 +103,16 @@ async function prodMain() {

hydrateRoot(document, browserRoot);
} else {
// SSR off: Root shell only, mount App client-side
const browserRoot = <BrowserRoot />;
// SSR off: Root shell only, mount App client-side.
// The error boundary is embedded because the mount point is an inner
// element, where the document-level fallback's <html> would be invalid.
const browserRoot = (
<React.StrictMode>
<GlobalErrorBoundary embedded>
<BrowserRoot />
</GlobalErrorBoundary>
</React.StrictMode>
);
const appRootId = manifest.marker!;

const appMarker = document.getElementById(appRootId);
Expand All @@ -110,6 +127,7 @@ async function prodMain() {
`App root element has no parent element. This is likely a bug.`,
);
}
warnIfDestructiveMount(appMarker);
appMarker.remove();

createRoot(appRoot).render(browserRoot);
Expand Down
84 changes: 55 additions & 29 deletions packages/static/src/client/error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,73 @@ import { ErrorBoundary, type FallbackProps } from "react-error-boundary";

/**
* Whole-page error boundary for unexpected errors during development
*
* With `embedded`, the fallback is rendered without the `<html>`/`<body>`
* wrapper, for roots mounted inside an element rather than at the document.
*/
export const GlobalErrorBoundary: React.FC<React.PropsWithChildren> = (
props,
) => {
export const GlobalErrorBoundary: React.FC<
React.PropsWithChildren<{ embedded?: boolean }>
> = (props) => {
return (
<ErrorBoundary FallbackComponent={Fallback}>{props.children}</ErrorBoundary>
<ErrorBoundary
FallbackComponent={props.embedded ? EmbeddedFallback : Fallback}
>
{props.children}
</ErrorBoundary>
);
};

const Fallback: React.FC<FallbackProps> = ({ error, resetErrorBoundary }) => {
const fallbackStyle: React.CSSProperties = {
height: "100vh",
display: "flex",
flexDirection: "column",
placeContent: "center",
placeItems: "center",
fontSize: "24px",
fontWeight: 400,
lineHeight: "1.5em",
};

const FallbackContent: React.FC<FallbackProps> = ({
error,
resetErrorBoundary,
}) => {
const errorMessage = error instanceof Error ? error.message : String(error);
return (
<>
<h1>Caught an unexpected error</h1>
<p>See the console for details.</p>
<pre>Error: {errorMessage}</pre>
<button
onClick={() => {
startTransition(() => {
resetErrorBoundary();
});
}}
>
Reset
</button>
</>
);
};

const Fallback: React.FC<FallbackProps> = (props) => {
return (
<html>
<head>
<title>Unexpected Error</title>
</head>
<body
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
placeContent: "center",
placeItems: "center",
fontSize: "24px",
fontWeight: 400,
lineHeight: "1.5em",
}}
>
<h1>Caught an unexpected error</h1>
<p>See the console for details.</p>
<pre>Error: {errorMessage}</pre>
<button
onClick={() => {
startTransition(() => {
resetErrorBoundary();
});
}}
>
Reset
</button>
<body style={fallbackStyle}>
<FallbackContent {...props} />
</body>
</html>
);
};

const EmbeddedFallback: React.FC<FallbackProps> = (props) => {
return (
<div style={fallbackStyle}>
<FallbackContent {...props} />
</div>
);
};
Loading