Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .tech-debt/localstorage-allowlist-budget.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"production": 15,
"rationale": "Updated 2026-05-04 (Item 6 round-7 follow-up): production count 1615 after migrating apps/web/src/shared/hooks/useActiveFizrukWorkout.ts off direct localStorage onto safeReadStringLS/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": 14,
"rationale": "Updated 2026-05-04 (Item 6 round-8 follow-up): production count 1514 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."
}
101 changes: 101 additions & 0 deletions apps/web/src/shared/hooks/usePushNotifications.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -538,3 +538,104 @@ describe("usePushNotifications — native Capacitor branch", () => {
expect(result.current.subscribed).toBe(false);
});
});

describe("usePushNotifications — localStorage hardening (Item #6 round 8)", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
isCapacitorMock.mockReturnValue(false);
getPlatformMock.mockReturnValue("web");
getVapidPublicMock.mockResolvedValue({ publicKey: "AAAA" });
});

afterEach(() => {
vi.restoreAllMocks();
});

it("ініційний рендер не падає, якщо localStorage.getItem кидає (Safari Private Mode)", () => {
// Safari Private Mode / зруйнований storage backend кидає SecurityError
// на будь-який getItem. До міграції на `safeReadStringLS` ми ловили це
// через try/catch у самому хуку — тепер хедж лежить у `safe*LS`-обгортці.
const getItemSpy = vi
.spyOn(Storage.prototype, "getItem")
.mockImplementation(() => {
throw new Error("SecurityError: localStorage is disabled");
});

const apiClient = makeApiClientWithMocks(
makeRegisterMock({ ok: true, platform: "web" }),
);

const { result } = renderHook(() => usePushNotifications(), {
wrapper: makeWrapper(apiClient),
});

expect(result.current.subscribed).toBe(false);
expect(getItemSpy).toHaveBeenCalledWith("hub_push_subscribed");
});

it("subscribe не падає, якщо localStorage.setItem кидає QuotaExceededError", async () => {
stubNotification("granted");
const subscription = makeMockPushSubscription({
endpoint: "https://fcm.googleapis.com/wp/quota",
p256dh: "p",
auth: "a",
});
stubServiceWorker(subscription);

const registerMock = makeRegisterMock({ ok: true, platform: "web" });
const apiClient = makeApiClientWithMocks(registerMock);

vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("QuotaExceededError");
});

const { result } = renderHook(() => usePushNotifications(), {
wrapper: makeWrapper(apiClient),
});

await act(async () => {
await result.current.subscribe();
});

await waitFor(() => {
expect(registerMock).toHaveBeenCalledTimes(1);
});

// Серверна реєстрація все одно пройшла; in-memory стан виставився.
// Запис у LS свідомо проігнорено — наступний рендер прочитає `false`,
// що краще, ніж краш виклику subscribe() у середині retry-flow.
expect(result.current.subscribed).toBe(true);
});

it("unsubscribe не падає, якщо localStorage.removeItem кидає", async () => {
stubNotification("granted");
const apiClient = makeApiClientWithMocks(
makeRegisterMock({ ok: true, platform: "web" }),
makeUnregisterMock({ ok: true, platform: "web" }),
);

localStorage.setItem("hub_push_subscribed", "1");

const subscription = makeMockPushSubscription({
endpoint: "https://fcm.googleapis.com/wp/rm",
p256dh: "p",
auth: "a",
});
stubServiceWorker(subscription);

vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => {
throw new Error("SecurityError");
});

const { result } = renderHook(() => usePushNotifications(), {
wrapper: makeWrapper(apiClient),
});

await act(async () => {
await result.current.unsubscribe();
});

expect(result.current.subscribed).toBe(false);
});
});
27 changes: 15 additions & 12 deletions apps/web/src/shared/hooks/usePushNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import type { PushRegisterRequest } from "@sergeant/api-client";
import { getPlatform, isCapacitor } from "@sergeant/shared";
import { isApiError, pushApi } from "@shared/api";
import { pushKeys } from "@shared/lib/api/queryKeys";
import {
safeReadStringLS,
safeRemoveLS,
safeWriteLS,
} from "@shared/lib/storage/storage";
import {
getStoredNativePushToken,
subscribeNativePush,
Expand Down Expand Up @@ -85,6 +90,8 @@ export interface UsePushNotificationsResult {
*
* Локальна `subscribed`-мітка в localStorage — оптимістичний кеш для
* першого рендеру, щоб UI не мерехтів між "не підписано" і "підписано".
* Доступ через `safeReadStringLS`/`safeWriteLS`/`safeRemoveLS`, щоб quota /
* private-mode крахи не валили хук на маунт.
*/
export function usePushNotifications(): UsePushNotificationsResult {
const supported = isPushSupported();
Expand All @@ -101,13 +108,9 @@ export function usePushNotifications(): UsePushNotificationsResult {
if (!supported) return "denied";
return readInitialPermission();
});
const [subscribed, setSubscribed] = useState<boolean>(() => {
try {
return localStorage.getItem(PUSH_SUB_KEY) === "1";
} catch {
return false;
}
});
const [subscribed, setSubscribed] = useState<boolean>(
() => safeReadStringLS(PUSH_SUB_KEY) === "1",
);

useEffect(() => {
// Native-гілка тримає permission-стан всередині плагіна — читати
Expand Down Expand Up @@ -146,7 +149,7 @@ export function usePushNotifications(): UsePushNotificationsResult {
token: result.token,
};
await pushRegister.mutateAsync(payload);
localStorage.setItem(PUSH_SUB_KEY, "1");
safeWriteLS(PUSH_SUB_KEY, "1");
setSubscribed(true);
queryClient.invalidateQueries({ queryKey: pushKeys.status });
return;
Expand Down Expand Up @@ -183,7 +186,7 @@ export function usePushNotifications(): UsePushNotificationsResult {
};
await pushRegister.mutateAsync(payload);

localStorage.setItem(PUSH_SUB_KEY, "1");
safeWriteLS(PUSH_SUB_KEY, "1");
setSubscribed(true);
queryClient.invalidateQueries({ queryKey: pushKeys.status });
},
Expand Down Expand Up @@ -225,7 +228,7 @@ export function usePushNotifications(): UsePushNotificationsResult {
);
});
}
localStorage.removeItem(PUSH_SUB_KEY);
safeRemoveLS(PUSH_SUB_KEY);
setSubscribed(false);
queryClient.invalidateQueries({ queryKey: pushKeys.status });
return;
Expand All @@ -234,7 +237,7 @@ export function usePushNotifications(): UsePushNotificationsResult {
// Аналогічний build-time guard — у capacitor-білді web-гілка мертва
// і `loadWebPushModule()` разом з нею DCE-виноситься.
if (import.meta.env.VITE_TARGET === "capacitor") {
localStorage.removeItem(PUSH_SUB_KEY);
safeRemoveLS(PUSH_SUB_KEY);
setSubscribed(false);
queryClient.invalidateQueries({ queryKey: pushKeys.status });
return;
Expand All @@ -254,7 +257,7 @@ export function usePushNotifications(): UsePushNotificationsResult {
console.warn("[push] web unregister failed (best-effort)", err);
});
}
localStorage.removeItem(PUSH_SUB_KEY);
safeRemoveLS(PUSH_SUB_KEY);
setSubscribed(false);
queryClient.invalidateQueries({ queryKey: pushKeys.status });
},
Expand Down
6 changes: 4 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -428,11 +428,13 @@ export default [
"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-i Item 6
// Мігровано на `safeReadStringLS`/`safeReadLS`/`safeWriteLS` у PR-и Item 6
// follow-up (docs/diagnostics/2026-05-03-web-deep-dive/02 §2.2):
// - apps/web/src/shared/lib/ui/perf.ts (1 LS read)
// - apps/web/src/shared/hooks/useDarkMode.ts (4 LS reads/writes)
"apps/web/src/shared/hooks/usePushNotifications.ts",
// - 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`).
// 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",
Expand Down
Loading