authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 19:09:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 19:09:05-07:00
log1b2f39d018410a9e09c67e2629f6ca1562860e6b
tree2f8c9a841a0d53d24228bb0d4ec2346146a2a01f
parent8c2d33abde95fa21595f4e3b49ff3c5ba2e3cab4
signature Signed by PGP key B5690EEEBB952194

[codex] Structure theme synchronization failures (#3466)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 325 insertions(+), 14 deletions(-)

apps/web/src/hooks/useTheme.test.ts created+193
......@@ -0,0 +1,193 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3function createStorage(overrides: Partial<Storage> = {}): Storage {
4 const store = new Map<string, string>();
5 return {
6 clear: () => store.clear(),
7 getItem: (key) => store.get(key) ?? null,
8 key: (index) => [...store.keys()][index] ?? null,
9 get length() {
10 return store.size;
11 },
12 removeItem: (key) => {
13 store.delete(key);
14 },
15 setItem: (key, value) => {
16 store.set(key, value);
17 },
18 ...overrides,
19 };
20}
21
22afterEach(() => {
23 vi.doUnmock("react");
24 vi.resetModules();
25 vi.restoreAllMocks();
26 vi.unstubAllGlobals();
27});
28
29describe("theme failure handling", () => {
30 it("preserves exact storage causes and operation context", async () => {
31 const readCause = new Error("storage read blocked");
32 const writeCause = new Error("storage quota exceeded");
33 vi.stubGlobal("window", {
34 localStorage: createStorage({
35 getItem: () => {
36 throw readCause;
37 },
38 setItem: () => {
39 throw writeCause;
40 },
41 }),
42 });
43
44 const { readThemePreference, ThemeStorageError, writeThemePreference } =
45 await import("./useTheme");
46
47 try {
48 readThemePreference();
49 expect.unreachable("expected the theme read to fail");
50 } catch (error) {
51 expect(error).toBeInstanceOf(ThemeStorageError);
52 expect(error).toMatchObject({
53 operation: "read",
54 storageKey: "t3code:theme",
55 cause: readCause,
56 });
57 }
58
59 try {
60 writeThemePreference("dark");
61 expect.unreachable("expected the theme write to fail");
62 } catch (error) {
63 expect(error).toBeInstanceOf(ThemeStorageError);
64 expect(error).toMatchObject({
65 operation: "write",
66 storageKey: "t3code:theme",
67 theme: "dark",
68 cause: writeCause,
69 });
70 }
71 });
72
73 it("falls back during initial theme application and logs only safe attributes", async () => {
74 const cause = new Error("private browsing storage failure");
75 const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
76 vi.stubGlobal("window", {
77 localStorage: createStorage({
78 getItem: () => {
79 throw cause;
80 },
81 }),
82 matchMedia: () => ({ matches: false }),
83 });
84 vi.stubGlobal("document", {
85 documentElement: {
86 classList: { toggle: vi.fn() },
87 },
88 });
89
90 await expect(import("./useTheme")).resolves.toBeDefined();
91
92 expect(errorLog).toHaveBeenCalledWith(
93 "Failed to read theme preference for t3code:theme.",
94 expect.objectContaining({
95 operation: "read",
96 storageKey: "t3code:theme",
97 errorTag: "ThemeStorageError",
98 }),
99 );
100 const attributes = errorLog.mock.calls[0]?.[1];
101 expect(attributes).not.toHaveProperty("cause");
102 expect(JSON.stringify(attributes)).not.toContain(cause.message);
103 });
104
105 it("retries a failed storage read only after a relevant storage event", async () => {
106 const cause = new Error("persistent storage failure");
107 const getItem = vi.fn(() => {
108 throw cause;
109 });
110 const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
111 let readSnapshot: (() => unknown) | undefined;
112 let subscribeToTheme: ((listener: () => void) => () => void) | undefined;
113 let storageHandler: ((event: StorageEvent) => void) | undefined;
114 vi.doMock("react", () => ({
115 useCallback: <A>(callback: A) => callback,
116 useEffect: () => undefined,
117 useSyncExternalStore: (
118 subscribe: (listener: () => void) => () => void,
119 getSnapshot: () => unknown,
120 ) => {
121 subscribeToTheme = subscribe;
122 readSnapshot = getSnapshot;
123 return getSnapshot();
124 },
125 }));
126 vi.stubGlobal("window", {
127 addEventListener: (type: string, listener: (event: StorageEvent) => void) => {
128 if (type === "storage") storageHandler = listener;
129 },
130 localStorage: createStorage({ getItem }),
131 matchMedia: () => ({
132 matches: false,
133 addEventListener: () => undefined,
134 removeEventListener: () => undefined,
135 }),
136 removeEventListener: () => undefined,
137 });
138
139 const { useTheme } = await import("./useTheme");
140 useTheme();
141 readSnapshot?.();
142 readSnapshot?.();
143
144 expect(getItem).toHaveBeenCalledTimes(1);
145 expect(errorLog).toHaveBeenCalledTimes(1);
146
147 const unsubscribe = subscribeToTheme?.(() => undefined);
148 storageHandler?.({ key: "t3code:theme" } as StorageEvent);
149 readSnapshot?.();
150
151 expect(getItem).toHaveBeenCalledTimes(2);
152 expect(errorLog).toHaveBeenCalledTimes(2);
153 unsubscribe?.();
154 });
155
156 it("preserves desktop sync causes and retries after a failed cosmetic sync", async () => {
157 const cause = new Error("desktop IPC unavailable");
158 const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
159 const setTheme = vi.fn().mockRejectedValue(cause);
160 vi.stubGlobal("window", { desktopBridge: { setTheme } });
161
162 const { DesktopThemeSyncError, syncDesktopTheme, syncDesktopThemePreference } =
163 await import("./useTheme");
164
165 const error = await syncDesktopThemePreference({ setTheme }, "dark").then(
166 () => undefined,
167 (failure: unknown) => failure,
168 );
169 expect(error).toBeInstanceOf(DesktopThemeSyncError);
170 expect(error).toMatchObject({ theme: "dark", cause });
171
172 setTheme.mockClear();
173 syncDesktopTheme("dark");
174 await Promise.resolve();
175 await Promise.resolve();
176 syncDesktopTheme("dark");
177 await Promise.resolve();
178 await Promise.resolve();
179
180 expect(setTheme).toHaveBeenCalledTimes(2);
181 expect(errorLog).toHaveBeenCalledWith(
182 "Failed to sync the dark theme to the desktop shell.",
183 expect.objectContaining({
184 theme: "dark",
185 errorTag: "DesktopThemeSyncError",
186 }),
187 );
188 for (const [, attributes] of errorLog.mock.calls) {
189 expect(attributes).not.toHaveProperty("cause");
190 expect(JSON.stringify(attributes)).not.toContain(cause.message);
191 }
192 });
193});
apps/web/src/hooks/useTheme.ts+132-14
......@@ -1,11 +1,17 @@
1import type { DesktopBridge } from "@t3tools/contracts";
2import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
3import * as Schema from "effect/Schema";
14import { useCallback, useEffect, useSyncExternalStore } from "react";
25
3type Theme = "light" | "dark" | "system";
6const ThemePreference = Schema.Literals(["light", "dark", "system"]);
7type Theme = typeof ThemePreference.Type;
48type ThemeSnapshot = {
59 theme: Theme;
610 systemDark: boolean;
711};
812
13type DesktopThemeBridge = Pick<DesktopBridge, "setTheme">;
14
915const STORAGE_KEY = "t3code:theme";
1016const MEDIA_QUERY = "(prefers-color-scheme: dark)";
1117const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = {
......@@ -15,19 +21,46 @@ const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = {
1521const THEME_COLOR_META_NAME = "theme-color";
1622const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`;
1723
24export class ThemeStorageError extends Schema.TaggedErrorClass<ThemeStorageError>()(
25 "ThemeStorageError",
26 {
27 operation: Schema.Literals(["read", "write"]),
28 storageKey: Schema.String,
29 theme: Schema.optional(ThemePreference),
30 cause: Schema.Defect(),
31 },
32) {
33 override get message(): string {
34 return `Failed to ${this.operation} theme preference for ${this.storageKey}.`;
35 }
36}
37
38export const isThemeStorageError = Schema.is(ThemeStorageError);
39
40export class DesktopThemeSyncError extends Schema.TaggedErrorClass<DesktopThemeSyncError>()(
41 "DesktopThemeSyncError",
42 {
43 theme: ThemePreference,
44 cause: Schema.Defect(),
45 },
46) {
47 override get message(): string {
48 return `Failed to sync the ${this.theme} theme to the desktop shell.`;
49 }
50}
51
52export const isDesktopThemeSyncError = Schema.is(DesktopThemeSyncError);
53
1854let listeners: Array<() => void> = [];
1955let lastSnapshot: ThemeSnapshot | null = null;
2056let lastDesktopTheme: Theme | null = null;
2157let lastAppliedTheme: ThemeSnapshot | null = null;
58let themeStorageReadFailure: ThemeStorageError | null = null;
2259
2360function emitChange() {
2461 for (const listener of listeners) listener();
2562}
2663
27function hasThemeStorage() {
28 return typeof window !== "undefined" && typeof localStorage !== "undefined";
29}
30
3164function getSystemDark() {
3265 return (
3366 typeof window !== "undefined" &&
......@@ -36,13 +69,61 @@ function getSystemDark() {
3669 );
3770}
3871
39function getStored(): Theme {
40 if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme;
41 const raw = localStorage.getItem(STORAGE_KEY);
72export function readThemePreference(): Theme {
73 if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme;
74 let raw: string | null;
75 try {
76 raw = window.localStorage.getItem(STORAGE_KEY);
77 } catch (cause) {
78 throw new ThemeStorageError({
79 operation: "read",
80 storageKey: STORAGE_KEY,
81 cause,
82 });
83 }
4284 if (raw === "light" || raw === "dark" || raw === "system") return raw;
4385 return DEFAULT_THEME_SNAPSHOT.theme;
4486}
4587
88export function writeThemePreference(theme: Theme): void {
89 if (typeof window === "undefined") return;
90 try {
91 window.localStorage.setItem(STORAGE_KEY, theme);
92 themeStorageReadFailure = null;
93 } catch (cause) {
94 throw new ThemeStorageError({
95 operation: "write",
96 storageKey: STORAGE_KEY,
97 theme,
98 cause,
99 });
100 }
101}
102
103function getStored(): Theme {
104 if (themeStorageReadFailure !== null) {
105 return DEFAULT_THEME_SNAPSHOT.theme;
106 }
107 try {
108 return readThemePreference();
109 } catch (cause) {
110 const error = isThemeStorageError(cause)
111 ? cause
112 : new ThemeStorageError({
113 operation: "read",
114 storageKey: STORAGE_KEY,
115 cause,
116 });
117 themeStorageReadFailure = error;
118 console.error(error.message, {
119 operation: error.operation,
120 storageKey: error.storageKey,
121 ...safeErrorLogAttributes(error),
122 });
123 return DEFAULT_THEME_SNAPSHOT.theme;
124 }
125}
126
46127function ensureThemeColorMetaTag(): HTMLMetaElement {
47128 let element = document.querySelector<HTMLMetaElement>(DYNAMIC_THEME_COLOR_SELECTOR);
48129 if (element) {
......@@ -118,7 +199,18 @@ function applyTheme(theme: Theme, suppressTransitions = false) {
118199 }
119200}
120201
121function syncDesktopTheme(theme: Theme) {
202export async function syncDesktopThemePreference(
203 bridge: DesktopThemeBridge,
204 theme: Theme,
205): Promise<void> {
206 try {
207 await bridge.setTheme(theme);
208 } catch (cause) {
209 throw new DesktopThemeSyncError({ theme, cause });
210 }
211}
212
213export function syncDesktopTheme(theme: Theme) {
122214 if (typeof window === "undefined") return;
123215 const bridge = window.desktopBridge;
124216 if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) {
......@@ -126,7 +218,14 @@ function syncDesktopTheme(theme: Theme) {
126218 }
127219
128220 lastDesktopTheme = theme;
129 void bridge.setTheme(theme).catch(() => {
221 void syncDesktopThemePreference(bridge, theme).catch((cause: unknown) => {
222 const error = isDesktopThemeSyncError(cause)
223 ? cause
224 : new DesktopThemeSyncError({ theme, cause });
225 console.error(error.message, {
226 theme: error.theme,
227 ...safeErrorLogAttributes(error),
228 });
130229 if (lastDesktopTheme === theme) {
131230 lastDesktopTheme = null;
132231 }
......@@ -134,12 +233,12 @@ function syncDesktopTheme(theme: Theme) {
134233}
135234
136235// Apply immediately on module load to prevent flash
137if (typeof document !== "undefined" && hasThemeStorage()) {
236if (typeof document !== "undefined" && typeof window !== "undefined") {
138237 applyTheme(getStored());
139238}
140239
141240function getSnapshot(): ThemeSnapshot {
142 if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT;
241 if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT;
143242 const theme = getStored();
144243 const systemDark = theme === "system" ? getSystemDark() : false;
145244
......@@ -170,6 +269,7 @@ function subscribe(listener: () => void): () => void {
170269 // Listen for storage changes from other tabs
171270 const handleStorage = (e: StorageEvent) => {
172271 if (e.key === STORAGE_KEY) {
272 themeStorageReadFailure = null;
173273 applyTheme(getStored(), true);
174274 emitChange();
175275 }
......@@ -191,8 +291,26 @@ export function useTheme() {
191291 theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme;
192292
193293 const setTheme = useCallback((next: Theme) => {
194 if (!hasThemeStorage()) return;
195 localStorage.setItem(STORAGE_KEY, next);
294 if (typeof window === "undefined") return;
295 try {
296 writeThemePreference(next);
297 } catch (cause) {
298 const error = isThemeStorageError(cause)
299 ? cause
300 : new ThemeStorageError({
301 operation: "write",
302 storageKey: STORAGE_KEY,
303 theme: next,
304 cause,
305 });
306 console.error(error.message, {
307 operation: error.operation,
308 storageKey: error.storageKey,
309 theme: next,
310 ...safeErrorLogAttributes(error),
311 });
312 return;
313 }
196314 applyTheme(next, true);
197315 emitChange();
198316 }, []);