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;
  }
}