Files
buzz/desktop/src/app/RootErrorBoundary.test.mjs
T
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

85 lines
2.4 KiB
JavaScript

import assert from "node:assert/strict";
import { after, afterEach, before, test } from "node:test";
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
const originalConsoleError = console.error;
before(() => {
Object.assign(globalThis, {
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
window: dom.window,
});
dom.window.matchMedia = () => ({
matches: false,
addEventListener() {},
removeEventListener() {},
});
});
afterEach(async () => {
const { cleanup } = await import("@testing-library/react");
cleanup();
console.error = originalConsoleError;
});
after(() => dom.window.close());
test("ThemeProvider renders defaults when localStorage reads are denied", async () => {
const deniedStorage = {
getItem() {
throw new dom.window.DOMException("private diagnostic", "SecurityError");
},
setItem() {
throw new dom.window.DOMException("private diagnostic", "SecurityError");
},
removeItem() {
throw new dom.window.DOMException("private diagnostic", "SecurityError");
},
};
Object.defineProperty(dom.window, "localStorage", {
configurable: true,
value: deniedStorage,
});
const { createElement } = await import("react");
const { render, screen } = await import("@testing-library/react");
const { ThemeProvider } = await import("@/shared/theme/ThemeProvider");
render(
createElement(
ThemeProvider,
{ defaultTheme: "buzz" },
createElement("p", null, "Buzz is visible"),
),
);
assert.ok(screen.getByText("Buzz is visible"));
});
test("root boundary shows recovery UI without exposing error details", async () => {
const diagnostic = "/Users/alice/private/session-token";
console.error = () => {};
const { createElement } = await import("react");
const { render, screen } = await import("@testing-library/react");
const { RootErrorBoundary } = await import("./RootErrorBoundary.tsx");
function ThrowingProvider() {
throw new Error(diagnostic);
}
render(
createElement(RootErrorBoundary, null, createElement(ThrowingProvider)),
);
assert.ok(screen.getByText("Buzz failed to start"));
assert.ok(screen.getByRole("button", { name: "Reload" }));
assert.equal(document.body.textContent.includes(diagnostic), false);
assert.match(document.body.textContent, /contact support/i);
});