From f1824f3be375af897da4a51ea4033cfa58a52a81 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 23:08:50 +0000 Subject: [PATCH] fix(app): keep local/git package specifiers whole when resolving installs `App._get_frontend_package_name` split everything after `package@` at the first slash, treating the leading segment as a version and the rest as a package subpath. That is right for `@scope/pkg@1.0.0/dist/style.css`, but wrong for specifiers that name a *location* rather than a version: a local path like `@masenf/hello-react@../hello-react` was truncated to `@masenf/hello-react@..`, which bun then rejected with "Could not find package.json for 'file:..' dependency". The `:`-in-version check only saved protocol forms such as `file:` and `github:`. Split off the version only when the specifier is not a location: any protocol form, a git ref (`#`), or a relative/absolute/home-relative path now reaches the package manager unmodified. The `package_name == library_name` branch it replaced produced a string identical to the generic one, so it is folded in. Verified end-to-end with a wrapped local package: both `@masenf/hello-react@../hello-react` and `@masenf/hello-react@../masenf-hello-react-0.1.0.tgz` now install and build. Documented that a local *directory* dependency is linked in place, so its own runtime dependencies must be installed inside that directory (or a packed archive used instead). Fixes #7117 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LwE92843n9iYDVR7yfjwJX --- docs/wrapping-react/local-packages.md | 5 ++ news/7117.bugfix.md | 1 + reflex/app.py | 29 ++++++++--- tests/units/test_app.py | 75 +++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 news/7117.bugfix.md diff --git a/docs/wrapping-react/local-packages.md b/docs/wrapping-react/local-packages.md index ba59037b42b..eb5386755ab 100644 --- a/docs/wrapping-react/local-packages.md +++ b/docs/wrapping-react/local-packages.md @@ -149,6 +149,11 @@ Some important notes regarding this approach: - The repo or archive must contain a `package.json` file. - `prepare` or `build` scripts will NOT be executed. The distribution archive, directory, or repo must already contain the built javascript files (this is common). +- When pointing at a local _directory_, the package manager links the files in + place instead of copying them, so the bundler resolves that package's own + runtime dependencies from the directory itself. Run `npm install` (or `bun + install`) inside the local package directory, or reference a packed archive + created with `npm pack` instead. ````md alert # Ensure CSS files are exported in `package.json` diff --git a/news/7117.bugfix.md b/news/7117.bugfix.md new file mode 100644 index 00000000000..c9e42349606 --- /dev/null +++ b/news/7117.bugfix.md @@ -0,0 +1 @@ +Fixed local package specifiers such as `@masenf/hello-react@../hello-react` and `@masenf/hello-react@../hello-react.tgz` being truncated at the first slash (to `@masenf/hello-react@..`) before reaching the package manager, so wrapping a React package from a local directory or archive now installs correctly. diff --git a/reflex/app.py b/reflex/app.py index 558a1d0f607..8105623ce4c 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -355,6 +355,25 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.app(scope, receive, send) +def _is_location_specifier(specifier: str) -> bool: + """Check whether a dependency specifier points at a location. + + A location specifier names where a package comes from (a local path, a + protocol like ``file:``/``github:``/``git+ssh:``, or a git ref) instead of + naming a version. Its slashes belong to the location, so it must be kept + whole rather than split into a version and a package subpath. + + Args: + specifier: The part of an import name following ``package@``. + + Returns: + Whether the specifier is a location rather than a version or dist-tag. + """ + return ( + ":" in specifier or "#" in specifier or specifier.startswith((".", "/", "~/")) + ) + + @dataclasses.dataclass() class App(MiddlewareMixin, LifespanMixin): """The main Reflex app that encapsulates the backend and frontend. @@ -1530,13 +1549,11 @@ def _get_frontend_package_name(import_name: str) -> str | None: 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: + specifier = import_name[len(library_name) + 1 :] + version, slash, _ = specifier.partition("/") + if slash and not _is_location_specifier(specifier): return f"{package_name}@{version}" - if package_name == library_name: - return import_name - return f"{package_name}@{version_and_maybe_subpath}" + return f"{package_name}@{specifier}" if package_name == library_name: return import_name diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 8917ef90838..7e26f80377d 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -3264,6 +3264,81 @@ def test_get_frontend_packages_maps_versioned_subpath_imports_to_pinned_base( assert "@scope/pkg@2.0.0/subpath" not in install_set +def test_get_frontend_packages_keeps_local_and_git_specifiers_intact( + mocker: MockerFixture, +): + """Location specifiers must reach the package manager unmodified. + + Local paths, ``file:`` URLs and git references contain slashes that are + part of the location, not a package subpath, so they must not be + truncated at the first slash (reflex-dev/reflex#7117). + """ + 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" + ) + + specifiers = [ + "@masenf/hello-react@../hello-react", + "@masenf/hello-react@../hello-react.tgz", + "@masenf/hello-react@./vendor/hello-react", + "@masenf/hello-react@/opt/hello-react", + "@masenf/hello-react@~/hello-react", + "@masenf/hello-react@file:../hello-react", + "@masenf/hello-react@github:masenf/hello-react", + "@masenf/hello-react@masenf/hello-react#main", + "local-pkg@../local-pkg", + ] + + app = App(theme=None) + app._get_frontend_packages({ + specifier: {ImportVar(tag="Counter")} for specifier in specifiers + }) + + install_set, _ = install_frontend_packages.call_args.args + assert install_set == set(specifiers) + + +def test_get_frontend_packages_maps_subpath_of_local_package_to_its_specifier( + mocker: MockerFixture, +): + """A subpath import of a locally sourced package installs the local package once.""" + 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({ + "@masenf/hello-react@../hello-react": {ImportVar(tag="Counter")}, + "@masenf/hello-react/dist/style.css": {ImportVar(tag="")}, + }) + + install_set, _ = install_frontend_packages.call_args.args + assert install_set == {"@masenf/hello-react@../hello-react"} + + +def test_get_frontend_packages_maps_scoped_subpath_import_of_local_package( + mocker: MockerFixture, +): + """A library subpath pinned to a local path installs the base 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({ + "@scope/pkg/subpath@../pkg": {ImportVar(tag="Widget")}, + }) + + install_set, _ = install_frontend_packages.call_args.args + assert install_set == {"@scope/pkg@../pkg"} + + def test_app_state_determination(): """Test that the stateless status of an app is determined correctly.""" a1 = App()