From 4d832212147d44c3b77cddf4b15333ae1261f4d6 Mon Sep 17 00:00:00 2001 From: iroiro147 Date: Fri, 7 Aug 2026 08:29:46 +0530 Subject: [PATCH 1/2] fix(desktop): fence localStorage SecurityError from killing the React tree 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) unmounted the whole root and left a blank window. Measured in the repro arms attached to #5078: a single throwing getItem on 'buzz-communities' or 'buzz-active-community-id' kills the container. Adds shared/lib/safeStorage: getStorageItem / setStorageItem / removeStorageItem which fail closed (null / false) and warn-once per key instead of propagating. Rewires the init-path readers that ran before any UI existed: - communityStorage.ts: migrateLegacyCommunityStorage, loadCommunities, loadActiveCommunityId, loadCommunityDiscoveryAfterLeave, initFirstCommunity - legacyCommunityStorage.ts: migrateLegacyCommunityStorageBeforeRender - ThemeProvider.tsx: readStoredTheme, applyCachedVars, useState initializers for accentColor and followSystem - ThemeProvider.applyTheme: accent re-read Also installs a root-level RootErrorBoundary in main.tsx so any remaining uncaught render error (any future storage read that bypasses the helper) renders a degraded splash with a Reload affordance instead of a blank window. Includes unit tests for the safeStorage helpers (safeStorage.test.mjs) covering the happy path and the SecurityError path. Refs #5078 Signed-off-by: iroiro147 --- desktop/src/app/RootErrorBoundary.tsx | 57 +++++++++ .../features/communities/communityStorage.ts | 57 ++++++--- .../communities/legacyCommunityStorage.ts | 10 +- desktop/src/main.tsx | 7 +- desktop/src/shared/lib/safeStorage.test.mjs | 108 ++++++++++++++++++ desktop/src/shared/lib/safeStorage.ts | 86 ++++++++++++++ desktop/src/shared/theme/ThemeProvider.tsx | 19 +-- 7 files changed, 317 insertions(+), 27 deletions(-) create mode 100644 desktop/src/app/RootErrorBoundary.tsx create mode 100644 desktop/src/shared/lib/safeStorage.test.mjs create mode 100644 desktop/src/shared/lib/safeStorage.ts diff --git a/desktop/src/app/RootErrorBoundary.tsx b/desktop/src/app/RootErrorBoundary.tsx new file mode 100644 index 0000000000..48de4590aa --- /dev/null +++ b/desktop/src/app/RootErrorBoundary.tsx @@ -0,0 +1,57 @@ +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 ( +
+

Buzz failed to start

+

+ {error.message} +

+ +
+ ); + } + return this.props.children; + } +} diff --git a/desktop/src/features/communities/communityStorage.ts b/desktop/src/features/communities/communityStorage.ts index 7c8778712e..6f99ce8d33 100644 --- a/desktop/src/features/communities/communityStorage.ts +++ b/desktop/src/features/communities/communityStorage.ts @@ -1,6 +1,7 @@ import type { Community } from "./types"; import { homeDir } from "@tauri-apps/api/path"; import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; +import { getStorageItem, removeStorageItem } from "@/shared/lib/safeStorage"; const COMMUNITIES_KEY = "buzz-communities"; const ACTIVE_COMMUNITY_KEY = "buzz-active-community-id"; @@ -34,24 +35,36 @@ export async function expandTilde(input: string): Promise { export function migrateLegacyCommunityStorage( storage: Storage = localStorage, ): void { - if (storage.getItem(COMMUNITIES_KEY) === null) { - const legacyCommunities = storage.getItem(LEGACY_WORKSPACES_KEY); - if (legacyCommunities !== null) { - storage.setItem(COMMUNITIES_KEY, legacyCommunities); + try { + if (storage.getItem(COMMUNITIES_KEY) === null) { + const legacyCommunities = storage.getItem(LEGACY_WORKSPACES_KEY); + if (legacyCommunities !== null) { + storage.setItem(COMMUNITIES_KEY, legacyCommunities); + } } - } - if (storage.getItem(ACTIVE_COMMUNITY_KEY) === null) { - const legacyActiveCommunity = storage.getItem(LEGACY_ACTIVE_WORKSPACE_KEY); - if (legacyActiveCommunity !== null) { - storage.setItem(ACTIVE_COMMUNITY_KEY, legacyActiveCommunity); + if (storage.getItem(ACTIVE_COMMUNITY_KEY) === null) { + const legacyActiveCommunity = storage.getItem( + LEGACY_ACTIVE_WORKSPACE_KEY, + ); + if (legacyActiveCommunity !== null) { + storage.setItem(ACTIVE_COMMUNITY_KEY, legacyActiveCommunity); + } } + } catch (error) { + // WebKit throws SecurityError from getItem when storage access is denied + // for the origin (block/buzz#5078). Fencing here so the app can still + // boot with an empty/default community list instead of a blank window. + console.warn( + "[communityStorage] migrateLegacyCommunityStorage failed (storage denied?):", + error, + ); } } export function loadCommunities(): Community[] { try { migrateLegacyCommunityStorage(); - const raw = localStorage.getItem(COMMUNITIES_KEY); + const raw = getStorageItem(COMMUNITIES_KEY); if (!raw) { return []; } @@ -60,7 +73,7 @@ export function loadCommunities(): Community[] { return []; } if (parsed.length > 0) { - localStorage.removeItem(COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY); + removeStorageItem(COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY); } // Migration: older builds stored the user's `nsec` in localStorage and // re-applied it to the backend on every reload, which silently overwrote @@ -100,7 +113,17 @@ export function saveCommunities(communities: Community[]): boolean { export function loadCommunityDiscoveryAfterLeave( storage: Storage = localStorage, ): boolean { - return storage.getItem(COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY) === "1"; + try { + return storage.getItem(COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY) === "1"; + } catch (error) { + // block/buzz#5078 — storage access can be denied for the origin; degrade + // to the default ("didn't just leave") instead of crashing the boot path. + console.warn( + "[communityStorage] loadCommunityDiscoveryAfterLeave failed:", + error, + ); + return false; + } } export function markCommunityDiscoveryAfterLeave( @@ -129,7 +152,10 @@ export function clearCommunityStorage(storage: Storage = localStorage): void { export function loadActiveCommunityId(): string | null { migrateLegacyCommunityStorage(); - return localStorage.getItem(ACTIVE_COMMUNITY_KEY); + // block/buzz#5078 — WebKit can throw SecurityError from a denied-storage + // getItem. Fail closed so the boot path renders the default community UI + // instead of unmounting the root. + return getStorageItem(ACTIVE_COMMUNITY_KEY); } export function saveActiveCommunityId(id: string): boolean { @@ -199,7 +225,10 @@ export function initFirstCommunity( pubkey, addedAt: new Date().toISOString(), }; - const previousActiveCommunityId = localStorage.getItem(ACTIVE_COMMUNITY_KEY); + // block/buzz#5078 — read the prior active id through the throw-safe helper; + // a denied-storage origin would otherwise kill onboarding before a single + // write is attempted. + const previousActiveCommunityId = getStorageItem(ACTIVE_COMMUNITY_KEY); const didSaveActiveCommunity = saveActiveCommunityId(community.id); if (!didSaveActiveCommunity) { return null; diff --git a/desktop/src/features/communities/legacyCommunityStorage.ts b/desktop/src/features/communities/legacyCommunityStorage.ts index d140fa4c62..b871ec0bbe 100644 --- a/desktop/src/features/communities/legacyCommunityStorage.ts +++ b/desktop/src/features/communities/legacyCommunityStorage.ts @@ -1,4 +1,5 @@ import { invokeTauri } from "@/shared/api/tauri"; +import { getStorageItem } from "@/shared/lib/safeStorage"; import { migrateLegacyCommunityStorage } from "./communityStorage"; const BUZZ_COMMUNITIES_KEY = "buzz-communities"; @@ -116,11 +117,10 @@ export async function migrateLegacyCommunityStorageBeforeRender(): Promise } migrateLegacyCommunityStorage(window.localStorage); - const currentCommunitiesRaw = - window.localStorage.getItem(BUZZ_COMMUNITIES_KEY); - const hasCurrentActiveCommunity = window.localStorage.getItem( - BUZZ_ACTIVE_COMMUNITY_KEY, - ); + // block/buzz#5078 — read through the throw-safe accessor so a denied-storage + // origin degrades to "no community state" instead of crashing pre-render. + const currentCommunitiesRaw = getStorageItem(BUZZ_COMMUNITIES_KEY); + const hasCurrentActiveCommunity = getStorageItem(BUZZ_ACTIVE_COMMUNITY_KEY); if ( currentCommunitiesRaw && hasCurrentActiveCommunity && diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index d2751dfe16..50148bcf7b 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -1,6 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "@/app/App"; +import { RootErrorBoundary } from "@/app/RootErrorBoundary"; import { NostrBindConsentDialog } from "@/features/profile/ui/NostrBindConsentDialog"; import "@fontsource-variable/inter/wght.css"; import "@fontsource/jetbrains-mono/400.css"; @@ -76,7 +77,10 @@ function configureDevE2eBridgeFromUrl() { function renderApp() { ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + {/* block/buzz#5078 — catch any uncaught render error so a WebKit + SecurityError from localStorage can't blank the whole window. */} + + @@ -93,6 +97,7 @@ function renderApp() { + , ); } diff --git a/desktop/src/shared/lib/safeStorage.test.mjs b/desktop/src/shared/lib/safeStorage.test.mjs new file mode 100644 index 0000000000..eb46ce7619 --- /dev/null +++ b/desktop/src/shared/lib/safeStorage.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + __resetSafeStorageWarningsForTests, + getStorageItem, + removeStorageItem, + setStorageItem, +} from "./safeStorage.ts"; + +function createThrowingStorage(initial = {}) { + const values = new Map(Object.entries(initial)); + return { + values, + getItem: (key) => { + if (key === "__THROW__") throw new Error("SecurityError"); + return values.get(key) ?? null; + }, + setItem: (key, value) => { + if (key === "__THROW__") throw new Error("SecurityError"); + values.set(key, String(value)); + }, + removeItem: (key) => { + if (key === "__THROW__") throw new Error("SecurityError"); + values.delete(key); + }, + }; +} + +function patchLocalStorage(storage) { + const original = window.localStorage; + Object.defineProperty(window, "localStorage", { + configurable: true, + writable: true, + value: storage, + }); + return () => { + Object.defineProperty(window, "localStorage", { + configurable: true, + writable: true, + value: original, + }); + }; +} + +test.beforeEach(() => { + __resetSafeStorageWarningsForTests(); +}); + +test("getStorageItem returns the stored value when storage is healthy", () => { + const restore = patchLocalStorage( + createThrowingStorage({ "buzz-theme": "buzz" }), + ); + try { + assert.equal(getStorageItem("buzz-theme"), "buzz"); + assert.equal(getStorageItem("missing"), null); + } finally { + restore(); + } +}); + +test("getStorageItem returns null when the key is absent", () => { + const restore = patchLocalStorage(createThrowingStorage()); + try { + assert.equal(getStorageItem("buzz-theme"), null); + } finally { + restore(); + } +}); + +test("getStorageItem swallows SecurityError and returns the fallback", () => { + const restore = patchLocalStorage(createThrowingStorage()); + try { + assert.equal(getStorageItem("__THROW__"), null); + assert.equal(getStorageItem("__THROW__", "fallback"), "fallback"); + } finally { + restore(); + } +}); + +test("setStorageItem returns true on a healthy write", () => { + const storage = createThrowingStorage(); + const restore = patchLocalStorage(storage); + try { + assert.equal(setStorageItem("buzz-theme", "buzz-dark"), true); + assert.equal(storage.values.get("buzz-theme"), "buzz-dark"); + } finally { + restore(); + } +}); + +test("setStorageItem returns false instead of throwing on denied storage", () => { + const restore = patchLocalStorage(createThrowingStorage()); + try { + assert.equal(setStorageItem("__THROW__", "x"), false); + } finally { + restore(); + } +}); + +test("removeStorageItem returns false instead of throwing on denied storage", () => { + const restore = patchLocalStorage(createThrowingStorage()); + try { + assert.equal(removeStorageItem("__THROW__"), false); + } finally { + restore(); + } +}); diff --git a/desktop/src/shared/lib/safeStorage.ts b/desktop/src/shared/lib/safeStorage.ts new file mode 100644 index 0000000000..3dc4784bdb --- /dev/null +++ b/desktop/src/shared/lib/safeStorage.ts @@ -0,0 +1,86 @@ +/** + * Throw-safe localStorage accessors. + * + * WKWebView throws `SecurityError` from `localStorage.getItem` (not just + * `setItem`) when storage access is denied for the origin — e.g. the user + * disabled website data, or the Tauri webview runs under a restricted + * storage policy. A raw `window.localStorage.getItem(...)` executed inside + * a React `useState`/`useMemo` initializer or provider render propagates + * that throw up the reconcile path and unmounts the whole tree (there is + * no `ErrorBoundary` in `desktop/src`), leaving a dead window. + * + * These helpers make reads fail-closed (return the fallback / `null`) and + * writes fail-silently-with-a-warning, preserving the app's ability to start + * and degrade to in-memory state instead of crashing to a blank screen. + * + * Issue contexts: block/buzz#5078. + */ + +// Keep the console noise down on repeated reads — one warn per (action, key) +// pair per process is enough. This also keeps unit tests deterministic. +const WARNED_KEYS = new Set(); + +function warnOnce(action: "read" | "write", key: string, error: unknown): void { + if (WARNED_KEYS.has(`${action}:${key}`)) return; + WARNED_KEYS.add(`${action}:${key}`); + console.warn( + `[safeStorage] localStorage.${action === "read" ? "getItem" : "setItem"} threw for key "${key}" (storage access denied?):`, + error, + ); +} + +/** Reset the warn-once dedup — test-only helper. */ +export function __resetSafeStorageWarningsForTests(): void { + WARNED_KEYS.clear(); +} + +/** + * Read a localStorage entry, resolving `fallback` when the underlying call + * throws (e.g. WebKit `SecurityError` under a restricted storage policy) or + * storage is unavailable. Never throws. + */ +export function getStorageItem( + key: string, + fallback: string | null = null, +): string | null { + try { + return window.localStorage.getItem(key) ?? fallback; + } catch (error) { + warnOnce("read", key, error); + return fallback; + } +} + +/** + * Write a localStorage entry; returns `false` when the underlying call throws + * (quota exceeded or storage denied) instead of propagating. Prefer + * `setLocalStorageItemWithRecovery` from `./localStorageQuota` for writes that + * need cache-eviction recovery — this wrapper exists for call sites that only + * need the throw converted to a boolean result. + */ +export function setStorageItem(key: string, value: string): boolean { + try { + window.localStorage.setItem(key, value); + return true; + } catch (error) { + warnOnce("write", key, error); + return false; + } +} + +/** + * Remove a localStorage entry; never throws. Returns `false` when removal + * threw (treated as best-effort, matching `localStorage.removeItem` semantics + * callers assume). + */ +export function removeStorageItem(key: string): boolean { + try { + window.localStorage.removeItem(key); + return true; + } catch (error) { + // Reads/writes share the SecurityError class; log under the read bucket + // because a denied-storage origin will fail all three the same way. + warnOnce("read", key, error); + return false; + } +} diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx index 4596694345..1150f3baca 100644 --- a/desktop/src/shared/theme/ThemeProvider.tsx +++ b/desktop/src/shared/theme/ThemeProvider.tsx @@ -11,6 +11,7 @@ import { isTauri } from "@tauri-apps/api/core"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { invokeTauri } from "@/shared/api/tauri"; import { isMacPlatform } from "@/shared/lib/platform"; +import { getStorageItem } from "@/shared/lib/safeStorage"; import { createThemeVars, hexToHsl } from "./adaptive-theme"; import { SYNTAX_THEMES, @@ -80,7 +81,10 @@ function isValidThemeName(name: string): name is SyntaxThemeName { /** Read stored theme, migrating legacy "light"/"dark"/"system" values. */ function readStoredTheme(fallback: SyntaxThemeName): SyntaxThemeName { - const stored = window.localStorage.getItem(THEME_STORAGE_KEY); + // block/buzz#5078 — WebKit throws SecurityError from getItem under a + // denied-storage origin; the throw-safe helper lets the provider degrade to + // the fallback instead of unmounting the root during first render. + const stored = getStorageItem(THEME_STORAGE_KEY); if (!stored) return fallback; // Migrate legacy values @@ -416,8 +420,7 @@ function applyCachedVars(): string | null { root.classList.add(isDark ? "dark" : "light"); applyBuzzSidebar(themeName); - const accent = - window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT; + const accent = getStorageItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT; // Pin Buzz themes to the neutral accent here too, matching applyTheme. // Otherwise a cached Buzz theme + non-neutral stored accent flashes the // old accent on reload until the async applyTheme effect runs. @@ -471,7 +474,7 @@ async function applyTheme(name: SyntaxThemeName): Promise<{ applyAccentColor( resolveEffectiveAccent( name, - window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT, + getStorageItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT, ), ); @@ -506,15 +509,17 @@ export function ThemeProvider({ >(null); const loadingRef = useRef(null); const [accentColor, setAccentColorState] = useState(() => { - return window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT; + // block/buzz#5078 — use the throw-safe accessor for init-time reads; a + // denied-storage origin would otherwise kill the root on first mount. + return getStorageItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT; }); const [followSystem, setFollowSystemState] = useState(() => { - const stored = window.localStorage.getItem(FOLLOW_SYSTEM_KEY); + const stored = getStorageItem(FOLLOW_SYSTEM_KEY); if (stored !== null) return stored === "true"; // Fresh profiles (no saved theme) default to System mode so the Buzz // default tracks the OS light/dark scheme. Profiles that picked a theme // before this toggle existed keep their fixed theme until they opt in. - return window.localStorage.getItem(THEME_STORAGE_KEY) === null; + return getStorageItem(THEME_STORAGE_KEY) === null; }); const [systemIsDark, setSystemIsDark] = useState(() => { return window.matchMedia("(prefers-color-scheme: dark)").matches; From 59b0d0ffc49a2ca8fd9f7df525cbdcc4900962ea Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 7 Aug 2026 08:21:28 -0600 Subject: [PATCH 2/2] fix(desktop): harden storage crash recovery coverage Make the safe-storage tests run in the repository's Node harness, exercise the denied-storage provider path and root fallback as mounted React trees, and keep arbitrary exception details out of the user-visible recovery UI. Format the provider tree so the change passes the repository checks. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src/app/RootErrorBoundary.test.mjs | 84 +++++++++++++++++++++ desktop/src/app/RootErrorBoundary.tsx | 3 +- desktop/src/main.tsx | 34 +++++---- desktop/src/shared/lib/safeStorage.test.mjs | 11 ++- 4 files changed, 112 insertions(+), 20 deletions(-) create mode 100644 desktop/src/app/RootErrorBoundary.test.mjs diff --git a/desktop/src/app/RootErrorBoundary.test.mjs b/desktop/src/app/RootErrorBoundary.test.mjs new file mode 100644 index 0000000000..09cfed4746 --- /dev/null +++ b/desktop/src/app/RootErrorBoundary.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + 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); +}); diff --git a/desktop/src/app/RootErrorBoundary.tsx b/desktop/src/app/RootErrorBoundary.tsx index 48de4590aa..0c4bbe80b0 100644 --- a/desktop/src/app/RootErrorBoundary.tsx +++ b/desktop/src/app/RootErrorBoundary.tsx @@ -40,7 +40,8 @@ export class RootErrorBoundary extends Component<

Buzz failed to start

- {error.message} + Reload Buzz to try again. If this keeps happening, check that Buzz + can access website data, then contact support.