From ad8eb29736430c8365f900fbd855d7b7b0b31493 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:51:21 -0700 Subject: [PATCH 1/2] Structure theme synchronization failures Co-authored-by: codex --- apps/web/src/hooks/useTheme.test.ts | 141 ++++++++++++++++++++++++++++ apps/web/src/hooks/useTheme.ts | 139 ++++++++++++++++++++++++--- 2 files changed, 266 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/hooks/useTheme.test.ts diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts new file mode 100644 index 00000000000..6a8a4582755 --- /dev/null +++ b/apps/web/src/hooks/useTheme.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +function createStorage(overrides: Partial = {}): Storage { + const store = new Map(); + return { + clear: () => store.clear(), + getItem: (key) => store.get(key) ?? null, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + removeItem: (key) => { + store.delete(key); + }, + setItem: (key, value) => { + store.set(key, value); + }, + ...overrides, + }; +} + +afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("theme failure handling", () => { + it("preserves exact storage causes and operation context", async () => { + const readCause = new Error("storage read blocked"); + const writeCause = new Error("storage quota exceeded"); + vi.stubGlobal("window", { + localStorage: createStorage({ + getItem: () => { + throw readCause; + }, + setItem: () => { + throw writeCause; + }, + }), + }); + + const { readThemePreference, ThemeStorageError, writeThemePreference } = + await import("./useTheme"); + + try { + readThemePreference(); + expect.unreachable("expected the theme read to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ThemeStorageError); + expect(error).toMatchObject({ + operation: "read", + storageKey: "t3code:theme", + cause: readCause, + }); + } + + try { + writeThemePreference("dark"); + expect.unreachable("expected the theme write to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ThemeStorageError); + expect(error).toMatchObject({ + operation: "write", + storageKey: "t3code:theme", + theme: "dark", + cause: writeCause, + }); + } + }); + + it("falls back during initial theme application and logs only safe attributes", async () => { + const cause = new Error("private browsing storage failure"); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.stubGlobal("window", { + localStorage: createStorage({ + getItem: () => { + throw cause; + }, + }), + matchMedia: () => ({ matches: false }), + }); + vi.stubGlobal("document", { + documentElement: { + classList: { toggle: vi.fn() }, + }, + }); + + await expect(import("./useTheme")).resolves.toBeDefined(); + + expect(errorLog).toHaveBeenCalledWith( + "Failed to read theme preference for t3code:theme.", + expect.objectContaining({ + operation: "read", + storageKey: "t3code:theme", + errorTag: "ThemeStorageError", + }), + ); + const attributes = errorLog.mock.calls[0]?.[1]; + expect(attributes).not.toHaveProperty("cause"); + expect(JSON.stringify(attributes)).not.toContain(cause.message); + }); + + it("preserves desktop sync causes and retries after a failed cosmetic sync", async () => { + const cause = new Error("desktop IPC unavailable"); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + const setTheme = vi.fn().mockRejectedValue(cause); + vi.stubGlobal("window", { desktopBridge: { setTheme } }); + + const { DesktopThemeSyncError, syncDesktopTheme, syncDesktopThemePreference } = + await import("./useTheme"); + + const error = await syncDesktopThemePreference({ setTheme }, "dark").then( + () => undefined, + (failure: unknown) => failure, + ); + expect(error).toBeInstanceOf(DesktopThemeSyncError); + expect(error).toMatchObject({ theme: "dark", cause }); + + setTheme.mockClear(); + syncDesktopTheme("dark"); + await Promise.resolve(); + await Promise.resolve(); + syncDesktopTheme("dark"); + await Promise.resolve(); + await Promise.resolve(); + + expect(setTheme).toHaveBeenCalledTimes(2); + expect(errorLog).toHaveBeenCalledWith( + "Failed to sync the dark theme to the desktop shell.", + expect.objectContaining({ + theme: "dark", + errorTag: "DesktopThemeSyncError", + }), + ); + for (const [, attributes] of errorLog.mock.calls) { + expect(attributes).not.toHaveProperty("cause"); + expect(JSON.stringify(attributes)).not.toContain(cause.message); + } + }); +}); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index eec2e9c9363..0482139b1e9 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -1,11 +1,17 @@ +import type { DesktopBridge } from "@t3tools/contracts"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import * as Schema from "effect/Schema"; import { useCallback, useEffect, useSyncExternalStore } from "react"; -type Theme = "light" | "dark" | "system"; +const ThemePreference = Schema.Literals(["light", "dark", "system"]); +type Theme = typeof ThemePreference.Type; type ThemeSnapshot = { theme: Theme; systemDark: boolean; }; +type DesktopThemeBridge = Pick; + const STORAGE_KEY = "t3code:theme"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { @@ -15,6 +21,36 @@ const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { const THEME_COLOR_META_NAME = "theme-color"; const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; +export class ThemeStorageError extends Schema.TaggedErrorClass()( + "ThemeStorageError", + { + operation: Schema.Literals(["read", "write"]), + storageKey: Schema.String, + theme: Schema.optional(ThemePreference), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} theme preference for ${this.storageKey}.`; + } +} + +export const isThemeStorageError = Schema.is(ThemeStorageError); + +export class DesktopThemeSyncError extends Schema.TaggedErrorClass()( + "DesktopThemeSyncError", + { + theme: ThemePreference, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to sync the ${this.theme} theme to the desktop shell.`; + } +} + +export const isDesktopThemeSyncError = Schema.is(DesktopThemeSyncError); + let listeners: Array<() => void> = []; let lastSnapshot: ThemeSnapshot | null = null; let lastDesktopTheme: Theme | null = null; @@ -24,10 +60,6 @@ function emitChange() { for (const listener of listeners) listener(); } -function hasThemeStorage() { - return typeof window !== "undefined" && typeof localStorage !== "undefined"; -} - function getSystemDark() { return ( typeof window !== "undefined" && @@ -36,13 +68,56 @@ function getSystemDark() { ); } -function getStored(): Theme { - if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme; - const raw = localStorage.getItem(STORAGE_KEY); +export function readThemePreference(): Theme { + if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme; + let raw: string | null; + try { + raw = window.localStorage.getItem(STORAGE_KEY); + } catch (cause) { + throw new ThemeStorageError({ + operation: "read", + storageKey: STORAGE_KEY, + cause, + }); + } if (raw === "light" || raw === "dark" || raw === "system") return raw; return DEFAULT_THEME_SNAPSHOT.theme; } +export function writeThemePreference(theme: Theme): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, theme); + } catch (cause) { + throw new ThemeStorageError({ + operation: "write", + storageKey: STORAGE_KEY, + theme, + cause, + }); + } +} + +function getStored(): Theme { + try { + return readThemePreference(); + } catch (cause) { + const error = isThemeStorageError(cause) + ? cause + : new ThemeStorageError({ + operation: "read", + storageKey: STORAGE_KEY, + cause, + }); + console.error(error.message, { + operation: error.operation, + storageKey: error.storageKey, + ...safeErrorLogAttributes(error), + }); + return DEFAULT_THEME_SNAPSHOT.theme; + } +} + function ensureThemeColorMetaTag(): HTMLMetaElement { let element = document.querySelector(DYNAMIC_THEME_COLOR_SELECTOR); if (element) { @@ -118,7 +193,18 @@ function applyTheme(theme: Theme, suppressTransitions = false) { } } -function syncDesktopTheme(theme: Theme) { +export async function syncDesktopThemePreference( + bridge: DesktopThemeBridge, + theme: Theme, +): Promise { + try { + await bridge.setTheme(theme); + } catch (cause) { + throw new DesktopThemeSyncError({ theme, cause }); + } +} + +export function syncDesktopTheme(theme: Theme) { if (typeof window === "undefined") return; const bridge = window.desktopBridge; if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) { @@ -126,7 +212,14 @@ function syncDesktopTheme(theme: Theme) { } lastDesktopTheme = theme; - void bridge.setTheme(theme).catch(() => { + void syncDesktopThemePreference(bridge, theme).catch((cause: unknown) => { + const error = isDesktopThemeSyncError(cause) + ? cause + : new DesktopThemeSyncError({ theme, cause }); + console.error(error.message, { + theme: error.theme, + ...safeErrorLogAttributes(error), + }); if (lastDesktopTheme === theme) { lastDesktopTheme = null; } @@ -134,12 +227,12 @@ function syncDesktopTheme(theme: Theme) { } // Apply immediately on module load to prevent flash -if (typeof document !== "undefined" && hasThemeStorage()) { +if (typeof document !== "undefined" && typeof window !== "undefined") { applyTheme(getStored()); } function getSnapshot(): ThemeSnapshot { - if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT; + if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT; const theme = getStored(); const systemDark = theme === "system" ? getSystemDark() : false; @@ -191,8 +284,26 @@ export function useTheme() { theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; const setTheme = useCallback((next: Theme) => { - if (!hasThemeStorage()) return; - localStorage.setItem(STORAGE_KEY, next); + if (typeof window === "undefined") return; + try { + writeThemePreference(next); + } catch (cause) { + const error = isThemeStorageError(cause) + ? cause + : new ThemeStorageError({ + operation: "write", + storageKey: STORAGE_KEY, + theme: next, + cause, + }); + console.error(error.message, { + operation: error.operation, + storageKey: error.storageKey, + theme: next, + ...safeErrorLogAttributes(error), + }); + return; + } applyTheme(next, true); emitChange(); }, []); From 2058298d34fc2e33924e237f81ce6a90a9394f73 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:59:15 -0700 Subject: [PATCH 2/2] Bound repeated theme storage failures Co-authored-by: codex --- apps/web/src/hooks/useTheme.test.ts | 52 +++++++++++++++++++++++++++++ apps/web/src/hooks/useTheme.ts | 7 ++++ 2 files changed, 59 insertions(+) diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts index 6a8a4582755..6c814e30165 100644 --- a/apps/web/src/hooks/useTheme.test.ts +++ b/apps/web/src/hooks/useTheme.test.ts @@ -20,6 +20,7 @@ function createStorage(overrides: Partial = {}): Storage { } afterEach(() => { + vi.doUnmock("react"); vi.resetModules(); vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -101,6 +102,57 @@ describe("theme failure handling", () => { expect(JSON.stringify(attributes)).not.toContain(cause.message); }); + it("retries a failed storage read only after a relevant storage event", async () => { + const cause = new Error("persistent storage failure"); + const getItem = vi.fn(() => { + throw cause; + }); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + let readSnapshot: (() => unknown) | undefined; + let subscribeToTheme: ((listener: () => void) => () => void) | undefined; + let storageHandler: ((event: StorageEvent) => void) | undefined; + vi.doMock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => undefined, + useSyncExternalStore: ( + subscribe: (listener: () => void) => () => void, + getSnapshot: () => unknown, + ) => { + subscribeToTheme = subscribe; + readSnapshot = getSnapshot; + return getSnapshot(); + }, + })); + vi.stubGlobal("window", { + addEventListener: (type: string, listener: (event: StorageEvent) => void) => { + if (type === "storage") storageHandler = listener; + }, + localStorage: createStorage({ getItem }), + matchMedia: () => ({ + matches: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }), + removeEventListener: () => undefined, + }); + + const { useTheme } = await import("./useTheme"); + useTheme(); + readSnapshot?.(); + readSnapshot?.(); + + expect(getItem).toHaveBeenCalledTimes(1); + expect(errorLog).toHaveBeenCalledTimes(1); + + const unsubscribe = subscribeToTheme?.(() => undefined); + storageHandler?.({ key: "t3code:theme" } as StorageEvent); + readSnapshot?.(); + + expect(getItem).toHaveBeenCalledTimes(2); + expect(errorLog).toHaveBeenCalledTimes(2); + unsubscribe?.(); + }); + it("preserves desktop sync causes and retries after a failed cosmetic sync", async () => { const cause = new Error("desktop IPC unavailable"); const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 0482139b1e9..bdaf37f099d 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -55,6 +55,7 @@ let listeners: Array<() => void> = []; let lastSnapshot: ThemeSnapshot | null = null; let lastDesktopTheme: Theme | null = null; let lastAppliedTheme: ThemeSnapshot | null = null; +let themeStorageReadFailure: ThemeStorageError | null = null; function emitChange() { for (const listener of listeners) listener(); @@ -88,6 +89,7 @@ export function writeThemePreference(theme: Theme): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(STORAGE_KEY, theme); + themeStorageReadFailure = null; } catch (cause) { throw new ThemeStorageError({ operation: "write", @@ -99,6 +101,9 @@ export function writeThemePreference(theme: Theme): void { } function getStored(): Theme { + if (themeStorageReadFailure !== null) { + return DEFAULT_THEME_SNAPSHOT.theme; + } try { return readThemePreference(); } catch (cause) { @@ -109,6 +114,7 @@ function getStored(): Theme { storageKey: STORAGE_KEY, cause, }); + themeStorageReadFailure = error; console.error(error.message, { operation: error.operation, storageKey: error.storageKey, @@ -263,6 +269,7 @@ function subscribe(listener: () => void): () => void { // Listen for storage changes from other tabs const handleStorage = (e: StorageEvent) => { if (e.key === STORAGE_KEY) { + themeStorageReadFailure = null; applyTheme(getStored(), true); emitChange(); }