diff --git a/docs/getting_started/project-structure.md b/docs/getting_started/project-structure.md index b4e38f8a663..1004bf309f5 100644 --- a/docs/getting_started/project-structure.md +++ b/docs/getting_started/project-structure.md @@ -48,6 +48,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 39c90dec1a6..9027da0acbd 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 a730f2630ef..825eb02a823 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 af6d3df7dd2..19408a37435 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -4,12 +4,14 @@ import pytest from click.testing import CliRunner +from reflex_base import constants from reflex_base.config import Config from reflex_base.constants.installer import PackageJson 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, @@ -20,6 +22,28 @@ 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, + ) + + @pytest.mark.parametrize( ("config", "export", "expected_output"), [ @@ -109,6 +133,195 @@ def test_get_prod_command(frontend_path, expected_command): assert PackageJson.Commands.get_prod_command(frontend_path) == expected_command +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") + root_bun_lock_path = tmp_path / constants.Bun.LOCKFILE_PATH + root_bun_lock_path.write_text("root-lock") + web_dir = tmp_path / constants.Dirs.WEB + + monkeypatch.setattr( + frontend_skeleton.constants.Templates.Dirs, + "WEB_TEMPLATE", + template_dir, + ) + monkeypatch.setattr(frontend_skeleton, "get_project_hash", lambda: None) + monkeypatch.setattr(frontend_skeleton, "initialize_package_json", lambda: None) + monkeypatch.setattr(frontend_skeleton, "initialize_bun_config", lambda: None) + monkeypatch.setattr(frontend_skeleton, "initialize_npmrc", lambda: None) + monkeypatch.setattr(frontend_skeleton, "update_react_router_config", lambda: None) + monkeypatch.setattr(frontend_skeleton, "initialize_vite_config", lambda: None) + monkeypatch.setattr( + frontend_skeleton, + "init_reflex_json", + lambda project_hash: None, + ) + _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(tmp_path, monkeypatch): + web_dir = tmp_path / constants.Dirs.WEB + web_dir.mkdir() + root_bun_lock_path = tmp_path / constants.Bun.LOCKFILE_PATH + web_bun_lock_path = web_dir / constants.Bun.LOCKFILE_PATH + root_bun_lock_path.write_text("root-lock") + seen_web_lock_contents: list[str] = [] + + def run_package_manager(args, **kwargs): + seen_web_lock_contents.append(web_bun_lock_path.read_text()) + web_bun_lock_path.write_text("updated-lock") + + _patch_web_dir(monkeypatch, web_dir) + _patch_frontend_package_manager(monkeypatch, ["bun"], run_package_manager) + + with chdir(tmp_path): + js_runtimes.install_frontend_packages(set(), Config(app_name="test")) + + assert seen_web_lock_contents == ["root-lock"] + assert root_bun_lock_path.read_text() == "updated-lock" + + +def test_install_frontend_packages_creates_root_bun_lock(tmp_path, monkeypatch): + web_dir = tmp_path / constants.Dirs.WEB + web_dir.mkdir() + root_bun_lock_path = tmp_path / constants.Bun.LOCKFILE_PATH + web_bun_lock_path = web_dir / constants.Bun.LOCKFILE_PATH + + def run_package_manager(args, **kwargs): + web_bun_lock_path.write_text("generated-lock") + + _patch_web_dir(monkeypatch, web_dir) + _patch_frontend_package_manager(monkeypatch, ["bun"], run_package_manager) + + with chdir(tmp_path): + js_runtimes.install_frontend_packages(set(), Config(app_name="test")) + + assert root_bun_lock_path.read_text() == "generated-lock" + + +def test_install_frontend_packages_does_not_persist_partial_bun_lock( + tmp_path, monkeypatch +): + web_dir = tmp_path / constants.Dirs.WEB + web_dir.mkdir() + root_bun_lock_path = tmp_path / constants.Bun.LOCKFILE_PATH + web_bun_lock_path = web_dir / constants.Bun.LOCKFILE_PATH + root_bun_lock_path.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 web_bun_lock_path.read_text() == "root-lock" + web_bun_lock_path.write_text("partial-lock") + return + raise RuntimeError(error_message) + + _patch_web_dir(monkeypatch, web_dir) + _patch_frontend_package_manager(monkeypatch, ["bun"], run_package_manager) + + with chdir(tmp_path), pytest.raises(RuntimeError, match=error_message): + js_runtimes.install_frontend_packages( + {"custom-package"}, + Config(app_name="test"), + ) + + assert root_bun_lock_path.read_text() == "root-lock" + + +def test_install_frontend_packages_cache_respects_root_bun_lock(tmp_path, monkeypatch): + web_dir = tmp_path / constants.Dirs.WEB + web_dir.mkdir() + root_bun_lock_path = tmp_path / constants.Bun.LOCKFILE_PATH + web_bun_lock_path = web_dir / constants.Bun.LOCKFILE_PATH + root_bun_lock_path.write_text("lock-v1") + call_count = 0 + + def run_package_manager(args, **kwargs): + nonlocal call_count + call_count += 1 + if root_bun_lock_path.exists(): + web_bun_lock_path.write_text(root_bun_lock_path.read_text()) + else: + web_bun_lock_path.write_text("lock-regenerated") + + _patch_web_dir(monkeypatch, web_dir) + _patch_frontend_package_manager(monkeypatch, ["bun"], run_package_manager) + + with chdir(tmp_path): + config = Config(app_name="test") + js_runtimes.install_frontend_packages(set(), config) + js_runtimes.install_frontend_packages(set(), config) + root_bun_lock_path.write_text("lock-v2") + js_runtimes.install_frontend_packages(set(), config) + root_bun_lock_path.unlink() + js_runtimes.install_frontend_packages(set(), config) + + assert call_count == 3 + + +def test_install_frontend_packages_npm_does_not_create_bogus_bun_lock( + tmp_path, monkeypatch +): + web_dir = tmp_path / constants.Dirs.WEB + web_dir.mkdir() + root_bun_lock_path = tmp_path / constants.Bun.LOCKFILE_PATH + web_bun_lock_path = web_dir / constants.Bun.LOCKFILE_PATH + web_bun_lock_path.write_text("stale-lock") + call_count = 0 + + def run_package_manager(args, **kwargs): + nonlocal call_count + call_count += 1 + assert not web_bun_lock_path.exists() + + _patch_web_dir(monkeypatch, web_dir) + _patch_frontend_package_manager(monkeypatch, ["npm"], run_package_manager) + + with chdir(tmp_path): + js_runtimes.install_frontend_packages(set(), Config(app_name="test")) + + assert call_count == 1 + assert not root_bun_lock_path.exists() + assert not web_bun_lock_path.exists() + + +def test_install_frontend_packages_cache_hit_refreshes_web_bun_lock( + tmp_path, monkeypatch +): + web_dir = tmp_path / constants.Dirs.WEB + web_dir.mkdir() + root_bun_lock_path = tmp_path / constants.Bun.LOCKFILE_PATH + web_bun_lock_path = web_dir / constants.Bun.LOCKFILE_PATH + root_bun_lock_path.write_text("root-lock") + call_count = 0 + + def run_package_manager(args, **kwargs): + nonlocal call_count + call_count += 1 + web_bun_lock_path.write_text("root-lock") + + _patch_web_dir(monkeypatch, web_dir) + _patch_frontend_package_manager(monkeypatch, ["bun"], run_package_manager) + + with chdir(tmp_path): + config = Config(app_name="test") + js_runtimes.install_frontend_packages(set(), config) + web_bun_lock_path.unlink() + js_runtimes.install_frontend_packages(set(), config) + + assert call_count == 1 + assert web_bun_lock_path.read_text() == "root-lock" + + def test_cached_procedure(): call_count = 0