Skip to content

Commit a6c6a97

Browse files
committed
🤖 fix: apply theme synchronously in decorator before React renders
Root cause: useLayoutEffect in ThemeStorySync still created a race condition where Chromatic could capture snapshots before the theme was applied. Fix (same pattern as coder/coder): - Apply theme to document.documentElement synchronously in decorator - Pass forcedTheme prop to ThemeProvider so it doesn't read localStorage - Decorator runs BEFORE React tree is created, ensuring theme is correct - ThemeProvider respects forcedTheme and skips persistence/listener This eliminates all timing issues - theme is on document before any component renders or useLayoutEffect runs.
1 parent 36f480d commit a6c6a97

File tree

2 files changed

+34
-32
lines changed

2 files changed

+34
-32
lines changed

.storybook/preview.tsx

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,8 @@
1-
import React, { useLayoutEffect } from "react";
1+
import React from "react";
22
import type { Preview } from "@storybook/react-vite";
3-
import {
4-
ThemeProvider,
5-
useTheme,
6-
type ThemeMode,
7-
} from "../src/browser/contexts/ThemeContext";
3+
import { ThemeProvider, type ThemeMode } from "../src/browser/contexts/ThemeContext";
84
import "../src/browser/styles/globals.css";
95

10-
const ThemeStorySync: React.FC<{ mode: ThemeMode }> = ({ mode }) => {
11-
const { theme, setTheme } = useTheme();
12-
13-
useLayoutEffect(() => {
14-
if (theme !== mode) {
15-
setTheme(mode);
16-
}
17-
}, [mode, setTheme, theme]);
18-
19-
return null;
20-
};
21-
226
const preview: Preview = {
237
globalTypes: {
248
theme: {
@@ -37,10 +21,18 @@ const preview: Preview = {
3721
},
3822
decorators: [
3923
(Story, context) => {
40-
const mode = (context.globals.theme ?? "dark") as ThemeMode;
24+
const mode = context.globals.theme as ThemeMode | undefined;
25+
const selected = mode ?? "dark";
26+
27+
// Apply theme SYNCHRONOUSLY before React renders (prevents race condition)
28+
// This ensures Chromatic snapshots always capture the correct theme
29+
if (typeof document !== "undefined") {
30+
document.documentElement.dataset.theme = selected;
31+
document.documentElement.style.colorScheme = selected;
32+
}
33+
4134
return (
42-
<ThemeProvider>
43-
<ThemeStorySync mode={mode} />
35+
<ThemeProvider forcedTheme={mode}>
4436
<Story />
4537
</ThemeProvider>
4638
);

src/browser/contexts/ThemeContext.tsx

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

54-
export function ThemeProvider(props: { children: ReactNode }) {
55-
const [theme, setTheme] = usePersistedState<ThemeMode>(UI_THEME_KEY, resolveSystemTheme(), {
56-
listener: true,
57-
});
54+
export function ThemeProvider(props: { children: ReactNode; forcedTheme?: ThemeMode }) {
55+
const [persistedTheme, setPersistedTheme] = usePersistedState<ThemeMode>(
56+
UI_THEME_KEY,
57+
resolveSystemTheme(),
58+
{
59+
listener: !props.forcedTheme, // Disable listener when theme is forced
60+
}
61+
);
62+
63+
// Use forcedTheme if provided (e.g., from Storybook), otherwise use persisted theme
64+
const activeTheme = props.forcedTheme ?? persistedTheme;
5865

5966
useLayoutEffect(() => {
60-
applyThemeToDocument(theme);
61-
}, [theme]);
67+
applyThemeToDocument(activeTheme);
68+
}, [activeTheme]);
6269

6370
const toggleTheme = useCallback(() => {
64-
setTheme((current) => (current === "dark" ? "light" : "dark"));
65-
}, [setTheme]);
71+
// Only allow toggling when not forced
72+
if (!props.forcedTheme) {
73+
setPersistedTheme((current) => (current === "dark" ? "light" : "dark"));
74+
}
75+
}, [props.forcedTheme, setPersistedTheme]);
6676

6777
const value = useMemo<ThemeContextValue>(
6878
() => ({
69-
theme,
70-
setTheme,
79+
theme: activeTheme,
80+
setTheme: setPersistedTheme,
7181
toggleTheme,
7282
}),
73-
[setTheme, theme, toggleTheme]
83+
[activeTheme, setPersistedTheme, toggleTheme]
7484
);
7585

7686
return <ThemeContext.Provider value={value}>{props.children}</ThemeContext.Provider>;

0 commit comments

Comments
 (0)