Skip to content

Commit d729bcd

Browse files
committed
refactor(theme): simplify ThemeProvider — remove legacy migration, hardcode storage keys, add system reactivity
1 parent ec264a9 commit d729bcd

4 files changed

Lines changed: 107 additions & 130 deletions

File tree

packages/core/src/components/appshell.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -339,12 +339,7 @@ export const AppShell = (props: AppShellProps) => {
339339
<AppShellDataContext.Provider value={dataValue}>
340340
<BreadcrumbOverrideProvider>
341341
<CommandPaletteProvider searchSources={props.searchSources}>
342-
<ThemeProvider
343-
defaultTheme={props.defaultTheme}
344-
defaultFont={props.defaultFont}
345-
storageKey="appshell-ui-theme"
346-
fontStorageKey="appshell-ui-font"
347-
>
342+
<ThemeProvider defaultTheme={props.defaultTheme} defaultFont={props.defaultFont}>
348343
<RouterContainer>
349344
{props.children}
350345
<BuiltInCommandPalette />

packages/core/src/contexts/theme-context.test.tsx

Lines changed: 62 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1-
import { cleanup, render, waitFor } from "@testing-library/react";
1+
import { cleanup, render, act, waitFor } from "@testing-library/react";
22
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
33

4-
import { ThemeProvider, useTheme, useFont } from "./theme-context";
5-
6-
const storageKey = "theme-context-test-theme";
7-
const fontStorageKey = "theme-context-test-font";
4+
import { ThemeProvider, useTheme } from "./theme-context";
85

96
/** happy-dom / Node can omit a full `localStorage`; ThemeProvider persists via it. */
107
function installLocalStorageStub() {
@@ -27,8 +24,12 @@ function installLocalStorageStub() {
2724
return map;
2825
}
2926

27+
type MatchMediaListener = (e: MediaQueryListEvent) => void;
28+
let matchMediaListeners: MatchMediaListener[] = [];
29+
3030
/** `matchMedia` is not implemented in some test runtimes — stub to a controllable shape. */
3131
function installMatchMediaStub(matches: boolean) {
32+
matchMediaListeners = [];
3233
Object.defineProperty(window, "matchMedia", {
3334
configurable: true,
3435
value: vi.fn().mockImplementation((query: string) => ({
@@ -37,8 +38,12 @@ function installMatchMediaStub(matches: boolean) {
3738
onchange: null,
3839
addListener: vi.fn(),
3940
removeListener: vi.fn(),
40-
addEventListener: vi.fn(),
41-
removeEventListener: vi.fn(),
41+
addEventListener: (_: string, fn: MatchMediaListener) => {
42+
matchMediaListeners.push(fn);
43+
},
44+
removeEventListener: (_: string, fn: MatchMediaListener) => {
45+
matchMediaListeners = matchMediaListeners.filter((l) => l !== fn);
46+
},
4247
dispatchEvent: vi.fn(),
4348
})),
4449
});
@@ -52,6 +57,7 @@ beforeAll(() => {
5257

5358
beforeEach(() => {
5459
storageMap.clear();
60+
matchMediaListeners = [];
5561
document.documentElement.removeAttribute("data-theme");
5662
document.documentElement.removeAttribute("data-font");
5763
document.documentElement.classList.remove("light", "dark");
@@ -72,62 +78,58 @@ function ThemeProbe() {
7278
}
7379

7480
function FontProbe() {
75-
const { font } = useFont();
81+
const { font } = useTheme();
7682
return <span data-testid="font">{font}</span>;
7783
}
7884

79-
describe("ThemeProvider — legacy id migration", () => {
80-
it.each([
81-
["tailor-light", "cream"],
82-
["tailor-bloom", "bloom"],
83-
["tailor-dark", "dark"],
84-
])("maps stored %s → %s on first render", async (stored, expected) => {
85-
storageMap.set(storageKey, stored);
85+
describe("ThemeProvider — storage validation", () => {
86+
it("falls back to defaultTheme for an unrecognized stored value", () => {
87+
storageMap.set("appshell-ui-theme", "totally-not-a-theme");
8688

8789
const { getByTestId } = render(
88-
<ThemeProvider storageKey={storageKey} fontStorageKey={fontStorageKey} defaultTheme="bloom">
90+
<ThemeProvider defaultTheme="light">
8991
<ThemeProbe />
9092
</ThemeProvider>,
9193
);
9294

93-
expect(getByTestId("theme").textContent).toBe(expected);
94-
await waitFor(() => {
95-
expect(document.documentElement.dataset.theme).toBe(expected);
96-
});
95+
expect(getByTestId("theme").textContent).toBe("light");
9796
});
9897

99-
it("falls back to defaultTheme for an unrecognized stored value", () => {
100-
storageMap.set(storageKey, "totally-not-a-theme");
98+
it("falls back to defaultFont for an unrecognized stored font", () => {
99+
storageMap.set("appshell-ui-font", "wingdings");
101100

102101
const { getByTestId } = render(
103-
<ThemeProvider storageKey={storageKey} fontStorageKey={fontStorageKey} defaultTheme="light">
104-
<ThemeProbe />
102+
<ThemeProvider defaultFont="inter">
103+
<FontProbe />
105104
</ThemeProvider>,
106105
);
107106

108-
expect(getByTestId("theme").textContent).toBe("light");
107+
expect(getByTestId("font").textContent).toBe("inter");
109108
});
110109

111-
it("falls back to defaultFont for an unrecognized stored font", () => {
112-
storageMap.set(fontStorageKey, "wingdings");
110+
it("reads a valid stored theme", async () => {
111+
storageMap.set("appshell-ui-theme", "cream");
113112

114113
const { getByTestId } = render(
115-
<ThemeProvider storageKey={storageKey} fontStorageKey={fontStorageKey} defaultFont="inter">
116-
<FontProbe />
114+
<ThemeProvider>
115+
<ThemeProbe />
117116
</ThemeProvider>,
118117
);
119118

120-
expect(getByTestId("font").textContent).toBe("inter");
119+
expect(getByTestId("theme").textContent).toBe("cream");
120+
await waitFor(() => {
121+
expect(document.documentElement.dataset.theme).toBe("cream");
122+
});
121123
});
122124
});
123125

124126
describe("ThemeProvider — system resolution", () => {
125127
it("resolves system → dark when prefers-color-scheme: dark matches", async () => {
126128
installMatchMediaStub(true);
127-
storageMap.set(storageKey, "system");
129+
storageMap.set("appshell-ui-theme", "system");
128130

129131
const { getByTestId } = render(
130-
<ThemeProvider storageKey={storageKey} fontStorageKey={fontStorageKey}>
132+
<ThemeProvider>
131133
<ThemeProbe />
132134
</ThemeProvider>,
133135
);
@@ -142,10 +144,10 @@ describe("ThemeProvider — system resolution", () => {
142144

143145
it("resolves system → light when prefers-color-scheme: dark does not match", async () => {
144146
installMatchMediaStub(false);
145-
storageMap.set(storageKey, "system");
147+
storageMap.set("appshell-ui-theme", "system");
146148

147149
const { getByTestId } = render(
148-
<ThemeProvider storageKey={storageKey} fontStorageKey={fontStorageKey}>
150+
<ThemeProvider>
149151
<ThemeProbe />
150152
</ThemeProvider>,
151153
);
@@ -156,19 +158,37 @@ describe("ThemeProvider — system resolution", () => {
156158
expect(document.documentElement.classList.contains("light")).toBe(true);
157159
});
158160
});
161+
162+
it("reacts to OS color-scheme change while theme is system", async () => {
163+
installMatchMediaStub(false);
164+
storageMap.set("appshell-ui-theme", "system");
165+
166+
const { getByTestId } = render(
167+
<ThemeProvider>
168+
<ThemeProbe />
169+
</ThemeProvider>,
170+
);
171+
172+
expect(getByTestId("resolved").textContent).toBe("light");
173+
174+
// Simulate OS dark mode toggle
175+
act(() => {
176+
for (const listener of matchMediaListeners) {
177+
listener({ matches: true } as MediaQueryListEvent);
178+
}
179+
});
180+
181+
expect(getByTestId("resolved").textContent).toBe("dark");
182+
await waitFor(() => {
183+
expect(document.documentElement.dataset.theme).toBe("dark");
184+
});
185+
});
159186
});
160187

161-
describe("useTheme / useFont — provider guard", () => {
188+
describe("useTheme — provider guard", () => {
162189
it("throws when useTheme is called outside ThemeProvider", () => {
163-
// Silence React's expected error log for this assertion.
164190
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
165191
expect(() => render(<ThemeProbe />)).toThrow(/useTheme must be used within a ThemeProvider/);
166192
spy.mockRestore();
167193
});
168-
169-
it("throws when useFont is called outside ThemeProvider", () => {
170-
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
171-
expect(() => render(<FontProbe />)).toThrow(/useFont must be used within a ThemeProvider/);
172-
spy.mockRestore();
173-
});
174194
});

packages/core/src/contexts/theme-context.tsx

Lines changed: 44 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const ALL_THEMES: readonly Theme[] = ["light", "dark", "cream", "bloom", "system
1010

1111
/** Dropdown / switcher entries: order matches selectable themes; labels are user-facing. */
1212
export type ThemeOption = { readonly value: Theme; readonly label: string };
13+
export type FontOption = { readonly value: Font; readonly label: string };
1314

1415
export const THEME_OPTIONS: readonly ThemeOption[] = [
1516
{ value: "bloom", label: "Bloom" },
@@ -24,67 +25,28 @@ export type Font = "geist" | "inter";
2425

2526
const ALL_FONTS: readonly Font[] = ["geist", "inter"] as const;
2627

27-
export type FontOption = { readonly value: Font; readonly label: string };
28-
2928
export const FONT_OPTIONS: readonly FontOption[] = [
3029
{ value: "geist", label: "Geist" },
3130
{ value: "inter", label: "Inter" },
3231
] as const;
3332

34-
/** Migrate stored values from legacy `tailor-*` ids before the public rename. */
35-
const LEGACY_THEME_IDS: Partial<Record<string, Theme>> = {
36-
"tailor-light": "cream",
37-
"tailor-bloom": "bloom",
38-
"tailor-dark": "dark",
39-
};
40-
41-
function parseStoredTheme(value: string | null, fallback: Theme): Theme {
42-
if (!value) return fallback;
43-
const legacy = LEGACY_THEME_IDS[value];
44-
if (legacy) return legacy;
45-
if ((ALL_THEMES as readonly string[]).includes(value)) return value as Theme;
46-
return fallback;
47-
}
48-
49-
function parseStoredFont(value: string | null, fallback: Font): Font {
50-
if (!value) return fallback;
51-
if ((ALL_FONTS as readonly string[]).includes(value)) return value as Font;
52-
return fallback;
53-
}
33+
const THEME_STORAGE_KEY = "appshell-ui-theme";
34+
const FONT_STORAGE_KEY = "appshell-ui-font";
5435

55-
function readStored<T extends string>(
56-
storageKey: string,
57-
fallback: T,
58-
parse: (value: string | null, fallback: T) => T,
59-
): T {
36+
function readStored<T extends string>(key: string, allowList: readonly T[], fallback: T): T {
6037
if (typeof window === "undefined") return fallback;
61-
const ls = window.localStorage;
62-
const getItem = ls && typeof ls.getItem === "function" ? ls.getItem.bind(ls) : null;
63-
if (!getItem) return fallback;
6438
try {
65-
return parse(getItem(storageKey), fallback);
39+
const v = localStorage.getItem(key);
40+
return v && (allowList as readonly string[]).includes(v) ? (v as T) : fallback;
6641
} catch {
6742
return fallback;
6843
}
6944
}
7045

71-
function writeStored<T extends string>(storageKey: string, value: T) {
72-
if (typeof window === "undefined") return;
73-
const ls = window.localStorage;
74-
if (!ls || typeof ls.setItem !== "function") return;
75-
try {
76-
ls.setItem(storageKey, value);
77-
} catch {
78-
/* storage full or forbidden */
79-
}
80-
}
81-
8246
type ThemeProviderProps = {
8347
children: React.ReactNode;
8448
defaultTheme?: Theme;
8549
defaultFont?: Font;
86-
storageKey: string;
87-
fontStorageKey: string;
8850
};
8951

9052
type ThemeProviderState = {
@@ -97,27 +59,36 @@ type ThemeProviderState = {
9759

9860
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undefined);
9961

100-
function resolveTheme(theme: Theme): ResolvedTheme {
101-
if (theme !== "system") return theme;
102-
if (typeof window === "undefined") return "light";
103-
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
104-
}
105-
10662
export function ThemeProvider({
10763
children,
108-
storageKey,
109-
fontStorageKey,
11064
defaultTheme = "bloom",
11165
defaultFont = "geist",
11266
}: ThemeProviderProps) {
11367
const [theme, setThemeState] = useState<Theme>(() =>
114-
readStored(storageKey, defaultTheme, parseStoredTheme),
68+
readStored(THEME_STORAGE_KEY, ALL_THEMES, defaultTheme),
11569
);
11670
const [font, setFontState] = useState<Font>(() =>
117-
readStored(fontStorageKey, defaultFont, parseStoredFont),
71+
readStored(FONT_STORAGE_KEY, ALL_FONTS, defaultFont),
11872
);
11973

120-
const resolvedTheme = useMemo(() => resolveTheme(theme), [theme]);
74+
// Track OS color-scheme for live "system" theme resolution
75+
const [systemDark, setSystemDark] = useState(() =>
76+
typeof window !== "undefined"
77+
? window.matchMedia("(prefers-color-scheme: dark)").matches
78+
: false,
79+
);
80+
81+
useEffect(() => {
82+
const mql = window.matchMedia("(prefers-color-scheme: dark)");
83+
const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches);
84+
mql.addEventListener("change", handler);
85+
return () => mql.removeEventListener("change", handler);
86+
}, []);
87+
88+
const resolvedTheme: ResolvedTheme = (() => {
89+
if (theme !== "system") return theme;
90+
return systemDark ? "dark" : "light";
91+
})();
12192

12293
useEffect(() => {
12394
const root = window.document.documentElement;
@@ -130,21 +101,23 @@ export function ThemeProvider({
130101
window.document.documentElement.dataset.font = font;
131102
}, [font]);
132103

133-
const setTheme = useCallback(
134-
(newTheme: Theme) => {
135-
writeStored(storageKey, newTheme);
136-
setThemeState(newTheme);
137-
},
138-
[storageKey],
139-
);
140-
141-
const setFont = useCallback(
142-
(newFont: Font) => {
143-
writeStored(fontStorageKey, newFont);
144-
setFontState(newFont);
145-
},
146-
[fontStorageKey],
147-
);
104+
const setTheme = useCallback((newTheme: Theme) => {
105+
try {
106+
localStorage.setItem(THEME_STORAGE_KEY, newTheme);
107+
} catch {
108+
/* storage full or forbidden */
109+
}
110+
setThemeState(newTheme);
111+
}, []);
112+
113+
const setFont = useCallback((newFont: Font) => {
114+
try {
115+
localStorage.setItem(FONT_STORAGE_KEY, newFont);
116+
} catch {
117+
/* storage full or forbidden */
118+
}
119+
setFontState(newFont);
120+
}, []);
148121

149122
const value = useMemo<ThemeProviderState>(
150123
() => ({ theme, resolvedTheme, setTheme, font, setFont }),
@@ -157,15 +130,5 @@ export function ThemeProvider({
157130
export const useTheme = () => {
158131
const context = useContext(ThemeProviderContext);
159132
if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider");
160-
161-
const { theme, resolvedTheme, setTheme } = context;
162-
return useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]);
163-
};
164-
165-
export const useFont = () => {
166-
const context = useContext(ThemeProviderContext);
167-
if (context === undefined) throw new Error("useFont must be used within a ThemeProvider");
168-
169-
const { font, setFont } = context;
170-
return useMemo(() => ({ font, setFont }), [font, setFont]);
133+
return context;
171134
};

packages/core/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ export { WithGuard, type WithGuardProps } from "./components/with-guard";
2626
export { useAppShell, useAppShellConfig, useAppShellData } from "./contexts/appshell-context";
2727
export {
2828
useTheme,
29-
useFont,
3029
THEME_OPTIONS,
3130
FONT_OPTIONS,
3231
type ResolvedTheme,

0 commit comments

Comments
 (0)