Merge pull request 'docs: design for React error boundaries (#62)' (#83) from feature/62-error-boundary into main
SonarQube Analysis / sonarqube (push) Canceled after 4m10s
Tests / lint (push) Canceled after 0s
Tests / backend-unit (push) Canceled after 0s
Tests / frontend-e2e (push) Canceled after 0s

Reviewed-on: #83
This commit was merged in pull request #83.
This commit is contained in:
2026-08-20 17:46:23 -05:00
10 changed files with 1294 additions and 0 deletions
+2
View File
@@ -13,6 +13,7 @@ import customersRouter from './routes/customers';
import publicRouter from './routes/public'; import publicRouter from './routes/public';
import cartRouter from './routes/cart'; import cartRouter from './routes/cart';
import shippingAddressesRouter from './routes/shippingAddresses'; import shippingAddressesRouter from './routes/shippingAddresses';
import clientErrorsRouter from './routes/clientErrors';
import { attachCustomer } from './middleware/customerAuth'; import { attachCustomer } from './middleware/customerAuth';
import { asyncRoute } from './asyncRoute'; import { asyncRoute } from './asyncRoute';
@@ -52,6 +53,7 @@ app.use('/api/admin/tags', adminTagsRouter);
app.use('/api/admin', adminRouter); app.use('/api/admin', adminRouter);
app.use('/api/customers/me/addresses', shippingAddressesRouter); app.use('/api/customers/me/addresses', shippingAddressesRouter);
app.use('/api/customers', customersRouter); app.use('/api/customers', customersRouter);
app.use('/api/client-errors', clientErrorsRouter);
app.use('/', publicRouter); app.use('/', publicRouter);
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
+21
View File
@@ -33,3 +33,24 @@ export const passwordResetRequestLimiter = rateLimit({
legacyHeaders: false, legacyHeaders: false,
message: { error: 'too many attempts, please try again later' } message: { error: 'too many attempts, please try again later' }
}); });
// Client error reports carry no email, so this one is keyed on the caller
// alone — deliberately not reusing passwordResetRequestLimiter, whose comment
// above explains why its key is wrong for an endpoint without an email.
//
// `trust proxy` is set in app.ts, so `req.ip` is the real client address from
// X-Forwarded-For rather than Nginx Proxy Manager's, making this a per-customer
// allowance rather than one shared by everybody behind the proxy.
//
// Generous, because hitting the limit is harmless: the reporter ignores the
// response either way. It exists so a render loop cannot fill the log.
const CLIENT_ERROR_WINDOW_MS = 15 * 60 * 1000;
const CLIENT_ERROR_MAX_REQUESTS = 30;
export const clientErrorLimiter = rateLimit({
windowMs: CLIENT_ERROR_WINDOW_MS,
limit: CLIENT_ERROR_MAX_REQUESTS,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { error: 'too many reports' }
});
+75
View File
@@ -0,0 +1,75 @@
import { Router, Request, Response } from 'express';
import { clientErrorLimiter } from '../rateLimit';
const router = Router();
// The three error boundaries in the frontend. An unrecognised context means the
// client and the server disagree about something, which is worth surfacing
// rather than logging under a guessed label — the same reasoning as
// parseItemFilters refusing a malformed filter instead of coercing it.
const CONTEXTS: readonly string[] = ['page', 'catalogue', 'modal'];
const MAX_MESSAGE = 500;
const MAX_STACK = 4000;
const MAX_COMPONENT_STACK = 4000;
const MAX_PATH = 200;
// True for CR, LF and every other C0 control character, plus DEL (the
// C0 range is code points 0 through 31; DEL is 127). Written as a numeric
// comparison rather than a control-character regex literal so the source
// never has to embed a raw control character or an escape sequence for one.
const LAST_C0_CODE = 31;
const DEL_CODE = 127;
function isControlCharCode(code: number): boolean {
return code <= LAST_C0_CODE || code === DEL_CODE;
}
// Strips CR, LF and other control characters from a string, replacing each
// with a single space. The endpoint is unauthenticated, so without this a
// caller could embed a newline in any field to forge what looks like a
// second [client-error] line in the shared server log. The replacement is
// 1-for-1 (one control character becomes one space), so it cannot change
// the string's length either way.
function sanitize(value: string): string {
let result = '';
for (const char of value) {
result += isControlCharCode(char.codePointAt(0) ?? 0) ? ' ' : char;
}
return result;
}
// Anything that is not a string becomes empty rather than 'undefined' or
// '[object Object]', so a malformed field cannot dress itself up as content.
function clip(value: unknown, max: number): string {
if (typeof value !== 'string') {
return '';
}
// Sanitize before truncating, not after. Because the substitution above is
// 1-for-1, sanitizing first cannot push the stored length past `max` — an
// escaping scheme that expanded a control character into multiple visible
// characters would need the opposite order to keep that same guarantee, so
// the two are not interchangeable and must not be reordered without
// re-checking this.
const sanitized = sanitize(value);
return sanitized.length > max ? `${sanitized.slice(0, max)}… [truncated]` : sanitized;
}
// No asyncRoute: this handler is synchronous, so there is no promise for the
// error middleware to miss.
router.post('/', clientErrorLimiter, (req: Request, res: Response) => {
const context: unknown = req.body?.context;
if (typeof context !== 'string' || !CONTEXTS.includes(context)) {
return res.status(400).json({ error: 'invalid context' });
}
console.error(
`[client-error] context=${context} path=${clip(req.body?.path, MAX_PATH)}\n` +
` message: ${clip(req.body?.message, MAX_MESSAGE)}\n` +
` stack: ${clip(req.body?.stack, MAX_STACK)}\n` +
` componentStack: ${clip(req.body?.componentStack, MAX_COMPONENT_STACK)}`
);
res.status(204).end();
});
export default router;
@@ -0,0 +1,153 @@
import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { closeDb } from './setup/testDb';
// No resetDb: this endpoint never touches the database. The pool still has to
// be closed or Jest reports an open handle.
afterAll(async () => {
await pool.end();
await closeDb();
});
describe('POST /api/client-errors', () => {
let errorSpy: jest.SpyInstance;
beforeEach(() => {
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
errorSpy.mockRestore();
});
it('accepts a well-formed report and logs it', async () => {
const res = await request(app).post('/api/client-errors').send({
context: 'catalogue',
message: 'Cannot read properties of undefined',
stack: 'Error: Cannot read properties of undefined\n at ItemCard',
componentStack: '\n at Catalogue\n at App',
path: '/?category=3'
});
expect(res.status).toBe(204);
expect(errorSpy).toHaveBeenCalledTimes(1);
const logged = errorSpy.mock.calls[0][0] as string;
expect(logged).toContain('[client-error]');
expect(logged).toContain('context=catalogue');
expect(logged).toContain('Cannot read properties of undefined');
});
it('refuses a context it does not recognise rather than logging under a guess', async () => {
const res = await request(app)
.post('/api/client-errors')
.send({ context: 'checkout', message: 'boom' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid context');
expect(errorSpy).not.toHaveBeenCalled();
});
it('refuses a report with no context at all', async () => {
const res = await request(app).post('/api/client-errors').send({ message: 'boom' });
expect(res.status).toBe(400);
expect(errorSpy).not.toHaveBeenCalled();
});
// Truncated rather than refused: an over-long report is still the only record
// of the failure, and dropping it would lose the thing worth having.
it('truncates an oversized message instead of refusing it', async () => {
const res = await request(app).post('/api/client-errors').send({
context: 'page',
message: 'x'.repeat(5000)
});
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
expect(logged).toContain('[truncated]');
expect(logged.length).toBeLessThan(2000);
});
// The boundary itself, not just "way over the limit": a message of exactly
// MAX_MESSAGE (500) must survive untouched.
it('leaves a message of exactly the length limit unmodified', async () => {
const message = 'a'.repeat(500);
const res = await request(app).post('/api/client-errors').send({ context: 'page', message });
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
expect(logged).toContain(`message: ${message}\n`);
expect(logged).not.toContain('[truncated]');
});
// One character past the boundary must truncate.
it('truncates a message one character past the length limit', async () => {
const message = 'a'.repeat(501);
const res = await request(app).post('/api/client-errors').send({ context: 'page', message });
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
expect(logged).toContain(`message: ${'a'.repeat(500)}… [truncated]`);
});
it('truncates an oversized stack independently of message', async () => {
const res = await request(app).post('/api/client-errors').send({
context: 'page',
message: 'short',
stack: 'x'.repeat(4001)
});
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
const stackLine = logged.split('\n').find((line) => line.trim().startsWith('stack:'));
expect(stackLine).toContain('[truncated]');
});
it('truncates an oversized componentStack independently of message', async () => {
const res = await request(app).post('/api/client-errors').send({
context: 'page',
message: 'short',
componentStack: 'x'.repeat(4001)
});
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
const componentStackLine = logged
.split('\n')
.find((line) => line.trim().startsWith('componentStack:'));
expect(componentStackLine).toContain('[truncated]');
});
it('truncates an oversized path independently of message', async () => {
const res = await request(app).post('/api/client-errors').send({
context: 'page',
message: 'short',
path: '/'.concat('x'.repeat(201))
});
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
const pathLine = logged.split('\n')[0];
expect(pathLine).toContain('[truncated]');
});
// The endpoint is unauthenticated, so nothing stops a caller from sending a
// message crafted to look like a second [client-error] line. This is what
// Finding 1 closes: an embedded CR/LF must not survive into the log.
it('collapses embedded newlines so a report cannot forge a second log line', async () => {
const res = await request(app).post('/api/client-errors').send({
context: 'modal',
message: 'real error\n[client-error] context=page path=/fake\r\n message: forged entry'
});
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
// The template itself joins four fixed lines with three newlines; that
// count must not grow no matter what the caller sends.
expect(logged.split('\n')).toHaveLength(4);
expect(logged).not.toContain('\n[client-error]');
expect(logged).not.toContain('\r');
});
});
@@ -0,0 +1,787 @@
# React Error Boundaries Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stop a single render error from unmounting the whole storefront into a blank white page, and make sure that when it happens it leaves a trace in the server log.
**Architecture:** One `ErrorBoundary` class component mounted at three points — the root, the item grid, and the modal block — each rendering the same parameterized `ErrorFallback` in a different container. `componentDidCatch` fires a fire-and-forget POST to a new `/api/client-errors` endpoint that logs and returns 204. A development-only `DevThrow` component, gated behind `import.meta.env.DEV` so it cannot reach production, lets the end-to-end suite fire each boundary deliberately.
**Tech Stack:** React 18.3 + TypeScript, antd 5.20, react-router-dom 6.26, Vite 5, Express + `express-rate-limit`, Jest + supertest (backend), Playwright (end-to-end).
**Spec:** `docs/superpowers/specs/2026-08-20-error-boundary-design.md`
## Global Constraints
- **Branch:** `feature/62-error-boundary`. Every commit subject ends `(#62)`. Do not push — the repository owner pushes.
- **Node:** the dev machine defaults to Node 18, which cannot run this project's tooling. Prefix every command with `export PATH="/c/Users/tlamb/AppData/Local/nvm/v24.13.1:$PATH"`.
- **Integration tests need Postgres.** Port 55432 is Hyper-V-reserved on this machine. Start the container on another port and pass `TEST_PGPORT` rather than editing `docker-compose.test.yml`.
- **antd imports in new files:** deep imports from `antd/es/*`. Never `antd/lib/*`#65 records that it loads a second React context and breaks `ConfigProvider`.
- **Contexts are exactly** `'page' | 'catalogue' | 'modal'`, spelled identically in the frontend type and the server's allowlist.
- **Truncation limits:** message 500, stack 4000, componentStack 4000, path 200.
- **Rate limit:** 30 requests per 15 minutes, keyed on the caller only.
- **Fallback titles**, used verbatim as test locators: page `Something went wrong`, catalogue `The item list didn't load`, modal `Couldn't open that`.
- **Every escape action is a hard navigation** (`window.location.reload()` / `window.location.href = '/'`). A boundary does not reset on client-side navigation, so a `<Link>` would leave the fallback on screen.
- **Arrays of JSX elements need `key` props.** #81 fixed exactly this bug; the `Result` `extra` array is an array of elements.
- **Lint must stay at 0 errors.** `sonarjs/prefer-read-only-props` is on as a warning — declare component props as `Readonly<{…}>` from the start.
---
### Task 1: Backend endpoint — `POST /api/client-errors`
**Files:**
- Modify: `backend/src/rateLimit.ts` (append a second limiter)
- Create: `backend/src/routes/clientErrors.ts`
- Modify: `backend/src/app.ts` (import near the other route imports, mount beside the other `app.use('/api/...')` lines)
- Test: `backend/tests/integration/clientErrors.integration.test.ts`
**Interfaces:**
- Consumes: nothing from earlier tasks.
- Produces: `POST /api/client-errors` accepting `{ context: 'page'|'catalogue'|'modal', message?: string, stack?: string, componentStack?: string, path?: string }``204` on success, `400` on an unrecognised `context`. Task 2's `reportClientError` posts this exact shape.
- [ ] **Step 1: Start a test database and write the failing test**
Start Postgres first (once per session):
```bash
MSYS_NO_PATHCONV=1 docker run -d --name rd-eb-db \
-e POSTGRES_USER=redefined_test -e POSTGRES_PASSWORD=redefined_test -e POSTGRES_DB=redefined_test \
-p 55435:5432 --tmpfs /var/lib/postgresql/data postgres:16
```
Create `backend/tests/integration/clientErrors.integration.test.ts`:
```typescript
import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { closeDb } from './setup/testDb';
// No resetDb: this endpoint never touches the database. The pool still has to
// be closed or Jest reports an open handle.
afterAll(async () => {
await pool.end();
await closeDb();
});
describe('POST /api/client-errors', () => {
let errorSpy: jest.SpyInstance;
beforeEach(() => {
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
errorSpy.mockRestore();
});
it('accepts a well-formed report and logs it', async () => {
const res = await request(app).post('/api/client-errors').send({
context: 'catalogue',
message: 'Cannot read properties of undefined',
stack: 'Error: Cannot read properties of undefined\n at ItemCard',
componentStack: '\n at Catalogue\n at App',
path: '/?category=3'
});
expect(res.status).toBe(204);
expect(errorSpy).toHaveBeenCalledTimes(1);
const logged = errorSpy.mock.calls[0][0] as string;
expect(logged).toContain('[client-error]');
expect(logged).toContain('context=catalogue');
expect(logged).toContain('Cannot read properties of undefined');
});
it('refuses a context it does not recognise rather than logging under a guess', async () => {
const res = await request(app)
.post('/api/client-errors')
.send({ context: 'checkout', message: 'boom' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid context');
expect(errorSpy).not.toHaveBeenCalled();
});
it('refuses a report with no context at all', async () => {
const res = await request(app).post('/api/client-errors').send({ message: 'boom' });
expect(res.status).toBe(400);
expect(errorSpy).not.toHaveBeenCalled();
});
// Truncated rather than refused: an over-long report is still the only record
// of the failure, and dropping it would lose the thing worth having.
it('truncates an oversized message instead of refusing it', async () => {
const res = await request(app).post('/api/client-errors').send({
context: 'page',
message: 'x'.repeat(5000)
});
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
expect(logged).toContain('[truncated]');
expect(logged.length).toBeLessThan(2000);
});
});
```
- [ ] **Step 2: Run the test and confirm it fails for the right reason**
```bash
export PATH="/c/Users/tlamb/AppData/Local/nvm/v24.13.1:$PATH"
cd backend && TEST_PGPORT=55435 npx jest -c jest.integration.config.js --runInBand clientErrors
```
Expected: all four fail with `404` responses, because nothing is mounted at that path yet. A failure for any other reason means the harness is wrong — fix that before writing the route.
- [ ] **Step 3: Add the limiter**
Append to `backend/src/rateLimit.ts`:
```typescript
// Client error reports carry no email, so this one is keyed on the caller
// alone — deliberately not reusing passwordResetRequestLimiter, whose comment
// above explains why its key is wrong for an endpoint without an email.
//
// `trust proxy` is set in app.ts, so `req.ip` is the real client address from
// X-Forwarded-For rather than Nginx Proxy Manager's, making this a per-customer
// allowance rather than one shared by everybody behind the proxy.
//
// Generous, because hitting the limit is harmless: the reporter ignores the
// response either way. It exists so a render loop cannot fill the log.
const CLIENT_ERROR_WINDOW_MS = 15 * 60 * 1000;
const CLIENT_ERROR_MAX_REQUESTS = 30;
export const clientErrorLimiter = rateLimit({
windowMs: CLIENT_ERROR_WINDOW_MS,
limit: CLIENT_ERROR_MAX_REQUESTS,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { error: 'too many reports' }
});
```
Note it uses the default key generator rather than a custom one. That is deliberate: the custom `keyByCallerAndEmail` is what triggers the `ERR_ERL_KEY_GEN_IPV6` warning already visible in the backend log, and the default handles IPv6 correctly.
- [ ] **Step 4: Write the route**
Create `backend/src/routes/clientErrors.ts`:
```typescript
import { Router, Request, Response } from 'express';
import { clientErrorLimiter } from '../rateLimit';
const router = Router();
// The three error boundaries in the frontend. An unrecognised context means the
// client and the server disagree about something, which is worth surfacing
// rather than logging under a guessed label — the same reasoning as
// parseItemFilters refusing a malformed filter instead of coercing it.
const CONTEXTS: readonly string[] = ['page', 'catalogue', 'modal'];
const MAX_MESSAGE = 500;
const MAX_STACK = 4000;
const MAX_COMPONENT_STACK = 4000;
const MAX_PATH = 200;
// Anything that is not a string becomes empty rather than 'undefined' or
// '[object Object]', so a malformed field cannot dress itself up as content.
function clip(value: unknown, max: number): string {
if (typeof value !== 'string') {
return '';
}
return value.length > max ? `${value.slice(0, max)}… [truncated]` : value;
}
// No asyncRoute: this handler is synchronous, so there is no promise for the
// error middleware to miss.
router.post('/', clientErrorLimiter, (req: Request, res: Response) => {
const context: unknown = req.body?.context;
if (typeof context !== 'string' || !CONTEXTS.includes(context)) {
return res.status(400).json({ error: 'invalid context' });
}
console.error(
`[client-error] context=${context} path=${clip(req.body?.path, MAX_PATH)}\n` +
` message: ${clip(req.body?.message, MAX_MESSAGE)}\n` +
` stack: ${clip(req.body?.stack, MAX_STACK)}\n` +
` componentStack: ${clip(req.body?.componentStack, MAX_COMPONENT_STACK)}`
);
res.status(204).end();
});
export default router;
```
- [ ] **Step 5: Mount it**
In `backend/src/app.ts`, add the import beside the other route imports:
```typescript
import clientErrorsRouter from './routes/clientErrors';
```
and the mount beside the other `app.use('/api/...')` lines:
```typescript
app.use('/api/client-errors', clientErrorsRouter);
```
- [ ] **Step 6: Run the tests and confirm they pass**
```bash
cd backend && TEST_PGPORT=55435 npx jest -c jest.integration.config.js --runInBand clientErrors
```
Expected: 4 passed.
- [ ] **Step 7: Run the whole backend suite plus build and lint**
```bash
cd backend && npm run build && npm run lint && npm run test:unit
cd backend && TEST_PGPORT=55435 npm run test:integration
```
Expected: build clean, lint 0 errors, 78 unit pass, and **138 integration pass** — 134 existing plus the 4 new. Confirm the total actually rose by 4 rather than trusting the number here. `routesAreWrapped.test.ts` must still pass; it scans for unwrapped async handlers, and the new handler is synchronous.
- [ ] **Step 8: Commit**
```bash
git add backend/src/rateLimit.ts backend/src/routes/clientErrors.ts backend/src/app.ts backend/tests/integration/clientErrors.integration.test.ts
git commit -m "feat(backend): log client-side render errors to the server (#62)"
```
---
### Task 2: The boundary, the fallback, and the reporter
**Files:**
- Create: `frontend/src/errorReporting.ts`
- Create: `frontend/src/components/ErrorBoundary.tsx`
- Create: `frontend/src/components/ErrorFallback.tsx`
**Interfaces:**
- Consumes: `POST /api/client-errors` from Task 1.
- Produces:
- `type ErrorContext = 'page' | 'catalogue' | 'modal'`
- `reportClientError(report: ClientErrorReport): void`
- `<ErrorBoundary context={ErrorContext} fallback={(error: Error) => React.ReactNode}>` — default export of `components/ErrorBoundary`
- `<ErrorFallback error={Error} title={string} actions={React.ReactNode} fullPage?={boolean} />` — default export of `components/ErrorFallback`
Task 3 mounts all three of these.
Nothing here is independently testable — the frontend has no unit suite, which is #72's business. The gate for this task is that both files compile and lint clean; the behaviour is proved in Task 3.
- [ ] **Step 1: Write the reporter**
Create `frontend/src/errorReporting.ts`:
```typescript
// Where a caught render error came from. Kept in step with the allowlist in
// backend/src/routes/clientErrors.ts, which refuses anything else rather than
// logging under a guess — change one and you must change the other.
export type ErrorContext = 'page' | 'catalogue' | 'modal';
export interface ClientErrorReport {
context: ErrorContext;
message: string;
stack?: string;
componentStack?: string;
path: string;
}
// 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.
export function reportClientError(report: ClientErrorReport): void {
void fetch('/api/client-errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(report)
}).catch(() => undefined);
}
```
- [ ] **Step 2: Write the boundary**
Create `frontend/src/components/ErrorBoundary.tsx`:
```tsx
import React from 'react';
import { ErrorContext, reportClientError } from '../errorReporting';
type ErrorBoundaryProps = Readonly<{
context: ErrorContext;
fallback: (error: Error) => React.ReactNode;
children: React.ReactNode;
}>;
interface ErrorBoundaryState {
error: Error | null;
}
// The only class component in the codebase. getDerivedStateFromError and
// componentDidCatch have no hook equivalent, so a boundary cannot be written as
// a function component.
//
// It knows nothing about antd and nothing about how reporting reaches the
// server. The fallback is the caller's business, which is what lets one
// boundary serve a full page, an inline region and a modal.
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
reportClientError({
context: this.props.context,
message: error.message,
stack: error.stack,
componentStack: info.componentStack ?? undefined,
path: `${window.location.pathname}${window.location.search}`
});
}
render(): React.ReactNode {
if (this.state.error) {
return this.props.fallback(this.state.error);
}
return this.props.children;
}
}
```
- [ ] **Step 3: Write the fallback**
Create `frontend/src/components/ErrorFallback.tsx`:
```tsx
import React from 'react';
import Result from 'antd/es/result';
import Typography from 'antd/es/typography';
const { Paragraph, Text } = Typography;
type ErrorFallbackProps = Readonly<{
error: Error;
title: string;
actions: React.ReactNode;
fullPage?: boolean;
}>;
// The single place that decides whether a customer is shown a stack trace.
// Gated on DEV so a developer sees the throw immediately while a production
// bundle cannot render it at all — one decision in one file rather than the
// same judgement repeated at three mount points, where they would drift.
export default function ErrorFallback({ error, title, actions, fullPage = false }: ErrorFallbackProps) {
return (
<Result
status="error"
title={title}
subTitle="This has been reported. Nothing you did caused it."
style={{ paddingBlock: fullPage ? 64 : 24 }}
extra={actions}
>
{import.meta.env.DEV ? (
<Paragraph>
<Text code>{error.message}</Text>
</Paragraph>
) : null}
</Result>
);
}
```
- [ ] **Step 4: Build and lint**
```bash
export PATH="/c/Users/tlamb/AppData/Local/nvm/v24.13.1:$PATH"
cd frontend && npm run build && npm run lint
```
Expected: build clean. Lint 0 errors and **31 warnings** — the count on `main`. If it rose, the new files introduced a warning; fix it rather than accepting it. The likeliest is `sonarjs/prefer-read-only-props`, which the `Readonly<{…}>` props above are written to avoid.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/errorReporting.ts frontend/src/components/ErrorBoundary.tsx frontend/src/components/ErrorFallback.tsx
git commit -m "feat(frontend): add an error boundary, its fallback, and error reporting (#62)"
```
---
### Task 3: Mount the three boundaries and prove each one catches
**Files:**
- Create: `frontend/src/components/DevThrow.tsx`
- Modify: `frontend/src/main.tsx` (the `Root` function's `BrowserRouter` block, and the end of `AppRoutes`' returned fragment)
- Modify: `frontend/src/App.tsx` (around the `<Catalogue …/>` element)
- Test: `frontend/tests/e2e/error-boundary.spec.ts`
**Interfaces:**
- Consumes: `ErrorBoundary`, `ErrorFallback`, `ErrorContext` from Task 2; `POST /api/client-errors` from Task 1.
- Produces: nothing later tasks build on.
- [ ] **Step 1: Write the failing end-to-end spec**
Create `frontend/tests/e2e/error-boundary.spec.ts`:
```typescript
import { test, expect } from './fixtures';
// The ?boom= trigger only exists on the dev server, which is what Playwright
// runs against. A production build drops it entirely — verified separately by
// grepping dist for the marker.
test.describe('Error boundaries', () => {
test('a throw below the root shows a message rather than a blank page', async ({ page }) => {
await page.goto('/?boom=page');
await expect(page.getByRole('heading', { name: 'Something went wrong' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Reload' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Back to the shop' })).toBeVisible();
});
test('a throw in the item grid leaves the header and theme switch usable', async ({ page }) => {
await page.goto('/?boom=catalogue');
await expect(page.getByRole('heading', { name: "The item list didn't load" })).toBeVisible();
// The claim this boundary exists to make: a bad item no longer takes
// navigation down with it.
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
await expect(page.getByRole('switch')).toBeVisible();
// And the root boundary did not also fire — only the nearest one should.
await expect(page.getByRole('heading', { name: 'Something went wrong' })).toHaveCount(0);
});
test('a throw in the modal block leaves the storefront behind it intact', async ({ page }) => {
await page.goto('/?boom=modal');
await expect(page.getByRole('heading', { name: "Couldn't open that" })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
});
test('a caught error is reported to the server', async ({ page }) => {
const reports: string[] = [];
page.on('request', (request) => {
if (request.url().includes('/api/client-errors')) {
reports.push(request.postData() ?? '');
}
});
await page.goto('/?boom=catalogue');
await expect(page.getByRole('heading', { name: "The item list didn't load" })).toBeVisible();
// Observed on the wire rather than trusting that the reporter was called.
//
// Greater-than-zero, not exactly one: StrictMode double-invokes render in
// development, so the dev server this runs against may report twice where
// production reports once. Asserting an exact count would make the test
// fail for a reason that has nothing to do with the boundary.
await expect.poll(() => reports.length).toBeGreaterThan(0);
expect(reports[0]).toContain('"context":"catalogue"');
});
});
```
- [ ] **Step 2: Run it and confirm it fails**
The backend and a test database must be running. If they are not:
```bash
export PATH="/c/Users/tlamb/AppData/Local/nvm/v24.13.1:$PATH"
cd backend && PGHOST=localhost PGPORT=55435 PGUSER=redefined_test PGPASSWORD=redefined_test \
PGDATABASE=redefined_test PORT=3000 DEMO_MODE=true UPLOADS_DIR=/tmp/redefined-uploads \
node migrate.js up && node dist/server.js &
```
Then:
```bash
cd frontend && TEST_PGPORT=55435 npx playwright test tests/e2e/error-boundary.spec.ts
```
Expected: 4 failed. The `?boom=` parameter does nothing yet, so the storefront renders normally and no fallback heading is found.
- [ ] **Step 3: Write the development-only trigger**
Create `frontend/src/components/DevThrow.tsx`:
```tsx
import { ErrorContext } from '../errorReporting';
// A deliberate throw, reachable only from the dev server. Every mount site
// guards it with `import.meta.env.DEV &&`, which Vite replaces with `false` in
// a production build so Rollup drops both the element and this module.
//
// The marker is in the thrown message so a production bundle can be grepped for
// it. A gate that is silently always-off looks identical to one that works, so
// this is checked rather than trusted — the same reasoning as #61's coverage
// instrumentation gate.
export const DEV_THROW_MARKER = '__DEV_THROW_BOUNDARY__';
export default function DevThrow({ scope }: Readonly<{ scope: ErrorContext }>) {
const requested = new URLSearchParams(window.location.search).get('boom');
if (requested === scope) {
throw new Error(`${DEV_THROW_MARKER} deliberate throw in ${scope}`);
}
return null;
}
```
- [ ] **Step 4: Mount the root and modal boundaries in `main.tsx`**
Add to the imports at the top of `frontend/src/main.tsx`:
```typescript
import Button from 'antd/es/button';
import ModalDialog from 'antd/es/modal';
import ErrorBoundary from './components/ErrorBoundary';
import ErrorFallback from './components/ErrorFallback';
import DevThrow from './components/DevThrow';
```
`ModalDialog` rather than `Modal`, because `main.tsx` is one of the nine files still importing from the `antd` barrel and a bare `Modal` risks colliding with a future barrel import there. Renaming at the import keeps this change from depending on #65 landing first.
In `Root`, replace:
```tsx
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
```
with:
```tsx
<BrowserRouter>
<ErrorBoundary
context="page"
fallback={(error) => (
<ErrorFallback
error={error}
title="Something went wrong"
fullPage
actions={[
<Button key="reload" type="primary" onClick={() => window.location.reload()}>
Reload
</Button>,
<Button
key="home"
onClick={() => {
window.location.href = '/';
}}
>
Back to the shop
</Button>
]}
/>
)}
>
<AppRoutes />
</ErrorBoundary>
</BrowserRouter>
```
Then in `AppRoutes`, wrap the modal block. Replace the section that currently begins with the `{/* Rendered outside the Routes above… */}` comment and ends with the closing of the `/reset-password` branch, so that the whole run of `{modalPath === … && …}` expressions sits inside a boundary:
```tsx
{/* Rendered outside the Routes above, which are showing the backdrop. */}
<ErrorBoundary
context="modal"
fallback={(error) => (
<ModalDialog
open
title="Couldn't open that"
footer={null}
onCancel={() => {
window.location.href = '/';
}}
>
<ErrorFallback
error={error}
title="Couldn't open that"
actions={
<Button
type="primary"
onClick={() => {
window.location.href = '/';
}}
>
Close
</Button>
}
/>
</ModalDialog>
)}
>
{/* Unconditional, so /?boom=modal fires this boundary with the
storefront rendered behind it — no session needed. */}
{import.meta.env.DEV && <DevThrow scope="modal" />}
{modalPath === '/account' && <Account onClose={closeModal} />}
{modalPath === '/login' && (
<AuthRouteModal mode="login" onClose={closeModal} onNavigate={goWithinAuth} />
)}
{modalPath === '/register' && (
<AuthRouteModal mode="register" onClose={closeModal} onNavigate={goWithinAuth} />
)}
{modalPath === '/forgot-password' && (
<ForgotPassword onClose={closeModal} onBackToSignIn={() => goWithinAuth('/login')} />
)}
{modalPath === '/reset-password' && (
<ResetPassword
onClose={closeModal}
onRequestNewLink={() => goWithinAuth('/forgot-password')}
onBackToSignIn={() => goWithinAuth('/login')}
/>
)}
</ErrorBoundary>
```
The `ModalDialog` carries the title and the `ErrorFallback` repeats it. That is intentional: the antd `Modal` header is what a customer reads, and the `Result` title is what the test locates and what keeps the three fallbacks consistent.
Finally, add the page-scope trigger just inside the `<>` that `AppRoutes` returns, immediately before `<Routes location={backdrop}>`:
```tsx
{import.meta.env.DEV && <DevThrow scope="page" />}
```
- [ ] **Step 5: Mount the catalogue boundary in `App.tsx`**
Add to the imports at the top of `frontend/src/App.tsx`:
```typescript
import ErrorBoundary from './components/ErrorBoundary';
import ErrorFallback from './components/ErrorFallback';
import DevThrow from './components/DevThrow';
```
Replace the `<Catalogue … />` element with:
```tsx
<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>
```
`Button` is already imported in `App.tsx` from the `antd` barrel, so no new import is needed for it.
- [ ] **Step 6: Run the end-to-end spec and confirm it passes**
```bash
cd frontend && TEST_PGPORT=55435 npx playwright test tests/e2e/error-boundary.spec.ts
```
Expected: 4 passed.
If the tests fail with clicks timing out or the page obscured, check whether Vite's error overlay is rendering over the app — the spec flags this as a known risk. The fix is to disable the overlay for the dev server (`server: { hmr: { overlay: false } }` in `vite.config.ts`), not to work around it in the tests.
- [ ] **Step 7: Run the full frontend suite**
```bash
cd frontend && npm run build && npm run lint && TEST_PGPORT=55435 npm run test:e2e
```
Expected: build clean, lint 0 errors and 31 warnings, **87 e2e passing** (83 existing plus 4 new). If the database has already served an earlier run, recreate it first — the suite is not idempotent against a reused database, which produces failures that look like regressions in whatever you just changed.
- [ ] **Step 8: Commit**
```bash
git add frontend/src/components/DevThrow.tsx frontend/src/main.tsx frontend/src/App.tsx frontend/tests/e2e/error-boundary.spec.ts
git commit -m "feat(frontend): mount error boundaries at the root, the item grid and the modals (#62)"
```
---
### Task 4: Prove the trigger cannot reach production, then close out
**Files:**
- Modify: `docs/superpowers/specs/2026-08-20-error-boundary-design.md` (Status line)
**Interfaces:**
- Consumes: everything from Tasks 13.
- Produces: nothing.
- [ ] **Step 1: Confirm the development gate is on in development**
```bash
cd frontend && npm run dev &
curl -s "http://localhost:5173/src/components/DevThrow.tsx" | grep -c "__DEV_THROW_BOUNDARY__"
```
Expected: `1` or more. The module is served by the dev server. Stop the dev server afterwards.
This direction matters as much as the other: a gate that is always off looks identical to a gate that works, and only checking the production side would not tell them apart.
- [ ] **Step 2: Confirm it is absent from a production build**
```bash
cd frontend && npm run build
grep -c "__DEV_THROW_BOUNDARY__" dist/assets/*.js
grep -c "boom" dist/assets/*.js
```
Expected: `0` for the marker. The `boom` count is informational — a coincidental match in a minified bundle is possible, so the marker is the assertion and `boom` is a sanity check.
If the marker is present, `import.meta.env.DEV` is not being treated as statically false. Do not ship it; fix the gate.
- [ ] **Step 3: Run everything, both workspaces**
```bash
export PATH="/c/Users/tlamb/AppData/Local/nvm/v24.13.1:$PATH"
cd backend && npm run build && npm run lint && npm run test:unit
cd backend && TEST_PGPORT=55435 npm run test:integration
cd frontend && npm run build && npm run lint
cd frontend && TEST_PGPORT=55435 npm run test:e2e
```
Record the actual numbers rather than asserting success. Expected: backend build and lint clean, 78 unit, 138 integration; frontend build clean, lint 0 errors / 31 warnings, 87 e2e.
- [ ] **Step 4: Tear down the test environment**
```bash
docker rm -f rd-eb-db
```
and stop the backend `node dist/server.js` process.
- [ ] **Step 5: Mark the spec implemented and commit**
Change the spec's `**Status:** Approved` to `**Status:** Implemented`. If anything in the design turned out to be wrong during implementation, correct the spec in place — it is the durable record, and a spec that documents a decision nobody followed is worse than none.
```bash
git add docs/superpowers/specs/2026-08-20-error-boundary-design.md
git commit -m "docs: mark the error-boundary design implemented (#62)"
```
- [ ] **Step 6: Report on the issue**
Comment on #62 with what landed, the verification numbers actually observed, and anything the implementation contradicted in the design. Do not push — the repository owner pushes.
@@ -0,0 +1,153 @@
# React Error Boundaries — Design
**Issue:** [#62 — No React error boundary](https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/62)
**Date:** 2026-08-20
**Status:** Approved
## Goal
Stop a single render error from unmounting the whole storefront and leaving a blank white page, and make sure that when it happens somebody can find out.
## Why this one matters here
This project has been bitten repeatedly by failures that present as silence, and has designed against it each time: the storefront distinguishes "request failed" from "no items" because rendering an outage as an empty shop hid a production incident; the favorites filter answers 401 rather than an empty list; logout reports failure rather than appearing to succeed.
A missing error boundary defeats all of it one layer up. The storefront's careful "Couldn't load items" alert cannot render if the component that would render it has already thrown. A malformed API response, an unexpected `null`, or a bad `.map` anywhere in the tree produces a white page instead of any of the considered failure states.
## Decisions
Four decisions were open in the issue. All four are settled.
| Question | Decision |
| --- | --- |
| How many boundaries, and where | Three mount points: root, catalogue, modal block |
| Whether errors get reported anywhere | Yes — a server log endpoint, no storage |
| What the fallback offers | An action appropriate to each context; error detail in development only |
| How it gets verified | A development-gated throw trigger, exercised by end-to-end tests |
## Placement
```
StrictMode
ThemeModeProvider → CustomerAuthProvider → CartProvider → FavoritesProvider
Root → ConfigProvider
BrowserRouter
[EB "page"] full-page fallback
AppRoutes
<Routes location={backdrop}>
App
[EB "catalogue"] inline fallback
<Catalogue/>
[EB "modal"] antd Modal fallback
{modalPath === '/account' && <Account/>} …
```
**The root boundary sits inside `BrowserRouter`**, because `AppRoutes` is what it guards. Placing it outside would only additionally catch a throw from the router itself, which is not a realistic failure here.
**Every escape action is a hard navigation, not a `<Link>`.** This is worth stating because the obvious implementation is wrong: a React error boundary does not reset when the route changes. A fallback offering `<Link to="/">` would change the URL and go on rendering the fallback, which reads as the app being permanently broken. So Reload calls `window.location.reload()` and the two "leave this page" actions set `window.location.href`, both of which remount the tree and clear the error. An earlier draft of this design justified the boundary's placement by the fallback needing router context to link; that reasoning was wrong and the placement is justified above instead.
**The catalogue boundary is the one that earns its keep.** The likeliest throw in this application is a component rendering data from the API, and the item grid is where the most API data is rendered per page. Containing it there keeps the header, the cart badge, the filters and the footer alive, so the customer can still navigate rather than being handed one dead page.
**The modal boundary exists because the modal-route arrangement couples two independent trees.** `/account`, `/login`, `/register`, `/forgot-password` and `/reset-password` render as modals over the storefront as a backdrop. Without a boundary between them, a throw in `Account` blanks the storefront behind it and a throw in the storefront takes the open modal down with it. One boundary around the modal block separates the two in both directions.
### Deliberately excluded: an outermost boundary around the providers
An additional boundary outside `ThemeModeProvider` would catch a throw from the providers themselves. It is not included. Their render bodies are state, `useCallback`, `useMemo` and JSX, with no data mapping and no array indexing — the throw risk is remote. It would also sit outside `ConfigProvider`, so its fallback could not use antd and would need a second, hand-styled fallback to maintain for a case that is unlikely to occur.
If a provider does start doing real work later, this decision should be revisited rather than assumed still valid.
## Components
### `components/ErrorBoundary.tsx`
The only class component in the codebase — `getDerivedStateFromError` and `componentDidCatch` have no hook equivalent, so this cannot be a function component.
```
props: { children, context: 'page' | 'catalogue' | 'modal', fallback: (error: Error) => ReactNode }
```
`getDerivedStateFromError` records the error; `componentDidCatch` hands it to the reporter along with the component stack. The boundary itself knows nothing about antd and nothing about how reporting works, so it stays testable and the fallback stays the caller's business.
### `components/ErrorFallback.tsx`
One component, three containers. It renders a title, a set of actions, and — only under `import.meta.env.DEV` — the error message and component stack.
| Mount point | Title | Container | Actions |
| --- | --- | --- | --- |
| `page` | Something went wrong | Full-page antd `Result` | Reload, Back to the shop |
| `catalogue` | The item list didn't load | Inline, in place of the grid | Reload |
| `modal` | Couldn't open that | antd `Modal` | Close, returning to the storefront |
The three titles are deliberately distinct rather than one shared string. They tell a customer which part failed — the difference between "the shop is broken" and "the list didn't load but everything else works" — and they give the end-to-end tests an unambiguous locator for *which* boundary caught, which a shared title could not.
Keeping the development-only detail in one component means there is exactly one place where a decision about showing customers a stack trace lives, rather than three that can drift apart.
The modal fallback is a `Modal` rather than inline markup because the modal block renders after the routed content in the DOM. An inline fallback there would appear at the foot of the page, disconnected from the thing that failed.
### `errorReporting.ts`
```
reportClientError({ context, message, stack, componentStack, path })
```
Posts to `/api/client-errors` and ignores the outcome — `.catch(() => undefined)`. A reporter that throws inside `componentDidCatch` is the one failure this whole change must not introduce, so it swallows deliberately and that is the single place in this design where swallowing is correct.
### `components/DevThrow.tsx`
Throws when the current URL carries `?boom=<scope>` matching its own scope. Mounted inside each boundary as `{import.meta.env.DEV && <DevThrow scope="…" />}`, so Rollup eliminates both the element and its import from a production build.
The modal trigger is mounted as an unconditional sibling inside the modal boundary rather than inside one of the `modalPath === …` branches. That way `/?boom=modal` exercises it with the storefront rendered behind, which is the exact assertion the test needs to make, without depending on `/account` first resolving a session.
This is test-only code adjacent to a production path, which is exactly what the project's test/production boundary rule exists for. The gate is therefore verified rather than trusted: the production build is grepped for a distinctive marker string and must contain zero occurrences.
## Backend: `POST /api/client-errors`
A new router mounted in `app.ts`, taking `{ context, message, stack, componentStack, path }`.
**Validation.** `context` must be one of `page`, `catalogue`, `modal`; anything else is a 400. This follows `parseItemFilters`, which refuses a malformed filter rather than coercing it — a report from an unknown context means the client and server disagree about something, and that is worth surfacing rather than logging under a guessed label.
**Truncation**, applied before logging rather than rejecting, because a report that is too long is still worth having:
| Field | Limit |
| --- | --- |
| `message` | 500 |
| `stack` | 4000 |
| `componentStack` | 4000 |
| `path` | 200 |
**Rate limiting needs its own limiter, not the existing one.** `rateLimit.ts` documents that `passwordResetRequestLimiter` is keyed on caller *and* email, and that "applying the same limiter to an endpoint without one collapses every caller into a single `ip:` bucket". This endpoint has no email, so it gets a separate limiter keyed on `req.ip` alone at 30 requests per 15 minutes. `app.set('trust proxy', 1)` is already in place, so `req.ip` is the real client address from `X-Forwarded-For` rather than the proxy's — per-customer, not per-deployment. Hitting the limit is harmless: the client ignores the response either way.
**Logs** with a `[client-error]` prefix and returns **204**. No body, because the client ignores the response.
**Unauthenticated**, because render errors happen to signed-out visitors and an error report that requires a session would miss exactly the cases worth knowing about.
**No storage.** The container log is where this project's operational visibility already lives. A table, a retention policy and an admin screen are a subsystem, and were rejected as larger than the rest of this issue.
## Verification
**End-to-end**, in a new `error-boundary.spec.ts`:
1. `/?boom=page` renders the full-page fallback rather than a blank document.
2. `/?boom=catalogue` renders the inline fallback while the wordmark and the theme switch remain visible — the specific claim that a bad item no longer takes down navigation.
3. `/?boom=modal` leaves the storefront rendered behind the modal fallback.
4. A caught error actually reaches `/api/client-errors`, asserted by observing the request from the page rather than by trusting that the reporter was called.
Assertions are on the three distinct fallback titles, so a test cannot pass because *some* boundary caught when the wrong one did.
**Backend integration**, for the new route: a well-formed report returns 204, an unknown `context` is a 400, and an oversized message is truncated rather than rejected. The rate limiter is deliberately **not** asserted in the integration suite — its store is in-memory and process-wide, so a test that exhausts the allowance leaks that state into every later test keyed on the same address, and the order-dependent failure it produces later would cost more than the assertion is worth. The limit is verified by reading the configuration, the way the other limiter is.
**Production safety**, both directions, as #61 did for its instrumentation gate: a normal `npm run build` produces a bundle containing zero occurrences of the `DevThrow` marker, and the development server does throw when the parameter is present. A gate that is silently always-off looks identical to a gate that works.
## Risks to confirm during implementation
**React 18 StrictMode double-invokes render in development**, so a caught error may be reported twice locally. Harmless in production, where StrictMode's double render does not apply, but the end-to-end assertions must not depend on a single report.
**Vite's error overlay** may intercept runtime errors in the development server that Playwright drives. If it renders over the page it will block clicks and the tests will fail in a way that looks like the boundary not working. If that happens, the overlay is disabled for the test run rather than the tests worked around.
## A note on antd import style
The new frontend files use deep imports from `antd/es/*`, which is this project's documented convention and the style every recently-added file follows. Not `antd/lib/*`: #65 records that as the mistake which loads a second React context and breaks `ConfigProvider`. The nine files still using the `antd` barrel are #65's business, not this change's.
## Out of scope
Error reporting to an external service, persisting errors, alerting, and a frontend unit-test suite — the last belongs to #72, which already owns it.
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import { ErrorContext, reportClientError } from '../errorReporting';
type ErrorBoundaryProps = Readonly<{
context: ErrorContext;
fallback: (error: Error) => React.ReactNode;
children: React.ReactNode;
}>;
interface ErrorBoundaryState {
error: Error | null;
}
// The only class component in the codebase. getDerivedStateFromError and
// componentDidCatch have no hook equivalent, so a boundary cannot be written as
// a function component.
//
// It knows nothing about antd and nothing about how reporting reaches the
// server. The fallback is the caller's business, which is what lets one
// boundary serve a full page, an inline region and a modal.
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
reportClientError({
context: this.props.context,
message: error.message,
stack: error.stack,
componentStack: info.componentStack ?? undefined,
path: `${window.location.pathname}${window.location.search}`
});
}
render(): React.ReactNode {
if (this.state.error) {
return this.props.fallback(this.state.error);
}
return this.props.children;
}
}
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import Result from 'antd/es/result';
import Typography from 'antd/es/typography';
const { Paragraph, Text } = Typography;
type ErrorFallbackProps = Readonly<{
error: Error;
title: string;
actions: React.ReactNode;
fullPage?: boolean;
}>;
// The single place that decides whether a customer is shown a stack trace.
// Gated on DEV so a developer sees the throw immediately while a production
// bundle cannot render it at all — one decision in one file rather than the
// same judgement repeated at three mount points, where they would drift.
export default function ErrorFallback({ error, title, actions, fullPage = false }: ErrorFallbackProps) {
return (
<Result
status="error"
title={title}
subTitle="This has been reported. Nothing you did caused it."
style={{ paddingBlock: fullPage ? 64 : 24 }}
extra={actions}
>
{import.meta.env.DEV ? (
<Paragraph>
<Text code>{error.message}</Text>
</Paragraph>
) : null}
</Result>
);
}
+24
View File
@@ -0,0 +1,24 @@
// Where a caught render error came from. Kept in step with the allowlist in
// backend/src/routes/clientErrors.ts, which refuses anything else rather than
// logging under a guess — change one and you must change the other.
export type ErrorContext = 'page' | 'catalogue' | 'modal';
export interface ClientErrorReport {
context: ErrorContext;
message: string;
stack?: string;
componentStack?: string;
path: string;
}
// 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.
export function reportClientError(report: ClientErrorReport): void {
void fetch('/api/client-errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(report)
}).catch(() => undefined);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />