Skip to content

Commit cf2e5e2

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

File tree

3 files changed

+118
-38
lines changed

3 files changed

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

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)