feat(frontend): mount error boundaries at the root, the item grid and the modals (#62)

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=<scope> 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 18:02:45 -05:00
co-authored by Claude Opus 5
parent b4be15ae0d
commit 5b9cf91cef
5 changed files with 196 additions and 31 deletions
+30 -11
View File
@@ -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() {
</div>
{loading && !items.length && !failed ? <Spin /> : null}
<Catalogue
failed={failed}
loading={loading}
items={items}
filters={filters}
needsFavoritesAuth={needsFavoritesAuth}
onRetry={handleRetry}
onSignIn={openAuthModal}
onClearFilters={clearFilters}
onChanged={reload}
/>
<ErrorBoundary
context="catalogue"
fallback={(error) => (
<ErrorFallback
error={error}
title="The item list didn't load"
actions={
<Button type="primary" onClick={() => window.location.reload()}>
Reload
</Button>
}
/>
)}
>
{import.meta.env.DEV && <DevThrow scope="catalogue" />}
<Catalogue
failed={failed}
loading={loading}
items={items}
filters={filters}
needsFavoritesAuth={needsFavoritesAuth}
onRetry={handleRetry}
onSignIn={openAuthModal}
onClearFilters={clearFilters}
onChanged={reload}
/>
</ErrorBoundary>
</Content>
<Footer style={{ textAlign: 'center', background: token.colorBgContainer }}>
<Link to="/privacy">Privacy Policy</Link>