Skip to content

Commit 9d9ff9e

Browse files
authored
fix FOUC on refresh (#5608)
* 1st attempt to fix FOUC * fix flicker * remove previous attempt * fix tests * fix initial load * fix pretteir * fix flash?
1 parent 49570ea commit 9d9ff9e

File tree

4 files changed

+85
-24
lines changed

4 files changed

+85
-24
lines changed

reflex/.templates/web/utils/react-theme.js

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,18 @@ const ThemeContext = createContext({
1818

1919
export function ThemeProvider({ children, defaultTheme = "system" }) {
2020
const [theme, setTheme] = useState(defaultTheme);
21-
const [systemTheme, setSystemTheme] = useState(
22-
defaultTheme !== "system" ? defaultTheme : "light",
23-
);
21+
22+
// Detect system preference synchronously during initialization
23+
const getInitialSystemTheme = () => {
24+
if (defaultTheme !== "system") return defaultTheme;
25+
if (typeof window === "undefined") return "light";
26+
return window.matchMedia("(prefers-color-scheme: dark)").matches
27+
? "dark"
28+
: "light";
29+
};
30+
31+
const [systemTheme, setSystemTheme] = useState(getInitialSystemTheme);
32+
const [isInitialized, setIsInitialized] = useState(false);
2433

2534
const firstRender = useRef(true);
2635

@@ -43,6 +52,7 @@ export function ThemeProvider({ children, defaultTheme = "system" }) {
4352
// Load saved theme from localStorage
4453
const savedTheme = localStorage.getItem("theme") || defaultTheme;
4554
setTheme(savedTheme);
55+
setIsInitialized(true);
4656
});
4757

4858
const resolvedTheme = useMemo(
@@ -68,10 +78,12 @@ export function ThemeProvider({ children, defaultTheme = "system" }) {
6878
};
6979
});
7080

71-
// Save theme to localStorage whenever it changes
81+
// Save theme to localStorage whenever it changes (but not on initial mount)
7282
useEffect(() => {
73-
localStorage.setItem("theme", theme);
74-
}, [theme]);
83+
if (isInitialized) {
84+
localStorage.setItem("theme", theme);
85+
}
86+
}, [theme, isInitialized]);
7587

7688
useEffect(() => {
7789
const root = window.document.documentElement;

reflex/compiler/utils.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,8 @@ def create_document_root(
359359
Returns:
360360
The document root.
361361
"""
362+
from reflex.utils.misc import preload_color_theme
363+
362364
existing_meta_types = set()
363365

364366
for component in head_components or []:
@@ -385,7 +387,11 @@ def create_document_root(
385387
Meta.create(name="viewport", content="width=device-width, initial-scale=1")
386388
)
387389

390+
# Add theme preload script as the very first component to prevent FOUC
391+
theme_preload_components = [preload_color_theme()]
392+
388393
head_components = [
394+
*theme_preload_components,
389395
*(head_components or []),
390396
*maybe_head_components,
391397
*always_head_components,

reflex/utils/misc.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,46 @@ def with_cwd_in_syspath():
9090
yield
9191
finally:
9292
sys.path[:] = orig_sys_path
93+
94+
95+
def preload_color_theme():
96+
"""Create a script component that preloads the color theme to prevent FOUC.
97+
98+
This script runs immediately in the document head before React hydration,
99+
reading the saved theme from localStorage and applying the correct CSS classes
100+
to prevent flash of unstyled content.
101+
102+
Returns:
103+
Script: A script component to add to App.head_components
104+
"""
105+
from reflex.components.el.elements.scripts import Script
106+
107+
# Create direct inline script content (like next-themes dangerouslySetInnerHTML)
108+
script_content = """
109+
// Only run in browser environment, not during SSR
110+
if (typeof document !== 'undefined') {
111+
try {
112+
const theme = localStorage.getItem("theme") || "system";
113+
const systemPreference = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
114+
const resolvedTheme = theme === "system" ? systemPreference : theme;
115+
116+
console.log("[PRELOAD] Theme applied:", resolvedTheme, "from theme:", theme, "system:", systemPreference);
117+
118+
// Apply theme immediately - blocks until complete
119+
// Use classList to avoid overwriting other classes
120+
document.documentElement.classList.remove("light", "dark");
121+
document.documentElement.classList.add(resolvedTheme);
122+
document.documentElement.style.colorScheme = resolvedTheme;
123+
124+
} catch (e) {
125+
// Fallback to system preference on any error (resolve "system" to actual theme)
126+
const fallbackTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
127+
console.log("[PRELOAD] Error, falling back to:", fallbackTheme);
128+
document.documentElement.classList.remove("light", "dark");
129+
document.documentElement.classList.add(fallbackTheme);
130+
document.documentElement.style.colorScheme = fallbackTheme;
131+
}
132+
}
133+
"""
134+
135+
return Script.create(script_content)

tests/units/compiler/test_compiler.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -364,17 +364,17 @@ def test_create_document_root():
364364
assert isinstance(lang, LiteralStringVar)
365365
assert lang.equals(Var.create("en"))
366366
# No children in head.
367-
assert len(root.children[0].children) == 4
368-
assert isinstance(root.children[0].children[0], utils.Meta)
369-
char_set = root.children[0].children[0].char_set # pyright: ignore [reportAttributeAccessIssue]
367+
assert len(root.children[0].children) == 5
368+
assert isinstance(root.children[0].children[1], utils.Meta)
369+
char_set = root.children[0].children[1].char_set # pyright: ignore [reportAttributeAccessIssue]
370370
assert isinstance(char_set, LiteralStringVar)
371371
assert char_set.equals(Var.create("utf-8"))
372-
assert isinstance(root.children[0].children[1], utils.Meta)
373-
name = root.children[0].children[1].name # pyright: ignore [reportAttributeAccessIssue]
372+
assert isinstance(root.children[0].children[2], utils.Meta)
373+
name = root.children[0].children[2].name # pyright: ignore [reportAttributeAccessIssue]
374374
assert isinstance(name, LiteralStringVar)
375375
assert name.equals(Var.create("viewport"))
376-
assert isinstance(root.children[0].children[2], document.Meta)
377-
assert isinstance(root.children[0].children[3], document.Links)
376+
assert isinstance(root.children[0].children[3], document.Meta)
377+
assert isinstance(root.children[0].children[4], document.Links)
378378

379379

380380
def test_create_document_root_with_scripts():
@@ -389,9 +389,9 @@ def test_create_document_root_with_scripts():
389389
html_custom_attrs={"project": "reflex"},
390390
)
391391
assert isinstance(root, utils.Html)
392-
assert len(root.children[0].children) == 6
392+
assert len(root.children[0].children) == 7
393393
names = [c.tag for c in root.children[0].children]
394-
assert names == ["Scripts", "Scripts", "meta", "meta", "Meta", "Links"]
394+
assert names == ["script", "Scripts", "Scripts", "meta", "meta", "Meta", "Links"]
395395
lang = root.lang # pyright: ignore [reportAttributeAccessIssue]
396396
assert isinstance(lang, LiteralStringVar)
397397
assert lang.equals(Var.create("rx"))
@@ -408,10 +408,10 @@ def test_create_document_root_with_meta_char_set():
408408
head_components=comps,
409409
)
410410
assert isinstance(root, utils.Html)
411-
assert len(root.children[0].children) == 4
411+
assert len(root.children[0].children) == 5
412412
names = [c.tag for c in root.children[0].children]
413-
assert names == ["meta", "meta", "Meta", "Links"]
414-
assert str(root.children[0].children[0].char_set) == '"cp1252"' # pyright: ignore [reportAttributeAccessIssue]
413+
assert names == ["script", "meta", "meta", "Meta", "Links"]
414+
assert str(root.children[0].children[1].char_set) == '"cp1252"' # pyright: ignore [reportAttributeAccessIssue]
415415

416416

417417
def test_create_document_root_with_meta_viewport():
@@ -424,10 +424,10 @@ def test_create_document_root_with_meta_viewport():
424424
head_components=comps,
425425
)
426426
assert isinstance(root, utils.Html)
427-
assert len(root.children[0].children) == 5
427+
assert len(root.children[0].children) == 6
428428
names = [c.tag for c in root.children[0].children]
429-
assert names == ["meta", "meta", "meta", "Meta", "Links"]
430-
assert str(root.children[0].children[0].http_equiv) == '"refresh"' # pyright: ignore [reportAttributeAccessIssue]
431-
assert str(root.children[0].children[1].name) == '"viewport"' # pyright: ignore [reportAttributeAccessIssue]
432-
assert str(root.children[0].children[1].content) == '"foo"' # pyright: ignore [reportAttributeAccessIssue]
433-
assert str(root.children[0].children[2].char_set) == '"utf-8"' # pyright: ignore [reportAttributeAccessIssue]
429+
assert names == ["script", "meta", "meta", "meta", "Meta", "Links"]
430+
assert str(root.children[0].children[1].http_equiv) == '"refresh"' # pyright: ignore [reportAttributeAccessIssue]
431+
assert str(root.children[0].children[2].name) == '"viewport"' # pyright: ignore [reportAttributeAccessIssue]
432+
assert str(root.children[0].children[2].content) == '"foo"' # pyright: ignore [reportAttributeAccessIssue]
433+
assert str(root.children[0].children[3].char_set) == '"utf-8"' # pyright: ignore [reportAttributeAccessIssue]

0 commit comments

Comments
 (0)