Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
101 changes: 82 additions & 19 deletions packages/reflex-base/src/reflex_base/compiler/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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],
):
Expand All @@ -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.

Expand All @@ -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}
Expand All @@ -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, {{}},
Expand Down
16 changes: 16 additions & 0 deletions packages/reflex-base/src/reflex_base/components/dynamic.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,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.

Expand Down Expand Up @@ -103,6 +114,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)
Expand Down
3 changes: 2 additions & 1 deletion packages/reflex-base/src/reflex_base/plugins/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, ParamSpec, Protocol, TypedDict

from typing_extensions import Unpack
from typing_extensions import NotRequired, Unpack


class HookOrder(str, Enum):
Expand Down Expand Up @@ -55,6 +55,7 @@ class PreCompileContext(CommonContext):
add_modify_task: Callable[[str, Callable[[str], str]], None]
radix_themes_plugin: Any
unevaluated_pages: Sequence["UnevaluatedPage"]
theme_roots: NotRequired[Sequence["BaseComponent | None"]]


class PostCompileContext(CommonContext):
Expand Down
22 changes: 22 additions & 0 deletions packages/reflex-base/src/reflex_base/plugins/shared_tailwind.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tailwind CSS configuration types for Reflex plugins."""

import dataclasses
import re
from collections.abc import Mapping
from copy import deepcopy
from typing import Any, Literal, TypedDict
Expand All @@ -9,6 +10,27 @@

from .base import Plugin as PluginBase

_RADIX_IMPORT_RE = re.compile(
r"^@import (?:url\(['\"]|['\"])@radix-ui/themes/[^'\"]+['\"](?:\))?(?:\s+layer\(\w+\))?;\s*\n?",
re.MULTILINE,
)


def strip_radix_theme_imports(css: str) -> tuple[str, int]:
"""Remove every Radix Themes @import line from a stylesheet.

Handles both the monolithic ``styles.css`` and the granular per-token
imports emitted by the compiler.

Args:
css: The stylesheet content.

Returns:
The stripped content and the number of imports removed.
"""
return _RADIX_IMPORT_RE.subn("", css)


TailwindPluginImport = TypedDict(
"TailwindPluginImport",
{
Expand Down
47 changes: 32 additions & 15 deletions packages/reflex-base/src/reflex_base/plugins/tailwind_v3.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
"""Base class for all plugins."""

import dataclasses
from collections.abc import Sequence
from pathlib import Path
from types import SimpleNamespace
from typing import TYPE_CHECKING

from reflex_base.constants.base import Dirs
from reflex_base.constants.compiler import Ext, PageNames
from reflex_base.plugins.shared_tailwind import (
TailwindConfig,
TailwindPlugin,
strip_radix_theme_imports,
tailwind_config_js_template,
)

if TYPE_CHECKING:
from reflex_base.components.component import BaseComponent


class Constants(SimpleNamespace):
"""Tailwind constants."""
Expand All @@ -29,7 +35,7 @@ class Constants(SimpleNamespace):
ROOT_STYLE_CONTENT = """
@import "tailwindcss/base";

{radix_import}
{radix_imports}

@tailwind components;
@tailwind utilities;
Expand All @@ -54,23 +60,32 @@ def compile_config(config: TailwindConfig):
)


def compile_root_style(include_radix_themes: bool = True):
def compile_root_style(
include_radix_themes: bool = True,
theme_roots: Sequence["BaseComponent | None"] | None = None,
):
"""Compile the Tailwind root style.

Args:
include_radix_themes: Whether to include the Radix stylesheet import.
include_radix_themes: Whether to emit any Radix stylesheet imports.
theme_roots: Component roots used to detect which Radix color scales are
actually referenced so only those CSS files are imported.

Returns:
The compiled Tailwind root style.
"""
from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET
from reflex_components_radix.plugin import get_radix_themes_stylesheets

radix_imports = ""
if include_radix_themes:
radix_imports = "\n".join(
f"@import url('{sheet}');"
for sheet in get_radix_themes_stylesheets(theme_roots)
)
return str(
Path(Dirs.STYLES) / Constants.ROOT_STYLE_PATH
), Constants.ROOT_STYLE_CONTENT.format(
radix_import=(
f"@import url('{RADIX_THEMES_STYLESHEET}');" if include_radix_themes else ""
),
radix_imports=radix_imports,
)


Expand Down Expand Up @@ -129,15 +144,13 @@ def add_tailwind_to_css_file(
Returns:
The modified css file content.
"""
from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET

if Constants.TAILWIND_CSS.splitlines()[0] in css_file_content:
return css_file_content
if include_radix_themes and RADIX_THEMES_STYLESHEET in css_file_content:
return css_file_content.replace(
f"@import url('{RADIX_THEMES_STYLESHEET}');",
Constants.TAILWIND_CSS,
)

if include_radix_themes:
stripped, count = strip_radix_theme_imports(css_file_content)
if count > 0:
return stripped.rstrip() + "\n" + Constants.TAILWIND_CSS + "\n"

lines = css_file_content.splitlines()
insert_at = next(
Expand Down Expand Up @@ -179,7 +192,11 @@ def pre_compile(self, **context):
context["add_save_task"](compile_config, self.get_unversioned_config())
include_radix_themes = context["radix_themes_plugin"].enabled

context["add_save_task"](compile_root_style, include_radix_themes)
context["add_save_task"](
compile_root_style,
include_radix_themes,
context.get("theme_roots"),
)
context["add_modify_task"](Dirs.POSTCSS_JS, add_tailwind_to_postcss_config)
context["add_modify_task"](
str(Path(Dirs.STYLES) / (PageNames.STYLESHEET_ROOT + Ext.CSS)),
Expand Down
Loading
Loading