// --------------------------------------------------------------------------- // shell / ErrorBoundary.tsx — WAVE 30, owner item 2 (ruling R5). // // ⛔ WHY THIS FILE EXISTS, AND THE MEASUREMENT BEHIND IT. Until today, // `ErrorBoundary|componentDidCatch|getDerivedStateFromError` had ZERO hits in the // whole of `web/src`. React's contract when a render throws and nothing catches it // is to unmount the ENTIRE tree — so one unguarded property access anywhere in the // product produced a blank white page. That is exactly what a user calls "it // crashes", and it is why owner item 2 arrived as a report with no diagnosis: there // was nothing left on screen to diagnose. // // A boundary is therefore not a workaround for the hide-fields panel. It is the // difference between "one surface failed and said so" and "the product vanished". // // ⚠ WHAT NO GATE IN THIS REPO CAN PROVE, measured 2026-08-12 rather than assumed — // written here so nobody reads the gate as stronger than it is: // · there is NO DOM. `jsdom`, `happy-dom`, `linkedom`, `domino` and // `react-test-renderer` are all absent from `web/node_modules` (160 packages), // and there is no vitest. So the React RECONCILER cannot be run under node. // · React 18.3.1's server renderers do NOT invoke error boundaries. Both // `renderToString` and `renderToStaticMarkup` were fed one throwing child under // a boundary declaring `getDerivedStateFromError`; both PROPAGATED the throw and // `componentDidCatch` never fired. // ⇒ the fallback is a SEPARATE exported component precisely so it can be rendered // for real, and the class's protocol is driven by hand in `verify_grid_ux.py` // (throw → catch → `getDerivedStateFromError` → `render()`), which is React's own // documented sequence. The reconciler's guarantee is React's, not ours; what IS // ours — that every surface is actually wrapped — is a source scan, and that is // the check with teeth ([[reachable-is-not-the-same-as-built]]: a boundary that // exists and wraps nothing is this repo's sixth whole-correct-unreachable feature). // --------------------------------------------------------------------------- import { Component } from "react"; import type { ErrorInfo, ReactNode } from "react"; import { API_V1, CREDENTIALS } from "../apiContract"; import "./errorBoundary.css"; /** * ⭐⭐ D-144 (W33-T32) — forward ONE caught render error to the server, and NOTHING else. * * ⛔⛔ THE DEBT'S HARD CONDITION IS "IT MUST CARRY NO ROW DATA", and this function is where that * is decided. React hands `componentDidCatch` the error OBJECT and an `ErrorInfo`; the error's * `message` and the component STACK are the two things that name the defect, and everything else * within reach — props, state, the cell that was rendering — is a customer's data. So the body is * built from exactly two strings. There is no spread here and none at the door. A crash reporter * that shipped props would do more harm than the silence it replaces. * * ⚠ FIRE AND FORGET, AND IT SWALLOWS ITS OWN FAILURE. This runs while the tree is already * unmounting; a rejected promise here would be a second error inside the handler for the first, * and an error boundary that can itself throw is worse than none. * * ⚠ NO RETRY, EVER. The failure mode is a render LOOP — catch, reset, re-throw — and a reporter * that retried would turn one broken surface into a request flood from every open tab. The server * drops beyond its per-account window and still answers 200 for the same reason: the client must * never learn there is anything to retry. */ export function reportRenderError(surface: string, error: unknown, componentStack: string): void { try { const message = `[${surface}] ${ error instanceof Error ? error.message : String(error ?? "") }`; void fetch(`${API_V1}/client-error`, { method: "POST", credentials: CREDENTIALS, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message, componentStack: String(componentStack || "") }), }).catch(() => undefined); } catch { // A reporter that throws inside the handler for a throw helps nobody. } } export interface FailurePanelProps { /** The surface that failed, named the way the user got to it ("Odoo orders", * "Automation", "Loopable"). A panel that will not say WHAT broke sends the * reader back to us with the same sentence item 2 arrived with. */ surface: string; /** The thrown message. Shown, not hidden: a user who can paste one line saves a * scouting session. ⚠ It is the message ONLY — never a stack, and never a value * from a row, because this panel is on screen wherever the failure happened. */ message?: string; onReload: () => void; } /** * The contained failure card. * * It borrows `.shell-placeholder` and `.shell-retry` on purpose — the shell already * has two failure panels (the data-error card and the nav-failed card) and a third * visual language for the same idea would read as a different kind of problem. The * only new paint is the tone strip in `errorBoundary.css`, which is what separates * "this did not load" from "this broke". * * Exported as its own component so it can be rendered for real under node; see the * measurement in this file's header for why the class cannot be. */ export function FailurePanel({ surface, message, onReload }: FailurePanelProps) { return (

{surface} could not be shown

Something here failed while drawing. The rest of the app still works — pick another database from the sidebar, or reload to try this one again.

{message ?

{message}

: null}
); } export interface ErrorBoundaryProps { surface: string; children: ReactNode; } export interface ErrorBoundaryState { error: Error | null; } /** * Catch a render throw and paint `FailurePanel` in its place, leaving every sibling * mounted. * * ⚠ A BOUNDARY LATCHES, AND THAT IS THE TRAP TO KNOW ABOUT. Once it holds an error * it renders the panel until it is REMOUNTED — there is no "it will sort itself out * on the next render". So every mount below passes a `key` that changes when the * user moves (the route, the surface), which turns "navigate away and back" into the * recovery path a person will actually try. `Reload` is the second door, and it is * the same control the shell's two existing failure cards offer. */ export class ErrorBoundary extends Component { constructor(props: ErrorBoundaryProps) { super(props); this.state = { error: null }; } static getDerivedStateFromError(error: unknown): ErrorBoundaryState { // Anything can be thrown in JavaScript, including a string or `undefined`. // Normalising here means `render` has one shape to reason about and the panel // can never itself throw reading `.message` off a non-Error. return { error: error instanceof Error ? error : new Error(String(error)) }; } componentDidCatch(error: unknown, info: ErrorInfo): void { // The console keeps its job: it is what a developer with the tab open reads. console.error(`[${this.props.surface}] render failed`, error, info.componentStack); // ⭐⭐ D-144 (W33-T32) — AND NOW IT TELLS SOMEBODY. The paragraph this replaces said // forwarding "needs a route, and it must be proven to carry no row values before it ships". // Both conditions are met: `POST /api/v1/client-error` exists (session-gated, rate-limited // per account, stream-bounded), and it rebuilds its record from exactly two strings — there // is no `**body` on either side of the wire. Until this, the next user complaint still // arrived as a sentence rather than a stack, which is what made owner item 2 undiagnosable. reportRenderError(this.props.surface, error, info.componentStack ?? ""); } render(): ReactNode { const { error } = this.state; if (error) { return ( window.location.reload()} /> ); } return this.props.children; } }