From db1ff09637e54175a96808d11b3e825131b6e1f7 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Mon, 8 Jun 2026 13:58:50 -0700 Subject: [PATCH 1/7] fix: emit deep per-icon imports for static lucide icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static-string `rx.icon` tags compiled to a barrel import `import { WifiOff } from "lucide-react"`. Once the module graph also contains `lucide-react/dynamic.mjs` (used by DynamicIcon for Var tags), esbuild's dep-optimizer emits a per-icon chunk for the dynamic-import map and rewrites the barrel to statically import every one of them. Since DefaultOverlayComponents (mounted on every page) barrel-imports WifiOff, loading any page in dev fired ~1700 requests — the whole icon library. Emit deep default imports instead, lucide's recommended form: `import LucideWifiOff from "lucide-react/dist/esm/icons/wifi-off.mjs"`. lucide-react has no exports map, so the path targets the kebab-case file directly; the .mjs extension is required for Node/Bun ESM resolution. The dynamic Var -> DynamicIcon path is left unchanged. A 6-entry file-name override covers the icons whose kebab name doesn't match lucide's file (fingerprint, grid_2x_2*, grid_3x_3), verified against the full icon list. Fixes #6627 ENG-9721 --- .../src/reflex_components_lucide/icon.py | 45 +++++++++++++++ pyi_hashes.json | 2 +- tests/units/components/lucide/test_icon.py | 57 +++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) 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..eccce5ed654 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 (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..a2e56f2d54c 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": "59379328189397759c8d35f58ebfe79d", "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..a6ff11f32f6 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,59 @@ 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 + + +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() From 057b83214d613551f7c40798d6274b9226c0df57 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Mon, 8 Jun 2026 14:03:08 -0700 Subject: [PATCH 2/7] chore: add changelog fragment for #6627 --- news/6627.bugfix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/6627.bugfix.md diff --git a/news/6627.bugfix.md b/news/6627.bugfix.md new file mode 100644 index 00000000000..aa933aa078e --- /dev/null +++ b/news/6627.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. From 6c9ca113188175718b64aa0b469dd77b6f99d566 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Mon, 8 Jun 2026 14:06:48 -0700 Subject: [PATCH 3/7] fix: regenerate banner stub and relocate changelog fragment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WifiOffPulse extends Icon, so it inherits the new _import_path field — regenerate its .pyi hash, which I missed. Move the news fragment from news/6627 (issue number, root) to packages/reflex-components-lucide/news/6628 (PR number, affected package) so the per-package towncrier check finds it. --- .../reflex-components-lucide/news/6628.bugfix.md | 0 pyi_hashes.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename news/6627.bugfix.md => packages/reflex-components-lucide/news/6628.bugfix.md (100%) diff --git a/news/6627.bugfix.md b/packages/reflex-components-lucide/news/6628.bugfix.md similarity index 100% rename from news/6627.bugfix.md rename to packages/reflex-components-lucide/news/6628.bugfix.md diff --git a/pyi_hashes.json b/pyi_hashes.json index a2e56f2d54c..49e2cf7886d 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -14,7 +14,7 @@ "packages/reflex-components-core/src/reflex_components_core/base/strict_mode.pyi": "8ee129808abb4389cbd77a1736190eae", "packages/reflex-components-core/src/reflex_components_core/core/__init__.pyi": "dd5142b3c9087bf2bf22651adf6f2724", "packages/reflex-components-core/src/reflex_components_core/core/auto_scroll.pyi": "918dfad4d5925addd0f741e754b3b076", - "packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "6040fbada9b96c55637a9c8cc21a5e10", + "packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "f463995f28f9e86247d8a8371e2984c0", "packages/reflex-components-core/src/reflex_components_core/core/clipboard.pyi": "e3950e0963a6d04299ff58294687e407", "packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "58138b5f1d5901839729d839620ea4da", "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "7fd81a99bde5b0ff94bb52523597fd5c", From 6b820843f34cb086ec572514b8bbac4bd623a90d Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Mon, 8 Jun 2026 14:13:35 -0700 Subject: [PATCH 4/7] fix: regenerate AccordionIcon stub for inherited _import_path field AccordionIcon(Icon) inherits the new _import_path field, so its generated .pyi hash changed too. This was the remaining drift causing pre-commit's update-pyi-files hook to fail. AccordionIcon and WifiOffPulse are the only two Icon subclasses; both stubs are now regenerated. --- pyi_hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyi_hashes.json b/pyi_hashes.json index 49e2cf7886d..ceb8a20d7a0 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -45,7 +45,7 @@ "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "91e956633778c6992f04940c69ff7140", "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "19216eb3618f68c8a76e5e43801cf4af", "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "5404a8da97e8b5129133d7f300e3f642", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/accordion.pyi": "e8ef2b44f2afe3e9b8d678d523673882", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/accordion.pyi": "1c9c5b191a7ac3dc71f4338cb158d56f", "packages/reflex-components-radix/src/reflex_components_radix/primitives/base.pyi": "e779c6739baee98c8a588768a88de45a", "packages/reflex-components-radix/src/reflex_components_radix/primitives/dialog.pyi": "ffb06f3aa8722c2345a952869118e224", "packages/reflex-components-radix/src/reflex_components_radix/primitives/drawer.pyi": "cc724f697e62efba294e19b58c6f1bd8", From 3e89b7982612e568440f7972dc4338f76df3ba39 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Mon, 8 Jun 2026 14:18:08 -0700 Subject: [PATCH 5/7] test: spot-check literal icon import paths; fix comment grammar Address Greptile review on #6628: - test_static_icon_import_path was circular for override entries (built the expected path from the same LUCIDE_ICON_FILENAME_OVERRIDE the code reads). Add test_static_icon_import_path_literal with hardcoded paths, independent of the override dict, so a typo in an override value fails. - Complete the dangling 'doesn't' clause in the override-map comment. --- .../src/reflex_components_lucide/icon.py | 6 ++--- tests/units/components/lucide/test_icon.py | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) 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 eccce5ed654..17ff0db58de 100644 --- a/packages/reflex-components-lucide/src/reflex_components_lucide/icon.py +++ b/packages/reflex-components-lucide/src/reflex_components_lucide/icon.py @@ -1868,9 +1868,9 @@ def _get_imports(self): # 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 (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. +# 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", diff --git a/tests/units/components/lucide/test_icon.py b/tests/units/components/lucide/test_icon.py index a6ff11f32f6..d813e43ae8c 100644 --- a/tests/units/components/lucide/test_icon.py +++ b/tests/units/components/lucide/test_icon.py @@ -49,6 +49,33 @@ def test_static_icon_import_path(tag): 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)) From 1990ff2e970579ca9f7af7d8f9a48ae6af3c95d7 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Mon, 8 Jun 2026 14:53:35 -0700 Subject: [PATCH 6/7] pyi_generator: exclude props with underscore prefix These are "private" and should not form part of the IDE/type hinting documentation. They are still accepted and won't raise any typing errors due to all components _also_ taking `**props` --- packages/reflex-base/src/reflex_base/utils/pyi_generator.py | 1 + pyi_hashes.json | 6 +++--- .../utils/pyi_generator/golden/classvar_and_private.pyi | 2 -- 3 files changed, 4 insertions(+), 5 deletions(-) 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/pyi_hashes.json b/pyi_hashes.json index ceb8a20d7a0..6376354d7d9 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -14,7 +14,7 @@ "packages/reflex-components-core/src/reflex_components_core/base/strict_mode.pyi": "8ee129808abb4389cbd77a1736190eae", "packages/reflex-components-core/src/reflex_components_core/core/__init__.pyi": "dd5142b3c9087bf2bf22651adf6f2724", "packages/reflex-components-core/src/reflex_components_core/core/auto_scroll.pyi": "918dfad4d5925addd0f741e754b3b076", - "packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "f463995f28f9e86247d8a8371e2984c0", + "packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "6040fbada9b96c55637a9c8cc21a5e10", "packages/reflex-components-core/src/reflex_components_core/core/clipboard.pyi": "e3950e0963a6d04299ff58294687e407", "packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "58138b5f1d5901839729d839620ea4da", "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "7fd81a99bde5b0ff94bb52523597fd5c", @@ -39,13 +39,13 @@ "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": "59379328189397759c8d35f58ebfe79d", + "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", "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "19216eb3618f68c8a76e5e43801cf4af", "packages/reflex-components-radix/src/reflex_components_radix/primitives/__init__.pyi": "5404a8da97e8b5129133d7f300e3f642", - "packages/reflex-components-radix/src/reflex_components_radix/primitives/accordion.pyi": "1c9c5b191a7ac3dc71f4338cb158d56f", + "packages/reflex-components-radix/src/reflex_components_radix/primitives/accordion.pyi": "e8ef2b44f2afe3e9b8d678d523673882", "packages/reflex-components-radix/src/reflex_components_radix/primitives/base.pyi": "e779c6739baee98c8a588768a88de45a", "packages/reflex-components-radix/src/reflex_components_radix/primitives/dialog.pyi": "ffb06f3aa8722c2345a952869118e224", "packages/reflex-components-radix/src/reflex_components_radix/primitives/drawer.pyi": "cc724f697e62efba294e19b58c6f1bd8", 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. From f9641ec349289b1c716af5a10ef65c28495b0cdc Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Mon, 8 Jun 2026 14:56:57 -0700 Subject: [PATCH 7/7] add news for pyi_generator change --- packages/reflex-base/news/6628.bugfix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/reflex-base/news/6628.bugfix.md 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