diff --git a/news/7016.bugfix.md b/news/7016.bugfix.md new file mode 100644 index 00000000000..09c95d6d91c --- /dev/null +++ b/news/7016.bugfix.md @@ -0,0 +1 @@ +Explicit `bundle_library()` registrations now survive frontend compilation instead of being discarded when compiler plugin dependencies are collected. diff --git a/packages/reflex-base/news/7016.bugfix.md b/packages/reflex-base/news/7016.bugfix.md new file mode 100644 index 00000000000..ea8dfbc5852 --- /dev/null +++ b/packages/reflex-base/news/7016.bugfix.md @@ -0,0 +1 @@ +Dynamic components can now use subpath imports from a bundled frontend library, including deep default imports such as static Lucide icons. diff --git a/packages/reflex-base/src/reflex_base/components/dynamic.py b/packages/reflex-base/src/reflex_base/components/dynamic.py index 1f15953fd36..ca3d5f1134c 100644 --- a/packages/reflex-base/src/reflex_base/components/dynamic.py +++ b/packages/reflex-base/src/reflex_base/components/dynamic.py @@ -71,8 +71,16 @@ def get_cdn_url(lib: str) -> str: def reset_bundled_libraries() -> None: """Reset the bundled library registry to its default values.""" - bundled = RegistrationContext.ensure_context().bundled_libraries - bundled[:] = _default_bundled_libraries() + context = RegistrationContext.ensure_context() + context.bundled_libraries[:] = _default_bundled_libraries() + context._explicit_bundled_libraries.clear() + + +def _reset_bundled_libraries_for_compile() -> None: + """Reset derived libraries while preserving explicit registrations.""" + context = RegistrationContext.ensure_context() + context.bundled_libraries[:] = _default_bundled_libraries() + context.bundled_libraries.extend(context._explicit_bundled_libraries) def bundle_library(component: Union["Component", str]): @@ -84,14 +92,42 @@ def bundle_library(component: Union["Component", str]): Raises: DynamicComponentMissingLibraryError: Raised when a dynamic component is missing a library. """ - bundled = RegistrationContext.ensure_context().bundled_libraries + _bundle_library(component, explicit=True) + + +def _bundle_library( + component: Union["Component", str], *, explicit: bool = False +) -> None: + """Register a library for the current compile. + + Args: + component: The component or library to bundle. + explicit: Whether this is an application-level registration that should + survive compiler resets. + + Raises: + DynamicComponentMissingLibraryError: Raised when a dynamic component is missing a library. + """ + context = RegistrationContext.ensure_context() + bundled = context.bundled_libraries if isinstance(component, str): - bundled.append(format_library_name(component)) + library = format_library_name(component) + bundled.append(library) + if explicit: + context._explicit_bundled_libraries.append(library) return if component.library is None: msg = "Component must have a library to bundle." raise DynamicComponentMissingLibraryError(msg) - bundled.append(format_library_name(component.library)) + library = format_library_name(component.library) + bundled.append(library) + if explicit: + context._explicit_bundled_libraries.append(library) + + +_BUNDLED_IMPORT_LIB = "lib" +_BUNDLED_IMPORT_DEFAULT = "default" +_BUNDLED_IMPORT_REST = "rest" def load_dynamic_serializer(): @@ -138,6 +174,7 @@ def make_component(component: Component) -> str: compiler._apply_common_imports(component_imports) imports = {} + bundled_subpath_imports: set[str] = set() for lib, names in component_imports.items(): formatted_lib_name = format_library_name(lib) if ( @@ -148,9 +185,46 @@ def make_component(component: Component) -> str: imports[get_cdn_url(lib)] = names else: imports[lib] = names + if formatted_lib_name in libs_in_window: + for name in names: + if name.package_path in {"/", ""}: + continue + import_path = formatted_lib_name + name.package_path + _bundle_library(import_path) + bundled_subpath_imports.add(import_path) + + compiled_imports = utils.compile_imports(imports) + bundled_subpath_rewrites = {} + for module in compiled_imports: + if module[_BUNDLED_IMPORT_LIB] not in bundled_subpath_imports: + continue + + window_library = f"window.__reflex['{module[_BUNDLED_IMPORT_LIB]}']" + statements = [] + if module[_BUNDLED_IMPORT_DEFAULT]: + statements.append( + f"const {module[_BUNDLED_IMPORT_DEFAULT]} = " + f"{window_library}.default" + ) + + named_imports = [] + for imported_name in module[_BUNDLED_IMPORT_REST]: + if imported_name.startswith("* as "): + statements.append( + f"const {imported_name.removeprefix('* as ')} = {window_library}" + ) + else: + named_imports.append(imported_name.replace(" as ", ": ")) + if named_imports: + statements.append( + f"const {{{','.join(named_imports)}}} = {window_library}" + ) + + if statements: + bundled_subpath_rewrites[module["lib"]] = "\n".join(statements) module_code_lines = templates.dynamic_components_module_template( - imports=utils.compile_imports(imports), + imports=compiled_imports, memoized_code="\n".join(rendered_components), ).splitlines() @@ -166,6 +240,14 @@ def make_component(component: Component) -> str: + "]" ) else: + subpath_rewritten = False + for import_path, replacement in bundled_subpath_rewrites.items(): + if line.endswith(f'from "{import_path}"'): + module_code_lines[ix] = replacement + subpath_rewritten = True + break + if subpath_rewritten: + continue for lib in libs_in_window: if f'from "{lib}"' in line: module_code_lines[ix] = ( diff --git a/packages/reflex-base/src/reflex_base/registry.py b/packages/reflex-base/src/reflex_base/registry.py index 61963cb538f..cf2388346e4 100644 --- a/packages/reflex-base/src/reflex_base/registry.py +++ b/packages/reflex-base/src/reflex_base/registry.py @@ -68,6 +68,10 @@ class RegistrationContext(BaseContext): default_factory=_default_bundled_libraries, repr=False, ) + _explicit_bundled_libraries: list[str] = dataclasses.field( + default_factory=list, + repr=False, + ) _app: App | None = dataclasses.field(default=None, repr=False) @property @@ -140,6 +144,7 @@ def fork(self) -> Self: }, decorated_pages=list(self.decorated_pages), bundled_libraries=list(self.bundled_libraries), + _explicit_bundled_libraries=list(self._explicit_bundled_libraries), ) def _set_config(self, config: Config) -> None: diff --git a/packages/reflex-components-radix/news/7016.bugfix.md b/packages/reflex-components-radix/news/7016.bugfix.md new file mode 100644 index 00000000000..1b507936eb9 --- /dev/null +++ b/packages/reflex-components-radix/news/7016.bugfix.md @@ -0,0 +1 @@ +Radix Themes auto-enablement now avoids retaining stale frontend library registrations across recompiles. diff --git a/packages/reflex-components-radix/src/reflex_components_radix/plugin.py b/packages/reflex-components-radix/src/reflex_components_radix/plugin.py index 8f8764032c0..01a159d5ded 100644 --- a/packages/reflex-components-radix/src/reflex_components_radix/plugin.py +++ b/packages/reflex-components-radix/src/reflex_components_radix/plugin.py @@ -5,8 +5,8 @@ import dataclasses from typing import TYPE_CHECKING, Any +import reflex_base.components.dynamic as dynamic_components from reflex_base.components.component import BaseComponent, Component -from reflex_base.components.dynamic import bundle_library from reflex_base.plugins.base import Plugin from reflex_base.utils import console @@ -23,6 +23,14 @@ _REMOVAL_VERSION = "1.0" +def _bundle_library_for_compile(library: str) -> None: + """Register a compile-derived library across supported reflex-base versions.""" + bundle_library = getattr( + dynamic_components, "_bundle_library", dynamic_components.bundle_library + ) + bundle_library(library) + + @dataclasses.dataclass class RadixThemesPlugin(Plugin): """Opt-in plugin for Radix Themes assets and app-level wrapping.""" @@ -67,7 +75,7 @@ def enter_component( return self.enabled = True - bundle_library(RADIX_THEMES_PACKAGE) + _bundle_library_for_compile(RADIX_THEMES_PACKAGE) if not self._explicit and not self._app_theme_warning_emitted: console.deprecate( feature_name="Implicit Radix Themes enablement", diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 3ba8746316e..371ee428c72 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -130,7 +130,42 @@ def _normalize_library_name(lib: str) -> str: """ if lib == "react": return "React" - return lib.replace("$/", "").replace("@", "").replace("/", "_").replace("-", "_") + return ( + lib + .replace("$/", "") + .replace("@", "") + .replace("/", "_") + .replace("-", "_") + .replace(".", "_") + ) + + +def _get_window_libraries() -> list[tuple[str, str]]: + """Build unique aliases for libraries exposed through ``window.__reflex``. + + Returns: + Library aliases paired with their original package names. + """ + used_aliases: set[str] = set() + seen_libraries: set[str] = set() + window_libraries: list[tuple[str, str]] = [] + + for library in RegistrationContext.ensure_context().bundled_libraries: + if library in seen_libraries: + continue + seen_libraries.add(library) + + base_alias = _normalize_library_name(library) + alias = base_alias + suffix = 2 + while alias in used_aliases: + alias = f"{base_alias}_{suffix}" + suffix += 1 + + used_aliases.add(alias) + window_libraries.append((alias, library)) + + return window_libraries def _compile_app( @@ -146,12 +181,7 @@ def _compile_app( Returns: The compiled app. """ - window_libraries = [ - (_normalize_library_name(name), name) - for name in RegistrationContext.ensure_context().bundled_libraries - ] - - window_libraries_deduped = list(dict.fromkeys(window_libraries)) + window_libraries = _get_window_libraries() app_root_imports = app_root._get_all_imports() _apply_common_imports(app_root_imports) @@ -160,7 +190,7 @@ def _compile_app( imports=utils.compile_imports(app_root_imports), custom_codes=app_root._get_all_custom_code(), hooks=app_root._get_all_hooks(), - window_libraries=window_libraries_deduped, + window_libraries=window_libraries, render=app_root.render(), dynamic_imports=app_root._get_all_dynamic_imports(), hydrate_fallback_export=hydrate_fallback_export, @@ -1164,7 +1194,10 @@ def compile_app( ``True`` when a real frontend compile ran, ``False`` when the call short-circuited (backend-only paths that only re-evaluate pages). """ - from reflex_base.components.dynamic import bundle_library, reset_bundled_libraries + from reflex_base.components.dynamic import ( + _bundle_library, + _reset_bundled_libraries_for_compile, + ) from reflex_base.utils.exceptions import ReflexRuntimeError app._apply_decorated_pages() @@ -1206,14 +1239,14 @@ def compile_app( app, config.plugins, ) - reset_bundled_libraries() + _reset_bundled_libraries_for_compile() # Drop cached memo wrapper classes so each compile recomputes a memo's # ``library`` from the current module layout (handles a module flipping to # a package across hot reloads). reset_memo_component_classes() for plugin in compiler_plugins: for dependency in plugin.get_frontend_dependencies(): - bundle_library(dependency) + _bundle_library(dependency) base_total = (len(app._unevaluated_pages) * 2) + fixed_steps + len(config.plugins) progress.start() task = progress.add_task("Compiling:", total=base_total) diff --git a/tests/integration/test_dynamic_components.py b/tests/integration/test_dynamic_components.py index 3de8eef034f..fecbe7aa358 100644 --- a/tests/integration/test_dynamic_components.py +++ b/tests/integration/test_dynamic_components.py @@ -14,6 +14,9 @@ def DynamicComponents(): """App with var operations.""" import reflex as rx + from reflex.components.dynamic import bundle_library + + bundle_library("lucide-react") class DynamicComponentsState(rx.State): value: int = 10 @@ -84,6 +87,15 @@ def counter_component(self) -> rx.Component: ), ) + @rx.var + def icon_component(self) -> rx.Component: + """Get a dynamic component with a bundled-library subpath import. + + Returns: + A Lucide icon component. + """ + return rx.icon("apple", id="dynamic-icon") + app = rx.App() def factorial(n: int) -> int: @@ -97,6 +109,7 @@ def index(): DynamicComponentsState.client_token_component, DynamicComponentsState.button, DynamicComponentsState.counter_component, + DynamicComponentsState.icon_component, rx.text( DynamicComponentsState._evaluate( lambda state: factorial(state.value), of_type=int @@ -176,6 +189,10 @@ def test_dynamic_components(driver, dynamic_components: AppHarness): ) assert factorial.text == "3628800" + assert AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "dynamic-icon") + ) + count = AppHarness.poll_for_or_raise_timeout( lambda: driver.find_element(By.ID, "count") ) diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index efcfff556e3..7272d30198f 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -476,6 +476,55 @@ def test_compile_app_root_includes_radix_window_library_when_bundled(): reset_bundled_libraries() +def test_compile_app_preserves_user_bundled_libraries( + tmp_path: Path, mocker: MockerFixture +): + """Frontend compilation should retain explicit bundle registrations.""" + + class StopCompile(Exception): + """Stop after bundled libraries are prepared.""" + + with RegistrationContext() as registration_context: + bundle_library("lucide-react") + registration_context.bundled_libraries.append("stale-plugin") + app = rx.App() + config = rx.Config(app_name="testing", plugins=[]) + + mocker.patch.object(app, "_apply_decorated_pages") + mocker.patch.object(app, "_should_compile", return_value=True) + mocker.patch.object(compiler, "get_config", return_value=config) + mocker.patch.object( + compiler.prerequisites, "get_backend_dir", return_value=tmp_path + ) + mocker.patch.object(compiler, "_register_plugin_routes") + mocker.patch.object( + compiler, + "_resolve_radix_themes_plugin", + return_value=([], mocker.Mock()), + ) + mocker.patch.object(compiler, "CompileContext", side_effect=StopCompile) + + with pytest.raises(StopCompile): + compiler.compile_app(app, dry_run=True, use_rich=False) + + assert "lucide-react" in registration_context.bundled_libraries + assert "stale-plugin" not in registration_context.bundled_libraries + + +def test_compile_app_root_uses_unique_window_library_aliases(): + """Bundled library aliases should remain unique after normalization.""" + with RegistrationContext(): + bundle_library("foo.bar") + bundle_library("foo_bar") + + _, code = compiler.compile_app_root(rx.el.div("hello")) + + assert 'import * as foo_bar from "foo.bar";' in code + assert 'import * as foo_bar_2 from "foo_bar";' in code + assert '"foo.bar": foo_bar' in code + assert '"foo_bar": foo_bar_2' in code + + def test_compile_contexts_has_default_color_mode_context(): """ColorModeContext should have a safe fallback value without Radix.""" _, code = compiler.compile_contexts(None, None) diff --git a/tests/units/compiler/test_dynamic_components_codegen.py b/tests/units/compiler/test_dynamic_components_codegen.py index 0e8ce9bded2..7359613f8ab 100644 --- a/tests/units/compiler/test_dynamic_components_codegen.py +++ b/tests/units/compiler/test_dynamic_components_codegen.py @@ -2,9 +2,13 @@ from pathlib import Path +from reflex_base.components.dynamic import bundle_library +from reflex_base.registry import RegistrationContext from reflex_base.utils import serializers +from reflex_base.vars.base import Var import reflex as rx +from reflex.compiler import compiler from reflex.state import State STATE_JS_TEMPLATE = ( @@ -13,6 +17,32 @@ ) +def test_dynamic_component_codegen_rewrites_bundled_library_subpath() -> None: + """Bundled library subpaths should resolve through the window namespace.""" + with RegistrationContext(): + bundle_library("lucide-react") + + code = serializers.serialize(rx.icon("apple")) + dynamic_code = serializers.serialize(rx.icon(Var("icon_name").to(str))) + _, app_root_code = compiler.compile_app_root(rx.el.div()) + + assert isinstance(code, str) + assert 'from "lucide-react/dist/esm/icons/apple.mjs"' not in code + assert ( + "const LucideApple = " + "window.__reflex['lucide-react/dist/esm/icons/apple.mjs'].default" + ) in code + assert isinstance(dynamic_code, str) + assert 'from "lucide-react/dynamic.mjs"' not in dynamic_code + assert ( + "const {DynamicIcon} = window.__reflex['lucide-react/dynamic.mjs']" + ) in dynamic_code + assert ( + "import * as lucide_react_dist_esm_icons_apple_mjs " + 'from "lucide-react/dist/esm/icons/apple.mjs";' + ) in app_root_code + + def test_dynamic_component_codegen_wires_event_handlers() -> None: """Dynamic component codegen should preserve backend event handlers.""" state = State(_reflex_internal_init=True) # pyright: ignore[reportCallIssue]