diff --git a/src/stores/__tests__/settingsStore.test.ts b/src/stores/__tests__/settingsStore.test.ts index 1433452..4f776cb 100644 --- a/src/stores/__tests__/settingsStore.test.ts +++ b/src/stores/__tests__/settingsStore.test.ts @@ -177,6 +177,112 @@ describe("settingsStore", () => { }); }); + describe("storage failure surfacing (issue #454)", () => { + let spyWarn: ReturnType; + + beforeEach(() => { + vi.resetModules(); + spyWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + spyWarn.mockRestore(); + // localStorage is shared across tests; clean it between runs. + if (typeof window !== "undefined" && window.localStorage) { + window.localStorage.clear(); + } + }); + + it("setQuerySettings sets settingsStorageError when localStorage throws QuotaExceededError", async () => { + const { useSettingsStore } = await import("../settingsStore"); + + // jsdom's localStorage keeps setItem as an own property; spy on the + // instance, not the prototype, so the override actually fires. + const throwQuota = () => { + throw new DOMException("quota", "QuotaExceededError"); + }; + const original = window.localStorage.setItem; + // @ts-expect-error -- testing assignment to host-provided method + window.localStorage.setItem = throwQuota; + + try { + useSettingsStore.getState().setQuerySettings({ + maxResultRows: 999, + limitEnabled: true, + }); + + // In-memory write still succeeded. + expect(useSettingsStore.getState().querySettings.maxResultRows).toBe(999); + // Persistence failed; error surfaced. + const err = useSettingsStore.getState().settingsStorageError; + expect(typeof err).toBe("string"); + expect(err).toMatch(/quota/i); + expect(err).toMatch(/query settings/); + // Also logged via console.warn + expect(spyWarn).toHaveBeenCalled(); + } finally { + window.localStorage.setItem = original; + } + }); + + it("setFormatterSettings sets settingsStorageError when localStorage throws SecurityError", async () => { + const { useSettingsStore } = await import("../settingsStore"); + + const throwSecurity = () => { + throw new DOMException("blocked", "SecurityError"); + }; + const original = window.localStorage.setItem; + // @ts-expect-error -- testing assignment to host-provided method + window.localStorage.setItem = throwSecurity; + + try { + useSettingsStore.getState().setFormatterSettings({ + ...defaultFormatterSettings, + tabWidth: 4, + }); + + const err = useSettingsStore.getState().settingsStorageError; + expect(typeof err).toBe("string"); + expect(err).toMatch(/private mode|cookies|blocked/i); + } finally { + window.localStorage.setItem = original; + } + }); + + it("clears settingsStorageError on a successful write after a prior failure", async () => { + const { useSettingsStore } = await import("../settingsStore"); + + let calls = 0; + let storeOriginal: ((k: string, v: string) => void) | null = null; + storeOriginal = window.localStorage.setItem; + // @ts-expect-error -- testing + window.localStorage.setItem = (() => { + calls += 1; + if (calls === 1) { + throw new DOMException("quota", "QuotaExceededError"); + } + // second call: real write + return storeOriginal!.call(window.localStorage, "k", "v"); + }) as typeof window.localStorage.setItem; + + try { + useSettingsStore.getState().setQuerySettings({ + maxResultRows: 1, + limitEnabled: false, + }); + expect(useSettingsStore.getState().settingsStorageError).toMatch(/quota/i); + + useSettingsStore.getState().setQuerySettings({ + maxResultRows: 2, + limitEnabled: false, + }); + expect(useSettingsStore.getState().settingsStorageError).toBeNull(); + } finally { + window.localStorage.setItem = storeOriginal; + } + }); + }); + describe("checkForUpdates", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 7a0b5b5..ecd89f3 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -78,6 +78,7 @@ interface DownloadProgress { interface SettingsState { querySettings: QuerySettings; formatterSettings: FormatterSettings; + settingsStorageError: string | null; updateStatus: | "idle" | "checking" @@ -111,6 +112,7 @@ function buildRpmUrl(version: string): string { export const useSettingsStore = create((set, get) => ({ querySettings: loadQuerySettings(), formatterSettings: loadSettings(), + settingsStorageError: null, updateStatus: "idle", updateVersion: null, updateError: null, @@ -206,20 +208,44 @@ export const useSettingsStore = create((set, get) => ({ setUpdateError: (message) => set({ updateError: message }), setQuerySettings: (settings) => { + let storageError: string | null = null; try { localStorage.setItem(QUERY_SETTINGS_KEY, JSON.stringify(settings)); - } catch { - // localStorage unavailable + } catch (e) { + // Quota exceeded, Safari private mode, or storage disabled. Without a + // visible signal here the user's settings silently revert to defaults + // on next launch. (refs #454) + storageError = describeStorageError(e, "query settings"); + console.warn("[settingsStore] could not persist query settings:", e); } - set({ querySettings: settings }); + set({ querySettings: settings, settingsStorageError: storageError }); }, setFormatterSettings: (settings) => { + let storageError: string | null = null; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); - } catch { - // localStorage unavailable + } catch (e) { + storageError = describeStorageError(e, "formatter settings"); + console.warn("[settingsStore] could not persist formatter settings:", e); } - set({ formatterSettings: settings }); + set({ formatterSettings: settings, settingsStorageError: storageError }); }, })); + +/** + * Convert a caught `localStorage.setItem` exception into a short, user-facing + * description for the `settingsStorageError` store slot. Distinguishes + * QuotaExceededError from generic failures so the UI can suggest "clear + * space" vs "check privacy mode". + */ +function describeStorageError(e: unknown, label: string): string { + if (e instanceof DOMException && (e.name === "QuotaExceededError" || e.code === 22)) { + return `Could not save ${label}: browser storage quota exceeded. Clear site data or free up space, then re-save.`; + } + if (e instanceof DOMException && (e.name === "SecurityError" || e.code === 18)) { + return `Could not save ${label}: browser storage access blocked (private mode or disabled cookies).`; + } + const msg = e instanceof Error ? e.message : String(e); + return `Could not save ${label}: ${msg}`; +}