From abce050c2fc856843fabe51156f473dd60d34d9c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 20:23:04 +0000 Subject: [PATCH] refactor(web): migrate weeklyDigestStorage off raw localStorage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item #6 round-9 follow-up. Migrate the web-side StorageReader adapter in apps/web/src/shared/lib/storage/weeklyDigestStorage.ts off the inline try/catch around localStorage.getItem onto the shared safeReadStringLS helper, removing the file from the sergeant-design/no-raw-local-storage allowlist. Production allowlist count: 14 → 13 (headroom 0). Updated rationale in .tech-debt/localstorage-allowlist-budget.json. Tests: 3 existing + 2 new resilience scenarios that assert loadDigest and hasLiveWeeklyDigest do not throw when localStorage.getItem itself throws (Safari Private Mode / disabled storage). Co-Authored-By: Бу Ка --- .tech-debt/localstorage-allowlist-budget.json | 4 +- .../lib/storage/weeklyDigestStorage.test.ts | 42 ++++++++++++++++++- .../shared/lib/storage/weeklyDigestStorage.ts | 13 +++--- eslint.config.js | 3 +- 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/.tech-debt/localstorage-allowlist-budget.json b/.tech-debt/localstorage-allowlist-budget.json index ac409f11f..c7266f34a 100644 --- a/.tech-debt/localstorage-allowlist-budget.json +++ b/.tech-debt/localstorage-allowlist-budget.json @@ -1,4 +1,4 @@ { - "production": 14, - "rationale": "Updated 2026-05-04 (Item 6 round-8 follow-up): production count 15 → 14 after migrating apps/web/src/shared/hooks/usePushNotifications.ts off direct localStorage onto safeReadStringLS/safeWriteLS/safeRemoveLS. (Headroom: 0 — bump only with a deliberate decision; new sites must migrate an existing one to keep parity.) Burndown plan in docs/diagnostics/2026-05-03-web-deep-dive/02-architecture-and-state.md §2.2 + docs/tech-debt/frontend.md §2." + "production": 13, + "rationale": "Updated 2026-05-04 (Item 6 round-9 follow-up): production count 14 → 13 after migrating apps/web/src/shared/lib/storage/weeklyDigestStorage.ts off direct localStorage onto safeReadStringLS. (Headroom: 0 — bump only with a deliberate decision; new sites must migrate an existing one to keep parity.) Burndown plan in docs/diagnostics/2026-05-03-web-deep-dive/02-architecture-and-state.md §2.2 + docs/tech-debt/frontend.md §2." } diff --git a/apps/web/src/shared/lib/storage/weeklyDigestStorage.test.ts b/apps/web/src/shared/lib/storage/weeklyDigestStorage.test.ts index d05ad76d7..64e17cd76 100644 --- a/apps/web/src/shared/lib/storage/weeklyDigestStorage.test.ts +++ b/apps/web/src/shared/lib/storage/weeklyDigestStorage.test.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { loadDigest, hasLiveWeeklyDigest } from "./weeklyDigestStorage"; beforeEach(() => { @@ -22,3 +22,43 @@ describe("hasLiveWeeklyDigest", () => { expect(hasLiveWeeklyDigest(new Date("2025-06-15"))).toBe(false); }); }); + +// `safeReadStringLS` — це новий адаптер замість inline try/catch (Item 6 +// round 9). Перевіряємо, що Safari Private Mode / quota / disabled-storage +// випадки не пробивають через `loadDigest` як throw. +describe("storage adapter resilience (Item 6 round 9)", () => { + const original = Object.getOwnPropertyDescriptor( + Storage.prototype, + "getItem", + ); + + afterEach(() => { + if (original) { + Object.defineProperty(Storage.prototype, "getItem", original); + } + }); + + it("повертає null коли localStorage.getItem кидає (Safari Private Mode)", () => { + Object.defineProperty(Storage.prototype, "getItem", { + configurable: true, + value: vi.fn(() => { + throw new DOMException("SecurityError"); + }), + }); + + expect(() => loadDigest("2025-W24")).not.toThrow(); + expect(loadDigest("2025-W24")).toBeNull(); + }); + + it("hasLiveWeeklyDigest не кидає коли storage недоступний", () => { + Object.defineProperty(Storage.prototype, "getItem", { + configurable: true, + value: vi.fn(() => { + throw new Error("disabled"); + }), + }); + + expect(() => hasLiveWeeklyDigest(new Date("2025-06-15"))).not.toThrow(); + expect(hasLiveWeeklyDigest(new Date("2025-06-15"))).toBe(false); + }); +}); diff --git a/apps/web/src/shared/lib/storage/weeklyDigestStorage.ts b/apps/web/src/shared/lib/storage/weeklyDigestStorage.ts index db94511be..abcecb71c 100644 --- a/apps/web/src/shared/lib/storage/weeklyDigestStorage.ts +++ b/apps/web/src/shared/lib/storage/weeklyDigestStorage.ts @@ -7,6 +7,12 @@ * adapts the real `localStorage` to that contract and returns wrapped * helpers with the storage argument pre-bound. Every web call-site * keeps its old signature after importing from here. + * + * The adapter delegates to `safeReadStringLS` so quota-exceeded / + * Safari Private Mode / disabled-storage failures collapse to `null` + * — the same try/catch contract the prior inline version provided. + * Going through the shared helper keeps this file off the + * `no-raw-local-storage` allowlist (Item 6 burndown). */ import { @@ -15,14 +21,11 @@ import { type StorageReader, type WeeklyDigestRecord, } from "@sergeant/shared"; +import { safeReadStringLS } from "./storage"; const webStorageReader: StorageReader = { getItem(key) { - try { - return localStorage.getItem(key); - } catch { - return null; - } + return safeReadStringLS(key); }, }; diff --git a/eslint.config.js b/eslint.config.js index fadfe3ed1..e614908dd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -426,7 +426,6 @@ export default [ "apps/web/src/shared/lib/storage/storageQuota.ts", "apps/web/src/shared/lib/storage/typedStore.ts", "apps/web/src/shared/lib/storage/createModuleStorage.ts", - "apps/web/src/shared/lib/storage/weeklyDigestStorage.ts", "apps/web/src/shared/hooks/useLocalStorageState.ts", // Мігровано на `safeReadStringLS`/`safeReadLS`/`safeWriteLS` у PR-и Item 6 // follow-up (docs/diagnostics/2026-05-03-web-deep-dive/02 §2.2): @@ -435,6 +434,8 @@ export default [ // - apps/web/src/shared/hooks/useActiveFizrukWorkout.ts (round 7) // - apps/web/src/shared/hooks/usePushNotifications.ts (round 8: 1 read + // 2 writes + 4 removes на ключ `hub_push_subscribed`). + // - apps/web/src/shared/lib/storage/weeklyDigestStorage.ts (round 9: + // `webStorageReader.getItem` → `safeReadStringLS`). // Cloud-sync internals — the queue / enqueue / state writer all // need direct access; users should call the cloud-sync API. "apps/web/src/core/cloudSync/logger.ts",