loopable / web /src /shell /ErrorBoundary.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
f546440 verified
Raw
History Blame
8.54 kB
// ---------------------------------------------------------------------------
// 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;
}
}