diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 838d414b964..abb0f7cf7ce 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re from collections.abc import Iterable, Mapping from typing import TYPE_CHECKING, Any, Literal @@ -161,12 +162,85 @@ def document_root_template(*, imports: list[_ImportDict], document: dict[str, An }}""" +_JS_IDENTIFIER_RE = re.compile(r"^[A-Za-z_$][\w$]*$") + + +def _normalize_window_lib_alias(lib: str) -> str: + """Produce a JS identifier from a library path by stripping ``$/`` and ``@`` and replacing ``/ - .`` with ``_``. + + Args: + lib: The library path to normalize. + + Returns: + A JS-safe identifier derived from the library path. + """ + return ( + lib + .replace("$/", "") + .replace("@", "") + .replace("/", "_") + .replace("-", "_") + .replace(".", "_") + ) + + +def _render_window_reflex_block( + window_library_imports: dict[str, set[str] | None], +) -> tuple[str, str]: + """Render the extra imports + useEffect block for window.__reflex. + + External libraries (``@radix-ui/themes`` etc.) use named imports derived + from the app's actual usage so Rolldown can tree-shake unused exports; + a star import would pin the library's entire surface onto the critical + path. Internal ``$/utils/*`` modules still use star imports since their + surface is small and Reflex-controlled. A library whose declared tag + set contains anything that isn't a valid JS identifier also falls back + to a star import rather than emit a SyntaxError. + + Args: + window_library_imports: Mapping from library path to the set of + named exports to expose (external libs) or ``None`` (internal + libs, star import). + + Returns: + A tuple of ``(import_block, useEffect_body)``. Both are empty when + no dynamic components are in play. + """ + if not window_library_imports: + return "", "" + import_lines: list[str] = [] + entries: list[str] = [] + for lib, names in window_library_imports.items(): + alias = f"__reflex_{_normalize_window_lib_alias(lib)}" + if names is None or any(not _JS_IDENTIFIER_RE.match(n) for n in names): + import_lines.append(f'import * as {alias} from "{lib}";') + entries.append(f' "{lib}": {alias},') + else: + sorted_names = sorted(names) + specs = ", ".join(f"{n} as {alias}_{n}" for n in sorted_names) + import_lines.append(f'import {{ {specs} }} from "{lib}";') + obj_entries = ", ".join(f"{n}: {alias}_{n}" for n in sorted_names) + entries.append(f' "{lib}": {{ {obj_entries} }},') + if not entries: + return "", "" + import_block = "\n".join(import_lines) + entries_str = "\n".join(entries) + effect = ( + " useEffect(() => {\n" + ' window["__reflex"] = {\n' + f"{entries_str}\n" + " };\n" + " }, []);\n" + ) + return import_block, effect + + def app_root_template( *, imports: list[_ImportDict], custom_codes: Iterable[str], hooks: dict[str, VarData | None], - window_libraries: list[tuple[str, str]], + window_library_imports: dict[str, set[str] | None], render: dict[str, Any], dynamic_imports: set[str], ): @@ -176,7 +250,8 @@ def app_root_template( imports: The list of import statements. custom_codes: The set of custom code snippets. hooks: The dictionary of hooks. - window_libraries: The list of window libraries. + window_library_imports: Per-library named-export surface for + ``window.__reflex`` (see ``collect_window_library_imports``). render: The dictionary of render functions. dynamic_imports: The set of dynamic imports. @@ -188,14 +263,9 @@ def app_root_template( custom_code_str = "\n".join(custom_codes) - import_window_libraries = "\n".join([ - f'import * as {lib_alias} from "{lib_path}";' - for lib_alias, lib_path in window_libraries - ]) - - window_imports_str = "\n".join([ - f' "{lib_path}": {lib_alias},' for lib_alias, lib_path in window_libraries - ]) + window_imports_block, window_reflex_effect = _render_window_reflex_block( + window_library_imports + ) return f""" {imports_str} @@ -204,19 +274,12 @@ def app_root_template( import {{ ThemeProvider }} from '$/utils/react-theme'; import {{ Layout as AppLayout }} from './_document'; import {{ Outlet }} from 'react-router'; -{import_window_libraries} +{window_imports_block} {custom_code_str} function ReflexProviders({{children}}) {{ - useEffect(() => {{ - // Make contexts and state objects available globally for dynamic eval'd components - let windowImports = {{ - {window_imports_str} - }}; - window["__reflex"] = windowImports; - }}, []); - +{window_reflex_effect} return jsx(ThemeProvider, {{defaultTheme: defaultColorMode, attribute: "class"}}, jsx(StateProvider, {{}}, jsx(EventLoopProvider, {{}}, diff --git a/packages/reflex-base/src/reflex_base/components/dynamic.py b/packages/reflex-base/src/reflex_base/components/dynamic.py index 762dd93aed1..053be0984c1 100644 --- a/packages/reflex-base/src/reflex_base/components/dynamic.py +++ b/packages/reflex-base/src/reflex_base/components/dynamic.py @@ -41,6 +41,17 @@ def reset_bundled_libraries() -> None: bundled_libraries.extend(DEFAULT_BUNDLED_LIBRARIES) +# Tags reachable only through eval'd dynamic components -- captured during +# Component serialization so ``collect_window_library_imports`` can expose +# them on ``window.__reflex`` (otherwise ``evalReactComponent`` can't resolve). +dynamic_component_imports: dict[str, set[imports.ImportVar]] = {} + + +def reset_dynamic_component_imports() -> None: + """Clear the captured dynamic-component import set.""" + dynamic_component_imports.clear() + + def bundle_library(component: Union["Component", str]): """Bundle a library with the component. @@ -102,6 +113,11 @@ def make_component(component: Component) -> str: component_imports = component._get_all_imports() compiler._apply_common_imports(component_imports) + for lib, ivs in component_imports.items(): + named = {iv for iv in ivs if iv.tag and not iv.is_default} + if named: + dynamic_component_imports.setdefault(lib, set()).update(named) + imports = {} for lib, names in component_imports.items(): formatted_lib_name = format_library_name(lib) diff --git a/packages/reflex-base/src/reflex_base/plugins/tailwind_v3.py b/packages/reflex-base/src/reflex_base/plugins/tailwind_v3.py index 66f575db5c2..dbf3c0939d7 100644 --- a/packages/reflex-base/src/reflex_base/plugins/tailwind_v3.py +++ b/packages/reflex-base/src/reflex_base/plugins/tailwind_v3.py @@ -63,7 +63,7 @@ def compile_root_style(include_radix_themes: bool = True): Returns: The compiled Tailwind root style. """ - from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET + from reflex_components_radix.plugin import RADIX_THEMES_STYLESHEET return str( Path(Dirs.STYLES) / Constants.ROOT_STYLE_PATH @@ -129,7 +129,7 @@ def add_tailwind_to_css_file( Returns: The modified css file content. """ - from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET + from reflex_components_radix.plugin import RADIX_THEMES_STYLESHEET if Constants.TAILWIND_CSS.splitlines()[0] in css_file_content: return css_file_content diff --git a/packages/reflex-base/src/reflex_base/plugins/tailwind_v4.py b/packages/reflex-base/src/reflex_base/plugins/tailwind_v4.py index a3067edd582..15143ad8b77 100644 --- a/packages/reflex-base/src/reflex_base/plugins/tailwind_v4.py +++ b/packages/reflex-base/src/reflex_base/plugins/tailwind_v4.py @@ -61,7 +61,7 @@ def compile_root_style(include_radix_themes: bool = True): Returns: The compiled Tailwind root style. """ - from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET + from reflex_components_radix.plugin import RADIX_THEMES_STYLESHEET return str( Path(Dirs.STYLES) / Constants.ROOT_STYLE_PATH @@ -133,7 +133,7 @@ def add_tailwind_to_css_file( Returns: The modified css file content. """ - from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET + from reflex_components_radix.plugin import RADIX_THEMES_STYLESHEET if Constants.TAILWIND_CSS.splitlines()[0] in css_file_content: return css_file_content diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index b4b562e1121..ebed56556b1 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -6,6 +6,7 @@ import sys from collections.abc import Callable, Iterable, Sequence from inspect import getmodule +from itertools import chain from pathlib import Path from typing import TYPE_CHECKING, Any @@ -16,6 +17,7 @@ ComponentStyle, evaluate_style_namespaces, ) +from reflex_base.components.dynamic import bundled_libraries, dynamic_component_imports from reflex_base.components.memo import ( MEMOS, MemoComponentDefinition, @@ -29,7 +31,7 @@ from reflex_base.plugins import CompileContext, CompilerHooks, PageContext, Plugin from reflex_base.style import SYSTEM_COLOR_MODE from reflex_base.utils.exceptions import ReflexError -from reflex_base.utils.format import to_title_case +from reflex_base.utils.format import format_library_name, to_title_case from reflex_base.utils.imports import ImportVar from reflex_base.vars.base import LiteralVar, Var from reflex_components_core.base.app_wrap import AppWrap @@ -44,8 +46,6 @@ from reflex.utils.exec import get_compile_context, is_prod_mode from reflex.utils.prerequisites import get_web_dir -RADIX_THEMES_STYLESHEET = "@radix-ui/themes/styles.css" - def _set_progress_total( progress: Progress | console.PoorProgress, @@ -112,45 +112,90 @@ def _compile_document_root(root: Component) -> str: ) -def _normalize_library_name(lib: str) -> str: - """Normalize the library name. +# Path-like prefixes mark Reflex-controlled internal modules (e.g. "$/utils/..."); +# the rest are external npm libraries where star imports defeat tree-shaking. +_INTERNAL_LIB_PREFIXES = ("$/", "/", ".") + +# Runtime-eval'd dynamic components reach for ``window.__reflex.react`` and +# ``window.__reflex['@emotion/react']`` as if they were the whole module +# (``state.js`` aliases ``window.React = window.__reflex.react``). Tree-shaking +# these to the host app's static surface drops APIs that user custom_code or +# third-party libraries may legitimately read at runtime, so always star-import. +_ALWAYS_STAR_IMPORT_LIBS = frozenset({"react", "@emotion/react"}) + + +def collect_window_library_imports( + import_sources: Iterable[dict[str, list[ImportVar]]], +) -> dict[str, set[str] | None]: + """Build the ``window.__reflex`` surface for runtime-eval'd code. + + Each bundled library gets either a set of named exports (for external libs, + collected from the app's actual static usage so Rolldown can tree-shake) or + ``None`` (for internal Reflex modules, which use a star import). + + External library tags come from two sources: (1) static page / app-root + imports, and (2) tags captured during compile-time serialization of + dynamic Component values (Component-typed state field defaults, computed + Component vars evaluated when generating the initial state) -- see + ``reflex_base.components.dynamic.dynamic_component_imports``. + + Takes an iterable of import dicts (one per page / app_root / memo group) + rather than a pre-merged dict so callers don't have to fold the same + library's named exports across sources before calling. Args: - lib: The library name to normalize. + import_sources: One import dict per source (page, app_root, etc). Returns: - The normalized library name. + Mapping from library path to either the set of named exports to expose + (external libs) or None (internal libs, use star import). """ - if lib == "react": - return "React" - return lib.replace("$/", "").replace("@", "").replace("/", "_").replace("-", "_") + per_lib_tags: dict[str, set[str]] = {} + for source in chain(import_sources, (dynamic_component_imports,)): + for imported_lib, import_vars in source.items(): + key = format_library_name(imported_lib) + for iv in import_vars: + if iv.tag and not iv.is_default: + per_lib_tags.setdefault(key, set()).add(iv.tag) + + result: dict[str, set[str] | None] = {} + for lib in bundled_libraries: + if lib.startswith(_INTERNAL_LIB_PREFIXES) or lib in _ALWAYS_STAR_IMPORT_LIBS: + result[lib] = None + continue + tags = per_lib_tags.get(lib) + if tags: + result[lib] = tags + return result -def _compile_app(app_root: Component) -> str: +def _compile_app( + app_root: Component, + window_library_imports: dict[str, set[str] | None] | None = None, + app_root_imports: dict[str, list[ImportVar]] | None = None, +) -> str: """Compile the app template component. Args: app_root: The app root to compile. + window_library_imports: Per-library named-export surface to expose on + ``window.__reflex`` for dynamic components. Empty/None skips the + bootstrap entirely. + app_root_imports: Precomputed result of ``app_root._get_all_imports()``; + pass it to avoid a second full-tree walk on the hot path. Returns: The compiled app. """ - from reflex_base.components.dynamic import bundled_libraries - - window_libraries = [ - (_normalize_library_name(name), name) for name in bundled_libraries - ] - - window_libraries_deduped = list(dict.fromkeys(window_libraries)) - - app_root_imports = app_root._get_all_imports() + if app_root_imports is None: + app_root_imports = app_root._get_all_imports() _apply_common_imports(app_root_imports) return templates.app_root_template( 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_library_imports=window_library_imports or {}, render=app_root.render(), dynamic_imports=app_root._get_all_dynamic_imports(), ) @@ -543,22 +588,29 @@ def compile_document_root( return output_path, code -def compile_app_root(app_root: Component) -> tuple[str, str]: +def compile_app_root( + app_root: Component, + window_library_imports: dict[str, set[str] | None] | None = None, + app_root_imports: dict[str, list[ImportVar]] | None = None, +) -> tuple[str, str]: """Compile the app root. Args: app_root: The app root component to compile. + window_library_imports: Per-library named-export surface for + ``window.__reflex`` (see ``collect_window_library_imports``). Pass + ``None`` to skip emitting ``window.__reflex`` entirely. + app_root_imports: Precomputed ``app_root._get_all_imports()``; reused + from the caller to avoid a second tree walk. Returns: The path and code of the compiled app wrapper. """ - # Get the path for the output file. output_path = str( get_web_dir() / constants.Dirs.PAGES / constants.PageNames.APP_ROOT ) - # Compile the document root. - code = _compile_app(app_root) + code = _compile_app(app_root, window_library_imports, app_root_imports) return output_path, code @@ -967,9 +1019,14 @@ 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, + reset_dynamic_component_imports, + ) from reflex_base.utils.exceptions import ReflexRuntimeError + reset_dynamic_component_imports() app._apply_decorated_pages() app._pages = {} @@ -1093,7 +1150,8 @@ def compile_app( app_wrappers = _resolve_app_wrap_components(app, compile_ctx.app_wrap_components) app_root = app._app_root(app_wrappers) - all_imports = utils.merge_imports(all_imports, app_root._get_all_imports()) + app_root_imports = app_root._get_all_imports() + all_imports = utils.merge_imports(all_imports, app_root_imports) memo_component_files, memo_components_imports = compile_memo_components( ( @@ -1187,7 +1245,15 @@ def add_save_task( ) progress.advance(task) - compile_results.append(compile_app_root(app_root)) + window_library_imports = collect_window_library_imports( + chain( + (app_root_imports,), + (p.frontend_imports for p in compile_ctx.compiled_pages.values()), + ) + ) + compile_results.append( + compile_app_root(app_root, window_library_imports, app_root_imports) + ) progress.advance(task) progress.stop() diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index e2f1b769668..d9f0ff5c7e9 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -383,15 +383,25 @@ def test_compile_app_root_omits_radix_window_library_by_default(): def test_compile_app_root_includes_radix_window_library_when_bundled(): - """Bundled Radix libraries should be exposed to window.__reflex.""" + """Bundled Radix libraries are exposed to window.__reflex via named imports + derived from the app's actual static usage (so Rolldown can tree-shake). + """ + from reflex_base.utils.imports import ImportVar + reset_bundled_libraries() try: bundle_library("@radix-ui/themes@3.3.0") - _, code = compiler.compile_app_root(rx.el.div("hello")) + window_library_imports = compiler.collect_window_library_imports([ + {"@radix-ui/themes@3.3.0": [ImportVar(tag="Theme")]}, + ]) + _, code = compiler.compile_app_root(rx.el.div("hello"), window_library_imports) - assert 'import * as radix_ui_themes from "@radix-ui/themes";' in code - assert '"@radix-ui/themes": radix_ui_themes' in code + assert ( + 'import { Theme as __reflex_radix_ui_themes_Theme } from "@radix-ui/themes";' + in code + ) + assert '"@radix-ui/themes": { Theme: __reflex_radix_ui_themes_Theme }' in code finally: reset_bundled_libraries() @@ -425,6 +435,89 @@ def test_compile_nonexistent_stylesheet(tmp_path, mocker: MockerFixture): compiler.compile_root_stylesheet(stylesheets) +@pytest.fixture +def _isolate_dynamic_imports(): + """Reset window-import state so each test sees only its own bundled libs.""" + from reflex_base.components.dynamic import reset_dynamic_component_imports + + reset_dynamic_component_imports() + reset_bundled_libraries() + bundle_library("@radix-ui/themes@3.3.0") + yield + reset_dynamic_component_imports() + reset_bundled_libraries() + + +@pytest.mark.usefixtures("_isolate_dynamic_imports") +def test_collect_window_library_imports_internal_modules_always_star_imported(): + """Internal Reflex modules map to None (star import) so dynamic components + and plugins reading ``window.__reflex`` find what they need even when the + app has no static external references. + """ + result = compiler.collect_window_library_imports([{}]) + assert result["$/utils/state"] is None + assert "@radix-ui/themes" not in result + + +@pytest.mark.usefixtures("_isolate_dynamic_imports") +def test_collect_window_library_imports_external_lib_uses_named_imports(): + """External libraries on ``window.__reflex`` use named imports so Rolldown + can tree-shake unused exports. + """ + sources = [ + {"$/utils/state": [ImportVar(tag="evalReactComponent")]}, + { + "@radix-ui/themes@3.3.0": [ + ImportVar(tag="Theme"), + ImportVar(tag="Button"), + ] + }, + ] + result = compiler.collect_window_library_imports(sources) + assert result["@radix-ui/themes"] == {"Theme", "Button"} + + +@pytest.mark.usefixtures("_isolate_dynamic_imports") +def test_collect_window_library_imports_unions_dynamic_component_tags(): + """Tags captured during dynamic-Component serialization are unioned into + the named-import surface so runtime-eval'd code finds them on + ``window.__reflex``. + """ + from reflex_base.components.dynamic import dynamic_component_imports + + sources = [{"@radix-ui/themes@3.3.0": [ImportVar(tag="Theme")]}] + dynamic_component_imports["@radix-ui/themes@3.3.0"] = {ImportVar(tag="Flex")} + + result = compiler.collect_window_library_imports(sources) + assert result["@radix-ui/themes"] == {"Theme", "Flex"} + + +@pytest.mark.usefixtures("_isolate_dynamic_imports") +def test_collect_window_library_imports_react_is_always_star_imported(): + """``react`` and ``@emotion/react`` must expose the full module on + ``window.__reflex`` -- ``state.js`` aliases ``window.React`` to + ``window.__reflex.react``, and runtime code may legitimately read APIs + the host app didn't statically import. + """ + sources = [{"react": [ImportVar(tag="useState")]}] + result = compiler.collect_window_library_imports(sources) + assert result["react"] is None + assert result["@emotion/react"] is None + + +def test_render_window_reflex_block_falls_back_to_star_for_invalid_tag(): + """If any declared tag isn't a valid JS identifier, the library falls back + to a star import rather than emit ``import { Foo.Bar as ... }`` (SyntaxError). + """ + from reflex_base.compiler.templates import _render_window_reflex_block + + import_block, _ = _render_window_reflex_block({ + "@some/lib": {"Foo.Bar"}, + }) + assert "Foo.Bar" not in import_block + assert 'import * as __reflex_some_lib from "@some/lib";' in import_block + + def test_create_document_root(): """Test that the document root is created correctly.""" # Test with no components.