Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/react-lang/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@
"devDependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"react-dom": "catalog:",
"vitest": "^4.0.18"
}
}
28 changes: 11 additions & 17 deletions packages/react-lang/src/Renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,17 @@ interface ErrorBoundaryState {
}

/**
* Error boundary that intentionally shows the last successfully rendered
* children when a render error occurs. This "show last good state" behavior
* prevents the UI from going blank during streaming or transient evaluation
* errors, and auto-recovers when new valid children arrive.
* Error boundary that isolates a single rendered element: when a child throws
* during render it renders nothing for that element (instead of tearing down
* the whole tree) and auto-recovers as soon as new valid children arrive, e.g.
* the next streaming update.
*
* It deliberately does NOT re-present the previously rendered children on error.
* Those are element instances from an earlier render whose DOM React has already
* reconciled/moved, so re-inserting them desyncs the fiber tree from the live
* DOM and throws an uncatchable `insertBefore` error in the commit phase (#727).
*/
class ElementErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
private lastValidChildren: React.ReactNode = null;

export class ElementErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
Expand All @@ -84,16 +87,7 @@ class ElementErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
return { hasError: true };
}

componentDidMount(): void {
if (!this.state.hasError) {
this.lastValidChildren = this.props.children;
}
}

componentDidUpdate(prevProps: ErrorBoundaryProps): void {
if (!this.state.hasError) {
this.lastValidChildren = this.props.children;
}
if (this.state.hasError && prevProps.children !== this.props.children) {
this.setState({ hasError: false });
}
Expand All @@ -111,7 +105,7 @@ class ElementErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt

render() {
if (this.state.hasError) {
return this.lastValidChildren;
return null;
}
return this.props.children;
}
Expand Down
61 changes: 61 additions & 0 deletions packages/react-lang/src/__tests__/ElementErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ElementErrorBoundary } from "../Renderer";

// Throws when `explode` is set, otherwise renders a marker we can assert on.
function Child({ explode, label }: { explode: boolean; label: string }) {
if (explode) throw new Error("boom");
return <span>{label}</span>;
}

describe("ElementErrorBoundary", () => {
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
// React logs boundary-caught render errors to console.error; silence them.
consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
act(() => root.unmount());
container.remove();
consoleError.mockRestore();
});

const render = (node: ReactNode) => act(() => root.render(node));

it("renders nothing on error instead of re-presenting stale children, then recovers", () => {
const onError = vi.fn();
const wrap = (explode: boolean, label: string) => (
<ElementErrorBoundary componentName="Child" onError={onError}>
<Child explode={explode} label={label} />
</ElementErrorBoundary>
);

render(wrap(false, "good"));
expect(container.textContent).toBe("good");

// Child throws: the boundary must render nothing, not re-present the stale
// "good" subtree (whose DOM React has already moved) — that desync is what
// crashes the host app with a commit-phase insertBefore error (#727).
render(wrap(true, "good"));
expect(container.textContent).toBe("");
// (React may retry the throwing render, so onError can fire more than once.)
expect(onError).toHaveBeenCalled();
expect(onError.mock.calls[0]?.[0]).toMatchObject({
code: "render-error",
component: "Child",
});

// New valid children arrive: the boundary auto-recovers.
render(wrap(false, "recovered"));
expect(container.textContent).toBe("recovered");
});
});
8 changes: 8 additions & 0 deletions packages/react-lang/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
environment: "jsdom",
exclude: ["dist/**", "node_modules/**"],
},
});
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading