diff --git a/packages/react/src/error-boundary.tsx b/packages/react/src/error-boundary.tsx
new file mode 100644
index 00000000..54224e0d
--- /dev/null
+++ b/packages/react/src/error-boundary.tsx
@@ -0,0 +1,43 @@
+/** @jsxImportSource react */
+import { Component, type ErrorInfo, type ReactNode } from "react";
+
+export interface IslandErrorBoundaryProps {
+ name: string;
+ development: boolean;
+ children: ReactNode;
+}
+
+interface IslandErrorBoundaryState {
+ error: Error | null;
+}
+
+/**
+ * Contains island failures locally: a crashed island must never blank the
+ * surrounding server-rendered page.
+ */
+export class IslandErrorBoundary extends Component<
+ IslandErrorBoundaryProps,
+ IslandErrorBoundaryState
+> {
+ override state: IslandErrorBoundaryState = { error: null };
+
+ static getDerivedStateFromError(error: Error): IslandErrorBoundaryState {
+ return { error };
+ }
+
+ override componentDidCatch(error: Error, info: ErrorInfo): void {
+ console.error(`[wrnexus] island '${this.props.name}' failed to render`, error, info);
+ }
+
+ override render(): ReactNode {
+ const { error } = this.state;
+ if (!error) return this.props.children;
+ if (!this.props.development) return null;
+ return (
+
+
{`Island '${this.props.name}' failed`}
+
{error.stack ?? error.message}
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/packages/react/test/error-boundary.test.tsx b/packages/react/test/error-boundary.test.tsx
new file mode 100644
index 00000000..f0c1b843
--- /dev/null
+++ b/packages/react/test/error-boundary.test.tsx
@@ -0,0 +1,104 @@
+/** @jsxImportSource react */
+import { afterEach, expect, test } from "bun:test";
+import { Window } from "happy-dom";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { IslandErrorBoundary } from "../src/error-boundary.tsx";
+
+// React error boundaries only engage during client rendering — the server
+// renderers rethrow. Islands are client-only, so this is also how they run.
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+function Boom(): never {
+ throw new Error("chart exploded");
+}
+
+function mountHost() {
+ const window = new Window();
+ (globalThis as any).window = window;
+ (globalThis as any).document = window.document;
+ const container = window.document.createElement("div");
+ window.document.body.appendChild(container);
+ return { window, container: container as unknown as HTMLElement };
+}
+
+const silenced: Array<() => void> = [];
+
+afterEach(() => {
+ for (const restore of silenced.splice(0)) restore();
+});
+
+function silenceExpectedErrors() {
+ const original = console.error;
+ console.error = () => {};
+ silenced.push(() => {
+ console.error = original;
+ });
+}
+
+test("renders children when nothing throws", async () => {
+ const { container } = mountHost();
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(
+
+ ok
+ ,
+ );
+ });
+
+ expect(container.innerHTML).toBe("ok
");
+});
+
+test("contains a thrown error and shows details in development", async () => {
+ silenceExpectedErrors();
+ const { container } = mountHost();
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+
+ expect(container.innerHTML).toContain("Chart");
+ expect(container.innerHTML).toContain("chart exploded");
+});
+
+test("renders nothing in production when an island throws", async () => {
+ silenceExpectedErrors();
+ const { container } = mountHost();
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+
+ expect(container.innerHTML).toBe("");
+});
+
+test("a crashed island does not remove sibling server markup", async () => {
+ silenceExpectedErrors();
+ const { window, container } = mountHost();
+ const sibling = window.document.createElement("p");
+ sibling.textContent = "server rendered";
+ window.document.body.appendChild(sibling);
+
+ const root = createRoot(container);
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+
+ expect(window.document.body.textContent).toContain("server rendered");
+});
diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json
new file mode 100644
index 00000000..b191b260
--- /dev/null
+++ b/packages/react/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "react"
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"]
+}