| // --------------------------------------------------------------------------- | |
| // 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 ( | |
| <div className="shell-placeholder shell-crash" role="alert"> | |
| <h1>{surface} could not be shown</h1> | |
| <p> | |
| 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. | |
| </p> | |
| {message ? <p className="shell-crash-detail">{message}</p> : null} | |
| <button | |
| className="login-submit shell-retry" | |
| type="button" | |
| onClick={onReload} | |
| > | |
| Reload | |
| </button> | |
| </div> | |
| ); | |
| } | |
| 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<ErrorBoundaryProps, ErrorBoundaryState> { | |
| 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 ( | |
| <FailurePanel | |
| surface={this.props.surface} | |
| message={error.message} | |
| onReload={() => window.location.reload()} | |
| /> | |
| ); | |
| } | |
| return this.props.children; | |
| } | |
| } | |