Skip to content

Commit 9b236a7

Browse files
committed
Merge branch 'pr-164'
2 parents af87a8a + 9a5d5d1 commit 9b236a7

3 files changed

Lines changed: 100 additions & 3 deletions

File tree

docs/theme-storage.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Theme Storage
2+
3+
StableRoute stores the appearance preference in `localStorage` under
4+
`stableroute.theme`. The only accepted values are `light`, `dark`, and
5+
`system`.
6+
7+
If storage is unavailable, throws, or contains an unknown value, `readTheme`
8+
falls back to `system`. `writeTheme` is best-effort and silently skips the write
9+
when storage rejects access, so theme persistence cannot crash hydration or
10+
rendering.

src/lib/__tests__/theme.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { effectiveTheme, readTheme, writeTheme } from "../theme";
2+
3+
const originalLocalStorage = window.localStorage;
4+
const originalMatchMedia = window.matchMedia;
5+
6+
function replaceLocalStorage(storage: Partial<Storage>) {
7+
Object.defineProperty(window, "localStorage", {
8+
configurable: true,
9+
value: storage,
10+
});
11+
}
12+
13+
afterEach(() => {
14+
Object.defineProperty(window, "localStorage", {
15+
configurable: true,
16+
value: originalLocalStorage,
17+
});
18+
window.localStorage.clear();
19+
window.matchMedia = originalMatchMedia;
20+
jest.restoreAllMocks();
21+
});
22+
23+
describe("theme storage helpers", () => {
24+
it("round-trips valid stored themes", () => {
25+
writeTheme("dark");
26+
27+
expect(window.localStorage.getItem("stableroute.theme")).toBe("dark");
28+
expect(readTheme()).toBe("dark");
29+
});
30+
31+
it("falls back to system for missing or corrupted stored values", () => {
32+
expect(readTheme()).toBe("system");
33+
34+
window.localStorage.setItem("stableroute.theme", "midnight");
35+
expect(readTheme()).toBe("system");
36+
});
37+
38+
it("falls back to system when localStorage.getItem throws", () => {
39+
replaceLocalStorage({
40+
getItem: jest.fn(() => {
41+
throw new Error("storage disabled");
42+
}),
43+
});
44+
45+
expect(readTheme()).toBe("system");
46+
});
47+
48+
it("treats write failures as a no-op", () => {
49+
const setItem = jest.fn(() => {
50+
throw new Error("quota exceeded");
51+
});
52+
replaceLocalStorage({ setItem });
53+
54+
expect(() => writeTheme("light")).not.toThrow();
55+
expect(setItem).toHaveBeenCalledWith("stableroute.theme", "light");
56+
});
57+
});
58+
59+
describe("effectiveTheme", () => {
60+
it("returns explicit light or dark themes without media queries", () => {
61+
expect(effectiveTheme("light")).toBe("light");
62+
expect(effectiveTheme("dark")).toBe("dark");
63+
});
64+
65+
it("resolves system through prefers-color-scheme", () => {
66+
window.matchMedia = jest.fn().mockReturnValue({ matches: true });
67+
68+
expect(effectiveTheme("system")).toBe("dark");
69+
expect(window.matchMedia).toHaveBeenCalledWith(
70+
"(prefers-color-scheme: dark)",
71+
);
72+
});
73+
});

src/lib/theme.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,29 @@ export type Theme = "light" | "dark" | "system";
22

33
const KEY = "stableroute.theme";
44

5+
function isTheme(value: string | null): value is Theme {
6+
return value === "light" || value === "dark" || value === "system";
7+
}
8+
59
export function readTheme(): Theme {
610
if (typeof window === "undefined") return "system";
7-
const v = window.localStorage.getItem(KEY);
8-
return v === "light" || v === "dark" || v === "system" ? v : "system";
11+
12+
try {
13+
const v = window.localStorage.getItem(KEY);
14+
return isTheme(v) ? v : "system";
15+
} catch {
16+
return "system";
17+
}
918
}
1019

1120
export function writeTheme(theme: Theme) {
1221
if (typeof window === "undefined") return;
13-
window.localStorage.setItem(KEY, theme);
22+
23+
try {
24+
window.localStorage.setItem(KEY, theme);
25+
} catch {
26+
// Storage can throw in privacy modes; theme writes are best-effort only.
27+
}
1428
}
1529

1630
export function effectiveTheme(theme: Theme): "light" | "dark" {

0 commit comments

Comments
 (0)