Skip to content

Commit 3023520

Browse files
committed
🤖 fix: make Storybook theme deterministic via forcedTheme
1 parent c188d03 commit 3023520

File tree

3 files changed

+108
-38
lines changed

3 files changed

+108
-38
lines changed

.storybook/preview.tsx

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,8 @@ import {
66
useTheme,
77
} from "../src/browser/contexts/ThemeContext";
88
import isChromatic from "chromatic/isChromatic";
9-
import { UI_THEME_KEY } from "@/common/constants/storage";
10-
import { updatePersistedState } from "@/browser/hooks/usePersistedState";
119
import "../src/browser/styles/globals.css";
1210

13-
const DEFAULT_THEME: ThemeMode = "light";
14-
1511
const isAutomatedChromaticRun = () => {
1612
if (typeof window === "undefined" || !isChromatic()) {
1713
return false;
@@ -36,35 +32,6 @@ const isE2ETestEnv = () => {
3632
return false;
3733
};
3834

39-
const getPersistedTheme = (): ThemeMode | undefined => {
40-
if (typeof window === "undefined") {
41-
return undefined;
42-
}
43-
44-
try {
45-
const stored = window.localStorage.getItem(UI_THEME_KEY);
46-
return stored ? (JSON.parse(stored) as ThemeMode) : undefined;
47-
} catch (error) {
48-
console.warn("Failed to read Storybook theme:", error);
49-
return undefined;
50-
}
51-
};
52-
53-
const syncStorybookTheme = (mode?: ThemeMode): ThemeMode => {
54-
if (typeof window === "undefined") {
55-
return mode ?? DEFAULT_THEME; // Keep default aligned with baseline to avoid Chromatic diffs
56-
}
57-
58-
const persisted = getPersistedTheme();
59-
const resolved = mode ?? persisted ?? DEFAULT_THEME; // Default to light for Latest when unset
60-
61-
if (resolved !== persisted) {
62-
updatePersistedState(UI_THEME_KEY, resolved);
63-
}
64-
65-
return resolved;
66-
};
67-
6835
const StorybookThemeToggle: React.FC = () => {
6936
const { theme, toggleTheme } = useTheme();
7037

@@ -114,9 +81,8 @@ const preview: Preview = {
11481
decorators: [
11582
(Story, context) => {
11683
const mode = context.globals.theme as ThemeMode | undefined;
117-
syncStorybookTheme(mode);
11884
return (
119-
<ThemeProvider>
85+
<ThemeProvider forcedTheme={mode}>
12086
<Story />
12187
{!mode && !isE2ETestEnv() && <StorybookThemeToggle />}
12288
</ThemeProvider>
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { GlobalWindow } from "happy-dom";
2+
3+
// Setup basic DOM environment for testing-library
4+
const dom = new GlobalWindow();
5+
(global as any).window = dom.window;
6+
(global as any).document = dom.window.document;
7+
// Polyfill console since happy-dom might interfere or we just want standard console
8+
(global as any).console = console;
9+
10+
import { afterEach, describe, expect, mock, test, beforeEach } from "bun:test";
11+
12+
import { render, cleanup } from "@testing-library/react";
13+
import React from "react";
14+
import { ThemeProvider, useTheme } from "./ThemeContext";
15+
import { UI_THEME_KEY } from "@/common/constants/storage";
16+
17+
// Helper to access internals
18+
const TestComponent = () => {
19+
const { theme, toggleTheme } = useTheme();
20+
return (
21+
<div>
22+
<span data-testid="theme-value">{theme}</span>
23+
<button onClick={toggleTheme} data-testid="toggle-btn">Toggle</button>
24+
</div>
25+
);
26+
};
27+
28+
describe("ThemeContext", () => {
29+
// Mock matchMedia
30+
const mockMatchMedia = mock(() => ({
31+
matches: false,
32+
media: "",
33+
onchange: null,
34+
addListener: () => {},
35+
removeListener: () => {},
36+
addEventListener: () => {},
37+
removeEventListener: () => {},
38+
dispatchEvent: () => true,
39+
}));
40+
41+
beforeEach(() => {
42+
// Ensure window exists (Bun test with happy-dom should provide it)
43+
if (typeof window !== "undefined") {
44+
window.matchMedia = mockMatchMedia;
45+
window.localStorage.clear();
46+
}
47+
});
48+
49+
afterEach(() => {
50+
cleanup();
51+
if (typeof window !== "undefined") {
52+
window.localStorage.clear();
53+
}
54+
});
55+
56+
test("uses persisted state by default", () => {
57+
const { getByTestId } = render(
58+
<ThemeProvider>
59+
<TestComponent />
60+
</ThemeProvider>
61+
);
62+
// If matchMedia matches is false (default mock), resolveSystemTheme returns 'dark' (since it checks prefers-color-scheme: light)
63+
// resolveSystemTheme logic: window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"
64+
expect(getByTestId("theme-value").textContent).toBe("dark");
65+
});
66+
67+
test("respects forcedTheme prop", () => {
68+
const { getByTestId, rerender } = render(
69+
<ThemeProvider forcedTheme="light">
70+
<TestComponent />
71+
</ThemeProvider>
72+
);
73+
expect(getByTestId("theme-value").textContent).toBe("light");
74+
75+
rerender(
76+
<ThemeProvider forcedTheme="dark">
77+
<TestComponent />
78+
</ThemeProvider>
79+
);
80+
expect(getByTestId("theme-value").textContent).toBe("dark");
81+
});
82+
83+
test("forcedTheme overrides persisted state", () => {
84+
window.localStorage.setItem(UI_THEME_KEY, JSON.stringify("light"));
85+
86+
const { getByTestId } = render(
87+
<ThemeProvider forcedTheme="dark">
88+
<TestComponent />
89+
</ThemeProvider>
90+
);
91+
expect(getByTestId("theme-value").textContent).toBe("dark");
92+
93+
// Check that localStorage is still light (since forcedTheme doesn't write to storage by itself)
94+
expect(JSON.parse(window.localStorage.getItem(UI_THEME_KEY)!)).toBe("light");
95+
});
96+
});

src/browser/contexts/ThemeContext.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,19 @@ function applyThemeToDocument(theme: ThemeMode) {
5151
}
5252
}
5353

54-
export function ThemeProvider(props: { children: ReactNode }) {
55-
const [theme, setTheme] = usePersistedState<ThemeMode>(UI_THEME_KEY, resolveSystemTheme(), {
54+
export function ThemeProvider({
55+
children,
56+
forcedTheme,
57+
}: {
58+
children: ReactNode;
59+
forcedTheme?: ThemeMode;
60+
}) {
61+
const [persistedTheme, setTheme] = usePersistedState<ThemeMode>(UI_THEME_KEY, resolveSystemTheme(), {
5662
listener: true,
5763
});
5864

65+
const theme = forcedTheme ?? persistedTheme;
66+
5967
useLayoutEffect(() => {
6068
applyThemeToDocument(theme);
6169
}, [theme]);
@@ -73,7 +81,7 @@ export function ThemeProvider(props: { children: ReactNode }) {
7381
[setTheme, theme, toggleTheme]
7482
);
7583

76-
return <ThemeContext.Provider value={value}>{props.children}</ThemeContext.Provider>;
84+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
7785
}
7886

7987
export function useTheme(): ThemeContextValue {

0 commit comments

Comments
 (0)