Skip to content

feat(ui): add error boundaries so one throw stops taking the whole page - #90

Merged
chriskehayias merged 1 commit into
mainfrom
feat/error-boundaries
Sep 13, 2026
Merged

chriskehayias merged 1 commit into
mainfrom
feat/error-boundaries

Conversation

@chriskehayias

Copy link
Copy Markdown
Contributor

The app had no error boundary anywhere — no error.tsx, no global-error.tsx, no ErrorBoundary in src/. Any throw during a client render escaped to Next's default global error screen and replaced the entire page.

That wasn't theoretical: the formatDateTime RangeError fixed in #89 blanked the whole contact page from a single bad Contact_Date. That fix removed one cause; this contains the class.

Three boundaries, because placement is the whole design

File Catches Renders inside
src/app/(web)/error.tsx anything thrown below the (web) layout the app shell — Header, avatar, user menu and sign-out all survive
src/app/error.tsx /signin, /session-error, /auth-error the root layout, bare (those routes have no shell)
src/app/global-error.tsx a throw in the root layout.tsx itself nothing — it replaces the root layout

error.tsx never wraps the layout of its own segment, so one boundary will not do:

  • src/app/error.tsx alone would replace the (web) shell on any page error and take the user's sign-out with it — the exact trap /session-error exists to avoid.
  • Neither error.tsx catches a root-layout throw. That is what global-error.tsx is for.

Next 16 renamed the prop: retry, not reset

Earlier versions passed reset. Next 16 passes retry, which re-fetches and re-renders the segment; reset survives but only clears error state without re-fetching. A boundary wired to the stale name renders fine and its button silently does nothing — so each boundary has a test asserting retry is called.

Logging: identifiers only, never the message

These boundaries sit above components that render pastoral notes, names and emails. Unlike a controlled catch block around an HTTP call, a render error's message is not guaranteed content-free. So none of them log it — only { boundary, name, digest } under the structured event ui.render.error, with digest as the join key to the un-redacted server log.

Each boundary has a test that fails if a message ever reaches the log, and the shell boundary also asserts the message never reaches the page. Follows the F5 logging policy (.claude/references/auth.md § Logging policy).

global-error constraints, all load-bearing

  • Imports nothing from the app — a test enforces this by reading the source. Whatever failed may be that very code, and per Next's docs global-error doesn't receive the app's global styles anyway.
  • Styling is therefore inline, which is safe only because the CSP is style-src 'self' 'unsafe-inline' with no nonce (.claude/references/security-headers.md). A nonce-based style-src would silently drop every one of those styles.
  • Title via React's <title>, because a client component cannot export metadata.

Verification

npm run build succeeds — which is what actually proves Next accepts and wires these file conventions; a unit test of the component cannot. Full suite, all coverage thresholds, npx tsc --noEmit and npx eslint . are clean.

Metric Before After
Statements 99.73% 99.74% (1159/1162)
Branches 97.18% 97.21% (593/610)
Functions 99.29% 99.31% (291/293)
Lines 99.91% 99.91% (1126/1127)

996 → 1015 tests. All three new files are at 100%.

Two testing mechanics worth knowing

Both are documented in the testing reference, because each presents as a component bug rather than a failed assertion:

  • React 19 hoists <html>, <body> and <title> out of the render container. After render(<GlobalError />), container.querySelector("html") is null and the first child is the inner <div> — even though the component returns them. The structural contract is asserted with renderToStaticMarkup instead.
  • These components can't be called as plain functions the way layout.test.tsx does, because they use useEffect and hooks need a real render.

🤖 Generated with Claude Code

The app had no error boundary anywhere - no error.tsx, no global-error.tsx, no
ErrorBoundary in src/. Any throw during a client render escaped to Next's default
global error screen and replaced the entire page. That was not theoretical: the
formatDateTime RangeError fixed in #89 blanked the whole contact page from a
single bad Contact_Date. That fix stopped one cause; this contains the class.

Three boundaries, because placement is the whole design
--------------------------------------------------
  src/app/(web)/error.tsx     anything below the (web) layout, rendered INSIDE
                              the shell so Header, user menu and sign-out survive
  src/app/error.tsx           /signin, /session-error, /auth-error - bare, since
                              those routes have no shell
  src/app/global-error.tsx    a throw in the root layout itself; replaces it

error.tsx never wraps the layout of its OWN segment, so one boundary will not do.
src/app/error.tsx alone would replace the (web) shell on any page error and take
the user's sign-out with it - the exact trap /session-error exists to avoid. And
neither error.tsx catches a root-layout throw, which is what global-error is for.

Next 16: the prop is `retry`, not `reset`
--------------------------------------------------
Earlier versions passed `reset`. Next 16 renamed it to `retry`, which re-fetches
and re-renders the segment; `reset` survives but only clears error state without
re-fetching. A boundary wired to the stale name renders fine and its button
silently does nothing, so each boundary has a test asserting `retry` is called.

Logging: identifiers only, never the message
--------------------------------------------------
These boundaries sit above components that render pastoral notes, names and
emails. Unlike a controlled catch block around an HTTP call, a render error's
message is not guaranteed to be content-free, so none of them log it - only
`{ boundary, name, digest }` under the structured event `ui.render.error`, with
`digest` as the join key to the un-redacted server log. Each boundary has a test
that fails if a message ever reaches the log, and the shell boundary also asserts
the message never reaches the page. This follows the F5 logging policy
(.claude/references/auth.md § Logging policy).

global-error constraints, all load-bearing
--------------------------------------------------
It imports nothing from the app (a test enforces this by reading the source) -
whatever failed may be that very code, and per Next's docs global-error does not
receive the app's global styles anyway. Styling is therefore inline, which is
safe ONLY because the CSP is `style-src 'self' 'unsafe-inline'` with no nonce
(see .claude/references/security-headers.md); a nonce-based style-src would
silently drop all of it. The tab title uses React's <title> element because a
client component cannot export metadata.

Verification
--------------------------------------------------
`npm run build` succeeds, which is what actually proves Next accepts and wires
these file conventions - a unit test of the component cannot. Full suite, all
coverage thresholds, `npx tsc --noEmit` and `npx eslint .` are clean.

  Statements 99.73% -> 99.74%  (1159/1162)
  Branches   97.18% -> 97.21%  (593/610)
  Functions  99.29% -> 99.31%  (291/293)
  Lines      99.91% -> 99.91%  (1126/1127)

996 -> 1015 tests. All three new files are at 100%.

Two testing mechanics are documented in the testing reference because they cost
time here and present as component bugs: React 19 HOISTS <html>/<body>/<title>
out of the render container, so the structural contract has to be asserted with
renderToStaticMarkup rather than querying the DOM; and these components cannot be
called as plain functions the way layout.test.tsx does, because they use
useEffect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@chriskehayias
chriskehayias merged commit 5bc505a into main Sep 13, 2026
3 checks passed
@chriskehayias
chriskehayias deleted the feat/error-boundaries branch September 13, 2026 01:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant