+export default function Root({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
{children}
+
+
+
+ );
+}
+```
+
+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.
diff --git a/packages/static/e2e/fixture-multi-entry/src/entries.tsx b/packages/static/e2e/fixture-multi-entry/src/entries.tsx
index 2586517..f1782d3 100644
--- a/packages/static/e2e/fixture-multi-entry/src/entries.tsx
+++ b/packages/static/e2e/fixture-multi-entry/src/entries.tsx
@@ -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"),
+ },
];
}
diff --git a/packages/static/e2e/fixture-multi-entry/src/pages/Destructive.tsx b/packages/static/e2e/fixture-multi-entry/src/pages/Destructive.tsx
new file mode 100644
index 0000000..811a173
--- /dev/null
+++ b/packages/static/e2e/fixture-multi-entry/src/pages/Destructive.tsx
@@ -0,0 +1,8 @@
+export default function Destructive() {
+ return (
+
+ Destructive Page
+ destructive
+
+ );
+}
diff --git a/packages/static/e2e/fixture-multi-entry/src/root-destructive.tsx b/packages/static/e2e/fixture-multi-entry/src/root-destructive.tsx
new file mode 100644
index 0000000..55d87c7
--- /dev/null
+++ b/packages/static/e2e/fixture-multi-entry/src/root-destructive.tsx
@@ -0,0 +1,21 @@
+// A Root that violates the "keep {children} alone in its parent element"
+// constraint: the
shares 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 (
+
+
+
+ Destructive Mount Fixture
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/static/e2e/tests-dev/multi-entry.spec.ts b/packages/static/e2e/tests-dev/multi-entry.spec.ts
index dfc520c..d2d373c 100644
--- a/packages/static/e2e/tests-dev/multi-entry.spec.ts
+++ b/packages/static/e2e/tests-dev/multi-entry.spec.ts
@@ -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("");
+ });
+
+ 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),
diff --git a/packages/static/e2e/tests/multi-entry.spec.ts b/packages/static/e2e/tests/multi-entry.spec.ts
index 67657fb..eec6699 100644
--- a/packages/static/e2e/tests/multi-entry.spec.ts
+++ b/packages/static/e2e/tests/multi-entry.spec.ts
@@ -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 shares 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("");
+ });
+
+ 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([]);
+ });
+});
diff --git a/packages/static/skills/funstack-static-knowledge/SKILL.md b/packages/static/skills/funstack-static-knowledge/SKILL.md
index e436578..1aa305b 100644
--- a/packages/static/skills/funstack-static-knowledge/SKILL.md
+++ b/packages/static/skills/funstack-static-knowledge/SKILL.md
@@ -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. `{children}
`) 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.
diff --git a/packages/static/src/client/entry.tsx b/packages/static/src/client/entry.tsx
index 0f0730d..1a7149a 100644
--- a/packages/static/src/client/entry.tsx
+++ b/packages/static/src/client/entry.tsx
@@ -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";
@@ -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);
}
@@ -94,8 +103,16 @@ async function prodMain() {
hydrateRoot(document, browserRoot);
} else {
- // SSR off: Root shell only, mount App client-side
- const 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 would be invalid.
+ const browserRoot = (
+
+
+
+
+
+ );
const appRootId = manifest.marker!;
const appMarker = document.getElementById(appRootId);
@@ -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);
diff --git a/packages/static/src/client/error-boundary.tsx b/packages/static/src/client/error-boundary.tsx
index 4d98328..b13d7a2 100644
--- a/packages/static/src/client/error-boundary.tsx
+++ b/packages/static/src/client/error-boundary.tsx
@@ -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 ``/``
+ * wrapper, for roots mounted inside an element rather than at the document.
*/
-export const GlobalErrorBoundary: React.FC = (
- props,
-) => {
+export const GlobalErrorBoundary: React.FC<
+ React.PropsWithChildren<{ embedded?: boolean }>
+> = (props) => {
return (
- {props.children}
+
+ {props.children}
+
);
};
-const Fallback: React.FC = ({ 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 = ({
+ error,
+ resetErrorBoundary,
+}) => {
const errorMessage = error instanceof Error ? error.message : String(error);
+ return (
+ <>
+ Caught an unexpected error
+ See the console for details.
+ Error: {errorMessage}
+ {
+ startTransition(() => {
+ resetErrorBoundary();
+ });
+ }}
+ >
+ Reset
+
+ >
+ );
+};
+
+const Fallback: React.FC = (props) => {
return (
Unexpected Error
-
- Caught an unexpected error
- See the console for details.
- Error: {errorMessage}
- {
- startTransition(() => {
- resetErrorBoundary();
- });
- }}
- >
- Reset
-
+
+
);
};
+
+const EmbeddedFallback: React.FC = (props) => {
+ return (
+
+
+
+ );
+};
diff --git a/packages/static/src/client/mount-validation.test.ts b/packages/static/src/client/mount-validation.test.ts
new file mode 100644
index 0000000..c980a80
--- /dev/null
+++ b/packages/static/src/client/mount-validation.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from "vitest";
+import {
+ collectDestructiveSiblings,
+ describeNode,
+ type ContainerLike,
+ type NodeLike,
+} from "./mount-validation";
+
+const ELEMENT_NODE = 1;
+const TEXT_NODE = 3;
+const COMMENT_NODE = 8;
+
+function element(tagName: string): NodeLike {
+ return {
+ nodeType: ELEMENT_NODE,
+ nodeName: tagName.toUpperCase(),
+ textContent: "",
+ };
+}
+
+function text(content: string): NodeLike {
+ return {
+ nodeType: TEXT_NODE,
+ nodeName: "#text",
+ textContent: content,
+ };
+}
+
+function comment(content: string): NodeLike {
+ return {
+ nodeType: COMMENT_NODE,
+ nodeName: "#comment",
+ textContent: content,
+ };
+}
+
+function container(...childNodes: NodeLike[]): ContainerLike {
+ return {
+ nodeType: ELEMENT_NODE,
+ nodeName: "BODY",
+ textContent: "",
+ childNodes,
+ };
+}
+
+const marker = element("span");
+
+describe("collectDestructiveSiblings", () => {
+ it("returns nothing when the marker is the only child", () => {
+ expect(collectDestructiveSiblings(container(marker), marker)).toEqual([]);
+ });
+
+ it("ignores whitespace-only text nodes", () => {
+ const c = container(text("\n "), marker, text("\t\n"));
+ expect(collectDestructiveSiblings(c, marker)).toEqual([]);
+ });
+
+ it("ignores comment nodes", () => {
+ const c = container(comment("$"), marker, comment("/$"));
+ expect(collectDestructiveSiblings(c, marker)).toEqual([]);
+ });
+
+ it("ignores script elements", () => {
+ // the framework emits bootstrap / RSC payload scripts next to the marker
+ const c = container(marker, element("script"), element("script"));
+ expect(collectDestructiveSiblings(c, marker)).toEqual([]);
+ });
+
+ it("collects element siblings", () => {
+ const header = element("header");
+ const footer = element("footer");
+ const c = container(header, marker, footer);
+ expect(collectDestructiveSiblings(c, marker)).toEqual([header, footer]);
+ });
+
+ it("collects non-whitespace text siblings", () => {
+ const greeting = text("Hello");
+ const c = container(greeting, marker);
+ expect(collectDestructiveSiblings(c, marker)).toEqual([greeting]);
+ });
+
+ it("collects siblings mixed with ignorable nodes", () => {
+ const nav = element("nav");
+ const c = container(
+ text("\n"),
+ nav,
+ comment("x"),
+ marker,
+ element("script"),
+ );
+ expect(collectDestructiveSiblings(c, marker)).toEqual([nav]);
+ });
+});
+
+describe("describeNode", () => {
+ it("describes elements by tag name", () => {
+ expect(describeNode(element("header"))).toBe("");
+ });
+
+ it("describes text nodes by quoted content", () => {
+ expect(describeNode(text(" Hello "))).toBe('"Hello"');
+ });
+
+ it("truncates long text content", () => {
+ expect(describeNode(text("a".repeat(50)))).toBe(`"${"a".repeat(30)}…"`);
+ });
+});
diff --git a/packages/static/src/client/mount-validation.ts b/packages/static/src/client/mount-validation.ts
new file mode 100644
index 0000000..d34c9b6
--- /dev/null
+++ b/packages/static/src/client/mount-validation.ts
@@ -0,0 +1,91 @@
+import { appMarkerPrefix } from "../rsc/marker";
+
+const ELEMENT_NODE = 1;
+const TEXT_NODE = 3;
+
+/**
+ * Structural subset of DOM Node used by the validation logic,
+ * allowing unit tests to run without a DOM implementation.
+ */
+export interface NodeLike {
+ readonly nodeType: number;
+ readonly nodeName: string;
+ readonly textContent: string | null;
+}
+
+export interface ContainerLike extends NodeLike {
+ readonly childNodes: Iterable;
+}
+
+/**
+ * Collects the container's child nodes that would be destroyed by mounting
+ * the App into the container (React clears all existing children of the
+ * container on the first render).
+ *
+ * Nodes that are safe to lose are excluded:
+ * - the app marker itself
+ * - whitespace-only text nodes
+ * - non-element, non-text nodes (comments, doctypes, ...)
+ * - `