File size: 6,270 Bytes
e1b3e71 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | // ---------------------------------------------------------------------------
// 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 "./errorBoundary.css";
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 is the only sink this client has today. It is enough to turn the
// next report from "it crashes" into a stack β which was the whole cost of item
// 2 arriving undiagnosed. Forwarding this to the server is booked as pending
// work rather than smuggled in here: it needs a route, and it must be proven to
// carry no row values before it ships.
console.error(`[${this.props.surface}] render failed`, 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;
}
}
|