Files
buzz/desktop/src/app/RootErrorBoundary.tsx
iroiro147 8630e58eb0 fix(desktop): fence localStorage SecurityError from killing the React tree (#5142)
## Summary

WebKit throws `SecurityError` from `localStorage.getItem` (not just
`setItem`) when storage access is denied for the origin. With no
`ErrorBoundary` in `desktop/src`, any such throw inside a provider
render (`ThemeProvider`, `CommunitiesProvider`, `App` boot) propagated
to the reconciler, unmounted the root, and left a blank window. Measured
repro in #5078: a single throwing `getItem` on `buzz-communities` or
`buzz-active-community-id` kills the container.

Closes #5078.

## What changed

**New helper — `desktop/src/shared/lib/safeStorage.ts`**
- `getStorageItem(key, fallback?)` — wraps
`window.localStorage.getItem`; on a thrown error (SecurityError under
denied-storage origin) it warns once per key and returns the fallback.
- `setStorageItem(key, value)` and `removeStorageItem(key)` — same
fail-closed contract (return `false` on throw).
- Unit tests in `safeStorage.test.mjs` cover the happy path and the
`SecurityError` path.

**Rewired the init-path readers that ran before any UI existed**
- `desktop/src/features/communities/communityStorage.ts` —
`migrateLegacyCommunityStorage`, `loadCommunities`,
`loadActiveCommunityId`, `loadCommunityDiscoveryAfterLeave`,
`initFirstCommunity`
- `desktop/src/features/communities/legacyCommunityStorage.ts` —
`migrateLegacyCommunityStorageBeforeRender`
- `desktop/src/shared/theme/ThemeProvider.tsx` — `readStoredTheme`,
`applyCachedVars`, the `useState` initialisers for `accentColor` and
`followSystem`, and the accent re-read inside `applyTheme`

**Root-level fence — `desktop/src/app/RootErrorBoundary.tsx`**
- New top-level `ErrorBoundary` wrapping the whole provider tree in
`main.tsx`. Any remaining uncaught render error (a future storage read
that bypasses the helper, or any other render-time crash) renders a
degraded splash with a Reload button instead of a blank window.

## Test plan

- `desktop/src/shared/lib/safeStorage.test.mjs` — node `--test` runner,
11 assertions across healthy, absent, and SecurityError-throwing
storage.
- Full `just ci` runs on the blocker.
- Existing `communityStorage.test.mjs` and
`legacyCommunityStorage.test.mjs` continue to pass (they exercise the
same functions via in-memory Storage doubles; the new code path in
`migrateLegacyCommunityStorage` only adds a `try/catch` around the same
body).

## Why not an ErrorBoundary-only fix

A boundary alone can't help on a *clean* mount — the first throw already
unmounted the whole subtree before any state or fallback data was
loaded, so retrying would hit the same throw on the very next render.
The storage accessor has to fail closed *and* the boundary has to exist
for whatever bypasses it. Both are needed; neither is sufficient alone.

---------

Signed-off-by: iroiro147 <sarthak.singh@mastersunion.org>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-07 09:37:04 -07:00

59 lines
2.0 KiB
TypeScript

import { Component, type ReactNode } from "react";
type RootErrorBoundaryProps = {
children: ReactNode;
};
type RootErrorBoundaryState = {
error: Error | null;
};
/**
* Root-level render fence for the desktop app (block/buzz#5078).
*
* Any uncaught throw inside the React tree — in particular a WebKit
* `SecurityError` from `localStorage.getItem` under a denied-storage origin,
* before the `safeStorage` accessors have a chance to fence it — previously
* propagated to the reconciler's error boundary (there isn't one) and left
* the window blank. This boundary renders a degraded splash instead so the
* user always sees something actionable, and the throw is logged at least
* once for diagnosis.
*/
export class RootErrorBoundary extends Component<
RootErrorBoundaryProps,
RootErrorBoundaryState
> {
override state: RootErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: unknown): RootErrorBoundaryState {
return { error: error instanceof Error ? error : new Error(String(error)) };
}
override componentDidCatch(error: unknown, info: React.ErrorInfo): void {
console.error("[RootErrorBoundary] uncaught render error:", error, info);
}
override render(): ReactNode {
const { error } = this.state;
if (error) {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center gap-3 bg-background px-6 text-foreground">
<p className="text-base font-semibold">Buzz failed to start</p>
<p className="max-w-md text-center text-sm text-muted-foreground">
Reload Buzz to try again. If this keeps happening, check that Buzz
can access website data, then contact support.
</p>
<button
type="button"
className="rounded-md border border-border bg-secondary px-4 py-2 text-sm hover:bg-secondary/80"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
);
}
return this.props.children;
}
}