From df2ee036eb4881f56b9a95d72ab07b54a0edddb5 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 18 Aug 2026 15:16:42 +0530 Subject: [PATCH] feat(react): add per-island error boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A crashed island renders its error in dev and nothing in prod, leaving the surrounding server-rendered page intact. Island .tsx sources carry an explicit @jsxImportSource react pragma: the repo's root tsconfig points jsxImportSource at @wrnexus/core, so without it island JSX compiles to WRNexus's string renderer instead of React elements. Tests render on the client via createRoot rather than a server renderer, because React error boundaries do not engage during SSR — and islands are client-only regardless. Co-Authored-By: Claude Opus 5 --- packages/react/src/error-boundary.tsx | 43 ++++++++ packages/react/test/error-boundary.test.tsx | 104 ++++++++++++++++++++ packages/react/tsconfig.json | 9 ++ 3 files changed, 156 insertions(+) create mode 100644 packages/react/src/error-boundary.tsx create mode 100644 packages/react/test/error-boundary.test.tsx create mode 100644 packages/react/tsconfig.json 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"] +}