Skip to content

fix(error): stop the boundary stranding admins and misreporting the write - #81

Merged
guarzo merged 1 commit into
mainfrom
fix/error-boundary-harden
Aug 4, 2026
Merged

fix(error): stop the boundary stranding admins and misreporting the write#81
guarzo merged 1 commit into
mainfrom
fix/error-boundary-harden

Conversation

@guarzo

@guarzo guarzo commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Design-sweep HANDOVER items 1 and 2 — both target src/app/error.tsx, done as one pass. (Item 1's other half, the not-found boundaries, shipped in #78.)

What changed

Chrome follows the section (finding 4). The boundary was a member-shaped page for everyone. usePathname() is the only session-free signal a client component has, and it's what AdminNav is already built on — ADMIN_ITEMS matches its ITEMS list exactly, "Members" naming included (WCAG 3.2.4: the same route must not acquire a second name because the page under it threw).

The honesty question is answered in the file rather than left to the next reader: the path is evidence, not proof. /admin/* is behind the admin guard, /payouts/* behind requirePayoutReader — a member standing on either cleared that guard at their last render. The gap is a de-role landing between that render and the throw, and what that member gets is links that redirect to /account, which is exactly where the un-branched boundary sent them anyway. Bad case costs one hop; good case stops stranding an admin four destinations from the section they were working in.

Deliberately the opposite answer from not-found.tsx, which shows minimal nav because an unrouted URL is no evidence at all.

The lede stops claiming the write failed (critique #2). "The request didn't go through" is a claim this component has no evidence for — it holds a thrown Error and an optional digest. This file's own comment names the counterexample: a DB hiccup while enqueueing is a throw that follows a committed tier change. The critique's replacement copy was judged, not transcribed: the fault assignment is kept verbatim (it's the best sentence on the page) and the freed clause spends itself on the one thing that changes the member's next move — check whether it took effect before resending.

The digest moves into the instruction that asks for it (findings 6, 10). It was a separate dim mono line below the alert with no instruction attached. Now it's inside the sentence, as <code className="mono"> — only the value is monospaced, the prose around it stays proportional, which is DESIGN.md's split run the right way round. Plus a branch for the no-digest case: a client-side throw was measured arriving without one, and the old markup dropped the whole line and left "tell an admin" pointing at nothing.

Retry gets a pending state (finding 3), via aria-busy on the existing .btn[aria-busy="true"] styling. No disabled — that would move focus to <body> at the moment the member is waiting to hear something.

Measurements

Everything below was taken with throwaway instrumented probes, since removed. The reports predate #71/#74/#75/#78 and several of their premises no longer hold.

  1. A React 19 hoisted <title> in error.tsx does win over the failed segment's metadata — measured "Something broke · Zoo Landers" on /payouts/not-a-uuid, whose page.tsx exports title: "Payout operation". This is the opposite of what fix(404): ship the app's own not-found boundaries, with focus that survives a soft nav #78 measured for the segment-scoped not-found.tsx, so both files now carry cross-referencing comments warning against generalising.
  2. usePathname() resolves inside the root error boundary.
  3. reset() remounts the boundary rather than reusing it — an in-component attempt counter went 1 → 0 across one press. An "it still hasn't cleared" escalation would need sessionStorage keyed on the digest; not built, and the reason is recorded so it isn't rediscovered.
  4. useTransition's pending state is observable before that remount, so aria-busy fires and can't get stuck.
  5. A client component that throws during render reaches this boundary with no digest.

Measurement 3 killed the critique's retry-counter proposal. It also pays for itself: the remount re-runs FocusHeading, so a failed retry re-focuses the h1 and re-announces — the only confirmation a screen-reader user gets that the press landed and produced the same answer. That's the compensating win, and it's tested.

global-error.tsx (finding 9): not built, per the report's own verdict, and the decision is now recorded durably in error.tsx's docblock. It catches only a RootLayout throw (font registration and a <body>, self-hosted at build time — no request-time failure path) and a throw inside error.tsx itself. Covering them means emitting its own <html>/<body>, rendering without globals.css and both faces: a permanently off-brand second copy of the shell for a failure this layout can't produce.

Dropped, with reasons

  • Finding 5 (payouts/loading.tsx) — an addition to a different segment, not a fix to this file, and a separate synthesis item. Not in this pass.
  • Finding 6's copy-to-clipboard affordance — needs either duplicated clipboard logic or promoting CopyAmountButton to a shared primitive. That's shared-component work the sweep explicitly doesn't carry. The digest is ~10 characters and now sits in the sentence that asks for it, which is most of the value.
  • Finding 6's overflow-wrap: anywhere on .mono — a global CSS change touching every mono surface in the app for a value that doesn't overflow.
  • The audit's argument to demote Try again from primary — kept as the gold ration. It's the only control that keeps the member where they are, and the Notice names it in its own words.
  • Finding 7 — out of scope, has its own prompt.

Verification

typecheck exit 0
lint 0 errors, 3 warnings — all pre-existing no-img-element in ui.tsx:84, login/page.tsx:42,81
format:check All matched files use Prettier code style!
test 67 files, 710 passed (own DB, not the shared :5433)
test:e2e 124 passed (2.3m)
build

e2e/error-boundary.spec.ts is 7 new tests, all driving a real throw via /payouts/not-a-uuid (22P02, deterministic — a retry that might succeed would test nothing). The admin-section test can't use a URL, because every admin page validates its own params and none can be made to throw; it renames sync_run out from under /admin/sync, which reads that one table after requireAdminPage(), and renames it back in finally.

The 5 pre-existing e2e/not-found.spec.ts tests are untouched and pass — including the soft-navigation one, whose deliberate absence of a title assertion was left alone.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 14 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d1f861b7-712f-490d-9b53-5a011be37431

📥 Commits

Reviewing files that changed from the base of the PR and between b43e575 and 717e3b9.

📒 Files selected for processing (4)
  • e2e/error-boundary.spec.ts
  • src/app/_components/focus-heading.tsx
  • src/app/error.tsx
  • src/app/payouts/[id]/not-found.tsx

Comment @coderabbitai help to get the list of available commands.

…rite

The route error boundary was a member-shaped page regardless of who hit
it or where. Four changes, each backed by a measurement rather than an
assumption about what the framework does:

- Chrome follows the section. `usePathname()` is the only session-free
  signal a client boundary has; `/admin/*` and `/payouts/*` are both
  behind guards the member cleared at their last render, so the path is
  evidence. An admin now keeps the admin nav and lands back on Members
  instead of four hops away at /account. The reasoning, including what
  a de-role costs, is written down in the file.

- The lede stops claiming "The request didn't go through." A throw while
  enqueueing follows a committed write, so the sentence is false in the
  case this file's own comment describes. The fault assignment survives
  verbatim; the freed clause tells the member to check before resending.

- The digest moves inside the instruction that asks for it, as a `code`
  rather than a `mono` run over English prose, with a branch for the
  client-side throws measured to arrive without one.

- Retry gets a pending state via `aria-busy`, using the styling that
  already exists. A failed `reset()` remounts the boundary (measured),
  which re-runs FocusHeading and re-announces — the confirmation a
  screen-reader user previously never got. The same remount rules out an
  attempt counter; recorded as the reason rather than left to be
  rediscovered.

Also records the "no global-error.tsx" decision durably, and pins the
hoisted <title> behaviour, which is the opposite of what the
segment-scoped not-found was measured doing.

e2e/error-boundary.spec.ts covers all seven behaviours against real
throws.
@guarzo
guarzo force-pushed the fix/error-boundary-harden branch from d5d0958 to 717e3b9 Compare August 4, 2026 17:29
@guarzo

guarzo commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto main after #80. Two things it changed here, one of them more than a conflict.

The conflict was one paragraph in payouts/[id]/not-found.tsx. #80 rewrote it to describe the generateMetadata fix, which supersedes the "left as follow-up" text my version was appended to. Kept #80's paragraph and re-grafted only the error.tsx cross-reference, since the substance still holds — a not-found boundary loses to the segment's resolved metadata and an error boundary doesn't — with the cited route updated.

#80 removed this spec's throw trigger. /payouts/not-a-uuid now 404s, which is correct; it was sweep finding 7, and the spec's header comment said in as many words that the coupling was intended. Six of the seven tests used it.

The replacement is the technique the admin test already used: rename a table out from under a page, restore in finally. /payouts reads loot_pool in its body, after requirePayoutReader, so the guard still passes and only the body falls over — the exact shape the boundary exists for. It does not depend on any URL being malformed, so the next validation fix cannot quietly turn these into assertions about a 404 page. Both sections now use one technique, factored into a breakPayoutsList helper.

/payouts also happens to be the better subject for the title test than the old route was: it exports a static metadata: { title: "Payouts" }, which resolves whether or not the body throws. (/payouts/[id] no longer would — after #80 its generateMetadata runs the same broken lookup and throws too, so there would be nothing left to compete with.)

Re-measured rather than assumed. The title test only proves "beats" if the static metadata actually resolves through the throw, so I stripped the hoisted <title> and re-ran it:

Expected: "Something broke · Zoo Landers"
Received: "Payouts · Zoo Landers"

The metadata does resolve; the hoisted title genuinely wins. Restored, and the assertion is not vacuous.

The "Back to Payouts" test now follows the link only after the table is restored, so it asserts the escape reaches a working list rather than bouncing off the same throw.

Re-verified on the new base

typecheck exit 0
lint 0 errors, 3 warnings (same pre-existing no-img-element)
format:check All matched files use Prettier code style!
test 67 files, 710 passed
test:e2e 125 passed (2.4m) — was 124; #80 added one
build

All 7 boundary tests pass on the new trigger, as do #80's own additions to not-found.spec.ts and payouts.spec.ts.

@guarzo
guarzo enabled auto-merge (squash) August 4, 2026 17:31
@guarzo
guarzo merged commit 98788e1 into main Aug 4, 2026
6 checks passed
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