From 562963f3359b2061ff04e2977eda43faa5375e27 Mon Sep 17 00:00:00 2001 From: Lendemor Date: Mon, 21 Jul 2025 20:53:01 +0200 Subject: [PATCH 1/7] 1st attempt to fix FOUC --- reflex/.templates/web/app/entry.client.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/reflex/.templates/web/app/entry.client.js b/reflex/.templates/web/app/entry.client.js index 9545bc28309..2f35d164e04 100644 --- a/reflex/.templates/web/app/entry.client.js +++ b/reflex/.templates/web/app/entry.client.js @@ -2,6 +2,25 @@ import { startTransition } from "react"; import { hydrateRoot } from "react-dom/client"; import { HydratedRouter } from "react-router/dom"; import { createElement } from "react"; +import { defaultColorMode } from "$/utils/context"; + +// Pre-hydration theme script - runs before React hydrates +(function () { + try { + const theme = localStorage.getItem("theme") || defaultColorMode || "light"; + const systemPreference = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light"; + const resolvedTheme = theme === "system" ? systemPreference : theme; + const root = document.documentElement; + root.classList.remove("light", "dark"); + root.classList.add(resolvedTheme); + root.style.colorScheme = resolvedTheme; + } catch (e) { + document.documentElement.classList.add(defaultColorMode || "light"); + } +})(); startTransition(() => { hydrateRoot(document, createElement(HydratedRouter)); From fee425d3dc63bbd006ca38194f77aaead381095c Mon Sep 17 00:00:00 2001 From: Lendemor Date: Wed, 23 Jul 2025 17:38:45 +0200 Subject: [PATCH 2/7] fix flicker --- reflex/.templates/web/utils/react-theme.js | 10 +++++-- reflex/compiler/utils.py | 6 ++++ reflex/utils/misc.py | 35 ++++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/reflex/.templates/web/utils/react-theme.js b/reflex/.templates/web/utils/react-theme.js index e0f48dc98b3..c9996f94a53 100644 --- a/reflex/.templates/web/utils/react-theme.js +++ b/reflex/.templates/web/utils/react-theme.js @@ -21,6 +21,7 @@ export function ThemeProvider({ children, defaultTheme = "system" }) { const [systemTheme, setSystemTheme] = useState( defaultTheme !== "system" ? defaultTheme : "light", ); + const [isInitialized, setIsInitialized] = useState(false); const firstRender = useRef(true); @@ -43,6 +44,7 @@ export function ThemeProvider({ children, defaultTheme = "system" }) { // Load saved theme from localStorage const savedTheme = localStorage.getItem("theme") || defaultTheme; setTheme(savedTheme); + setIsInitialized(true); }); const resolvedTheme = useMemo( @@ -68,10 +70,12 @@ export function ThemeProvider({ children, defaultTheme = "system" }) { }; }); - // Save theme to localStorage whenever it changes + // Save theme to localStorage whenever it changes (but not on initial mount) useEffect(() => { - localStorage.setItem("theme", theme); - }, [theme]); + if (isInitialized) { + localStorage.setItem("theme", theme); + } + }, [theme, isInitialized]); useEffect(() => { const root = window.document.documentElement; diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 6bbb0a2c79d..1a5693482d3 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -359,6 +359,8 @@ def create_document_root( Returns: The document root. """ + from reflex.utils.misc import preload_color_theme + existing_meta_types = set() for component in head_components or []: @@ -385,7 +387,11 @@ def create_document_root( Meta.create(name="viewport", content="width=device-width, initial-scale=1") ) + # Add theme preload script as the very first component to prevent FOUC + theme_preload_components = [preload_color_theme()] + head_components = [ + *theme_preload_components, *(head_components or []), *maybe_head_components, *always_head_components, diff --git a/reflex/utils/misc.py b/reflex/utils/misc.py index 421dd9c19a0..ae21cd5c1d3 100644 --- a/reflex/utils/misc.py +++ b/reflex/utils/misc.py @@ -90,3 +90,38 @@ def with_cwd_in_syspath(): yield finally: sys.path[:] = orig_sys_path + + +def preload_color_theme(): + """Create a script component that preloads the color theme to prevent FOUC. + + This script runs immediately in the document head before React hydration, + reading the saved theme from localStorage and applying the correct CSS classes + to prevent flash of unstyled content. + + Returns: + Script: A script component to add to App.head_components + """ + from reflex.components.el.elements.scripts import Script + + # Create direct inline script content (like next-themes dangerouslySetInnerHTML) + script_content = """ +// Only run in browser environment, not during SSR +if (typeof document !== 'undefined') { + try { + const theme = localStorage.getItem("theme") || "system"; + const systemPreference = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + const resolvedTheme = theme === "system" ? systemPreference : theme; + + // Apply theme immediately - blocks until complete + document.documentElement.className = resolvedTheme; + document.documentElement.style.colorScheme = resolvedTheme; + + } catch (e) { + // Fallback to light theme on any error + document.documentElement.className = "light"; + } +} +""" + + return Script.create(script_content) From b0d785e6d02729a0e9b82003c3e7fdb15efdb8ac Mon Sep 17 00:00:00 2001 From: Lendemor Date: Wed, 23 Jul 2025 17:53:39 +0200 Subject: [PATCH 3/7] remove previous attempt --- reflex/.templates/web/app/entry.client.js | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/reflex/.templates/web/app/entry.client.js b/reflex/.templates/web/app/entry.client.js index 2f35d164e04..9545bc28309 100644 --- a/reflex/.templates/web/app/entry.client.js +++ b/reflex/.templates/web/app/entry.client.js @@ -2,25 +2,6 @@ import { startTransition } from "react"; import { hydrateRoot } from "react-dom/client"; import { HydratedRouter } from "react-router/dom"; import { createElement } from "react"; -import { defaultColorMode } from "$/utils/context"; - -// Pre-hydration theme script - runs before React hydrates -(function () { - try { - const theme = localStorage.getItem("theme") || defaultColorMode || "light"; - const systemPreference = window.matchMedia("(prefers-color-scheme: dark)") - .matches - ? "dark" - : "light"; - const resolvedTheme = theme === "system" ? systemPreference : theme; - const root = document.documentElement; - root.classList.remove("light", "dark"); - root.classList.add(resolvedTheme); - root.style.colorScheme = resolvedTheme; - } catch (e) { - document.documentElement.classList.add(defaultColorMode || "light"); - } -})(); startTransition(() => { hydrateRoot(document, createElement(HydratedRouter)); From edfffb90c4a3618f35651075fe777a1948528604 Mon Sep 17 00:00:00 2001 From: Lendemor Date: Wed, 23 Jul 2025 18:33:11 +0200 Subject: [PATCH 4/7] fix tests --- tests/units/compiler/test_compiler.py | 36 +++++++++++++-------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 4e4a618c4b8..18324e610a2 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -364,17 +364,17 @@ def test_create_document_root(): assert isinstance(lang, LiteralStringVar) assert lang.equals(Var.create("en")) # No children in head. - assert len(root.children[0].children) == 4 - assert isinstance(root.children[0].children[0], utils.Meta) - char_set = root.children[0].children[0].char_set # pyright: ignore [reportAttributeAccessIssue] + assert len(root.children[0].children) == 5 + assert isinstance(root.children[0].children[1], utils.Meta) + char_set = root.children[0].children[1].char_set # pyright: ignore [reportAttributeAccessIssue] assert isinstance(char_set, LiteralStringVar) assert char_set.equals(Var.create("utf-8")) - assert isinstance(root.children[0].children[1], utils.Meta) - name = root.children[0].children[1].name # pyright: ignore [reportAttributeAccessIssue] + assert isinstance(root.children[0].children[2], utils.Meta) + name = root.children[0].children[2].name # pyright: ignore [reportAttributeAccessIssue] assert isinstance(name, LiteralStringVar) assert name.equals(Var.create("viewport")) - assert isinstance(root.children[0].children[2], document.Meta) - assert isinstance(root.children[0].children[3], document.Links) + assert isinstance(root.children[0].children[3], document.Meta) + assert isinstance(root.children[0].children[4], document.Links) def test_create_document_root_with_scripts(): @@ -389,9 +389,9 @@ def test_create_document_root_with_scripts(): html_custom_attrs={"project": "reflex"}, ) assert isinstance(root, utils.Html) - assert len(root.children[0].children) == 6 + assert len(root.children[0].children) == 7 names = [c.tag for c in root.children[0].children] - assert names == ["Scripts", "Scripts", "meta", "meta", "Meta", "Links"] + assert names == ["script", "Scripts", "Scripts", "meta", "meta", "Meta", "Links"] lang = root.lang # pyright: ignore [reportAttributeAccessIssue] assert isinstance(lang, LiteralStringVar) assert lang.equals(Var.create("rx")) @@ -408,10 +408,10 @@ def test_create_document_root_with_meta_char_set(): head_components=comps, ) assert isinstance(root, utils.Html) - assert len(root.children[0].children) == 4 + assert len(root.children[0].children) == 5 names = [c.tag for c in root.children[0].children] - assert names == ["meta", "meta", "Meta", "Links"] - assert str(root.children[0].children[0].char_set) == '"cp1252"' # pyright: ignore [reportAttributeAccessIssue] + assert names == ["script", "meta", "meta", "Meta", "Links"] + assert str(root.children[0].children[1].char_set) == '"cp1252"' # pyright: ignore [reportAttributeAccessIssue] def test_create_document_root_with_meta_viewport(): @@ -424,10 +424,10 @@ def test_create_document_root_with_meta_viewport(): head_components=comps, ) assert isinstance(root, utils.Html) - assert len(root.children[0].children) == 5 + assert len(root.children[0].children) == 6 names = [c.tag for c in root.children[0].children] - assert names == ["meta", "meta", "meta", "Meta", "Links"] - assert str(root.children[0].children[0].http_equiv) == '"refresh"' # pyright: ignore [reportAttributeAccessIssue] - assert str(root.children[0].children[1].name) == '"viewport"' # pyright: ignore [reportAttributeAccessIssue] - assert str(root.children[0].children[1].content) == '"foo"' # pyright: ignore [reportAttributeAccessIssue] - assert str(root.children[0].children[2].char_set) == '"utf-8"' # pyright: ignore [reportAttributeAccessIssue] + assert names == ["script", "meta", "meta", "meta", "Meta", "Links"] + assert str(root.children[0].children[1].http_equiv) == '"refresh"' # pyright: ignore [reportAttributeAccessIssue] + assert str(root.children[0].children[2].name) == '"viewport"' # pyright: ignore [reportAttributeAccessIssue] + assert str(root.children[0].children[2].content) == '"foo"' # pyright: ignore [reportAttributeAccessIssue] + assert str(root.children[0].children[3].char_set) == '"utf-8"' # pyright: ignore [reportAttributeAccessIssue] From 2d48b56b91796e833423efcfa1e0abe938291eb9 Mon Sep 17 00:00:00 2001 From: Lendemor Date: Wed, 23 Jul 2025 19:57:58 +0200 Subject: [PATCH 5/7] fix initial load --- reflex/.templates/web/utils/react-theme.js | 12 +++++++++--- reflex/utils/misc.py | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/reflex/.templates/web/utils/react-theme.js b/reflex/.templates/web/utils/react-theme.js index c9996f94a53..8ccac51c7ea 100644 --- a/reflex/.templates/web/utils/react-theme.js +++ b/reflex/.templates/web/utils/react-theme.js @@ -18,9 +18,15 @@ const ThemeContext = createContext({ export function ThemeProvider({ children, defaultTheme = "system" }) { const [theme, setTheme] = useState(defaultTheme); - const [systemTheme, setSystemTheme] = useState( - defaultTheme !== "system" ? defaultTheme : "light", - ); + + // Detect system preference synchronously during initialization + const getInitialSystemTheme = () => { + if (defaultTheme !== "system") return defaultTheme; + if (typeof window === "undefined") return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + }; + + const [systemTheme, setSystemTheme] = useState(getInitialSystemTheme); const [isInitialized, setIsInitialized] = useState(false); const firstRender = useRef(true); diff --git a/reflex/utils/misc.py b/reflex/utils/misc.py index ae21cd5c1d3..e0631d860d4 100644 --- a/reflex/utils/misc.py +++ b/reflex/utils/misc.py @@ -118,8 +118,8 @@ def preload_color_theme(): document.documentElement.style.colorScheme = resolvedTheme; } catch (e) { - // Fallback to light theme on any error - document.documentElement.className = "light"; + // Fallback to system preference on any error + document.documentElement.className = "system"; } } """ From 1e8e11db7d10caad1d2c7a71e922c78e274421bd Mon Sep 17 00:00:00 2001 From: Lendemor Date: Wed, 23 Jul 2025 22:55:57 +0200 Subject: [PATCH 6/7] fix pretteir --- reflex/.templates/web/utils/react-theme.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/reflex/.templates/web/utils/react-theme.js b/reflex/.templates/web/utils/react-theme.js index 8ccac51c7ea..efb06cd0efb 100644 --- a/reflex/.templates/web/utils/react-theme.js +++ b/reflex/.templates/web/utils/react-theme.js @@ -18,14 +18,16 @@ const ThemeContext = createContext({ export function ThemeProvider({ children, defaultTheme = "system" }) { const [theme, setTheme] = useState(defaultTheme); - + // Detect system preference synchronously during initialization const getInitialSystemTheme = () => { if (defaultTheme !== "system") return defaultTheme; if (typeof window === "undefined") return "light"; - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; }; - + const [systemTheme, setSystemTheme] = useState(getInitialSystemTheme); const [isInitialized, setIsInitialized] = useState(false); From 2b281fd4d9a1fd4bc3e4e5845f7d53a9c21e84d5 Mon Sep 17 00:00:00 2001 From: Lendemor Date: Thu, 24 Jul 2025 20:44:42 +0200 Subject: [PATCH 7/7] fix flash? --- reflex/utils/misc.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/reflex/utils/misc.py b/reflex/utils/misc.py index e0631d860d4..396b9037df2 100644 --- a/reflex/utils/misc.py +++ b/reflex/utils/misc.py @@ -113,13 +113,21 @@ def preload_color_theme(): const systemPreference = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; const resolvedTheme = theme === "system" ? systemPreference : theme; + console.log("[PRELOAD] Theme applied:", resolvedTheme, "from theme:", theme, "system:", systemPreference); + // Apply theme immediately - blocks until complete - document.documentElement.className = resolvedTheme; + // Use classList to avoid overwriting other classes + document.documentElement.classList.remove("light", "dark"); + document.documentElement.classList.add(resolvedTheme); document.documentElement.style.colorScheme = resolvedTheme; } catch (e) { - // Fallback to system preference on any error - document.documentElement.className = "system"; + // Fallback to system preference on any error (resolve "system" to actual theme) + const fallbackTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + console.log("[PRELOAD] Error, falling back to:", fallbackTheme); + document.documentElement.classList.remove("light", "dark"); + document.documentElement.classList.add(fallbackTheme); + document.documentElement.style.colorScheme = fallbackTheme; } } """