From f5a912f9625dd51d733cfe4918484cc75771e7a6 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 7 Jul 2026 15:28:53 -0700 Subject: [PATCH 1/4] Expose `default_color_mode` in rx.Config --- .../reflex-base/src/reflex_base/config.py | 5 ++- .../src/reflex_base/constants/__init__.py | 8 +++++ .../src/reflex_base/constants/base.py | 6 ++++ packages/reflex-base/src/reflex_base/style.py | 11 +++---- reflex/compiler/compiler.py | 3 +- tests/units/compiler/test_compiler.py | 33 +++++++++++++++++++ 6 files changed, 57 insertions(+), 9 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index 44c5060d02f..8f2eee473f5 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal from reflex_base import constants -from reflex_base.constants.base import LogLevel +from reflex_base.constants.base import LiteralColorMode, LogLevel from reflex_base.environment import EnvironmentVariables as EnvironmentVariables from reflex_base.environment import EnvVar as EnvVar from reflex_base.environment import ( @@ -177,6 +177,7 @@ class BaseConfig: redis_token_expiration: Token expiration time for redis state manager. env_file: Path to file containing key-values pairs to override in the environment; Dotenv format. state_auto_setters: Whether to automatically create setters for state base vars. + default_color_mode: The default color mode for the app: "system" (follow the OS preference), "light", or "dark". Applies to the built-in color mode switcher and `color_mode_cond` without requiring a radix theme. show_built_with_reflex: Whether to display the sticky "Built with Reflex" badge on all pages. is_reflex_cloud: Whether the app is running in the reflex cloud environment. extra_overlay_function: Extra overlay function to run after the app is built. Formatted such that `from path_0.path_1... import path[-1]`, and calling it with no arguments would work. For example, "reflex_components_moment.moment". @@ -251,6 +252,8 @@ class BaseConfig: state_auto_setters: bool = False + default_color_mode: LiteralColorMode = "system" + show_built_with_reflex: bool | None = None is_reflex_cloud: bool = False diff --git a/packages/reflex-base/src/reflex_base/constants/__init__.py b/packages/reflex-base/src/reflex_base/constants/__init__.py index e442355d9a2..714cf0faa84 100644 --- a/packages/reflex-base/src/reflex_base/constants/__init__.py +++ b/packages/reflex-base/src/reflex_base/constants/__init__.py @@ -3,18 +3,22 @@ from .base import ( APP_HARNESS_FLAG, COOKIES, + DARK_COLOR_MODE, IS_LINUX, IS_MACOS, IS_WINDOWS, + LIGHT_COLOR_MODE, LOCAL_STORAGE, POLLING_MAX_HTTP_BUFFER_SIZE, PYTEST_CURRENT_TEST, REFLEX_VAR_CLOSING_TAG, REFLEX_VAR_OPENING_TAG, SESSION_STORAGE, + SYSTEM_COLOR_MODE, ColorMode, Dirs, Env, + LiteralColorMode, LogLevel, Ping, ReactRouter, @@ -68,9 +72,11 @@ "ALEMBIC_CONFIG", "APP_HARNESS_FLAG", "COOKIES", + "DARK_COLOR_MODE", "IS_LINUX", "IS_MACOS", "IS_WINDOWS", + "LIGHT_COLOR_MODE", "LOCAL_STORAGE", "NOCOMPILE_FILE", "POLLING_MAX_HTTP_BUFFER_SIZE", @@ -83,6 +89,7 @@ "ROUTE_NOT_FOUND", "SESSION_STORAGE", "SETTER_PREFIX", + "SYSTEM_COLOR_MODE", "AgentsMd", "Bun", "ColorMode", @@ -103,6 +110,7 @@ "GitIgnore", "Hooks", "Imports", + "LiteralColorMode", "LogLevel", "MemoizationDisposition", "MemoizationMode", diff --git a/packages/reflex-base/src/reflex_base/constants/base.py b/packages/reflex-base/src/reflex_base/constants/base.py index dc06fbf0cb9..c1b91dd5014 100644 --- a/packages/reflex-base/src/reflex_base/constants/base.py +++ b/packages/reflex-base/src/reflex_base/constants/base.py @@ -176,6 +176,12 @@ class ReactRouter(Javascript): # Color mode variables +SYSTEM_COLOR_MODE: str = "system" +LIGHT_COLOR_MODE: str = "light" +DARK_COLOR_MODE: str = "dark" +LiteralColorMode = Literal["system", "light", "dark"] + + class ColorMode(SimpleNamespace): """Constants related to ColorMode.""" diff --git a/packages/reflex-base/src/reflex_base/style.py b/packages/reflex-base/src/reflex_base/style.py index d80d650297c..78eb046e7f3 100644 --- a/packages/reflex-base/src/reflex_base/style.py +++ b/packages/reflex-base/src/reflex_base/style.py @@ -3,10 +3,14 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, Literal +from typing import Any from reflex_base import constants from reflex_base.breakpoints import Breakpoints, breakpoints_values +from reflex_base.constants.base import DARK_COLOR_MODE as DARK_COLOR_MODE +from reflex_base.constants.base import LIGHT_COLOR_MODE as LIGHT_COLOR_MODE +from reflex_base.constants.base import SYSTEM_COLOR_MODE as SYSTEM_COLOR_MODE +from reflex_base.constants.base import LiteralColorMode as LiteralColorMode from reflex_base.event import EventChain, EventHandler, EventSpec, run_script from reflex_base.utils import format from reflex_base.utils.exceptions import ReflexError @@ -17,11 +21,6 @@ from reflex_base.vars.function import FunctionVar from reflex_base.vars.object import ObjectVar -SYSTEM_COLOR_MODE: str = "system" -LIGHT_COLOR_MODE: str = "light" -DARK_COLOR_MODE: str = "dark" -LiteralColorMode = Literal["system", "light", "dark"] - # Reference the global ColorModeContext color_mode_imports = { f"$/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="ColorModeContext")], diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 4e521d47b92..23004e034d8 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -31,7 +31,6 @@ from reflex_base.constants.state import FIELD_MARKER from reflex_base.environment import environment from reflex_base.plugins import CompileContext, CompilerHooks, PageContext, Plugin -from reflex_base.style import SYSTEM_COLOR_MODE from reflex_base.utils import memo_paths from reflex_base.utils.exceptions import ReflexError from reflex_base.utils.format import to_title_case @@ -188,7 +187,7 @@ def _compile_contexts(state: type[BaseState] | None, theme: Component | None) -> """ appearance = getattr(theme, "appearance", None) if appearance is None or str(LiteralVar.create(appearance)) == '"inherit"': - appearance = LiteralVar.create(SYSTEM_COLOR_MODE) + appearance = LiteralVar.create(get_config().default_color_mode) return ( templates.context_template( diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 1dbb4ab27bc..17c8dc0dfcc 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -6,6 +6,7 @@ from pytest_mock import MockerFixture from reflex_base import constants from reflex_base.components.dynamic import bundle_library, reset_bundled_libraries +from reflex_base.constants.base import LiteralColorMode from reflex_base.constants.compiler import PageNames from reflex_base.utils.imports import ImportVar, ParsedImportDict from reflex_base.vars.base import Var @@ -425,6 +426,38 @@ def test_compile_contexts_has_default_color_mode_context(): assert 'resolvedColorMode: defaultColorMode === "dark" ? "dark" : "light"' in code +def test_compile_contexts_default_color_mode_is_system(): + """Without any config override, the default color mode is "system".""" + _, code = compiler.compile_contexts(None, None) + + assert 'export const defaultColorMode = "system"' in code + + +@pytest.mark.parametrize("mode", ["system", "light", "dark"]) +def test_compile_contexts_uses_config_default_color_mode( + mode: LiteralColorMode, mocker: MockerFixture +): + """The Config.default_color_mode option should drive the compiled default.""" + config = rx.config.get_config() + config.default_color_mode = mode + mocker.patch("reflex.compiler.compiler.get_config", return_value=config) + + _, code = compiler.compile_contexts(None, None) + + assert f'export const defaultColorMode = "{mode}"' in code + + +def test_compile_contexts_theme_appearance_overrides_config(mocker: MockerFixture): + """An explicit theme appearance should still win over the config default.""" + config = rx.config.get_config() + config.default_color_mode = "light" + mocker.patch("reflex.compiler.compiler.get_config", return_value=config) + + _, code = compiler.compile_contexts(None, rx.theme(appearance="dark")) + + assert 'export const defaultColorMode = "dark"' in code + + def test_compile_nonexistent_stylesheet(tmp_path, mocker: MockerFixture): """Test that an error is thrown for non-existent stylesheets. From 0b76e741c57a9d0b09d91beee3c8dc88b622f52e Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 7 Jul 2026 15:41:00 -0700 Subject: [PATCH 2/4] better mocking of Config is color mode tests --- tests/units/compiler/test_compiler.py | 27 +++++++++++++++++---------- tests/units/test_config.py | 11 +++++++++++ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 17c8dc0dfcc..33a51495c54 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -426,11 +426,22 @@ def test_compile_contexts_has_default_color_mode_context(): assert 'resolvedColorMode: defaultColorMode === "dark" ? "dark" : "light"' in code -def test_compile_contexts_default_color_mode_is_system(): - """Without any config override, the default color mode is "system".""" - _, code = compiler.compile_contexts(None, None) +def _mock_config_color_mode(mocker: MockerFixture, mode: LiteralColorMode) -> None: + """Point the compiler's get_config at a fresh config with the given mode. + + Builds a standalone Config rather than mutating the shared singleton so the + override cannot leak into other tests regardless of execution order. - assert 'export const defaultColorMode = "system"' in code + Args: + mocker: Pytest mocker fixture. + mode: The default color mode to configure. + """ + config = rx.Config( + app_name="test_default_color_mode", + default_color_mode=mode, + _skip_plugins_checks=True, + ) + mocker.patch("reflex.compiler.compiler.get_config", return_value=config) @pytest.mark.parametrize("mode", ["system", "light", "dark"]) @@ -438,9 +449,7 @@ def test_compile_contexts_uses_config_default_color_mode( mode: LiteralColorMode, mocker: MockerFixture ): """The Config.default_color_mode option should drive the compiled default.""" - config = rx.config.get_config() - config.default_color_mode = mode - mocker.patch("reflex.compiler.compiler.get_config", return_value=config) + _mock_config_color_mode(mocker, mode) _, code = compiler.compile_contexts(None, None) @@ -449,9 +458,7 @@ def test_compile_contexts_uses_config_default_color_mode( def test_compile_contexts_theme_appearance_overrides_config(mocker: MockerFixture): """An explicit theme appearance should still win over the config default.""" - config = rx.config.get_config() - config.default_color_mode = "light" - mocker.patch("reflex.compiler.compiler.get_config", return_value=config) + _mock_config_color_mode(mocker, "light") _, code = compiler.compile_contexts(None, rx.theme(appearance="dark")) diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 3039dc6ac0d..eb4b17d9dc1 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -38,6 +38,16 @@ def test_set_app_name(base_config_values): assert config.app_name == base_config_values["app_name"] +def test_default_color_mode_default(base_config_values): + """Test that default_color_mode defaults to "system". + + Args: + base_config_values: Config values. + """ + config = rx.Config(**base_config_values) + assert config.default_color_mode == "system" + + @pytest.mark.parametrize( ("env_var", "value"), [ @@ -53,6 +63,7 @@ def test_set_app_name(base_config_values): ("REFLEX_REDIS_URL", "redis://localhost:6379"), ("REFLEX_TELEMETRY_ENABLED", False), ("REFLEX_TELEMETRY_ENABLED", True), + ("REFLEX_DEFAULT_COLOR_MODE", "dark"), ], ) def test_update_from_env( From 9b98b431006c63f10151735d85db1d415eb99282 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 7 Jul 2026 16:27:28 -0700 Subject: [PATCH 3/4] preload_color_mode script uses the configured default_color_mode --- reflex/compiler/compiler.py | 44 +++++++++++++++++++++---- reflex/compiler/utils.py | 5 ++- reflex/utils/misc.py | 21 +++++++----- tests/units/compiler/test_compiler.py | 47 +++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 15 deletions(-) diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 23004e034d8..995e4d3c346 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -36,6 +36,7 @@ from reflex_base.utils.format import to_title_case from reflex_base.utils.imports import ABSOLUTE_IMPORT_PREFIXES, ImportVar from reflex_base.vars.base import LiteralVar, Var +from reflex_base.vars.sequence import LiteralStringVar from reflex_components_core.base.app_wrap import AppWrap from reflex_components_core.base.fragment import Fragment from reflex_components_radix.plugin import RadixThemesPlugin @@ -175,6 +176,30 @@ def _compile_theme(theme: str) -> str: return templates.theme_template(theme=theme) +def _resolve_default_color_mode(theme: Component | None) -> str: + """Resolve the app's compile-time default color mode. + + An explicit theme appearance ("light"/"dark") takes precedence over the + ``Config.default_color_mode`` option; "inherit", no appearance, or a + non-literal appearance Var falls back to the config value. + + Args: + theme: The top-level app theme, if any. + + Returns: + One of "system", "light", or "dark". + """ + appearance = getattr(theme, "appearance", None) + if appearance is not None: + appearance_var = LiteralVar.create(appearance) + if ( + isinstance(appearance_var, LiteralStringVar) + and appearance_var._var_value != "inherit" + ): + return appearance_var._var_value + return get_config().default_color_mode + + def _compile_contexts(state: type[BaseState] | None, theme: Component | None) -> str: """Compile the initial state and contexts. @@ -185,9 +210,7 @@ def _compile_contexts(state: type[BaseState] | None, theme: Component | None) -> Returns: The compiled context file. """ - appearance = getattr(theme, "appearance", None) - if appearance is None or str(LiteralVar.create(appearance)) == '"inherit"': - appearance = LiteralVar.create(get_config().default_color_mode) + default_color_mode = str(LiteralVar.create(_resolve_default_color_mode(theme))) return ( templates.context_template( @@ -195,12 +218,12 @@ def _compile_contexts(state: type[BaseState] | None, theme: Component | None) -> state_name=state.get_name(), client_storage=utils.compile_client_storage(state), is_dev_mode=not is_prod_mode(), - default_color_mode=str(appearance), + default_color_mode=default_color_mode, ) if state else templates.context_template( is_dev_mode=not is_prod_mode(), - default_color_mode=str(appearance), + default_color_mode=default_color_mode, ) ) @@ -594,6 +617,7 @@ def compile_document_root( head_components: list[Component], html_lang: str | None = None, html_custom_attrs: dict[str, Var | Any] | None = None, + default_color_mode: str = "system", ) -> tuple[str, str]: """Compile the document root. @@ -601,6 +625,8 @@ def compile_document_root( head_components: The components to include in the head. html_lang: The language of the document, will be added to the html root element. html_custom_attrs: custom attributes added to the html root element. + default_color_mode: The color mode applied before hydration when no theme + is saved in the browser. Returns: The path and code of the compiled document root. @@ -612,7 +638,10 @@ def compile_document_root( # Create the document root. document_root = utils.create_document_root( - head_components, html_lang=html_lang, html_custom_attrs=html_custom_attrs + head_components, + html_lang=html_lang, + html_custom_attrs=html_custom_attrs, + default_color_mode=default_color_mode, ) # Compile the document root. @@ -1268,6 +1297,9 @@ def compile_app( if app.html_custom_attrs else {"suppressHydrationWarning": True} ), + default_color_mode=_resolve_default_color_mode( + radix_themes_plugin.get_theme() + ), ) ) progress.advance(task) diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index fb537e4d176..309c178c28f 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -559,6 +559,7 @@ def create_document_root( head_components: Sequence[Component] | None = None, html_lang: str | None = None, html_custom_attrs: dict[str, Var | Any] | None = None, + default_color_mode: str = "system", ) -> Component: """Create the document root. @@ -566,6 +567,8 @@ def create_document_root( head_components: The components to add to the head. html_lang: The language of the document, will be added to the html root element. html_custom_attrs: custom attributes added to the html root element. + default_color_mode: The color mode applied before hydration when no theme + is saved in the browser. Returns: The document root. @@ -613,7 +616,7 @@ def create_document_root( ) # Add theme preload script as the very first component to prevent FOUC - theme_preload_components = [preload_color_theme()] + theme_preload_components = [preload_color_theme(default_color_mode)] head_components = [ *theme_preload_components, diff --git a/reflex/utils/misc.py b/reflex/utils/misc.py index 67ae79a13ab..81c0830540a 100644 --- a/reflex/utils/misc.py +++ b/reflex/utils/misc.py @@ -93,24 +93,29 @@ def with_cwd_in_syspath(): sys.path[:] = orig_sys_path -def preload_color_theme(): +def preload_color_theme(default_color_mode: str = "system"): """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. + Args: + default_color_mode: The color mode to apply when the browser has no saved + theme. Must match the compiled context default so the pre-hydration + paint agrees with React. + Returns: Script: A script component to add to App.head_components """ from reflex_components_core.el.elements.scripts import Script # Create direct inline script content (like next-themes dangerouslySetInnerHTML) - script_content = """ + script_content = f""" // Only run in browser environment, not during SSR -if (typeof document !== 'undefined') { - try { - const theme = localStorage.getItem("theme") || "system"; +if (typeof document !== 'undefined') {{ + try {{ + const theme = localStorage.getItem("theme") || {default_color_mode!r}; const systemPreference = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; const resolvedTheme = theme === "system" ? systemPreference : theme; @@ -120,14 +125,14 @@ def preload_color_theme(): document.documentElement.classList.add(resolvedTheme); document.documentElement.style.colorScheme = resolvedTheme; - } catch (e) { + }} catch (e) {{ // Fallback to system preference on any error (resolve "system" to actual theme) const fallbackTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; document.documentElement.classList.remove("light", "dark"); document.documentElement.classList.add(fallbackTheme); document.documentElement.style.colorScheme = fallbackTheme; - } -} + }} +}} """ return Script.create(script_content) diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 33a51495c54..c6791fc5be8 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -465,6 +465,53 @@ def test_compile_contexts_theme_appearance_overrides_config(mocker: MockerFixtur assert 'export const defaultColorMode = "dark"' in code +@pytest.mark.parametrize( + ("theme", "config_mode", "expected"), + [ + (None, "system", "system"), + (None, "dark", "dark"), + (None, "light", "light"), + # An explicit theme appearance wins over the config default. + (rx.theme(appearance="dark"), "light", "dark"), + # "inherit" (the default) falls back to the config default. + (rx.theme(appearance="inherit"), "dark", "dark"), + (rx.theme(), "dark", "dark"), + ], +) +def test_resolve_default_color_mode( + theme, config_mode: LiteralColorMode, expected: str, mocker: MockerFixture +): + """The resolved default matches theme appearance, else the config default.""" + _mock_config_color_mode(mocker, config_mode) + + assert compiler._resolve_default_color_mode(theme) == expected + + +@pytest.mark.parametrize("mode", ["system", "light", "dark"]) +def test_preload_color_theme_uses_default(mode: str): + """The preload script falls back to the given default when no theme is saved.""" + from reflex.utils.misc import preload_color_theme + + code = str(preload_color_theme(mode)) + + assert f"|| {mode!r}" in code + + +def test_compile_document_root_bakes_default_color_mode(mocker: MockerFixture): + """compile_document_root threads the resolved default into the preload script.""" + _mock_config_color_mode(mocker, "dark") + mocker.patch( + "reflex.compiler.compiler.get_web_dir", + return_value=Path("/tmp/does_not_matter"), + ) + + _, code = compiler.compile_document_root( + [], default_color_mode=compiler._resolve_default_color_mode(None) + ) + + assert "|| 'dark'" in code + + def test_compile_nonexistent_stylesheet(tmp_path, mocker: MockerFixture): """Test that an error is thrown for non-existent stylesheets. From 3709c9bd0a4c0a5bbc4512a1c88b5a1edabca3d8 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 7 Jul 2026 19:16:27 -0700 Subject: [PATCH 4/4] add news fragment --- news/6716.feature.md | 1 + packages/reflex-base/news/6716.feature.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 news/6716.feature.md create mode 100644 packages/reflex-base/news/6716.feature.md diff --git a/news/6716.feature.md b/news/6716.feature.md new file mode 100644 index 00000000000..dae6f5ebcdb --- /dev/null +++ b/news/6716.feature.md @@ -0,0 +1 @@ +Added `default_color_mode` to `rx.Config` (`"system"`, `"light"`, or `"dark"`, also settable via `REFLEX_DEFAULT_COLOR_MODE`), so apps can set the initial color mode — and use the built-in color mode switcher and `rx.color_mode_cond` — without pulling in the large Radix themes CSS. The value drives both the compiled `ThemeProvider` default and the pre-hydration preload script, so there is no flash of the wrong theme on first paint. An explicit `rx.theme(appearance=...)` still takes precedence. diff --git a/packages/reflex-base/news/6716.feature.md b/packages/reflex-base/news/6716.feature.md new file mode 100644 index 00000000000..d2873616344 --- /dev/null +++ b/packages/reflex-base/news/6716.feature.md @@ -0,0 +1 @@ +Added `default_color_mode` to `rx.Config` (`"system"`, `"light"`, or `"dark"`, also settable via `REFLEX_DEFAULT_COLOR_MODE`) and moved the shared `LiteralColorMode` type and color-mode string constants into `reflex_base.constants`. This lets apps set the initial color mode without depending on the Radix themes appearance prop.