Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions src/stores/__tests__/settingsStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,112 @@ describe("settingsStore", () => {
});
});

describe("storage failure surfacing (issue #454)", () => {
let spyWarn: ReturnType<typeof vi.spyOn>;

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();
Expand Down
38 changes: 32 additions & 6 deletions src/stores/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ interface DownloadProgress {
interface SettingsState {
querySettings: QuerySettings;
formatterSettings: FormatterSettings;
settingsStorageError: string | null;
updateStatus:
| "idle"
| "checking"
Expand Down Expand Up @@ -111,6 +112,7 @@ function buildRpmUrl(version: string): string {
export const useSettingsStore = create<SettingsState>((set, get) => ({
querySettings: loadQuerySettings(),
formatterSettings: loadSettings(),
settingsStorageError: null,
updateStatus: "idle",
updateVersion: null,
updateError: null,
Expand Down Expand Up @@ -206,20 +208,44 @@ export const useSettingsStore = create<SettingsState>((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}`;
}
Loading