mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02: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>
85 lines
2.4 KiB
JavaScript
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);
|
|
});
|