diff --git a/packages/react-lang/package.json b/packages/react-lang/package.json index a8cd3672a..c7e085f5f 100644 --- a/packages/react-lang/package.json +++ b/packages/react-lang/package.json @@ -81,6 +81,8 @@ "devDependencies": { "@modelcontextprotocol/sdk": "^1.27.1", "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "react-dom": "catalog:", "vitest": "^4.0.18" } } diff --git a/packages/react-lang/src/Renderer.tsx b/packages/react-lang/src/Renderer.tsx index 0c06a8c93..c5daeb722 100644 --- a/packages/react-lang/src/Renderer.tsx +++ b/packages/react-lang/src/Renderer.tsx @@ -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 { - private lastValidChildren: React.ReactNode = null; - +export class ElementErrorBoundary extends Component { constructor(props: ErrorBoundaryProps) { super(props); this.state = { hasError: false }; @@ -84,16 +87,7 @@ class ElementErrorBoundary extends Component{label}; +} + +describe("ElementErrorBoundary", () => { + let container: HTMLDivElement; + let root: Root; + let consoleError: ReturnType; + + 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) => ( + + + + ); + + 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"); + }); +}); diff --git a/packages/react-lang/vitest.config.ts b/packages/react-lang/vitest.config.ts new file mode 100644 index 000000000..58c8bc2e2 --- /dev/null +++ b/packages/react-lang/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + exclude: ["dist/**", "node_modules/**"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c457f5e4..538fc9f0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1670,6 +1670,12 @@ importers: '@types/react': specifier: 'catalog:' version: 19.2.14 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.14) + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) vitest: specifier: ^4.0.18 version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.3.2)(jsdom@29.1.1)(vite@8.1.1(@types/node@25.3.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.89.2)(terser@5.48.0)(tsx@4.20.3)(yaml@2.9.0))