diff --git a/packages/reflex-base/news/6628.bugfix.md b/packages/reflex-base/news/6628.bugfix.md new file mode 100644 index 00000000000..d3e112f68c8 --- /dev/null +++ b/packages/reflex-base/news/6628.bugfix.md @@ -0,0 +1 @@ +pyi_generator no longer includes underscore-prefixed props in generated .pyi files. \ No newline at end of file diff --git a/packages/reflex-base/src/reflex_base/utils/pyi_generator.py b/packages/reflex-base/src/reflex_base/utils/pyi_generator.py index 14cff2109a7..2b7ee41fb59 100644 --- a/packages/reflex-base/src/reflex_base/utils/pyi_generator.py +++ b/packages/reflex-base/src/reflex_base/utils/pyi_generator.py @@ -631,6 +631,7 @@ def _extract_class_props_as_ast_nodes( or name in all_props or name in event_triggers or get_origin(value) is ClassVar + or name.startswith("_") ): continue all_props.add(name) diff --git a/packages/reflex-components-lucide/news/6628.bugfix.md b/packages/reflex-components-lucide/news/6628.bugfix.md new file mode 100644 index 00000000000..aa933aa078e --- /dev/null +++ b/packages/reflex-components-lucide/news/6628.bugfix.md @@ -0,0 +1 @@ +`rx.icon` with a static name now emits a deep per-icon import (`import LucideWifiOff from "lucide-react/dist/esm/icons/wifi-off.mjs"`) instead of a named import from the `lucide-react` barrel. The barrel import was harmless on its own, but once an app's module graph also contained `lucide-react/dynamic.mjs` (pulled in by dynamic `Var`-tagged icons, and by the connection overlay mounted on every page), esbuild's dev dep-optimizer rewrote the barrel to statically import every icon chunk — making each page load fetch the entire ~1700-icon library. Deep imports load only the icons actually used. The dynamic `Var` path still resolves through `lucide-react/dynamic.mjs` and is unchanged. diff --git a/packages/reflex-components-lucide/src/reflex_components_lucide/icon.py b/packages/reflex-components-lucide/src/reflex_components_lucide/icon.py index 99dab4610a8..17ff0db58de 100644 --- a/packages/reflex-components-lucide/src/reflex_components_lucide/icon.py +++ b/packages/reflex-components-lucide/src/reflex_components_lucide/icon.py @@ -22,6 +22,10 @@ class Icon(LucideIconComponent): size: Var[int] = field(doc="The size of the icon in pixels.") + # Deep-import subpath into lucide-react for this icon (e.g. + # "/dist/esm/icons/wifi-off.mjs"). Set during ``create``; not a rendered prop. + _import_path: str = field(default="") + @classmethod def create(cls, *children, **props) -> Component: """Initialize the Icon component. @@ -83,8 +87,35 @@ def create(cls, *children, **props) -> Component: props["tag"] = LUCIDE_ICON_MAPPING_OVERRIDE.get(tag, format.to_title_case(tag)) props["alias"] = f"Lucide{props['tag']}" + file_name = LUCIDE_ICON_FILENAME_OVERRIDE.get(tag, format.to_kebab_case(tag)) + props["_import_path"] = f"/dist/esm/icons/{file_name}.mjs" return super().create(**props) + def _get_imports(self): + """Emit a deep per-icon import instead of a lucide-react barrel import. + + Importing a static icon by name from the ``lucide-react`` barrel pulls + the whole library into the dev module graph once ``DynamicIcon`` (which + imports ``lucide-react/dynamic.mjs``) is also present, flooding the page + with a request per icon. A deep default import from the icon's own + module avoids this and is lucide's recommended form. + + Returns: + The imports needed by the component. + """ + imports_ = super()._get_imports() + if self.library and self._import_path: + imports_.pop(self.library, None) + imports_[LUCIDE_LIBRARY] = [ + ImportVar( + self.tag, + is_default=True, + alias=self.alias, + package_path=self._import_path, + ) + ] + return imports_ + class DynamicIcon(LucideIconComponent): """A DynamicIcon component.""" @@ -1834,3 +1865,17 @@ def _get_imports(self): "grid_3x2": "Grid3x2Icon", "fingerprint": "FingerprintPattern", } + +# For deep per-icon imports, the kebab-case icon name must match Lucide's own +# file name under dist/esm/icons. These are the entries where the default +# kebab conversion doesn't produce the correct file name (verified against the +# full LUCIDE_ICON_LIST): the digit-x-digit names gain a stray hyphen, and +# "fingerprint" is an alias of the "fingerprint-pattern" module. +LUCIDE_ICON_FILENAME_OVERRIDE = { + "fingerprint": "fingerprint-pattern", + "grid_2x_2": "grid-2x2", + "grid_2x_2_check": "grid-2x2-check", + "grid_2x_2_plus": "grid-2x2-plus", + "grid_2x_2_x": "grid-2x2-x", + "grid_3x_3": "grid-3x3", +} diff --git a/pyi_hashes.json b/pyi_hashes.json index f7cde8facda..6376354d7d9 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -39,7 +39,7 @@ "packages/reflex-components-core/src/reflex_components_core/react_router/dom.pyi": "1074a512195ae23d479c4a2d553954e1", "packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "8e379fa038c7c6c0672639eb5902934d", "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "d2dc211d707c402eb24678a4cba945f7", - "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "b692058e40b15da293fbf463ad300a83", + "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "d16d77881afaae71578177db4d479c13", "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "e04f22f5d3d2b5dfd99f9fbedb2b4f3d", "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "d6a02e447dfd3c91bba84bcd02722aed", "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "91e956633778c6992f04940c69ff7140", diff --git a/tests/units/components/lucide/test_icon.py b/tests/units/components/lucide/test_icon.py index 4480cb5936b..d813e43ae8c 100644 --- a/tests/units/components/lucide/test_icon.py +++ b/tests/units/components/lucide/test_icon.py @@ -1,8 +1,12 @@ import pytest from reflex_base.utils import format +from reflex_base.vars.base import Var from reflex_components_lucide.icon import ( + LUCIDE_ICON_FILENAME_OVERRIDE, LUCIDE_ICON_LIST, LUCIDE_ICON_MAPPING_OVERRIDE, + LUCIDE_LIBRARY, + DynamicIcon, Icon, ) @@ -15,6 +19,86 @@ def test_icon(tag): ) +def _lucide_imports(component): + return [ + var + for lib, vars_ in component._get_imports().items() + if "lucide-react" in lib + for var in vars_ + ] + + +def test_static_icon_deep_import(): + """Static string icons import from a deep per-icon module, not the barrel.""" + icon = Icon.create("wifi_off") + (import_var,) = _lucide_imports(icon) + assert import_var.package_path == "/dist/esm/icons/wifi-off.mjs" + assert import_var.is_default + assert import_var.alias == "LucideWifiOff" + # The barrel import (package_path "/") must not be emitted. + assert import_var.package_path != "/" + + +@pytest.mark.parametrize("tag", LUCIDE_ICON_LIST) +def test_static_icon_import_path(tag): + """Every icon maps to a deep import using its kebab-case file name.""" + icon = Icon.create(tag) + (import_var,) = _lucide_imports(icon) + file_name = LUCIDE_ICON_FILENAME_OVERRIDE.get(tag, format.to_kebab_case(tag)) + assert import_var.package_path == f"/dist/esm/icons/{file_name}.mjs" + assert import_var.is_default + + +# Literal expected paths — independent of LUCIDE_ICON_FILENAME_OVERRIDE and the +# kebab helper — so a typo in an override value (the hand-maintained, drift-prone +# part) is actually caught instead of being mirrored on both sides of the assert. +# The override file names were verified to exist under lucide-react's +# dist/esm/icons; the plain entries spot-check the default kebab conversion. +@pytest.mark.parametrize( + ("tag", "expected_path"), + [ + ("fingerprint", "/dist/esm/icons/fingerprint-pattern.mjs"), + ("grid_2x_2", "/dist/esm/icons/grid-2x2.mjs"), + ("grid_2x_2_check", "/dist/esm/icons/grid-2x2-check.mjs"), + ("grid_2x_2_plus", "/dist/esm/icons/grid-2x2-plus.mjs"), + ("grid_2x_2_x", "/dist/esm/icons/grid-2x2-x.mjs"), + ("grid_3x_3", "/dist/esm/icons/grid-3x3.mjs"), + # grid_3x2 is NOT an override — its kebab file name already matches. + ("grid_3x2", "/dist/esm/icons/grid-3x2.mjs"), + ("wifi_off", "/dist/esm/icons/wifi-off.mjs"), + ("circle_help", "/dist/esm/icons/circle-help.mjs"), + ("activity", "/dist/esm/icons/activity.mjs"), + ], +) +def test_static_icon_import_path_literal(tag, expected_path): + """Spot-check concrete import paths, especially the file-name overrides.""" + (import_var,) = _lucide_imports(Icon.create(tag)) + assert import_var.package_path == expected_path + + +def test_dynamic_icon_uses_dynamic_module(): + """Var-tagged icons still resolve through lucide-react/dynamic.mjs.""" + icon = Icon.create(Var("state_icon").to(str)) + assert isinstance(icon, DynamicIcon) + (import_var,) = _lucide_imports(icon) + assert import_var.tag == "DynamicIcon" + assert import_var.package_path == "/dynamic.mjs" + assert not import_var.is_default + + +def test_static_icon_import_idempotent(): + """Repeated import computation (cache hits) yields the same deep import.""" + icon = Icon.create("wifi_off") + first = _lucide_imports(icon) + second = _lucide_imports(icon) + assert first == second + assert all(var.package_path == "/dist/esm/icons/wifi-off.mjs" for var in second) + + +def test_lucide_library_constant(): + assert LUCIDE_LIBRARY.startswith("lucide-react") + + def test_icon_missing_tag(): with pytest.raises(AttributeError): _ = Icon.create() diff --git a/tests/units/reflex_base/utils/pyi_generator/golden/classvar_and_private.pyi b/tests/units/reflex_base/utils/pyi_generator/golden/classvar_and_private.pyi index d459b1b82bf..8831f961032 100644 --- a/tests/units/reflex_base/utils/pyi_generator/golden/classvar_and_private.pyi +++ b/tests/units/reflex_base/utils/pyi_generator/golden/classvar_and_private.pyi @@ -17,7 +17,6 @@ class StrictComponent(Component): def create( cls, *children, - _internal_counter: int | None = None, visible_prop: Var[str] | str | None = None, size: Var[int] | int | None = None, style: Sequence[Mapping[str, Any]] @@ -52,7 +51,6 @@ class StrictComponent(Component): Args: *children: The children of the component. - _internal_counter: no description visible_prop: A prop visible in the stub. size: The size of the component. style: The style of the component.