feat(react): add per-island error boundary
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||||
|
<div data-wrn-island-error={this.props.name} style={{ padding: "0.75rem" }}>
|
||||||
|
<strong>{`Island '${this.props.name}' failed`}</strong>
|
||||||
|
<pre>{error.stack ?? error.message}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<IslandErrorBoundary name="Chart" development={false}>
|
||||||
|
<p>ok</p>
|
||||||
|
</IslandErrorBoundary>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.innerHTML).toBe("<p>ok</p>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains a thrown error and shows details in development", async () => {
|
||||||
|
silenceExpectedErrors();
|
||||||
|
const { container } = mountHost();
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<IslandErrorBoundary name="Chart" development>
|
||||||
|
<Boom />
|
||||||
|
</IslandErrorBoundary>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
<IslandErrorBoundary name="Chart" development={false}>
|
||||||
|
<Boom />
|
||||||
|
</IslandErrorBoundary>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
<IslandErrorBoundary name="Chart" development={false}>
|
||||||
|
<Boom />
|
||||||
|
</IslandErrorBoundary>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.document.body.textContent).toContain("server rendered");
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user