From 9eb2144be178355b43644db0c93cefadbace74d0 Mon Sep 17 00:00:00 2001 From: "Sean (HiveLabs)" Date: Sat, 14 Mar 2026 16:04:24 +0000 Subject: [PATCH] feat: harden save system with defensive error handling Add safe localStorage abstraction (safeStorage) that catches QuotaExceededError, read/write failures, and unavailable storage. All three Zustand stores (game, settings, daily) now persist through the safe adapter. Hard reset path clears corrupt localStorage keys before resetting in-memory state. A non-intrusive StorageBanner component warns users when storage is unavailable or full. Closes #99 --- src/components/GameLayout.tsx | 2 + src/components/StorageBanner.tsx | 88 ++++++++++++ src/components/index.ts | 1 + src/store/dailyStore.ts | 3 +- src/store/gameStore.ts | 60 ++++---- src/store/settingsStore.ts | 3 +- src/utils/safeStorage.test.ts | 233 +++++++++++++++++++++++++++++++ src/utils/safeStorage.ts | 159 +++++++++++++++++++++ src/utils/saveManager.test.ts | 24 ++++ src/utils/saveManager.ts | 14 ++ 10 files changed, 561 insertions(+), 26 deletions(-) create mode 100644 src/components/StorageBanner.tsx create mode 100644 src/utils/safeStorage.test.ts create mode 100644 src/utils/safeStorage.ts diff --git a/src/components/GameLayout.tsx b/src/components/GameLayout.tsx index f22e944..7a00ba4 100644 --- a/src/components/GameLayout.tsx +++ b/src/components/GameLayout.tsx @@ -24,6 +24,7 @@ import { PetDisplay } from "./PetDisplay"; import { SettingsPanel } from "./SettingsPanel"; import { StatsBar } from "./StatsBar"; import { StatsPanel } from "./StatsPanel"; +import { StorageBanner } from "./StorageBanner"; import { UpgradesSidebar } from "./UpgradesSidebar"; export function GameLayout() { @@ -153,6 +154,7 @@ export function GameLayout() { onClose={() => setSettingsOpen(false)} /> + {konamiVisible && (
!getStorageAvailable()); + const [quotaError, setQuotaError] = useState(false); + const [dismissed, setDismissed] = useState(false); + + const handleStorageError = useCallback((error: StorageError) => { + if (error.type === "quota-exceeded") { + setQuotaError(true); + setDismissed(false); // Re-show on new quota errors + } + }, []); + + useEffect(() => { + setStorageErrorHandler(handleStorageError); + }, [handleStorageError]); + + if (dismissed) return null; + + if (storageUnavailable) { + return ( + setDismissed(true)} + style={{ + position: "fixed", + bottom: 16, + left: "50%", + transform: "translateX(-50%)", + zIndex: 9999, + maxWidth: 480, + fontFamily: "monospace", + }} + data-testid="storage-banner" + > + + localStorage is unavailable. Your progress will not be saved between + sessions. Use "Copy Save to Clipboard" in Settings to back up + manually. + + + ); + } + + if (quotaError) { + return ( + setDismissed(true)} + style={{ + position: "fixed", + bottom: 16, + left: "50%", + transform: "translateX(-50%)", + zIndex: 9999, + maxWidth: 480, + fontFamily: "monospace", + }} + data-testid="storage-banner" + > + + Storage is full! Your latest progress may not be saved. Export your + save from Settings or clear other site data to free space. + + + ); + } + + return null; +} diff --git a/src/components/index.ts b/src/components/index.ts index 3468847..21ba58a 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -12,4 +12,5 @@ export { SettingsPanel } from "./SettingsPanel"; export { SpeechBubble } from "./SpeechBubble"; export { StatsBar } from "./StatsBar"; export { StatsPanel } from "./StatsPanel"; +export { StorageBanner } from "./StorageBanner"; export { UpgradesSidebar } from "./UpgradesSidebar"; diff --git a/src/store/dailyStore.ts b/src/store/dailyStore.ts index 93b5033..ef3833e 100644 --- a/src/store/dailyStore.ts +++ b/src/store/dailyStore.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { getCurrentDateUTC } from "../engine/dailyObjectivesEngine"; +import { safeStorage } from "../utils/safeStorage"; // ── State ───────────────────────────────────────────────────────────────────── @@ -93,6 +94,6 @@ export const useDailyStore = create()( }; }), }), - { name: "glorp-daily" }, + { name: "glorp-daily", storage: safeStorage }, ), ); diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index 4627cf8..ed5e22c 100644 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -31,6 +31,7 @@ import { getUpgradeCost, } from "../engine/upgradeEngine"; import { D, Decimal, toDecimal } from "../utils/decimal"; +import { safeStorage } from "../utils/safeStorage"; export interface GameState { trainingData: Decimal; @@ -494,35 +495,46 @@ export const useGameStore = create()( }), { name: "glorp-game-state", + storage: safeStorage, merge: (persisted, current) => { - const saved = persisted as Partial> | undefined; - if (!saved) return current; + try { + const saved = persisted as + | Partial> + | undefined; + if (!saved) return current; - // Convert Decimal fields from persisted strings/numbers back to Decimal - const merged = { ...current, ...(saved as Partial) }; - for (const key of DECIMAL_KEYS) { - const val = saved[key]; - if (val !== undefined) { - (merged as Record)[key] = toDecimal( - val as string | number | null, - ); + // Convert Decimal fields from persisted strings/numbers back to Decimal + const merged = { ...current, ...(saved as Partial) }; + for (const key of DECIMAL_KEYS) { + const val = saved[key]; + if (val !== undefined) { + (merged as Record)[key] = toDecimal( + val as string | number | null, + ); + } } - } - // Migrate old saves: convert wisdomTokens to spendable balance - if (saved.prestigeUpgrades === undefined) { - merged.prestigeUpgrades = {}; - } - if (saved.prestigeTokenBalance === undefined) { - merged.prestigeTokenBalance = (saved.wisdomTokens as number) ?? 0; - } - if (saved.hasOpenedPrestigeShop === undefined) { - merged.hasOpenedPrestigeShop = false; - } - if (saved.runStart === undefined) { - merged.runStart = Date.now(); + // Migrate old saves: convert wisdomTokens to spendable balance + if (saved.prestigeUpgrades === undefined) { + merged.prestigeUpgrades = {}; + } + if (saved.prestigeTokenBalance === undefined) { + merged.prestigeTokenBalance = (saved.wisdomTokens as number) ?? 0; + } + if (saved.hasOpenedPrestigeShop === undefined) { + merged.hasOpenedPrestigeShop = false; + } + if (saved.runStart === undefined) { + merged.runStart = Date.now(); + } + return merged; + } catch (e) { + console.error( + "[gameStore] Failed to merge persisted state, falling back to defaults:", + e, + ); + return current; } - return merged; }, }, ), diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts index 1e53164..bbb37f7 100644 --- a/src/store/settingsStore.ts +++ b/src/store/settingsStore.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; +import { safeStorage } from "../utils/safeStorage"; export type BuyMode = 1 | 10 | 100 | "max"; @@ -42,6 +43,6 @@ export const useSettingsStore = create()( setNumberFormat: (numberFormat) => set({ numberFormat }), setSoundEnabled: (soundEnabled) => set({ soundEnabled }), }), - { name: "glorp-settings" }, + { name: "glorp-settings", storage: safeStorage }, ), ); diff --git a/src/utils/safeStorage.test.ts b/src/utils/safeStorage.test.ts new file mode 100644 index 0000000..60cdcde --- /dev/null +++ b/src/utils/safeStorage.test.ts @@ -0,0 +1,233 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getStorageAvailable, + isLocalStorageAvailable, + resetStorageAvailableCache, + type StorageError, + type StorageErrorHandler, + safeGetItem, + safeRemoveItem, + safeSetItem, + safeStateStorage, + setStorageErrorHandler, +} from "./safeStorage"; + +beforeEach(() => { + localStorage.clear(); + resetStorageAvailableCache(); + setStorageErrorHandler(null as unknown as StorageErrorHandler); + vi.restoreAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("isLocalStorageAvailable", () => { + it("returns true when localStorage works normally", () => { + expect(isLocalStorageAvailable()).toBe(true); + }); + + it("returns false when localStorage.setItem throws", () => { + const origSetItem = localStorage.setItem.bind(localStorage); + localStorage.setItem = () => { + throw new Error("SecurityError"); + }; + expect(isLocalStorageAvailable()).toBe(false); + localStorage.setItem = origSetItem; + }); +}); + +describe("getStorageAvailable", () => { + it("caches the result after first call", () => { + const origSetItem = localStorage.setItem.bind(localStorage); + let callCount = 0; + localStorage.setItem = (...args: [string, string]) => { + callCount++; + return origSetItem(...args); + }; + getStorageAvailable(); + const firstCallCount = callCount; + getStorageAvailable(); + // Second call should not call setItem again (cached) + expect(callCount).toBe(firstCallCount); + localStorage.setItem = origSetItem; + }); + + it("returns false when storage is unavailable", () => { + const origSetItem = localStorage.setItem.bind(localStorage); + localStorage.setItem = () => { + throw new Error("SecurityError"); + }; + expect(getStorageAvailable()).toBe(false); + localStorage.setItem = origSetItem; + }); +}); + +describe("safeGetItem", () => { + it("returns the stored value", () => { + localStorage.setItem("test-key", "test-value"); + expect(safeGetItem("test-key")).toBe("test-value"); + }); + + it("returns null for missing keys", () => { + expect(safeGetItem("nonexistent")).toBeNull(); + }); + + it("returns null and logs error when getItem throws", () => { + // Ensure storage is detected as available + getStorageAvailable(); + const origGetItem = localStorage.getItem.bind(localStorage); + localStorage.getItem = () => { + throw new Error("read failure"); + }; + const handler = vi.fn(); + setStorageErrorHandler(handler); + expect(safeGetItem("test-key")).toBeNull(); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ type: "read-error" }), + ); + localStorage.getItem = origGetItem; + }); + + it("returns null when storage is unavailable without throwing", () => { + const origSetItem = localStorage.setItem.bind(localStorage); + localStorage.setItem = () => { + throw new Error("SecurityError"); + }; + resetStorageAvailableCache(); + expect(safeGetItem("test-key")).toBeNull(); + localStorage.setItem = origSetItem; + }); +}); + +describe("safeSetItem", () => { + it("returns true on successful write", () => { + expect(safeSetItem("key", "value")).toBe(true); + expect(localStorage.getItem("key")).toBe("value"); + }); + + it("returns false and notifies on QuotaExceededError", () => { + // Ensure storage is detected as available first + getStorageAvailable(); + const origSetItem = localStorage.setItem.bind(localStorage); + const quotaError = new DOMException("Quota exceeded", "QuotaExceededError"); + localStorage.setItem = () => { + throw quotaError; + }; + const handler = vi.fn(); + setStorageErrorHandler(handler); + expect(safeSetItem("game-state", "data")).toBe(false); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ type: "quota-exceeded" }), + ); + localStorage.setItem = origSetItem; + }); + + it("returns false and notifies on generic write error", () => { + getStorageAvailable(); + const origSetItem = localStorage.setItem.bind(localStorage); + localStorage.setItem = () => { + throw new Error("write failure"); + }; + const handler = vi.fn(); + setStorageErrorHandler(handler); + expect(safeSetItem("game-state", "data")).toBe(false); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ type: "write-error" }), + ); + localStorage.setItem = origSetItem; + }); + + it("returns false without throwing when storage is unavailable", () => { + const origSetItem = localStorage.setItem.bind(localStorage); + localStorage.setItem = () => { + throw new Error("SecurityError"); + }; + resetStorageAvailableCache(); + expect(safeSetItem("key", "value")).toBe(false); + localStorage.setItem = origSetItem; + }); +}); + +describe("safeRemoveItem", () => { + it("removes the item from storage", () => { + localStorage.setItem("to-remove", "value"); + safeRemoveItem("to-remove"); + expect(localStorage.getItem("to-remove")).toBeNull(); + }); + + it("does not throw when storage is unavailable", () => { + const origSetItem = localStorage.setItem.bind(localStorage); + localStorage.setItem = () => { + throw new Error("SecurityError"); + }; + resetStorageAvailableCache(); + expect(() => safeRemoveItem("key")).not.toThrow(); + localStorage.setItem = origSetItem; + }); + + it("notifies handler on removeItem error", () => { + getStorageAvailable(); + const origRemoveItem = localStorage.removeItem.bind(localStorage); + localStorage.removeItem = () => { + throw new Error("remove failure"); + }; + const handler = vi.fn(); + setStorageErrorHandler(handler); + safeRemoveItem("key"); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ type: "write-error" }), + ); + localStorage.removeItem = origRemoveItem; + }); +}); + +describe("safeStateStorage (Zustand StateStorage adapter)", () => { + it("implements getItem, setItem, removeItem", () => { + expect(typeof safeStateStorage.getItem).toBe("function"); + expect(typeof safeStateStorage.setItem).toBe("function"); + expect(typeof safeStateStorage.removeItem).toBe("function"); + }); + + it("round-trips a value", () => { + safeStateStorage.setItem("zustand-key", '{"state":{"count":1}}'); + expect(safeStateStorage.getItem("zustand-key")).toBe( + '{"state":{"count":1}}', + ); + }); + + it("returns null after removeItem", () => { + safeStateStorage.setItem("zustand-key", "data"); + safeStateStorage.removeItem("zustand-key"); + expect(safeStateStorage.getItem("zustand-key")).toBeNull(); + }); +}); + +describe("error handler registration", () => { + it("calls the registered handler on errors", () => { + const errors: StorageError[] = []; + setStorageErrorHandler((e: StorageError) => errors.push(e)); + getStorageAvailable(); + const origGetItem = localStorage.getItem.bind(localStorage); + localStorage.getItem = () => { + throw new Error("fail"); + }; + safeGetItem("key"); + expect(errors).toHaveLength(1); + expect(errors[0].type).toBe("read-error"); + localStorage.getItem = origGetItem; + }); + + it("handles null handler gracefully", () => { + setStorageErrorHandler(null as unknown as StorageErrorHandler); + getStorageAvailable(); + const origGetItem = localStorage.getItem.bind(localStorage); + localStorage.getItem = () => { + throw new Error("fail"); + }; + expect(() => safeGetItem("key")).not.toThrow(); + localStorage.getItem = origGetItem; + }); +}); diff --git a/src/utils/safeStorage.ts b/src/utils/safeStorage.ts new file mode 100644 index 0000000..348431b --- /dev/null +++ b/src/utils/safeStorage.ts @@ -0,0 +1,159 @@ +/** + * Safe localStorage wrapper that handles all edge cases: + * - localStorage completely unavailable (private browsing, iframe sandbox) + * - QuotaExceededError on writes + * - Corrupted data on reads (invalid JSON) + * + * Provides a Zustand-compatible StateStorage adapter and a reactive + * "storage available" flag for the UI banner. + */ +import { createJSONStorage, type StateStorage } from "zustand/middleware"; + +/** Callback signature for storage error notifications. */ +export type StorageErrorHandler = (error: StorageError) => void; + +export interface StorageError { + type: "quota-exceeded" | "read-error" | "write-error" | "unavailable"; + message: string; + originalError?: unknown; +} + +let errorHandler: StorageErrorHandler | null = null; + +/** Register a handler that is called when storage errors occur. */ +export function setStorageErrorHandler(handler: StorageErrorHandler): void { + errorHandler = handler; +} + +function notifyError(error: StorageError): void { + console.error( + `[safeStorage] ${error.type}: ${error.message}`, + error.originalError, + ); + errorHandler?.(error); +} + +/** + * Detect whether localStorage is available and functional. + * Returns false if the browser blocks access entirely or throws on use. + */ +export function isLocalStorageAvailable(): boolean { + const testKey = "__glorp_storage_test__"; + try { + localStorage.setItem(testKey, "1"); + localStorage.removeItem(testKey); + return true; + } catch { + return false; + } +} + +/** Cached availability flag, computed once at module load. */ +let storageAvailable: boolean | null = null; + +/** Returns whether localStorage is available (cached after first call). */ +export function getStorageAvailable(): boolean { + if (storageAvailable === null) { + storageAvailable = isLocalStorageAvailable(); + } + return storageAvailable; +} + +/** + * Reset the cached availability flag. Only used in tests. + * @internal + */ +export function resetStorageAvailableCache(): void { + storageAvailable = null; +} + +/** + * Safe wrapper around localStorage.getItem. + * Returns null if storage is unavailable or the key doesn't exist. + */ +export function safeGetItem(key: string): string | null { + if (!getStorageAvailable()) return null; + try { + return localStorage.getItem(key); + } catch (e) { + notifyError({ + type: "read-error", + message: `Failed to read key "${key}" from localStorage`, + originalError: e, + }); + return null; + } +} + +/** + * Safe wrapper around localStorage.setItem. + * Catches QuotaExceededError and other write failures. + * Returns true if the write succeeded, false otherwise. + */ +export function safeSetItem(key: string, value: string): boolean { + if (!getStorageAvailable()) return false; + try { + localStorage.setItem(key, value); + return true; + } catch (e) { + if (e instanceof DOMException && e.name === "QuotaExceededError") { + notifyError({ + type: "quota-exceeded", + message: + "localStorage is full. Your progress may not be saved. Try exporting your save or clearing browser data.", + originalError: e, + }); + } else { + notifyError({ + type: "write-error", + message: `Failed to write key "${key}" to localStorage`, + originalError: e, + }); + } + return false; + } +} + +/** + * Safe wrapper around localStorage.removeItem. + * Silently handles errors — removal failure is non-critical. + */ +export function safeRemoveItem(key: string): void { + if (!getStorageAvailable()) return; + try { + localStorage.removeItem(key); + } catch (e) { + notifyError({ + type: "write-error", + message: `Failed to remove key "${key}" from localStorage`, + originalError: e, + }); + } +} + +/** + * Raw StateStorage implementation using the safe wrappers. + */ +const safeStateStorage: StateStorage = { + getItem: (name: string): string | null => { + return safeGetItem(name); + }, + setItem: (name: string, value: string): void => { + safeSetItem(name, value); + }, + removeItem: (name: string): void => { + safeRemoveItem(name); + }, +}; + +/** + * Zustand-compatible PersistStorage created via createJSONStorage. + * Use this as the `storage` option in Zustand persist config. + */ +export const safeStorage = createJSONStorage(() => safeStateStorage); + +/** + * Re-export the raw StateStorage for testing. + * @internal + */ +export { safeStateStorage }; diff --git a/src/utils/saveManager.test.ts b/src/utils/saveManager.test.ts index 4f96c94..2cac22a 100644 --- a/src/utils/saveManager.test.ts +++ b/src/utils/saveManager.test.ts @@ -125,6 +125,30 @@ describe("resetGame", () => { resetGame(); expect(useGameStore.getState().rebirthCount).toBe(0); }); + + it("clears stale localStorage save keys before re-persisting", () => { + // Put corrupt data in all three keys + localStorage.setItem("glorp-game-state", "CORRUPT"); + localStorage.setItem("glorp-settings", "CORRUPT"); + localStorage.setItem("glorp-daily", "CORRUPT"); + resetGame(); + // Settings and daily should be cleared (game store re-persists fresh state) + expect(localStorage.getItem("glorp-settings")).toBeNull(); + expect(localStorage.getItem("glorp-daily")).toBeNull(); + // Game state key should be re-written with fresh initial state, not corrupt + const stored = localStorage.getItem("glorp-game-state"); + expect(stored).not.toBe("CORRUPT"); + }); + + it("succeeds even when localStorage throws", () => { + useGameStore.setState({ trainingData: D(9999) }); + vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => { + throw new Error("storage unavailable"); + }); + // Should not throw — falls back gracefully + resetGame(); + expect(useGameStore.getState().trainingData.toNumber()).toBe(0); + }); }); describe("exportSave", () => { diff --git a/src/utils/saveManager.ts b/src/utils/saveManager.ts index a27d8e6..a07635d 100644 --- a/src/utils/saveManager.ts +++ b/src/utils/saveManager.ts @@ -130,7 +130,21 @@ export function applySave(save: GameState): void { useGameStore.setState(migrateSave(save)); } +/** + * Hard-reset the game to initial state. + * Defensive: clears localStorage keys directly, then resets the store. + * Succeeds even when localStorage is corrupt or throws on access. + */ export function resetGame(): void { + // Best-effort: wipe the persisted save keys so a corrupt save + // cannot re-hydrate on next load. + for (const key of ["glorp-game-state", "glorp-settings", "glorp-daily"]) { + try { + localStorage.removeItem(key); + } catch { + // Storage may be unavailable — that's fine, just reset in-memory. + } + } useGameStore.setState({ ...initialGameState, lastSaved: Date.now() }); }