Skip to content

Commit 7be9935

Browse files
authored
fix: detect destructive non-SSR mount and wrap it in error boundary (#158)
1 parent e16f2c8 commit 7be9935

15 files changed

Lines changed: 444 additions & 34 deletions

File tree

packages/docs/src/pages/FAQ.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# FAQ
22

3+
## I'm seeing a ``The Root component renders other content next to `{children}` `` error
4+
5+
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.
6+
7+
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.
8+
39
## I'm seeing a ``<link rel=preload> must have a valid `as` value`` warning
410

511
This is a bug in React itself. Please wait for [the fix](https://github.com/facebook/react/pull/34760) to be released.

packages/docs/src/pages/GettingStarted.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ The Root component:
6565
- is responsible for defining the shell HTML structure of your app
6666
- is a server component
6767
- **CANNOT** import client components; you could, but they are fully rendered into static HTML and never hydrated
68+
- 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)
6869

6970
### 3. Create Your App Component
7071

packages/docs/src/pages/advanced/SSR.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ This improves perceived performance, especially on:
3030

3131
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.
3232

33+
### No Root Layout Constraint
34+
35+
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.
36+
3337
## Cons
3438

3539
### Client Components Must Be SSR-Capable

packages/docs/src/pages/learn/HowItWorks.mdx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,44 @@ The Root component is special in two ways:
8585

8686
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.
8787

88+
### Keep `{children}` Alone in Its Parent Element
89+
90+
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:
91+
92+
```tsx
93+
// ❌ BAD: <header> and <footer> are removed when the App mounts
94+
export default function Root({ children }: { children: React.ReactNode }) {
95+
return (
96+
<html lang="en">
97+
<body>
98+
<header>My Site</header>
99+
{children}
100+
<footer>All rights reserved.</footer>
101+
</body>
102+
</html>
103+
);
104+
}
105+
```
106+
107+
To avoid this, make `{children}` the only content of its parent element. Static content is safe anywhere else:
108+
109+
```tsx
110+
// ✅ GOOD: {children} is the only content of its parent <div>
111+
export default function Root({ children }: { children: React.ReactNode }) {
112+
return (
113+
<html lang="en">
114+
<body>
115+
<header>My Site</header>
116+
<div>{children}</div>
117+
<footer>All rights reserved.</footer>
118+
</body>
119+
</html>
120+
);
121+
}
122+
```
123+
124+
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.
125+
88126
## Server-Side Rendering
89127

90128
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.

packages/static/e2e/fixture-multi-entry/src/entries.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,10 @@ export default function getEntries(): EntryDefinition[] {
1717
root: () => import("./root"),
1818
app: () => import("./pages/HmrTest"),
1919
},
20+
{
21+
path: "destructive.html",
22+
root: () => import("./root-destructive"),
23+
app: () => import("./pages/Destructive"),
24+
},
2025
];
2126
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default function Destructive() {
2+
return (
3+
<main>
4+
<h1>Destructive Page</h1>
5+
<p data-testid="page-id">destructive</p>
6+
</main>
7+
);
8+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// A Root that violates the "keep {children} alone in its parent element"
2+
// constraint: the <header> shares <body> with the app mount point, so the
3+
// production client mount destroys it and a console error is reported.
4+
export default function RootDestructive({
5+
children,
6+
}: {
7+
children: React.ReactNode;
8+
}) {
9+
return (
10+
<html lang="en">
11+
<head>
12+
<meta charSet="UTF-8" />
13+
<title>Destructive Mount Fixture</title>
14+
</head>
15+
<body>
16+
<header data-testid="doomed-header">Static Header</header>
17+
{children}
18+
</body>
19+
</html>
20+
);
21+
}

packages/static/e2e/tests-dev/multi-entry.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,48 @@ test.describe("Multi-entry page rendering (dev server)", () => {
7979
});
8080
});
8181

82+
test.describe("Destructive mount detection (dev server)", () => {
83+
test("warns about Root content that a production mount would destroy", async ({
84+
page,
85+
}) => {
86+
const consoleErrors: string[] = [];
87+
page.on("console", (msg) => {
88+
if (msg.type() === "error") {
89+
consoleErrors.push(msg.text());
90+
}
91+
});
92+
93+
await page.goto("/destructive");
94+
await expect(page.locator("h1")).toHaveText("Destructive Page");
95+
96+
// In dev the full tree (including Root) is rendered by React, so the
97+
// content survives — the console error is the only signal of the problem
98+
await expect(page.getByTestId("doomed-header")).toBeVisible();
99+
100+
const warning = consoleErrors.find((m) => m.includes("[@funstack/static]"));
101+
expect(warning).toBeDefined();
102+
expect(warning).toContain("<header>");
103+
});
104+
105+
test("does not warn when {children} is alone in its parent", async ({
106+
page,
107+
}) => {
108+
const consoleErrors: string[] = [];
109+
page.on("console", (msg) => {
110+
if (msg.type() === "error") {
111+
consoleErrors.push(msg.text());
112+
}
113+
});
114+
115+
await page.goto("/");
116+
await expect(page.locator("h1")).toHaveText("Home Page");
117+
118+
expect(
119+
consoleErrors.filter((m) => m.includes("[@funstack/static]")),
120+
).toEqual([]);
121+
});
122+
});
123+
82124
test.describe("Multi-entry HMR (dev server)", () => {
83125
const hmrPagePath = fileURLToPath(
84126
new URL("../fixture-multi-entry/src/pages/HmrTest.tsx", import.meta.url),

packages/static/e2e/tests/multi-entry.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,44 @@ test.describe("Multi-entry page rendering", () => {
9292
expect(errors).toEqual([]);
9393
});
9494
});
95+
96+
test.describe("Destructive mount detection", () => {
97+
test("warns when Root content shares the mount container", async ({
98+
page,
99+
}) => {
100+
const consoleErrors: string[] = [];
101+
page.on("console", (msg) => {
102+
if (msg.type() === "error") {
103+
consoleErrors.push(msg.text());
104+
}
105+
});
106+
107+
await page.goto("/destructive");
108+
await expect(page.locator("h1")).toHaveText("Destructive Page");
109+
110+
// The <header> shares <body> with the mount point, so mounting removed it
111+
await expect(page.getByTestId("doomed-header")).toHaveCount(0);
112+
113+
const warning = consoleErrors.find((m) => m.includes("[@funstack/static]"));
114+
expect(warning).toBeDefined();
115+
expect(warning).toContain("<header>");
116+
});
117+
118+
test("does not warn when {children} is alone in its parent", async ({
119+
page,
120+
}) => {
121+
const consoleErrors: string[] = [];
122+
page.on("console", (msg) => {
123+
if (msg.type() === "error") {
124+
consoleErrors.push(msg.text());
125+
}
126+
});
127+
128+
await page.goto("/");
129+
await expect(page.locator("h1")).toHaveText("Home Page");
130+
131+
expect(
132+
consoleErrors.filter((m) => m.includes("[@funstack/static]")),
133+
).toEqual([]);
134+
});
135+
});

packages/static/skills/funstack-static-knowledge/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export default defineConfig({
3131

3232
**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.
3333

34+
**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.
35+
3436
**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.
3537

3638
**Server Actions.** Note that Server Actions (`"use server"`) are **NOT** supported in FUNSTACK Static, as there is no server runtime deployed.

0 commit comments

Comments
 (0)