Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/7144.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Creating an `rx.App` no longer re-executes `rxconfig.py`, so classes defined there keep their State registration and Python class identity.
1 change: 1 addition & 0 deletions packages/reflex-base/news/7144.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The first `get_config()` call in a process reuses an `rxconfig` module that the app already imported instead of executing `rxconfig.py` again, so classes defined there (for example an `rx.State` subclass) keep a single identity under any ASGI server.
31 changes: 29 additions & 2 deletions packages/reflex-base/src/reflex_base/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,10 @@ def _set_persistent(self, **kwargs):
_config_module_deps: set[str] = set()
_config_module_deps_root: Path | None = None

# The rxconfig module that _get_config last executed. Any other rxconfig in
# sys.modules was imported by project code. Only mutated under _load_config_lock.
_loaded_config_module: ModuleType | None = None


class _ImportRecorder:
"""Meta-path finder that records import attempts made on one thread.
Expand Down Expand Up @@ -958,6 +962,28 @@ def get_state_auto_setters() -> bool:
return False


def _imported_config(project_root: Path) -> Config | None:
"""Get the config of an rxconfig module that project code already imported.

Executing rxconfig.py again would create a second copy of every class it
defines, distinct from the one the app module holds.

Args:
project_root: The root the imported module must live under.

Returns:
The imported module's config, or None when project code has not
imported rxconfig from project_root.
"""
module = sys.modules.get(constants.Config.MODULE)
if module is None or module is _loaded_config_module:
return None
origin = getattr(module, "__file__", None)
if not origin or not Path(origin).is_relative_to(project_root):
return None
return getattr(module, "config", None)


def _get_config(project_root: Path | None = None) -> Config:
"""Import rxconfig.py fresh from the project root and return its config.

Expand All @@ -975,7 +1001,7 @@ def _get_config(project_root: Path | None = None) -> Config:
Returns:
The app config.
"""
global _config_module_deps_root
global _config_module_deps_root, _loaded_config_module

project_root = (project_root or Path.cwd()).resolve()
with _load_config_lock:
Expand Down Expand Up @@ -1005,6 +1031,7 @@ def _get_config(project_root: Path | None = None) -> Config:
with _record_imports() as recorder:
try:
rxconfig = importlib.import_module(constants.Config.MODULE)
_loaded_config_module = rxconfig
finally:
# Record even on failure so a later load from another root
# evicts what this one imported. Nothing is evicted here:
Expand Down Expand Up @@ -1073,7 +1100,7 @@ def get_config(reload: bool = False) -> Config:
# Serialize check/load/set so threads sharing a context load once.
with _load_config_lock:
if ctx._config is None:
ctx._set_config(_get_config())
ctx._set_config(_imported_config(Path.cwd().resolve()) or _get_config())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Forks Share Mutable Config

When project code has already imported rxconfig, a forked RegistrationContext receives that module's existing mutable Config object. This conflicts with RegistrationContext.fork(), which resets _config and promises that the next get_config() call reloads it. The parent and fork can therefore share configuration mutations, allowing persistent overrides or other changes from one app to leak into the other.

Knowledge Base Used: Application lifecycle and configuration

return ctx.config


Expand Down
4 changes: 2 additions & 2 deletions reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

from reflex_base import constants, otel
from reflex_base.components.component import Component, ComponentStyle
from reflex_base.config import get_config, reload_config
from reflex_base.config import get_config
from reflex_base.context.base import BaseContext
from reflex_base.environment import environment
from reflex_base.event import (
Expand Down Expand Up @@ -537,7 +537,7 @@ def __post_init__(self):

self._registration_context._set_app(self)

reload_config()
get_config()

if "breakpoints" in self.style:
set_breakpoints(self.style.pop("breakpoints"))
Expand Down
3 changes: 0 additions & 3 deletions reflex/reflex.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,9 +578,6 @@ def _run(
if backend_host != config.backend_host:
config._set_persistent(backend_host=backend_host)

# Reload the config to make sure the env vars are persistent.
reload_config()

console.rule("[bold]Starting Reflex App")

prerequisites.check_latest_package_version(constants.Reflex.MODULE_NAME)
Expand Down
10 changes: 6 additions & 4 deletions tests/units/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,21 @@

@pytest.fixture(autouse=True)
def _isolate_app_in_context() -> Generator[None, None, None]:
"""Reset the App slot on the active RegistrationContext between tests.
"""Reset the App and Config slots on the active context between tests.

A RegistrationContext can only host one App instance, but unit tests
repeatedly instantiate `rx.App`, so we clear `_app` around each test
while keeping other registrations shared (matching prior behavior).
Unit tests repeatedly instantiate `rx.App` with different mocked configs.
Keep class registrations shared, but do not let an earlier test's App or
Config leak into the next one.

Yields:
None.
"""
ctx = RegistrationContext.ensure_context()
object.__setattr__(ctx, "_app", None)
object.__setattr__(ctx, "_config", None)
yield
object.__setattr__(ctx, "_app", None)
object.__setattr__(ctx, "_config", None)


@pytest.fixture
Expand Down
84 changes: 84 additions & 0 deletions tests/units/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
import contextlib
import contextvars
import functools
import importlib
import io
import json
import logging
import multiprocessing
import pickle
import re
import sys
import unittest.mock
import uuid
from collections.abc import Generator
Expand All @@ -22,13 +24,16 @@

import pytest
import reflex_base
import reflex_base.config
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from pytest_mock import MockerFixture
from reflex_base import constants as base_constants
from reflex_base import otel
from reflex_base.components.component import Component
from reflex_base.config import get_config
from reflex_base.constants.state import FIELD_MARKER
from reflex_base.event import Event
from reflex_base.event.context import EventContext
Expand Down Expand Up @@ -109,6 +114,85 @@ class EmptyState(BaseState):
"""An empty state."""


def test_app_reuses_preloaded_config_with_state(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""Creating an app does not re-execute a config that registered state.

Args:
tmp_path: The pytest temporary project directory.
monkeypatch: The pytest monkeypatch fixture.
"""
(tmp_path / base_constants.Config.FILE).write_text(
"import reflex as rx\n\n"
"class ConfigState(rx.State):\n"
" value: str = ''\n\n"
"class ConfigClass:\n"
" pass\n\n"
"config = rx.Config(app_name='config_state_app')\n"
)
monkeypatch.chdir(tmp_path)
config_module_name = base_constants.Config.MODULE
monkeypatch.delitem(sys.modules, config_module_name, raising=False)
previous_state_auto_setters = reflex_base.config._state_auto_setters
reflex_base.config._state_auto_setters = True

try:
with RegistrationContext():
config = get_config()
state = sys.modules[config_module_name].ConfigState
instance = sys.modules[config_module_name].ConfigClass()

App(enable_state=False)

assert get_config() is config
assert sys.modules[config_module_name].ConfigState is state
assert type(pickle.loads(pickle.dumps(instance))) is type(instance)
finally:
sys.modules.pop(config_module_name, None)
reflex_base.config._config_module_deps.clear()
reflex_base.config._config_module_deps_root = None
reflex_base.config._state_auto_setters = previous_state_auto_setters

assert reflex_base.config._state_auto_setters is previous_state_auto_setters


def test_app_reuses_config_module_imported_by_app(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""An app module that imports rxconfig before creating the App keeps its classes.

Args:
tmp_path: The pytest temporary project directory.
monkeypatch: The pytest monkeypatch fixture.
"""
(tmp_path / base_constants.Config.FILE).write_text(
"import reflex as rx\n\n"
"class DirectConfigState(rx.State):\n"
" value: str = ''\n\n"
"config = rx.Config(app_name='direct_config_app')\n"
)
monkeypatch.chdir(tmp_path)
monkeypatch.syspath_prepend(str(tmp_path))
config_module_name = base_constants.Config.MODULE
monkeypatch.delitem(sys.modules, config_module_name, raising=False)
previous_state_auto_setters = reflex_base.config._state_auto_setters

try:
with RegistrationContext():
rxconfig = importlib.import_module(config_module_name)

App(_state=rxconfig.DirectConfigState)

assert sys.modules[config_module_name] is rxconfig
assert get_config() is rxconfig.config
finally:
sys.modules.pop(config_module_name, None)
reflex_base.config._config_module_deps.clear()
reflex_base.config._config_module_deps_root = None
Comment thread
FarhanAliRaza marked this conversation as resolved.
reflex_base.config._state_auto_setters = previous_state_auto_setters


@pytest.fixture
def index_page() -> ComponentCallable:
"""An index page.
Expand Down