Skip to content

Commit 3db29cd

Browse files
committed
fix(app): harden theme persistence and add theme tests
1 parent 56cc21c commit 3db29cd

2 files changed

Lines changed: 287 additions & 9 deletions

File tree

apps/app/lib/theme.test.tsx

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import {
2+
cleanup,
3+
fireEvent,
4+
render,
5+
screen,
6+
waitFor,
7+
} from "@testing-library/react";
8+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9+
import { ThemeProvider, useTheme } from "./theme";
10+
11+
function ThemeProbe() {
12+
const { theme, setTheme, toggleTheme } = useTheme();
13+
14+
return (
15+
<>
16+
<div data-testid="theme-value">{theme}</div>
17+
<button onClick={() => setTheme("dark")} type="button">
18+
set-dark
19+
</button>
20+
<button onClick={() => setTheme("light")} type="button">
21+
set-light
22+
</button>
23+
<button onClick={toggleTheme} type="button">
24+
toggle-theme
25+
</button>
26+
</>
27+
);
28+
}
29+
30+
describe("ThemeProvider", () => {
31+
const storage = new Map<string, string>();
32+
let originalMatchMedia: typeof window.matchMedia | undefined;
33+
let originalLocalStorage: Storage;
34+
35+
const localStorageMock: Storage = {
36+
getItem: (key: string) => storage.get(key) ?? null,
37+
setItem: (key: string, value: string) => {
38+
storage.set(key, value);
39+
},
40+
removeItem: (key: string) => {
41+
storage.delete(key);
42+
},
43+
clear: () => {
44+
storage.clear();
45+
},
46+
key: (index: number) => {
47+
const keys = Array.from(storage.keys());
48+
return keys[index] ?? null;
49+
},
50+
get length() {
51+
return storage.size;
52+
},
53+
};
54+
55+
beforeEach(() => {
56+
originalMatchMedia = window.matchMedia;
57+
originalLocalStorage = window.localStorage;
58+
59+
Object.defineProperty(window, "localStorage", {
60+
configurable: true,
61+
writable: true,
62+
value: localStorageMock,
63+
});
64+
window.localStorage.clear();
65+
document.documentElement.classList.remove("dark");
66+
});
67+
68+
afterEach(() => {
69+
cleanup();
70+
71+
if (originalMatchMedia) {
72+
Object.defineProperty(window, "matchMedia", {
73+
configurable: true,
74+
writable: true,
75+
value: originalMatchMedia,
76+
});
77+
}
78+
79+
Object.defineProperty(window, "localStorage", {
80+
configurable: true,
81+
writable: true,
82+
value: originalLocalStorage,
83+
});
84+
85+
vi.restoreAllMocks();
86+
});
87+
88+
it("uses persisted localStorage theme on first render", () => {
89+
window.localStorage.setItem("app-theme", "dark");
90+
91+
render(
92+
<ThemeProvider>
93+
<ThemeProbe />
94+
</ThemeProvider>,
95+
);
96+
97+
expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
98+
expect(document.documentElement.classList.contains("dark")).toBe(true);
99+
});
100+
101+
it("falls back to system preference when no theme is persisted", () => {
102+
Object.defineProperty(window, "matchMedia", {
103+
configurable: true,
104+
writable: true,
105+
value: vi.fn().mockImplementation((query: string) => ({
106+
matches: query === "(prefers-color-scheme: dark)",
107+
media: query,
108+
onchange: null,
109+
addEventListener: vi.fn(),
110+
removeEventListener: vi.fn(),
111+
dispatchEvent: vi.fn(),
112+
})),
113+
});
114+
115+
render(
116+
<ThemeProvider>
117+
<ThemeProbe />
118+
</ThemeProvider>,
119+
);
120+
121+
expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
122+
expect(document.documentElement.classList.contains("dark")).toBe(true);
123+
});
124+
125+
it("writes updates to localStorage and keeps DOM class in sync", () => {
126+
render(
127+
<ThemeProvider>
128+
<ThemeProbe />
129+
</ThemeProvider>,
130+
);
131+
132+
fireEvent.click(screen.getByRole("button", { name: "set-dark" }));
133+
expect(window.localStorage.getItem("app-theme")).toBe("dark");
134+
expect(document.documentElement.classList.contains("dark")).toBe(true);
135+
136+
fireEvent.click(screen.getByRole("button", { name: "set-light" }));
137+
expect(window.localStorage.getItem("app-theme")).toBe("light");
138+
expect(document.documentElement.classList.contains("dark")).toBe(false);
139+
});
140+
141+
it("reacts to theme updates from storage events", async () => {
142+
render(
143+
<ThemeProvider>
144+
<ThemeProbe />
145+
</ThemeProvider>,
146+
);
147+
148+
const event = new Event("storage");
149+
Object.defineProperty(event, "key", { value: "app-theme" });
150+
Object.defineProperty(event, "newValue", { value: "dark" });
151+
window.dispatchEvent(event);
152+
153+
await waitFor(() => {
154+
expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
155+
expect(document.documentElement.classList.contains("dark")).toBe(true);
156+
});
157+
});
158+
159+
it("falls back to system preference when storage key is cleared", async () => {
160+
Object.defineProperty(window, "matchMedia", {
161+
configurable: true,
162+
writable: true,
163+
value: vi.fn().mockImplementation((query: string) => ({
164+
matches: query === "(prefers-color-scheme: dark)",
165+
media: query,
166+
onchange: null,
167+
addEventListener: vi.fn(),
168+
removeEventListener: vi.fn(),
169+
dispatchEvent: vi.fn(),
170+
})),
171+
});
172+
173+
render(
174+
<ThemeProvider>
175+
<ThemeProbe />
176+
</ThemeProvider>,
177+
);
178+
179+
fireEvent.click(screen.getByRole("button", { name: "set-light" }));
180+
window.localStorage.removeItem("app-theme");
181+
182+
const event = new Event("storage");
183+
Object.defineProperty(event, "key", { value: "app-theme" });
184+
Object.defineProperty(event, "newValue", { value: null });
185+
window.dispatchEvent(event);
186+
187+
await waitFor(() => {
188+
expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
189+
});
190+
});
191+
192+
it("recovers when storage read throws", () => {
193+
Object.defineProperty(window, "localStorage", {
194+
configurable: true,
195+
writable: true,
196+
value: {
197+
...localStorageMock,
198+
getItem: () => {
199+
throw new Error("read denied");
200+
},
201+
} as Storage,
202+
});
203+
204+
Object.defineProperty(window, "matchMedia", {
205+
configurable: true,
206+
writable: true,
207+
value: vi.fn().mockImplementation((query: string) => ({
208+
matches: query === "(prefers-color-scheme: dark)",
209+
media: query,
210+
onchange: null,
211+
addEventListener: vi.fn(),
212+
removeEventListener: vi.fn(),
213+
dispatchEvent: vi.fn(),
214+
})),
215+
});
216+
217+
render(
218+
<ThemeProvider>
219+
<ThemeProbe />
220+
</ThemeProvider>,
221+
);
222+
223+
expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
224+
});
225+
226+
it("ignores storage write failures", () => {
227+
const setItem = vi.fn(() => {
228+
throw new Error("write denied");
229+
});
230+
231+
Object.defineProperty(window, "localStorage", {
232+
configurable: true,
233+
writable: true,
234+
value: {
235+
...localStorageMock,
236+
setItem,
237+
} as Storage,
238+
});
239+
240+
render(
241+
<ThemeProvider>
242+
<ThemeProbe />
243+
</ThemeProvider>,
244+
);
245+
246+
fireEvent.click(screen.getByRole("button", { name: "set-dark" }));
247+
expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
248+
expect(document.documentElement.classList.contains("dark")).toBe(true);
249+
expect(setItem).toHaveBeenCalled();
250+
});
251+
});

apps/app/lib/theme.tsx

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import {
22
createContext,
33
type ReactNode,
4+
use,
5+
useCallback,
46
useEffect,
7+
useLayoutEffect,
58
useMemo,
69
useState,
7-
use,
810
} from "react";
911

1012
type Theme = "light" | "dark";
@@ -22,9 +24,13 @@ const STORAGE_KEY = "app-theme";
2224
function getInitialTheme(): Theme {
2325
if (typeof window === "undefined") return "light";
2426

25-
const stored = window.localStorage.getItem(STORAGE_KEY);
26-
if (stored === "light" || stored === "dark") {
27-
return stored;
27+
try {
28+
const stored = window.localStorage.getItem(STORAGE_KEY);
29+
if (stored === "light" || stored === "dark") {
30+
return stored;
31+
}
32+
} catch {
33+
// Continue with system preference fallback.
2834
}
2935

3036
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
@@ -37,7 +43,7 @@ function getInitialTheme(): Theme {
3743
export function ThemeProvider({ children }: { children: ReactNode }) {
3844
const [theme, setTheme] = useState<Theme>(getInitialTheme);
3945

40-
useEffect(() => {
46+
useLayoutEffect(() => {
4147
const root = document.documentElement;
4248

4349
if (theme === "dark") {
@@ -46,20 +52,41 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
4652
root.classList.remove("dark");
4753
}
4854

49-
window.localStorage.setItem(STORAGE_KEY, theme);
55+
try {
56+
window.localStorage.setItem(STORAGE_KEY, theme);
57+
} catch {
58+
// Ignore storage write failures (e.g., private browsing mode).
59+
}
5060
}, [theme]);
5161

52-
const toggleTheme = () => {
62+
useEffect(() => {
63+
const onStorage = (event: StorageEvent) => {
64+
if (event.key !== STORAGE_KEY) return;
65+
if (event.newValue === "light" || event.newValue === "dark") {
66+
setTheme(event.newValue);
67+
return;
68+
}
69+
70+
setTheme(getInitialTheme());
71+
};
72+
73+
window.addEventListener("storage", onStorage);
74+
return () => {
75+
window.removeEventListener("storage", onStorage);
76+
};
77+
}, []);
78+
79+
const toggleTheme = useCallback(() => {
5380
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
54-
};
81+
}, []);
5582

5683
const value = useMemo(
5784
() => ({
5885
theme,
5986
setTheme,
6087
toggleTheme,
6188
}),
62-
[theme, setTheme, toggleTheme],
89+
[theme],
6390
);
6491

6592
return <ThemeContext value={value}>{children}</ThemeContext>;

0 commit comments

Comments
 (0)