diff --git a/reflex/app.py b/reflex/app.py index bdbb90bfe40..26518d0df02 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -1009,12 +1009,11 @@ def _get_frontend_packages(self, imports: dict[str, set[ImportVar]]): dependencies = constants.PackageJson.DEPENDENCIES dev_dependencies = constants.PackageJson.DEV_DEPENDENCIES page_imports = { - i - for i, tags in imports.items() - if i not in dependencies - and i not in dev_dependencies - and not any(i.startswith(prefix) for prefix in ["/", "$/", "."]) - and i != "" + package_name + for import_name, tags in imports.items() + if (package_name := self._get_frontend_package_name(import_name)) + and package_name not in dependencies + and package_name not in dev_dependencies and any(tag.install for tag in tags) } pinned = {i.rpartition("@")[0] for i in page_imports if "@" in i} @@ -1032,6 +1031,48 @@ def _get_frontend_packages(self, imports: dict[str, set[ImportVar]]): page_imports.update(filtered_frontend_packages) js_runtimes.install_frontend_packages(page_imports, get_config()) + @staticmethod + def _get_frontend_package_name(import_name: str) -> str | None: + """Resolve the npm package name to install for a library import path. + + Args: + import_name: The import path key used in component imports. + + Returns: + The package name that should be installed, including pinned version when + available, or None when the import does not represent an installable npm package. + """ + if import_name == "" or any( + import_name.startswith(prefix) for prefix in ("/", "$/", ".") + ): + return None + if import_name.startswith(("https://", "http://")): + return import_name + + library_name = format.format_library_name(import_name) + if library_name.startswith("@"): + scope, slash, package_and_path = library_name.partition("/") + package_name = ( + f"{scope}/{package_and_path.split('/', maxsplit=1)[0]}" + if slash and package_and_path + else library_name + ) + else: + package_name = library_name.split("/", maxsplit=1)[0] + + if import_name.startswith(f"{library_name}@"): + version_and_maybe_subpath = import_name[len(library_name) + 1 :] + version, slash, _ = version_and_maybe_subpath.partition("/") + if slash and ":" not in version: + return f"{package_name}@{version}" + if package_name == library_name: + return import_name + return f"{package_name}@{version_and_maybe_subpath}" + + if package_name == library_name: + return import_name + return package_name + def _app_root(self, app_wrappers: dict[tuple[int, str], Component]) -> Component: for component in tuple(app_wrappers.values()): app_wrappers.update(component._get_all_app_wrap_components()) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 2a3eb5556f4..14041c77bcd 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -24,6 +24,7 @@ from reflex_base.registry import RegistrationContext from reflex_base.style import Style from reflex_base.utils import console, exceptions, format +from reflex_base.utils.imports import ImportVar from reflex_base.vars.base import computed_var from reflex_components_core.base.bare import Bare from reflex_components_core.base.fragment import Fragment @@ -2239,6 +2240,73 @@ def page(): assert expected.split(",") == function_app_definition.split(",") +def test_get_frontend_packages_maps_subpath_imports_to_installable_package_names( + mocker: MockerFixture, +): + """Subpath imports should install the base npm package.""" + conf = rx.Config(app_name="testing") + mocker.patch("reflex.app.get_config", return_value=conf) + install_frontend_packages = mocker.patch( + "reflex.app.js_runtimes.install_frontend_packages" + ) + + app = App(theme=None) + app._get_frontend_packages({ + "react-map-gl/maplibre": {ImportVar(tag="Map")}, + "@scope/pkg/subpath": {ImportVar(tag="Widget")}, + "react": {ImportVar(tag="useEffect")}, + }) + + install_set, _ = install_frontend_packages.call_args.args + assert "react-map-gl" in install_set + assert "@scope/pkg" in install_set + assert "react-map-gl/maplibre" not in install_set + assert "@scope/pkg/subpath" not in install_set + + +def test_get_frontend_packages_keeps_https_imports_unchanged( + mocker: MockerFixture, +): + """URL-based imports should be passed through unchanged.""" + conf = rx.Config(app_name="testing") + mocker.patch("reflex.app.get_config", return_value=conf) + install_frontend_packages = mocker.patch( + "reflex.app.js_runtimes.install_frontend_packages" + ) + + app = App(theme=None) + app._get_frontend_packages({ + "https://cdn.skypack.dev/some-lib": {ImportVar(tag="SomeTag")} + }) + + install_set, _ = install_frontend_packages.call_args.args + assert "https://cdn.skypack.dev/some-lib" in install_set + assert "https:" not in install_set + + +def test_get_frontend_packages_maps_versioned_subpath_imports_to_pinned_base( + mocker: MockerFixture, +): + """Versioned subpath imports should install the base package with its version.""" + conf = rx.Config(app_name="testing") + mocker.patch("reflex.app.get_config", return_value=conf) + install_frontend_packages = mocker.patch( + "reflex.app.js_runtimes.install_frontend_packages" + ) + + app = App(theme=None) + app._get_frontend_packages({ + "react-map-gl@1.0.0/maplibre": {ImportVar(tag="Map")}, + "@scope/pkg@2.0.0/subpath": {ImportVar(tag="Widget")}, + }) + + install_set, _ = install_frontend_packages.call_args.args + assert "react-map-gl@1.0.0" in install_set + assert "@scope/pkg@2.0.0" in install_set + assert "react-map-gl@1.0.0/maplibre" not in install_set + assert "@scope/pkg@2.0.0/subpath" not in install_set + + def test_app_state_determination(): """Test that the stateless status of an app is determined correctly.""" a1 = App()