diff --git a/docs/getting_started/project-structure.md b/docs/getting_started/project-structure.md index d244fc5a6e0..8068f316410 100644 --- a/docs/getting_started/project-structure.md +++ b/docs/getting_started/project-structure.md @@ -44,6 +44,8 @@ This is where the compiled Javascript files will be stored. You will never need Each Reflex page will compile to a corresponding `.js` file in the `.web/pages` directory. +If Reflex installs frontend dependencies with Bun, the canonical `bun.lock` lives in your project root and should be committed to version control. Reflex mirrors it into `.web` when it needs to run the package manager. + ## Assets The `assets` directory is where you can store any static assets you want to be publicly available. This includes images, fonts, and other files. diff --git a/packages/reflex-base/src/reflex_base/constants/installer.py b/packages/reflex-base/src/reflex_base/constants/installer.py index af2d77cb664..2133a4bba11 100644 --- a/packages/reflex-base/src/reflex_base/constants/installer.py +++ b/packages/reflex-base/src/reflex_base/constants/installer.py @@ -30,6 +30,9 @@ class Bun(SimpleNamespace): # Path of the bunfig file CONFIG_PATH = "bunfig.toml" + # Path of the bun lockfile. + LOCKFILE_PATH = "bun.lock" + @classproperty @classmethod def ROOT_PATH(cls): diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index dc60f5ae85b..12054b9a2d3 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -148,6 +148,55 @@ def initialize_requirements_txt( return False +def get_root_bun_lock_path() -> Path: + """Get the canonical bun lock path in the app root. + + This assumes the current working directory is the Reflex app root. + + Returns: + The canonical bun lock path in the app root. + """ + return Path.cwd() / constants.Bun.LOCKFILE_PATH + + +def get_web_bun_lock_path() -> Path: + """Get the mirrored bun lock path in the .web directory. + + Returns: + The mirrored bun lock path in the .web directory. + """ + return get_web_dir() / constants.Bun.LOCKFILE_PATH + + +def sync_root_bun_lock_to_web(): + """Mirror the canonical root bun.lock into .web. + + If the root lockfile is absent, remove any stale mirrored copy from .web. + """ + root_bun_lock_path = get_root_bun_lock_path() + web_bun_lock_path = get_web_bun_lock_path() + + if not root_bun_lock_path.exists(): + if web_bun_lock_path.exists(): + console.debug(f"Removing stale {web_bun_lock_path}") + path_ops.rm(web_bun_lock_path) + return + + console.debug(f"Copying {root_bun_lock_path} to {web_bun_lock_path}") + path_ops.cp(root_bun_lock_path, web_bun_lock_path) + + +def sync_web_bun_lock_to_root(): + """Persist the mirrored .web bun.lock back to the app root.""" + web_bun_lock_path = get_web_bun_lock_path() + if not web_bun_lock_path.exists(): + return + + root_bun_lock_path = get_root_bun_lock_path() + console.debug(f"Copying {web_bun_lock_path} to {root_bun_lock_path}") + path_ops.cp(web_bun_lock_path, root_bun_lock_path) + + def initialize_web_directory(): """Initialize the web directory on reflex init.""" console.log("Initializing the web directory.") @@ -158,6 +207,9 @@ def initialize_web_directory(): console.debug(f"Copying {constants.Templates.Dirs.WEB_TEMPLATE} to {get_web_dir()}") path_ops.copy_tree(constants.Templates.Dirs.WEB_TEMPLATE, str(get_web_dir())) + console.debug("Restoring the bun lock file.") + sync_root_bun_lock_to_web() + console.debug("Initializing the web directory.") initialize_package_json() diff --git a/reflex/utils/js_runtimes.py b/reflex/utils/js_runtimes.py index 20cffcb3b38..a4c0cd7a693 100644 --- a/reflex/utils/js_runtimes.py +++ b/reflex/utils/js_runtimes.py @@ -13,7 +13,7 @@ from reflex_base.utils.decorator import cached_procedure, once from reflex_base.utils.exceptions import SystemPackageMissingError -from reflex.utils import console, net, path_ops, processes +from reflex.utils import console, frontend_skeleton, net, path_ops, processes from reflex.utils.prerequisites import get_web_dir, windows_check_onedrive_in_path @@ -353,24 +353,59 @@ def remove_existing_bun_installation(): path_ops.rm(constants.Bun.ROOT_PATH) +def _frontend_packages_cache_path() -> Path: + """Get the cache file path for frontend package installs. + + Returns: + The cache file path for frontend package installs. + """ + return get_web_dir() / "reflex.install_frontend_packages.cached" + + +def _sync_root_bun_lock_for_frontend_install(): + """Sync the canonical bun.lock into .web and invalidate the install cache when needed.""" + root_bun_lock_path = frontend_skeleton.get_root_bun_lock_path() + web_bun_lock_path = frontend_skeleton.get_web_bun_lock_path() + cache_file = _frontend_packages_cache_path() + + if not root_bun_lock_path.exists(): + if web_bun_lock_path.exists(): + frontend_skeleton.sync_root_bun_lock_to_web() + if cache_file.exists(): + path_ops.rm(cache_file) + return + + if not web_bun_lock_path.exists(): + frontend_skeleton.sync_root_bun_lock_to_web() + return + + if web_bun_lock_path.read_bytes() != root_bun_lock_path.read_bytes(): + frontend_skeleton.sync_root_bun_lock_to_web() + if cache_file.exists(): + path_ops.rm(cache_file) + + @cached_procedure( - cache_file_path=lambda: get_web_dir() / "reflex.install_frontend_packages.cached", - payload_fn=lambda packages, config: f"{sorted(packages)!r},{config.json()}", + cache_file_path=_frontend_packages_cache_path, + payload_fn=lambda packages, config, install_package_managers: ( + f"{sorted(packages)!r},{config.json()},{list(install_package_managers)!r}" + ), ) -def install_frontend_packages(packages: set[str], config: Config): +def _install_frontend_packages( + packages: set[str], + config: Config, + install_package_managers: Sequence[str], +): """Installs the base and custom frontend packages. Args: packages: A list of package names to be installed. config: The config object. + install_package_managers: The package managers available for install. Example: >>> install_frontend_packages(["react", "react-dom"], get_config()) """ - install_package_managers = get_nodejs_compatible_package_managers( - raise_on_none=True - ) - env = ( { "NODE_TLS_REJECT_UNAUTHORIZED": "0", @@ -419,3 +454,13 @@ def install_frontend_packages(packages: set[str], config: Config): [primary_package_manager, "add", "--legacy-peer-deps", *packages], show_status_message="Installing frontend packages from config and components", ) + + +def install_frontend_packages(packages: set[str], config: Config): + """Install frontend packages while respecting the canonical root bun.lock.""" + install_package_managers = tuple( + get_nodejs_compatible_package_managers(raise_on_none=True) + ) + _sync_root_bun_lock_for_frontend_install() + _install_frontend_packages(set(packages), config, install_package_managers) + frontend_skeleton.sync_web_bun_lock_to_root() diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py index c9cb76c3c0b..0fc6321e3d4 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -1,14 +1,19 @@ import shutil import tempfile +from collections.abc import Callable, Generator +from dataclasses import dataclass from pathlib import Path +from typing import Protocol import pytest from click.testing import CliRunner +from reflex_base import constants from reflex_base.config import Config from reflex_base.utils.decorator import cached_procedure from reflex.reflex import cli from reflex.testing import chdir +from reflex.utils import frontend_skeleton, js_runtimes from reflex.utils.frontend_skeleton import ( _compile_vite_config, _update_react_router_config, @@ -19,6 +24,101 @@ runner = CliRunner() +def _patch_web_dir(monkeypatch: pytest.MonkeyPatch, web_dir: Path): + monkeypatch.setattr(frontend_skeleton, "get_web_dir", lambda: web_dir) + monkeypatch.setattr(js_runtimes, "get_web_dir", lambda: web_dir) + + +def _patch_frontend_package_manager( + monkeypatch: pytest.MonkeyPatch, + package_managers: list[str], + run_package_manager, +): + monkeypatch.setattr( + js_runtimes, + "get_nodejs_compatible_package_managers", + lambda raise_on_none=True: package_managers, + ) + monkeypatch.setattr( + js_runtimes.processes, + "run_process_with_fallbacks", + run_package_manager, + ) + + +class _InstallFn(Protocol): + def __call__(self, packages: set[str] | None = ...) -> None: ... + + +@dataclass +class InstallPackagesEnv: + """Test environment for install_frontend_packages tests.""" + + tmp_path: Path + web_dir: Path + root_lock: Path + web_lock: Path + config: Config + patch_pm: Callable[[list[str], Callable], None] + install: _InstallFn + + +@pytest.fixture +def install_packages_env( + tmp_path, monkeypatch +) -> Generator[InstallPackagesEnv, None, None]: + """Isolated environment for install_frontend_packages tests. + + Creates the web dir, patches get_web_dir, chdirs into tmp_path, and + exposes the bun lock paths, a Config, a package-manager patch helper, + and a runner that invokes install_frontend_packages. + + Yields: + An InstallPackagesEnv with paths, config, and patch_pm/install helpers. + """ + web_dir = tmp_path / constants.Dirs.WEB + web_dir.mkdir() + _patch_web_dir(monkeypatch, web_dir) + config = Config(app_name="test") + + def patch_pm(package_managers: list[str], run_package_manager: Callable) -> None: + _patch_frontend_package_manager( + monkeypatch, package_managers, run_package_manager + ) + + def install(packages: set[str] | None = None) -> None: + js_runtimes.install_frontend_packages(packages or set(), config) + + env = InstallPackagesEnv( + tmp_path=tmp_path, + web_dir=web_dir, + root_lock=tmp_path / constants.Bun.LOCKFILE_PATH, + web_lock=web_dir / constants.Bun.LOCKFILE_PATH, + config=config, + patch_pm=patch_pm, + install=install, + ) + with chdir(tmp_path): + yield env + + +@pytest.fixture +def _stub_skeleton_initializers(monkeypatch): + """Stub the frontend_skeleton initialize_* helpers to no-ops.""" + for name in ( + "initialize_package_json", + "initialize_bun_config", + "initialize_npmrc", + "update_react_router_config", + "initialize_vite_config", + ): + monkeypatch.setattr(frontend_skeleton, name, lambda: None) + monkeypatch.setattr(frontend_skeleton, "get_project_hash", lambda: None) + monkeypatch.setattr( + frontend_skeleton, "init_reflex_json", lambda project_hash: None + ) + + @pytest.mark.parametrize( ("config", "export", "expected_output"), [ @@ -90,6 +190,151 @@ def test_initialise_vite_config(config, expected_output): assert expected_output in output +@pytest.mark.usefixtures("_stub_skeleton_initializers") +def test_initialize_web_directory_restores_root_bun_lock(tmp_path, monkeypatch): + template_dir = tmp_path / "template" + template_dir.mkdir() + (template_dir / ".gitignore").write_text(".web\n") + monkeypatch.setattr( + frontend_skeleton.constants.Templates.Dirs, "WEB_TEMPLATE", template_dir + ) + + web_dir = tmp_path / constants.Dirs.WEB + (tmp_path / constants.Bun.LOCKFILE_PATH).write_text("root-lock") + _patch_web_dir(monkeypatch, web_dir) + + with chdir(tmp_path): + frontend_skeleton.initialize_web_directory() + + assert (web_dir / constants.Bun.LOCKFILE_PATH).read_text() == "root-lock" + + +def test_install_frontend_packages_syncs_root_bun_lock( + install_packages_env: InstallPackagesEnv, +): + env = install_packages_env + env.root_lock.write_text("root-lock") + seen_web_lock_contents: list[str] = [] + + def run_package_manager(args, **kwargs): + seen_web_lock_contents.append(env.web_lock.read_text()) + env.web_lock.write_text("updated-lock") + + env.patch_pm(["bun"], run_package_manager) + env.install() + + assert seen_web_lock_contents == ["root-lock"] + assert env.root_lock.read_text() == "updated-lock" + + +def test_install_frontend_packages_creates_root_bun_lock( + install_packages_env: InstallPackagesEnv, +): + env = install_packages_env + + def run_package_manager(args, **kwargs): + env.web_lock.write_text("generated-lock") + + env.patch_pm(["bun"], run_package_manager) + env.install() + + assert env.root_lock.read_text() == "generated-lock" + + +def test_install_frontend_packages_does_not_persist_partial_bun_lock( + install_packages_env: InstallPackagesEnv, +): + env = install_packages_env + env.root_lock.write_text("root-lock") + call_count = 0 + error_message = "package installation failed" + + def run_package_manager(args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + assert env.web_lock.read_text() == "root-lock" + env.web_lock.write_text("partial-lock") + return + raise RuntimeError(error_message) + + env.patch_pm(["bun"], run_package_manager) + + with pytest.raises(RuntimeError, match=error_message): + env.install({"custom-package"}) + + assert env.root_lock.read_text() == "root-lock" + + +def test_install_frontend_packages_cache_respects_root_bun_lock( + install_packages_env: InstallPackagesEnv, +): + env = install_packages_env + env.root_lock.write_text("lock-v1") + call_count = 0 + + def run_package_manager(args, **kwargs): + nonlocal call_count + call_count += 1 + if env.root_lock.exists(): + env.web_lock.write_text(env.root_lock.read_text()) + else: + env.web_lock.write_text("lock-regenerated") + + env.patch_pm(["bun"], run_package_manager) + + env.install() + env.install() + env.root_lock.write_text("lock-v2") + env.install() + env.root_lock.unlink() + env.install() + + assert call_count == 3 + + +def test_install_frontend_packages_npm_does_not_create_bogus_bun_lock( + install_packages_env: InstallPackagesEnv, +): + env = install_packages_env + env.web_lock.write_text("stale-lock") + call_count = 0 + + def run_package_manager(args, **kwargs): + nonlocal call_count + call_count += 1 + assert not env.web_lock.exists() + + env.patch_pm(["npm"], run_package_manager) + env.install() + + assert call_count == 1 + assert not env.root_lock.exists() + assert not env.web_lock.exists() + + +def test_install_frontend_packages_cache_hit_refreshes_web_bun_lock( + install_packages_env: InstallPackagesEnv, +): + env = install_packages_env + env.root_lock.write_text("root-lock") + call_count = 0 + + def run_package_manager(args, **kwargs): + nonlocal call_count + call_count += 1 + env.web_lock.write_text("root-lock") + + env.patch_pm(["bun"], run_package_manager) + + env.install() + env.web_lock.unlink() + env.install() + + assert call_count == 1 + assert env.web_lock.read_text() == "root-lock" + + def test_cached_procedure(): call_count = 0