## The bug
Two Sentry issues share one minified boot frame, `vq` →
`initializeTheme()`:
-
[PHP-LARAVEL-57](https://whisper-money.sentry.io/issues/PHP-LARAVEL-57)
— `TypeError: Cannot read properties of null (reading 'getItem')`.
Chrome Mobile WebView 131 / Android 15: the host app disabled DOM
storage, so `window.localStorage` is `null`.
-
[PHP-LARAVEL-4Y](https://whisper-money.sentry.io/issues/PHP-LARAVEL-4Y)
— `SecurityError: The operation is insecure.`, 17 events. Blocking
cookies/site data makes the very first access throw.
`app.tsx` runs `initializeTheme()` and `initializeChartColorScheme()` at
module scope, before React mounts, so either one white-screens the app.
Same class as PHP-LARAVEL-41 (Safari <14 `addEventListener`), which is
why `lib/media-query.ts` exists — `lib/safe-storage.ts` is its sibling.
## What both reviews caught
My first pass only routed the two initializers, and **that would not
have closed either issue**. Both events carry url
`https://whisper.money/`, and the landing page reads `localStorage`
unguarded in a mount effect (`welcome.tsx:2105`) — those users would
have white-screened a few milliseconds later instead. There is also **no
error boundary anywhere in this app**, so any throw in render or an
effect unmounts the whole root; there is no "broken widget" failure
mode.
So the fix also covers:
- **`welcome.tsx`** — the landing page's locale effect, the url both
issues carry.
- **`chunk-load-recovery.ts:61,71`** — `window.sessionStorage` was
evaluated in an argument list, *outside* the try/catch that guards its
use. For the SecurityError cohort the global `error` listener itself
threw, and that throw was reported as another error event, re-entering
the same listener. It also meant chunk-load auto-recovery was dead
precisely on the browsers most likely to hold stale assets.
- **`edit-transaction-dialog.tsx:104`** — a read inside a lazy
`useState`, i.e. during render, on `/transactions` and account pages.
Its `typeof window !== 'undefined'` guard checks the wrong axis:
`window` exists, `window.localStorage` is null.
## Not just "doesn't crash" — the preference still works
Making the crash survivable exposed what happens next. The `appearance`
cookie is unencrypted, `HandleAppearance` renders the page from it, and
the blade applies it before any JS runs. Then `initializeTheme` read an
empty localStorage, fell back to `'system'`, and stripped the `dark`
class again — so a no-storage user who chose Dark got light on every
load. The mount effect was worse: it called `updateAppearance`, which
writes the cookie, so simply opening the app reset the one channel that
still persisted for these users.
Now the cookie is read as a fallback, the mount effect hydrates without
writing back, and `initializeChartColorScheme` only overrides the
blade-rendered `data-chart-color` when something is actually stored
(previously it reset everyone to `colorful`, leaving the CSS palette
fighting the colors the charts draw with).
For users whose storage works, behaviour is unchanged: `readStoredValue`
returns exactly what `getItem` returned, `null` included.
## Tests
7 vitest cases. The helper's four (working storage, null global,
throwing getter, throwing `setItem`) plus three that pin the invariant
that actually regressed — the boot initializers survive a null and a
throwing storage, and leave the server's theme alone when nothing is
stored. Verified they fail if `initializeTheme` goes back to a bare
`localStorage` call; the helper-only tests do not.
## Follow-ups (not here)
- **An error boundary in `app.tsx`** — both reviews called this the
highest-leverage item in the whole change. ~10 unguarded storage sites
remain, and without a boundary each is a fresh white screen rather than
a contained failure plus a Sentry report. It needs fallback UI and a
visual check, so it deserves its own PR.
- **The remaining unguarded sites**, in blast-radius order:
`lib/debug.ts:4` (called ~30× per rule evaluation from the rule engine,
so auto-categorization dies for these users),
`transaction-analysis-drawer.tsx` / `category-analysis-drawer.tsx`
(drawers fail to open), `lib/key-storage.ts` (on a 1s interval, but
gated behind the deprecated encryption setup — and note storage that
silently fails to persist is the *wrong* policy for a key),
`hooks/use-admin.tsx` (no call sites — delete it).
- Six call sites already hand-roll this exact try/catch idiom, which is
the argument for the helper existing; folding them in would also remove
three duplicated localStorage stubs from the test suite.
Fixes PHP-LARAVEL-57
Fixes PHP-LARAVEL-4Y