mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
## 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>
59 lines
2.0 KiB
TypeScript
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;
|
|
}
|
|
}
|