Skip to content
21 changes: 9 additions & 12 deletions packages/reflex-base/src/reflex_base/components/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
run_script,
unwrap_var_annotation,
)
from reflex_base.registry import RegistrationContext
from reflex_base.style import Style, format_as_emotion
from reflex_base.utils import console, format, imports, types
from reflex_base.utils.imports import ImportDict, ImportVar, ParsedImportDict
Expand Down Expand Up @@ -2207,12 +2208,11 @@ def get_component(self) -> Component:
"""
component = self.component_fn(*self.get_prop_vars())

try:
from reflex.utils.prerequisites import get_and_validate_app

style = get_and_validate_app().app.style
except Exception:
style = {}
style = (
app.style
if (app := RegistrationContext.ensure_context()._app) is not None
else {}
)

component._add_style_recursive(style)
return component
Expand All @@ -2236,9 +2236,6 @@ def _get_all_app_wrap_components(
return self.get_component()._get_all_app_wrap_components(ignore_ids=ignore_ids)


CUSTOM_COMPONENTS: dict[str, CustomComponent] = {}


def _register_custom_component(
component_fn: Callable[..., Component],
):
Expand Down Expand Up @@ -2273,7 +2270,9 @@ def _register_custom_component(
if dummy_component.tag is None:
msg = f"Could not determine the tag name for {component_fn!r}"
raise TypeError(msg)
CUSTOM_COMPONENTS[dummy_component.tag] = dummy_component
RegistrationContext.ensure_context().custom_components[dummy_component.tag] = (
dummy_component
)
return dummy_component


Expand Down Expand Up @@ -2426,8 +2425,6 @@ def create(cls, component: Component) -> StatefulComponent | None:
"""
from reflex_components_core.core.foreach import Foreach

from reflex_base.registry import RegistrationContext

if component._memoization_mode.disposition == MemoizationDisposition.NEVER:
# Never memoize this component.
return None
Expand Down
20 changes: 6 additions & 14 deletions packages/reflex-base/src/reflex_base/components/dynamic.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import TYPE_CHECKING, Union

from reflex_base import constants
from reflex_base.registry import RegistrationContext
from reflex_base.utils import imports
from reflex_base.utils.exceptions import DynamicComponentMissingLibraryError
from reflex_base.utils.format import format_library_name
Expand All @@ -26,16 +27,6 @@ def get_cdn_url(lib: str) -> str:
return f"https://cdn.jsdelivr.net/npm/{lib}" + "/+esm"


bundled_libraries = [
"react",
"@radix-ui/themes",
"@emotion/react",
f"$/{constants.Dirs.UTILS}/context",
f"$/{constants.Dirs.UTILS}/state",
f"$/{constants.Dirs.UTILS}/components",
]


def bundle_library(component: Union["Component", str]):
"""Bundle a library with the component.

Expand All @@ -45,13 +36,14 @@ def bundle_library(component: Union["Component", str]):
Raises:
DynamicComponentMissingLibraryError: Raised when a dynamic component is missing a library.
"""
bundled = RegistrationContext.ensure_context().bundled_libraries
if isinstance(component, str):
bundled_libraries.append(component)
bundled.append(component)
return
if component.library is None:
msg = "Component must have a library to bundle."
raise DynamicComponentMissingLibraryError(msg)
bundled_libraries.append(format_library_name(component.library))
bundled.append(format_library_name(component.library))


def load_dynamic_serializer():
Expand All @@ -74,6 +66,8 @@ def make_component(component: Component) -> str:

from reflex.compiler import compiler, templates, utils

libs_in_window = RegistrationContext.ensure_context().bundled_libraries

component = Bare.create(Var.create(component))

rendered_components = {}
Expand All @@ -93,8 +87,6 @@ def make_component(component: Component) -> str:
)
] = None

libs_in_window = bundled_libraries

component_imports = component._get_all_imports()
compiler._apply_common_imports(component_imports)

Expand Down
59 changes: 41 additions & 18 deletions packages/reflex-base/src/reflex_base/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from reflex_base.environment import environment as environment
from reflex_base.plugins import Plugin
from reflex_base.plugins.sitemap import SitemapPlugin
from reflex_base.registry import RegistrationContext
from reflex_base.utils import console
from reflex_base.utils.exceptions import ConfigError

Expand Down Expand Up @@ -639,7 +640,7 @@ def _set_persistent(self, **kwargs):


def _get_config() -> Config:
"""Get the app config.
"""Import rxconfig.py fresh and return its config object.

Returns:
The app config.
Expand All @@ -651,37 +652,28 @@ def _get_config() -> Config:
# we need this condition to ensure that a ModuleNotFound error is not thrown when
# running unit/integration tests or during `reflex init`.
return Config(app_name="", _skip_plugins_checks=True)
# Never cache rxconfig — each load goes to disk so different
# RegistrationContexts can hold independent Config instances.
sys.modules.pop(constants.Config.MODULE, None)
rxconfig = importlib.import_module(constants.Config.MODULE)
return rxconfig.config


# Protect sys.path from concurrent modification
_config_lock = threading.RLock()
# Protect sys.path from concurrent modification during config loading.
_load_config_lock = threading.RLock()


def get_config(reload: bool = False) -> Config:
"""Get the app config.

Args:
reload: Re-import the rxconfig module from disk
def _load_config() -> Config:
"""Load the config from rxconfig.py with cwd on sys.path.

Returns:
The app config.
"""
cached_rxconfig = sys.modules.get(constants.Config.MODULE, None)
if cached_rxconfig is not None:
if reload:
# Remove any cached module when `reload` is requested.
del sys.modules[constants.Config.MODULE]
else:
return cached_rxconfig.config

with _config_lock:
with _load_config_lock:
orig_sys_path = sys.path.copy()
sys.path.clear()
sys.path.append(str(Path.cwd()))
try:
# Try to import the module with only the current directory in the path.
return _get_config()
except Exception:
# If the module import fails, try to import with the original sys.path.
Expand All @@ -696,3 +688,34 @@ def get_config(reload: bool = False) -> Config:
sys.path.clear()
sys.path.extend(extra_paths)
sys.path.extend(orig_sys_path)


def get_config() -> Config:
"""Get the app config from the current RegistrationContext.

The config is loaded from rxconfig.py once per RegistrationContext and
cached on the context thereafter. If no context is currently attached,
one is created and attached automatically.

Returns:
The app config.
"""
ctx = RegistrationContext.ensure_context()
if ctx._config is None:
ctx._set_config(_load_config())
return ctx.config


def reload_config() -> Config:
"""Force a fresh load of the config into the current RegistrationContext.

Clears any cached config on the current context and reloads rxconfig.py
from disk.

Returns:
The freshly loaded app config.
"""
ctx = RegistrationContext.ensure_context()
config = _load_config()
ctx._set_config(config)
return config
4 changes: 2 additions & 2 deletions packages/reflex-base/src/reflex_base/context/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from typing_extensions import Self


@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True, eq=False)
class BaseContext:
"""Base context class that acts as an async context manager to set the context var."""

Expand Down Expand Up @@ -67,7 +67,7 @@ def __enter__(self) -> Self:

def __exit__(self, *exc_info):
"""Exit the context."""
if (token := self._attached_context_token.pop(self)) is not None:
if (token := self._attached_context_token.pop(self, None)) is not None:
self._context_var.reset(token)

def ensure_context_attached(self):
Expand Down
3 changes: 1 addition & 2 deletions packages/reflex-base/src/reflex_base/event/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from reflex_base import constants
from reflex_base.components.field import BaseField
from reflex_base.constants.compiler import CompileVars, Hooks, Imports
from reflex_base.registry import RegistrationContext
from reflex_base.utils import format
from reflex_base.utils.decorator import once
from reflex_base.utils.exceptions import (
Expand Down Expand Up @@ -81,8 +82,6 @@ class Event:
@property
def state_cls(self) -> "type[BaseState]":
"""The state class for the event."""
from reflex_base.registry import RegistrationContext

substate_name = self.name.rpartition(".")[0]
return RegistrationContext.get().base_states[substate_name]

Expand Down
126 changes: 124 additions & 2 deletions packages/reflex-base/src/reflex_base/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,41 @@
from __future__ import annotations

import dataclasses
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from typing_extensions import Self

from reflex_base.context.base import BaseContext
from reflex_base.utils.exceptions import StateValueError
from reflex_base.utils.exceptions import ReflexRuntimeError, StateValueError

if TYPE_CHECKING:
from collections.abc import Callable

from reflex.app import App
from reflex.state import BaseState
from reflex_base.components.component import StatefulComponent
from reflex_base.config import Config
from reflex_base.event import EventHandler


def _default_bundled_libraries() -> list[str]:
"""Return the initial set of bundled libraries for a new context.

Returns:
The default list of libraries bundled into every app build.
"""
from reflex_base import constants

return [
"react",
"@radix-ui/themes",
"@emotion/react",
f"$/{constants.Dirs.UTILS}/context",
f"$/{constants.Dirs.UTILS}/state",
f"$/{constants.Dirs.UTILS}/components",
]


@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
class RegisteredEventHandler:
"""A registered event handler, which includes the handler and its full name."""
Expand Down Expand Up @@ -44,6 +66,106 @@ class RegistrationContext(BaseContext):
default_factory=dict,
repr=False,
)
_config: Config | None = dataclasses.field(default=None, repr=False)
decorated_pages: list[tuple[Callable, dict[str, Any]]] = dataclasses.field(
default_factory=list,
repr=False,
)
custom_components: dict[str, Any] = dataclasses.field(
default_factory=dict,
repr=False,
)
memo_definitions: dict[str, Any] = dataclasses.field(
default_factory=dict,
repr=False,
)
bundled_libraries: list[str] = dataclasses.field(
default_factory=_default_bundled_libraries,
repr=False,
)
_app: App | None = dataclasses.field(default=None, repr=False)

@property
def app(self) -> App:
"""Get the App instance associated with this context.

Returns:
The App instance.

Raises:
ReflexRuntimeError: If no App has been registered with this context.
"""
if self._app is None:
msg = "No App is registered with the active RegistrationContext."
raise ReflexRuntimeError(msg)
return self._app

@property
def config(self) -> Config:
"""Get the Config associated with this context.

Returns:
The Config instance.

Raises:
ReflexRuntimeError: If no Config has been loaded for this context.
"""
if self._config is None:
msg = "No Config has been loaded for the active RegistrationContext."
raise ReflexRuntimeError(msg)
return self._config

def _set_app(self, app: App) -> None:
"""Associate an App instance with this context.

Args:
app: The App instance to register.

Raises:
ReflexRuntimeError: If an App is already registered with this context.
"""
if self._app is not None:
msg = (
"A RegistrationContext can only be associated with a single App "
"instance. To create another App, call `.fork()` on the current "
"RegistrationContext to obtain a fresh context that preserves "
"existing registrations, or instantiate a new RegistrationContext "
"and set it as the current context before instantiating the new App."
)
raise ReflexRuntimeError(msg)
object.__setattr__(self, "_app", app)

def fork(self) -> Self:
"""Create a copy of this context with `_app` and `_config` reset to None.

Existing registrations (event handlers, base states, decorated pages, etc.)
are shallow-copied so the fork can evolve independently while preserving
already-registered classes. The next call to `get_config()` on the fork
will reload `rxconfig.py` from disk.

Returns:
A new RegistrationContext with the same registrations but no app or config.
"""
return type(self)(
event_handlers=dict(self.event_handlers),
base_states=dict(self.base_states),
base_state_substates={
k: set(v) for k, v in self.base_state_substates.items()
},
tag_to_stateful_component=dict(self.tag_to_stateful_component),
decorated_pages=list(self.decorated_pages),
custom_components=dict(self.custom_components),
memo_definitions=dict(self.memo_definitions),
bundled_libraries=list(self.bundled_libraries),
)

def _set_config(self, config: Config) -> None:
"""Set the config for this context.

Args:
config: The config to associate with this context.
"""
object.__setattr__(self, "_config", config)

@classmethod
def ensure_context(cls) -> Self:
Expand Down
Loading
Loading