From 3e9d9a57c0aa07ca659ff9d1a81864b03d2f0a17 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Thu, 20 Aug 2026 17:44:50 -0500 Subject: [PATCH 1/4] fix(frontend): make the error reporter genuinely unable to throw (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed the reporter could not throw and the code did not deliver it. JSON.stringify(report) and the call to fetch both run synchronously, as arguments, before the promise carrying the .catch exists — so a throw from either escaped straight out of componentDidCatch, where nothing remains to catch it. The boundary that exists to stop errors would itself have been the thing that crashed. Not merely theoretical: React does not guarantee the value handed to componentDidCatch is a real Error despite the parameter's type, because code can throw anything. An object whose message or stack is circular makes JSON.stringify throw. The body is now wrapped in try/catch for the synchronous part, and the existing .catch still covers rejection once the request is in flight. Neither covers the other, so both are kept, and the comment now says so rather than asserting a guarantee the code did not make. Verified: build clean, lint 0 errors and 31 warnings, unchanged. Refs #62 Co-Authored-By: Claude Opus 5 --- frontend/src/errorReporting.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/frontend/src/errorReporting.ts b/frontend/src/errorReporting.ts index eb9ea66..2a907ee 100644 --- a/frontend/src/errorReporting.ts +++ b/frontend/src/errorReporting.ts @@ -13,12 +13,24 @@ export interface ClientErrorReport { // Fire and forget, and deliberately swallowing — the one place in this change // where swallowing is correct. This runs inside componentDidCatch, so a -// reporter that rejected would throw from the very thing that exists to stop -// throws, and there would be nothing left to catch it. +// reporter that threw — or rejected — would throw from the very thing that +// exists to stop throws, and there would be nothing left to catch it. +// +// The try/catch guards the synchronous part: JSON.stringify(report) and the +// call to fetch() itself both run before any promise exists, so React does +// not guarantee componentDidCatch is handed a real Error — code can throw +// anything, and an object with a circular message or stack makes +// JSON.stringify throw. The .catch on the returned promise guards the async +// part, the rejection once fetch has actually started. Neither covers the +// other; both are required. export function reportClientError(report: ClientErrorReport): void { - void fetch('/api/client-errors', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(report) - }).catch(() => undefined); + try { + void fetch('/api/client-errors', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(report) + }).catch(() => undefined); + } catch { + // Nothing to do — see the comment above. + } } From f156cecd879f0eb3f91223b676b35691586493e6 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Thu, 20 Aug 2026 18:02:45 -0500 Subject: [PATCH 2/4] feat(frontend): mount error boundaries at the root, the item grid and the modals (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three mount points, so a render error costs the smallest part of the page it can. The catalogue boundary is the one that earns its keep. The likeliest throw in this app is a component rendering data from the API, and the item grid renders the most of it per page — contained there, the header, cart badge, filters and footer all survive, so a customer can still navigate instead of being handed one dead page. The modal boundary exists because the modal-route arrangement couples two independent trees. /account, /login and the rest render as modals over the storefront as a backdrop, so without a boundary between them a throw in Account blanks the storefront behind it and a throw in the storefront takes the open modal with it. One boundary separates them in both directions. Every escape action is a hard navigation rather than a Link. This is worth stating because the obvious implementation is wrong: a boundary does not reset when the route changes, so a Link would change the URL and go on rendering the fallback, which reads as the app being permanently broken. ErrorFallback changed too, outside this change's original scope and for a reason worth recording. antd's Result renders its title as a plain div with no heading semantics, so a page whose entire content is an error message offered a screen-reader user navigating by headings nothing at all to find. The title is now wrapped in Typography.Title. The tests assert a heading role and were right to; the component was what needed fixing, not the assertion. DevThrow throws on ?boom= and is mounted only behind import.meta.env.DEV, so Rollup drops it from a production build. Checked in both directions rather than trusted: the dev server serves it, and a production bundle greps to zero occurrences of its marker. A gate that is silently always-off looks identical to one that works. Verified: 87 end-to-end tests pass, 4 of them new — each boundary catches rather than blanking, the header survives a catalogue throw, the storefront survives a modal throw, and the report is observed reaching /api/client-errors on the wire rather than assumed. Build clean, lint 0 errors and 31 warnings. Refs #62 Co-Authored-By: Claude Opus 5 --- frontend/src/App.tsx | 41 +++++++--- frontend/src/components/DevThrow.tsx | 19 +++++ frontend/src/components/ErrorFallback.tsx | 13 ++- frontend/src/main.tsx | 98 ++++++++++++++++++----- frontend/tests/e2e/error-boundary.spec.ts | 56 +++++++++++++ 5 files changed, 196 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/DevThrow.tsx create mode 100644 frontend/tests/e2e/error-boundary.spec.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d210d4..5b7c3b3 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,9 @@ import AuthPromptModal from './customer/AuthPromptModal'; import { useThemeMode } from './theme/ThemeContext'; import { useCustomerAuth } from './customer/CustomerAuthContext'; import { useCart } from './cart/CartContext'; +import ErrorBoundary from './components/ErrorBoundary'; +import ErrorFallback from './components/ErrorFallback'; +import DevThrow from './components/DevThrow'; const { Header, Content, Footer } = Layout; const { Title } = Typography; @@ -251,17 +254,33 @@ export default function App() { {loading && !items.length && !failed ? : null} - + ( + window.location.reload()}> + Reload + + } + /> + )} + > + {import.meta.env.DEV && } + +