whisper-money/resources/js
Víctor Falcón a500c00b2b
fix(ui): show a recoverable screen instead of a blank page when render throws (#758)
Relates to
[PHP-LARAVEL-5A](https://whisper-money.sentry.io/issues/PHP-LARAVEL-5A).

## Why

The app has never had a React error boundary. Any throw during render or
in an effect unmounts the whole tree, so the user gets a blank white
page — no message, no way out, and nothing telling them to reload.

That is not hypothetical, it is how **every** frontend crash we have
fixed this year presented itself: #41 (`addEventListener` on old
Safari), #43 (missing `indexedDB`), #47 (recharts render loop), #57 /
#4Y (`localStorage` null in a restricted webview), #675
(`colorClasses.bg`). Each got a targeted fix, and the next unguarded
property access white-screened the app again. PHP-LARAVEL-5A is the
current one: `props.features` arrives undefined on `/dashboard` and
`props.features.cashflow` takes the page down.

**This does not fix 5A's root cause.** I could not confirm why
`props.features` goes missing — the server always shares it, and the
only mechanism I found is Inertia core's `mergeProps` bailing out when a
partial response's component differs from the current page, which I
could not reproduce. What this does is stop that class of bug from
costing the user their whole session.

## What it does

Wraps the tree in `@sentry/react`'s `ErrorBoundary` — already a
dependency, no new packages — with a fallback offering a reload and a
way to the dashboard.

Two things the boundary has to take over, because once React handles an
error it never reaches `window.onerror`:

- **Reporting.** `Sentry.ErrorBoundary` captures on its own via
`captureReactException`, and adds the React component stack. Note this
will fingerprint as a *new* issue rather than continuing 5A, since the
exception now carries a synthetic cause.
- **The stale-chunk reload.** `chunk-load-recovery`'s global listeners
only see what React did not catch, so `onError` re-runs
`reloadOnChunkLoadError`. Its actual reload-once behaviour stays covered
by `chunk-load-recovery.test.ts`.

## The two follow-up commits are the interesting ones

Both came out of review and both were real bugs in the first commit:

**`handled` was silently flipped.** The SDK derives `handled` from
whether a fallback was supplied (`errorboundary.js:51`), so simply
adding one would have reclassified every caught crash as handled — they
would stop counting against crash-free sessions and stop matching any
`error.handled:false` alert. Now passes `handled={false}` explicitly.

**Both escape hatches were dead ends.**
- "Back to home" pointed at `/`, which renders the marketing page — and
that page mounts `AuthenticatedRedirectDialog`, which `router.visit()`s
a signed-in user to the dashboard on a 3s timer. The escape hatch
trampolined the user straight back into whatever crashed. It now goes to
the dashboard directly, as a document load.
- The **back button** was worse. Inertia's popstate listener lives at
module scope and outlives React unmounting the page, so going back
changed the URL and painted nothing — the screen just looked frozen. The
fallback now reloads on `popstate`.

## Scope, honestly

- `initializeTheme()` and `initializeChartColorScheme()` run at module
scope, **before** the boundary mounts, so the boot-crash class (#57 /
#4Y) is still outside its reach. Those are already fixed by #743's
`safe-storage`; worth knowing the boundary is not a second net for them.
- One boundary at the root means a crash anywhere replaces the whole app
rather than just the page. Since React unmounts the entire tree on an
uncaught error anyway, the trade is white screen → error screen, not
working page → error screen. A second boundary around `<App>` only would
let the shell survive and make `resetError()` meaningful — worth doing
if this ever fires often enough to matter.
- `ssr.tsx` keeps its own unwrapped copy of the provider tree.
Pre-existing duplication, now one element wider; extracting a shared
`AppProviders` is the fix.
- No reload-loop guard. "Try again" is user-initiated, not automatic, so
a deterministic crash means the button does nothing rather than looping
— and the dashboard button is the real way out.

## Tests

`app-error-boundary.test.tsx`: children render normally; a throwing
child produces the recovery screen with both actions instead of nothing;
the error is handed to the chunk-load recovery; the popstate listener is
registered and cleaned up. 4/4 locally, plus
`chunk-load-recovery.test.ts` 5/5.

Copy uses existing `lang/es.json` keys except one new line, which is
added.
2026-08-10 15:02:39 +00:00
..
components fix(ui): show a recoverable screen instead of a blank page when render throws (#758) 2026-08-10 15:02:39 +00:00
contexts Move transaction filtering from client-side to server-side (#126) 2026-02-16 12:49:04 +01:00
hooks fix(ui): stop unusable web storage from white-screening the app (#743) 2026-08-09 15:33:22 +00:00
layouts feat(budgets): add per-budget email notifications (#731) 2026-07-24 12:52:03 +02:00
lib fix(ui): stop unusable web storage from white-screening the app (#743) 2026-08-09 15:33:22 +00:00
pages feat(transactions): allow manual transactions on bank-connected accounts (#747) 2026-08-10 05:32:40 +02:00
providers fix: move community link to user menu (#442) 2026-05-27 17:39:26 +02:00
services feat(transactions): allow editing all fields of manual transactions (#683) 2026-07-16 08:59:10 +02:00
types feat(budgets): add per-budget email notifications (#731) 2026-07-24 12:52:03 +02:00
utils feat(currency): add THB (Thai Baht) (#740) 2026-08-09 14:24:58 +00:00
app.tsx fix(ui): show a recoverable screen instead of a blank page when render throws (#758) 2026-08-10 15:02:39 +00:00
ssr.tsx Remove plaintext-transactions feature flag & E2E references (#116) 2026-02-13 11:10:21 +01:00