From 10aff96bcf6311c52c35e66124b105f7911948e0 Mon Sep 17 00:00:00 2001 From: Alek Petuskey <38776361+Alek99@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:58:03 -0700 Subject: [PATCH 1/6] Speed up deploy preparation and add opt-in frontend build reuse --- docs/hosting/self-hosting.md | 38 + .../+cache-production-frontend.performance.md | 1 + news/+faster-deploy-archives.performance.md | 1 + ...rve-formatted-package-cache.performance.md | 1 + ...se-installed-framework-pins.performance.md | 1 + .../+skip-cached-version-check.performance.md | 1 + ...able-frontend-install-cache.performance.md | 1 + .../news/+frontend-build-cache.feature.md | 1 + .../src/reflex_base/environment.py | 4 + ...nused-deploy-provider-check.performance.md | 1 + .../src/reflex_cli/v2/cli.py | 4 +- reflex/utils/build.py | 90 ++- reflex/utils/build_cache.py | 320 +++++++++ reflex/utils/frontend_skeleton.py | 16 +- reflex/utils/js_runtimes.py | 166 ++++- tests/units/reflex_cli/v2/test_cli.py | 42 +- tests/units/test_prerequisites.py | 657 +++++++++++++++++- tests/units/utils/test_build.py | 83 +++ tests/units/utils/test_build_cache.py | 359 ++++++++++ tests/units/utils/test_frontend_skeleton.py | 72 ++ 20 files changed, 1809 insertions(+), 50 deletions(-) create mode 100644 news/+cache-production-frontend.performance.md create mode 100644 news/+faster-deploy-archives.performance.md create mode 100644 news/+preserve-formatted-package-cache.performance.md create mode 100644 news/+reuse-installed-framework-pins.performance.md create mode 100644 news/+skip-cached-version-check.performance.md create mode 100644 news/+stable-frontend-install-cache.performance.md create mode 100644 packages/reflex-base/news/+frontend-build-cache.feature.md create mode 100644 packages/reflex-hosting-cli/news/+skip-unused-deploy-provider-check.performance.md create mode 100644 reflex/utils/build_cache.py create mode 100644 tests/units/utils/test_build_cache.py create mode 100644 tests/units/utils/test_frontend_skeleton.py diff --git a/docs/hosting/self-hosting.md b/docs/hosting/self-hosting.md index 4245bdf78d1..76bbea1d0df 100644 --- a/docs/hosting/self-hosting.md +++ b/docs/hosting/self-hosting.md @@ -91,6 +91,44 @@ this, use the `--no-zip` parameter. This provides the frontend in the `.web/build/client/` directory and the backend can be found in the root directory of the project. +## Reusing a Production Frontend Build + +For repeated deployments with deterministic frontend builds, you can opt in to a +local build cache: + +```bash +REFLEX_FRONTEND_BUILD_CACHE=true reflex deploy +``` + +The same option works with `reflex export` and production-mode `reflex run`. +It is disabled by default. On a cache hit, Reflex restores the pristine JavaScript +build, then runs post-build plugins, fallback generation, compression, and frontend +path processing again. Python compilation and the normal dependency checks still run. + +Enable this option only when build output is determined by the tracked local inputs. +Prerendering, Vite plugins, and custom export scripts can read remote data, the clock, +or files outside `.web`; changes to those inputs require a fresh build. Run with +`REFLEX_FRONTEND_BUILD_CACHE=false` to force one and discard the previous snapshot, +or remove `.web/reflex.build-cache`. Re-enabling the option then populates a new cache. + +The cache checks generated frontend source, assets and configuration within `.web`, +the build environment, runtime identity, and installed dependency file metadata. +It also verifies snapshot file contents before restoring them. Use it on a local +macOS or Linux filesystem that reports file modification and change timestamps +reliably; the cache is bypassed on Windows and when links lead outside tracked inputs. + +Generated build output, `.react-router`, the dependency-install cache marker, and +the top-level `node_modules/.vite`, `.vite-temp`, and `.cache` directories are +excluded from the input fingerprint. The private `last_reflex_run_datetime` and +`last_version_check_datetime` fields in `reflex.json` are also excluded. Custom code +that uses these excluded values or files to determine build output should keep the +cache disabled. Cache hits can retain the earlier private timestamps embedded in +bundles; the client framework uses the separately checked Reflex version value. + +Cache misses perform a normal build and have extra fingerprinting and snapshot-copy +work, so this option is most useful when the same frontend is deployed repeatedly. +Deleting `.web` also deletes its cached build. + ## Reflex Container Service Another option is to run your Reflex service in a container. For this diff --git a/news/+cache-production-frontend.performance.md b/news/+cache-production-frontend.performance.md new file mode 100644 index 00000000000..dd6e2d9d707 --- /dev/null +++ b/news/+cache-production-frontend.performance.md @@ -0,0 +1 @@ +Add an opt-in local production frontend build cache with `REFLEX_FRONTEND_BUILD_CACHE=true` for deterministic repeat builds on macOS and Linux. Post-build hooks and compression still run; set the variable to `false` to force a fresh build and discard cached output. diff --git a/news/+faster-deploy-archives.performance.md b/news/+faster-deploy-archives.performance.md new file mode 100644 index 00000000000..5b849a20ef0 --- /dev/null +++ b/news/+faster-deploy-archives.performance.md @@ -0,0 +1 @@ +Speed up deployment archive creation by reducing filesystem checks for excluded files and avoiding recompression of precompressed frontend assets. diff --git a/news/+preserve-formatted-package-cache.performance.md b/news/+preserve-formatted-package-cache.performance.md new file mode 100644 index 00000000000..f105c546c0b --- /dev/null +++ b/news/+preserve-formatted-package-cache.performance.md @@ -0,0 +1 @@ +Avoid reinstalling frontend dependencies solely because the package manager reformatted `package.json`. Actual changes to dependency versions, scripts, and other manifest values still invalidate the install cache. diff --git a/news/+reuse-installed-framework-pins.performance.md b/news/+reuse-installed-framework-pins.performance.md new file mode 100644 index 00000000000..02ea9964237 --- /dev/null +++ b/news/+reuse-installed-framework-pins.performance.md @@ -0,0 +1 @@ +Speed up frontend setup during deploys by reusing matching framework dependencies after installing from a persisted lockfile. Repair npm development dependency placement and keep build tools installed when `NODE_ENV=production`. diff --git a/news/+skip-cached-version-check.performance.md b/news/+skip-cached-version-check.performance.md new file mode 100644 index 00000000000..1ff2f0a9df7 --- /dev/null +++ b/news/+skip-cached-version-check.performance.md @@ -0,0 +1 @@ +Avoid redundant package version requests during deploy and startup when the app has already recorded a successful version check. diff --git a/news/+stable-frontend-install-cache.performance.md b/news/+stable-frontend-install-cache.performance.md new file mode 100644 index 00000000000..ea02b16af45 --- /dev/null +++ b/news/+stable-frontend-install-cache.performance.md @@ -0,0 +1 @@ +Reuse cached frontend dependency installs across CLI processes with unchanged configuration, avoiding reinstalls caused by Python's randomized set ordering. diff --git a/packages/reflex-base/news/+frontend-build-cache.feature.md b/packages/reflex-base/news/+frontend-build-cache.feature.md new file mode 100644 index 00000000000..161df210e2e --- /dev/null +++ b/packages/reflex-base/news/+frontend-build-cache.feature.md @@ -0,0 +1 @@ +Add the `REFLEX_FRONTEND_BUILD_CACHE` environment option for opting into local reuse of deterministic production frontend builds. It is disabled by default. diff --git a/packages/reflex-base/src/reflex_base/environment.py b/packages/reflex-base/src/reflex_base/environment.py index 3bb80d6970b..45f1fcc9c5e 100644 --- a/packages/reflex-base/src/reflex_base/environment.py +++ b/packages/reflex-base/src/reflex_base/environment.py @@ -571,6 +571,10 @@ class EnvironmentVariables: # Whether to use npm over bun to install and run the frontend. REFLEX_USE_NPM: EnvVar[bool] = env_var(False) + # Opt in to local reuse of deterministic production frontend builds. + # External/time-dependent build inputs require a forced fresh build. + REFLEX_FRONTEND_BUILD_CACHE: EnvVar[bool] = env_var(False) + # The npm registry to use. NPM_CONFIG_REGISTRY: EnvVar[str | None] = env_var(None) diff --git a/packages/reflex-hosting-cli/news/+skip-unused-deploy-provider-check.performance.md b/packages/reflex-hosting-cli/news/+skip-unused-deploy-provider-check.performance.md new file mode 100644 index 00000000000..21f5a0aa644 --- /dev/null +++ b/packages/reflex-hosting-cli/news/+skip-unused-deploy-provider-check.performance.md @@ -0,0 +1 @@ +Speed up non-interactive deploys by skipping the provider availability request used only for the interactive provider prompt. diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py index 1e1ac96eaf6..a83ae4d67f9 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py @@ -263,8 +263,8 @@ def _resolve_deploy_provider( # Explicit --provider always wins; validated by the caller already. target = hosting.normalize_provider(provider_arg) else: - gcp_status = hosting.gcp_deploy_available(client) - if not gcp_status or not interactive: + gcp_status = hosting.gcp_deploy_available(client) if interactive else None + if not gcp_status: # No GCP connected, or non-interactive with no explicit choice: keep # whatever the app already targets. target = current diff --git a/reflex/utils/build.py b/reflex/utils/build.py index 220a3f73bc5..538d8d24886 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -10,7 +10,14 @@ from reflex_base import constants from reflex_base.config import get_config -from reflex.utils import console, js_runtimes, path_ops, prerequisites, processes +from reflex.utils import ( + build_cache, + console, + js_runtimes, + path_ops, + prerequisites, + processes, +) from reflex.utils.exec import is_in_app_harness logger = logging.getLogger(__name__) @@ -62,6 +69,26 @@ def _zip( root_directory = Path(root_directory).resolve() directory_names_to_exclude = directory_names_to_exclude or set() files_to_exclude = files_to_exclude or set() + excluded_file_ids = set() + for excluded_file in files_to_exclude: + if excluded_file.exists(): + stat = excluded_file.stat() + excluded_file_ids.add((stat.st_dev, stat.st_ino)) + + def is_excluded(path: Path) -> bool: + """Check file identity without repeatedly statting every excluded path. + + Args: + path: The file or directory to check. + + Returns: + Whether the path refers to an excluded file or directory. + """ + if not excluded_file_ids: + return False + stat = path.stat() + return (stat.st_dev, stat.st_ino) in excluded_file_ids + files_to_zip: list[Path] = [] # Traverse the root directory in a top-down manner. In this traversal order, # we can modify the dirs list in-place to remove directories we don't want to include. @@ -74,11 +101,7 @@ def _zip( subdirectory_name for subdirectory_name in subdirectories_names if subdirectory_name not in directory_names_to_exclude - and not any( - (directory_path / subdirectory_name).samefile(exclude) - for exclude in files_to_exclude - if exclude.exists() - ) + and not is_excluded(directory_path / subdirectory_name) and not subdirectory_name.startswith(".") and ( not exclude_venv_directories @@ -95,11 +118,7 @@ def _zip( files_to_zip += [ directory_path / subfile_name for subfile_name in subfiles_names - if not any( - (directory_path / subfile_name).samefile(excluded_file) - for excluded_file in files_to_exclude - if excluded_file.exists() - ) + if not is_excluded(directory_path / subfile_name) ] if globs_to_include: for glob in globs_to_include: @@ -118,7 +137,16 @@ def _zip( for file in files_to_zip: logger.debug(f"{target}: {file}", extra={"progress": progress}) progress.advance(task) - zipf.write(file, Path(file).relative_to(root_directory)) + # Sidecars are already compressed for serving the frontend. + compress_type = ( + zipfile.ZIP_STORED + if component_name == constants.ComponentName.FRONTEND + and file.suffix in {".gz", ".br", ".zst"} + else zipfile.ZIP_DEFLATED + ) + zipf.write( + file, file.relative_to(root_directory), compress_type=compress_type + ) def zip_app( @@ -235,7 +263,31 @@ def build(): SystemExit: If the build process fails. """ wdir = prerequisites.get_web_dir() + command = [ + *js_runtimes.get_js_package_executor(raise_on_none=True)[0], + "run", + "export", + ] + with build_cache.frontend_build_cache(wdir, command) as cache: + if cache is None or not cache.restore(): + _build_frontend(wdir, command) + if cache is not None: + cache.capture() + _postprocess_frontend(wdir) + if cache is not None: + cache.commit() + + +def _build_frontend(wdir: Path, command: list[str]) -> None: + """Run a fresh production JavaScript build. + + Args: + wdir: Frontend working directory. + command: Package manager export command. + Raises: + SystemExit: The frontend build failed. + """ # Clean the static directory if it exists. path_ops.rm(str(wdir / constants.Dirs.BUILD_DIR)) @@ -248,11 +300,7 @@ def build(): # Start the subprocess with the progress bar. process = processes.new_process( - [ - *js_runtimes.get_js_package_executor(raise_on_none=True)[0], - "run", - "export", - ], + command, cwd=wdir, shell=constants.IS_WINDOWS, env={ @@ -267,6 +315,14 @@ def build(): "Failed to build the frontend. Please run with --loglevel debug for more information.", ) raise SystemExit(1) + + +def _postprocess_frontend(wdir: Path) -> None: + """Apply build hooks and serving transformations to pristine frontend output. + + Args: + wdir: Frontend working directory. + """ config = get_config() static_dir = wdir / constants.Dirs.STATIC diff --git a/reflex/utils/build_cache.py b/reflex/utils/build_cache.py new file mode 100644 index 00000000000..84fffae5a9b --- /dev/null +++ b/reflex/utils/build_cache.py @@ -0,0 +1,320 @@ +"""Opt-in local caching of pristine production frontend build output.""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import shutil +import stat +import tempfile +from collections.abc import Iterator, Sequence +from contextlib import contextmanager +from pathlib import Path + +from reflex_base import constants +from reflex_base.environment import environment + +from reflex.utils import path_ops + +logger = logging.getLogger(__name__) + +_CACHE_DIR = "reflex.build-cache" +_GENERATED_ROOT_ENTRIES = { + _CACHE_DIR, + constants.Dirs.BUILD_DIR, + ".react-router", + "reflex.install_frontend_packages.cached", +} +_DEPENDENCY_CACHE_ENTRIES = {".vite", ".vite-temp", ".cache"} +_TELEMETRY_FIELDS = {"last_reflex_run_datetime", "last_version_check_datetime"} + + +def _is_generated(relative: Path) -> bool: + """Identify paths omitted from the frontend input fingerprint. + + Args: + relative: Path relative to the frontend working directory. + + Returns: + Whether the path belongs to generated output or a transient cache. + """ + return bool(relative.parts) and ( + relative.parts[0] in _GENERATED_ROOT_ENTRIES + or ( + len(relative.parts) >= 2 + and relative.parts[0] == "node_modules" + and relative.parts[1] in _DEPENDENCY_CACHE_ENTRIES + ) + ) + + +def _remove_cache_entry(path: Path) -> None: + """Remove local cache data without following or chmodding a symlink target. + + Args: + path: Cache entry to discard. + """ + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.exists(): + shutil.rmtree(path) + + +def _tree_digest(root: Path, *, inputs: bool = False) -> str: + """Hash tracked tree entries, using change metadata for installed dependencies. + + Args: + root: Directory to inspect without following directory symlinks. + inputs: Whether this is the frontend input tree rather than a snapshot. + + Returns: + A digest of names, file types, modes, and file content or dependency metadata. + + Raises: + OSError: A file cannot be inspected or links outside the tracked tree. + ValueError: The tree contains unsupported file types or invalid metadata. + """ + if root.is_symlink(): + msg = "Build cache cannot inspect a symlink as its root" + raise ValueError(msg) + digest = hashlib.sha256() + + def visit(directory: Path) -> None: + """Hash entries in one directory and descend into physical directories. + + Args: + directory: Directory within the tracked tree. + """ + with os.scandir(directory) as entries: + ordered_entries = sorted(entries, key=lambda entry: entry.name) + for entry in ordered_entries: + path = Path(entry.path) + relative = path.relative_to(root) + if inputs and _is_generated(relative): + continue + info = entry.stat(follow_symlinks=False) + digest.update(json.dumps([relative.as_posix(), info.st_mode]).encode()) + if stat.S_ISLNK(info.st_mode): + try: + target = path.resolve(strict=True) + except RuntimeError as error: + # Python 3.10-3.12 report symlink loops as RuntimeError. + msg = "Build input contains a symlink loop" + raise ValueError(msg) from error + if not inputs or not target.is_relative_to(root): + msg = "Build cache cannot track an external symlink" + raise ValueError(msg) + target_relative = target.relative_to(root) + if _is_generated(target_relative): + msg = "Build input links to an untracked generated directory" + raise ValueError(msg) + # An intermediate link outside the tree can redirect to another + # tracked file without changing this link or either file. + digest.update( + json.dumps([ + str(path.readlink()), + target_relative.as_posix(), + ]).encode() + ) + elif stat.S_ISDIR(info.st_mode): + visit(path) + elif stat.S_ISREG(info.st_mode): + if inputs and relative.parts[0] == "node_modules": + # ctime detects edits even when size and mtime are restored. + digest.update( + json.dumps([ + info.st_dev, + info.st_ino, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + ]).encode() + ) + elif inputs and relative.as_posix() == constants.Reflex.JSON: + metadata = json.loads(path.read_text()) + if not isinstance(metadata, dict): + msg = "Frontend metadata must be an object" + raise ValueError(msg) + digest.update( + json.dumps( + { + key: value + for key, value in metadata.items() + if key not in _TELEMETRY_FIELDS + }, + sort_keys=True, + ).encode() + ) + else: + content_digest = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + content_digest.update(chunk) + digest.update(content_digest.digest()) + else: + msg = "Build cache only supports regular files and directories" + raise ValueError(msg) + digest.update(b"\0") + + visit(root) + return digest.hexdigest() + + +def _input_digest(web_dir: Path, command: Sequence[str | Path]) -> str: + """Fingerprint local build inputs and the executing runtime. + + Args: + web_dir: Resolved frontend working directory. + command: Production export command. + + Returns: + A digest suitable for comparing builds on this machine. + + Raises: + OSError: Installed dependencies or a runtime are missing. + ValueError: An input cannot safely be tracked. + """ + if not (web_dir / "node_modules").is_dir(): + msg = "Installed frontend dependencies are missing" + raise ValueError(msg) + runtime_paths = {str(command[0])} + if node := path_ops.get_node_path(): + runtime_paths.add(str(node)) + runtimes = [] + for runtime in sorted(runtime_paths): + path = Path(shutil.which(runtime) or runtime).resolve(strict=True) + info = path.stat() + runtimes.append(( + str(path), + info.st_dev, + info.st_ino, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + )) + payload = [ + 1, + str(web_dir), + constants.Reflex.VERSION, + [str(arg) for arg in command], + runtimes, + sorted(os.environ.items()), + _tree_digest(web_dir, inputs=True), + ] + return hashlib.sha256(json.dumps(payload).encode()).hexdigest() + + +class _FrontendBuildCache: + """A single pending snapshot, published only after a successful full build.""" + + def __init__(self, web_dir: Path, command: Sequence[str | Path]): + """Record the inputs before running Vite. + + Args: + web_dir: Resolved frontend directory. + command: Production export command. + """ + self.web_dir = web_dir + self.command = command + self.directory = web_dir / _CACHE_DIR + self.current = self.directory / "current" + self.key = _input_digest(web_dir, command) + self.pending: Path | None = None + + def restore(self) -> bool: + """Restore a verified pristine snapshot. + + Returns: + Whether Vite can be skipped. + """ + snapshot = self.current / "build" + try: + if self.current.is_symlink() or snapshot.is_symlink(): + return False + metadata = json.loads((self.current / "metadata.json").read_text()) + if metadata != {"input": self.key, "output": _tree_digest(snapshot)}: + return False + _remove_cache_entry(self.web_dir / constants.Dirs.BUILD_DIR) + shutil.copytree(snapshot, self.web_dir / constants.Dirs.BUILD_DIR) + except (OSError, ValueError): + return False + logger.info("Reusing cached production frontend build.") + return True + + def capture(self) -> None: + """Copy pristine Vite output before post-build hooks can mutate it.""" + try: + self.directory.mkdir(exist_ok=True) + self.pending = Path(tempfile.mkdtemp(prefix="pending-", dir=self.directory)) + source = self.web_dir / constants.Dirs.BUILD_DIR + output_digest = _tree_digest(source) + shutil.copytree(source, self.pending / "build") + if _tree_digest(self.pending / "build") != output_digest: + self.close() + return + (self.pending / "metadata.json").write_text( + json.dumps({ + "input": self.key, + "output": output_digest, + }) + ) + except (OSError, ValueError): + self.close() + logger.debug( + "Could not snapshot frontend output; continuing without caching." + ) + + def commit(self) -> None: + """Publish only if the complete build succeeded and tracked inputs stayed stable.""" + if self.pending is None: + return + try: + if _input_digest(self.web_dir, self.command) != self.key: + return + _remove_cache_entry(self.current) + self.pending.replace(self.current) + self.pending = None + except (OSError, ValueError): + logger.debug( + "Could not publish frontend cache; continuing without caching." + ) + + def close(self) -> None: + """Remove an unpublished snapshot without affecting the build result.""" + if self.pending is not None: + shutil.rmtree(self.pending, ignore_errors=True) + self.pending = None + + +@contextmanager +def frontend_build_cache( + web_dir: Path, command: Sequence[str | Path] +) -> Iterator[_FrontendBuildCache | None]: + """Manage the explicitly enabled local frontend build cache. + + Args: + web_dir: Frontend working directory. + command: Production export command. + + Yields: + A cache handle, or None when disabled or local inputs cannot be tracked. + """ + web_dir = web_dir.resolve() + directory = web_dir / _CACHE_DIR + enabled = environment.REFLEX_FRONTEND_BUILD_CACHE.get() and not constants.IS_WINDOWS + cache = None + try: + if not enabled: + # A forced fresh build must not leave an older reusable snapshot. + _remove_cache_entry(directory) + elif not directory.is_symlink(): + cache = _FrontendBuildCache(web_dir, command) + except (OSError, ValueError): + logger.debug("Frontend cache unavailable; running a fresh production build.") + try: + yield cache + finally: + if cache is not None: + cache.close() diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index 9a5fa3d9ee3..0daca6683d5 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -367,8 +367,20 @@ def sync_root_package_json_to_web() -> bool: output_path = get_web_lockfile_path(constants.PackageJson.PATH) rendered = _compile_package_json() - if output_path.exists() and output_path.read_text() == rendered: - return False + if output_path.exists(): + existing = output_path.read_text() + if existing == rendered: + return False + try: + # Package managers reformat this file after installs. Preserve their + # formatting when every JSON value is unchanged, including its type. + existing_json = json.dumps(json.loads(existing), sort_keys=True) + rendered_json = json.dumps(json.loads(rendered), sort_keys=True) + except json.JSONDecodeError: + pass + else: + if existing_json == rendered_json: + return False changed = output_path.exists() path_ops.mkdir(output_path.parent) diff --git a/reflex/utils/js_runtimes.py b/reflex/utils/js_runtimes.py index 9ebbf029bd3..9c966e30a2c 100644 --- a/reflex/utils/js_runtimes.py +++ b/reflex/utils/js_runtimes.py @@ -4,6 +4,7 @@ import json import logging import os +import re import tempfile from collections.abc import Sequence from pathlib import Path @@ -14,6 +15,7 @@ from reflex_base.environment import environment from reflex_base.utils.decorator import cached_procedure, once from reflex_base.utils.exceptions import SystemPackageMissingError +from reflex_base.utils.serializers import get_serializer, serialize_set from rich.markup import escape from reflex.utils import console, frontend_skeleton, net, path_ops, processes @@ -422,7 +424,7 @@ def _extract_package_name(package_spec: str) -> str: return package_spec.split("@", 1)[0] -def _existing_web_package_sections() -> tuple[set[str], set[str]]: +def _existing_web_package_sections() -> tuple[dict[str, str], dict[str, str]]: """Return packages currently declared in .web/package.json by section. Reads ``.web/package.json``'s ``dependencies`` and ``devDependencies`` @@ -431,24 +433,24 @@ def _existing_web_package_sections() -> tuple[set[str], set[str]]: deps. Returns: - A tuple ``(deps, dev_deps)`` of bare package names. Both empty if + A tuple ``(deps, dev_deps)`` mapping package names to versions. Both empty if the file is missing or unreadable. """ web_pkg_json_path = frontend_skeleton.get_web_lockfile_path( constants.PackageJson.PATH ) if not web_pkg_json_path.exists(): - return set(), set() + return {}, {} try: data = json.loads(web_pkg_json_path.read_text()) except (json.JSONDecodeError, OSError) as e: logger.warning( f"Failed to read {web_pkg_json_path}: {e}; skipping existing package check." ) - return set(), set() + return {}, {} return ( - set(data.get("dependencies") or {}), - set(data.get("devDependencies") or {}), + data.get("dependencies") or {}, + data.get("devDependencies") or {}, ) @@ -468,6 +470,81 @@ def _is_bun_package_manager(package_manager: str) -> bool: return Path(package_manager).stem.lower() == "bun" +def _npm_installed_package_sections( + declared_deps: dict[str, str], declared_dev_deps: dict[str, str] +) -> tuple[dict[str, str], dict[str, str]]: + """Verify npm's saved caret declarations against its lockfile and installed packages. + + Args: + declared_deps: Regular dependency declarations used for the install. + declared_dev_deps: Development dependency declarations used for the install. + + Returns: + Dependency sections with verified caret declarations replaced by their + exact installed versions. Unverified declarations remain unchanged. + """ + try: + lock = json.loads( + frontend_skeleton.get_web_lockfile_path( + constants.Node.LOCKFILE_PATH + ).read_text() + ) + except (OSError, ValueError): + return declared_deps, declared_dev_deps + if not isinstance(lock, dict) or lock.get("lockfileVersion") not in (2, 3): + return declared_deps, declared_dev_deps + packages = lock.get("packages") + if not isinstance(packages, dict) or not isinstance(root := packages.get(""), dict): + return declared_deps, declared_dev_deps + + installed_deps, installed_dev_deps = dict(declared_deps), dict(declared_dev_deps) + for section, declared, installed in ( + ("dependencies", declared_deps, installed_deps), + ("devDependencies", declared_dev_deps, installed_dev_deps), + ): + root_declarations = root.get(section, {}) + if not isinstance(root_declarations, dict): + continue + for name, declaration in declared.items(): + entry = packages.get(f"node_modules/{name}") + if ( + not isinstance(entry, dict) + or entry.get("link") + or entry.get("name", name) != name + or root_declarations.get(name) != declaration + ): + continue + installed_version = entry.get("version") + if ( + not isinstance(installed_version, str) + or declaration != f"^{installed_version}" + ): + continue + resolved = entry.get("resolved") + if isinstance(resolved, str) and resolved.startswith(( + "file:", + "link:", + "workspace:", + )): + continue + package_dir = get_web_dir() / "node_modules" / name + if package_dir.is_symlink(): + continue + # npm can ignore package-lock.json (package-lock=false or shrinkwrap). + # Check the installed package too so a stale lock cannot hide an upgrade. + try: + package = json.loads((package_dir / "package.json").read_text()) + except (OSError, ValueError): + continue + if ( + isinstance(package, dict) + and package.get("name") == name + and package.get("version") == installed_version + ): + installed[name] = installed_version + return installed_deps, installed_dev_deps + + def _run_initial_install( primary_package_manager: str, env: dict, frozen_lockfile: bool ) -> None: @@ -494,6 +571,8 @@ def _run_initial_install( "install", "--legacy-peer-deps", ] + if not _is_bun_package_manager(primary_package_manager): + install_args.append("--include=dev") if frozen_lockfile and _is_bun_package_manager(primary_package_manager): # ``--frozen-lockfile`` is bun-only; npm ignores it today and the # next major rejects unknown flags outright. @@ -571,16 +650,32 @@ def _split_by_version_specifier( return pinned, unpinned -def _pinned_args_from_constants(deps: dict[str, str]) -> set[str]: +def _pinned_args_from_constants( + deps: dict[str, str], + installed: dict[str, str] | None = None, + explicitly_requested: set[str] | None = None, +) -> set[str]: """Render constants-style dep dicts as ``name@version`` add args. Args: deps: Mapping of package name to version string. + installed: Dependencies already installed from a restored lockfile. + explicitly_requested: Package names with component or plugin pins whose + existing resolution behavior must be preserved. Returns: Set of ``name@version`` specs. """ - return {f"{name}@{version}" for name, version in deps.items()} + return { + f"{name}@{version}" + for name, version in deps.items() + if installed is None + or installed.get(name) != version + or (explicitly_requested is not None and name in explicitly_requested) + or not re.fullmatch( + r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?", version + ) + } def _frontend_packages_cache_payload( @@ -624,8 +719,8 @@ def _install_frontend_packages( Resolution rules: * Framework deps in :attr:`constants.PackageJson.DEPENDENCIES` and :attr:`constants.PackageJson.DEV_DEPENDENCIES` always carry version - specifiers and are added with strict pins so they overwrite any - existing entry in package.json. + specifiers and are added with strict pins. Matching entries in the + correct section are reused after a successful lockfile install. * Plugin/custom packages with explicit version specifiers are also added with strict pins. * Plugin/custom packages without version specifiers are skipped @@ -658,6 +753,8 @@ def _install_frontend_packages( ) primary_package_manager = install_package_managers[0] + is_bun = _is_bun_package_manager(primary_package_manager) + include_dev_args = [] if is_bun else ["--include=dev"] # No fallback to a different package manager: switching mid-flow could # bypass the persisted lockfile (e.g. on a package-integrity failure @@ -686,7 +783,8 @@ def _install_frontend_packages( ) - wanted_dep_names needed_names = wanted_dep_names | wanted_dev_dep_names - existing_deps, existing_dev_deps = _existing_web_package_sections() + declared_deps, declared_dev_deps = _existing_web_package_sections() + existing_deps, existing_dev_deps = set(declared_deps), set(declared_dev_deps) existing_names = existing_deps | existing_dev_deps # Drop deps lingering in package.json that no component, plugin, or @@ -704,6 +802,7 @@ def _install_frontend_packages( primary_package_manager, "remove", "--legacy-peer-deps", + *include_dev_args, *sorted(to_remove), ], show_status_message="Removing unused frontend packages", @@ -711,16 +810,22 @@ def _install_frontend_packages( # Install against the recovered lockfile so its pins are honored # before any further mutation. - if any( + has_lockfile = any( frontend_skeleton.get_web_lockfile_path(name).exists() for name in frontend_skeleton.LOCKFILE_NAMES - ): + ) + if has_lockfile: _run_initial_install(primary_package_manager, env, frozen_lockfile) # Framework overrides are withheld while the persisted package.json is # restored so the frozen install above sees exactly the file that produced # the persisted lockfile. Merge them now, before any resolution happens. overrides_changed = frontend_skeleton.update_package_json_overrides() + installed_deps, installed_dev_deps = declared_deps, declared_dev_deps + if has_lockfile and not is_bun and not overrides_changed: + installed_deps, installed_dev_deps = _npm_installed_package_sections( + declared_deps, declared_dev_deps + ) pinned_packages, unpinned_packages = _split_by_version_specifier(packages) pinned_dev_deps, unpinned_dev_deps = _split_by_version_specifier( @@ -737,19 +842,28 @@ def _install_frontend_packages( new_unpinned_dev_deps = unpinned_dev_deps - existing_dev_deps deps_to_add = ( - _pinned_args_from_constants(constants.PackageJson.DEPENDENCIES) + _pinned_args_from_constants( + constants.PackageJson.DEPENDENCIES, + installed_deps if has_lockfile else None, + explicitly_requested={_extract_package_name(p) for p in pinned_packages}, + ) | pinned_packages | new_unpinned_packages ) - deps_names_to_add = {_extract_package_name(p) for p in deps_to_add} dev_deps_to_add = { spec for spec in ( - _pinned_args_from_constants(constants.PackageJson.DEV_DEPENDENCIES) + _pinned_args_from_constants( + constants.PackageJson.DEV_DEPENDENCIES, + installed_dev_deps if has_lockfile else None, + explicitly_requested={ + _extract_package_name(p) for p in pinned_dev_deps + }, + ) | pinned_dev_deps | new_unpinned_dev_deps ) - if _extract_package_name(spec) not in deps_names_to_add + if _extract_package_name(spec) not in wanted_dep_names } # Add dev dependencies first so that any subsequent regular-dep add @@ -761,14 +875,21 @@ def _install_frontend_packages( primary_package_manager, "add", "--legacy-peer-deps", - "-d", + *include_dev_args, + "-d" if is_bun else "--save-dev", *dev_deps_to_add, ], show_status_message="Installing frontend development dependencies", ) if deps_to_add: run_package_manager( - [primary_package_manager, "add", "--legacy-peer-deps", *deps_to_add], + [ + primary_package_manager, + "add", + "--legacy-peer-deps", + *include_dev_args, + *deps_to_add, + ], show_status_message="Installing frontend packages", ) @@ -776,7 +897,12 @@ def _install_frontend_packages( # Newly merged overrides with no add to carry them into the lockfile: # resolve them now so the persisted pair stays frozen-install ready. run_package_manager( - [primary_package_manager, "install", "--legacy-peer-deps"], + [ + primary_package_manager, + "install", + "--legacy-peer-deps", + *include_dev_args, + ], show_status_message="Applying frontend package overrides", ) diff --git a/tests/units/reflex_cli/v2/test_cli.py b/tests/units/reflex_cli/v2/test_cli.py index 8a850882911..eeed93e3109 100644 --- a/tests/units/reflex_cli/v2/test_cli.py +++ b/tests/units/reflex_cli/v2/test_cli.py @@ -1160,6 +1160,24 @@ def test_deploy_without_instance_bounds_flags_skips_the_call( recorder.create_deployment.assert_called_once() +def test_deploy_non_interactive_skips_provider_availability_lookup( + mocker: MockerFixture, + mock_export_fn: MagicMock, +): + """A non-interactive deploy exports and submits without probing unused targets.""" + recorder = _deploy_call_recorder(mocker) + available = mocker.patch( + "reflex_cli.utils.hosting.gcp_deploy_available", + return_value={"configured": True, "allowed": True}, + ) + + cli.deploy(app_name="fake-app", export_fn=mock_export_fn, interactive=False) + + available.assert_not_called() + assert mock_export_fn.call_count == 2 + recorder.create_deployment.assert_called_once() + + def test_deploy_failed_export_does_not_apply_instance_bounds( mocker: MockerFixture, mock_export_import_error_fn: Callable[[str, str, str, bool, bool, bool], None], @@ -1549,19 +1567,25 @@ def test_resolve_deploy_provider_reflex_cloud_no_switch(mocker: MockFixture): mock_set.assert_not_called() -def test_resolve_deploy_provider_non_interactive_keeps_current(mocker: MockFixture): - """Non-interactive with no --provider keeps the app's provider, no prompt.""" +@pytest.mark.parametrize("provider", [None, "fly", "gcp"]) +def test_resolve_deploy_provider_non_interactive_keeps_current( + mocker: MockFixture, provider: str | None +): + """Non-interactive deploys keep their provider without a lookup or prompt.""" client = hosting.AuthenticatedClient(token="t", validated_data={}) - mocker.patch( + available = mocker.patch( "reflex_cli.utils.hosting.gcp_deploy_available", return_value={"configured": True, "allowed": True}, ) + ask = mocker.patch("reflex_cli.utils.console.ask") mock_set = mocker.patch("reflex_cli.utils.hosting.set_app_provider") - app = {"id": "app-1", "name": "myapp", "provider": "fly"} + app = {"id": "app-1", "name": "myapp", "provider": provider} result = cli._resolve_deploy_provider( app, None, interactive=False, app_was_created=False, client=client ) - assert result == "fly" + assert result == provider + available.assert_not_called() + ask.assert_not_called() mock_set.assert_not_called() @@ -1601,7 +1625,7 @@ def test_resolve_deploy_provider_switch_confirm_defaults_to_cancel( def test_resolve_deploy_provider_interactive_prompt_selects_gcp(mocker: MockFixture): """When GCP is available and the user picks it, the app switches to GCP.""" client = hosting.AuthenticatedClient(token="t", validated_data={}) - mocker.patch( + available = mocker.patch( "reflex_cli.utils.hosting.gcp_deploy_available", return_value={"configured": True, "allowed": True, "region": "us-central1"}, ) @@ -1614,6 +1638,7 @@ def test_resolve_deploy_provider_interactive_prompt_selects_gcp(mocker: MockFixt app, None, interactive=True, app_was_created=True, client=client ) assert result == "gcp" + available.assert_called_once_with(client) mock_set.assert_called_once() @@ -1690,8 +1715,9 @@ def test_resolve_deploy_provider_named_connection_is_pinned(mocker: MockFixture) ) +@pytest.mark.parametrize("provider_arg", [None, "gcp"]) def test_resolve_deploy_provider_repoints_without_a_provider_switch( - mocker: MockFixture, + mocker: MockFixture, provider_arg: str | None ): """An app already on GCP is still repointed when a connection is named.""" client = hosting.AuthenticatedClient(token="t", validated_data={}) @@ -1706,7 +1732,7 @@ def test_resolve_deploy_provider_repoints_without_a_provider_switch( result = cli._resolve_deploy_provider( app, - "gcp", + provider_arg, interactive=False, app_was_created=False, client=client, diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py index 9996d7d37b8..3e41ac9d895 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -1,5 +1,9 @@ import json +import logging +import os import shutil +import subprocess +import sys import tempfile import uuid from collections.abc import Callable, Generator @@ -7,13 +11,14 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Protocol +from unittest.mock import Mock import pytest from click.testing import CliRunner from reflex_base import constants from reflex_base.config import Config from reflex_base.environment import environment -from reflex_base.utils import log +from reflex_base.utils import log, serializers from reflex_base.utils.decorator import cached_procedure from reflex.reflex import cli @@ -289,6 +294,90 @@ def test_check_latest_package_version_can_be_disabled( assert json.loads(version_check_file.read_text()) == {} +@pytest.fixture +def version_check_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Isolate the persisted version check and its PyPI request. + + Args: + tmp_path: The temporary directory fixture. + monkeypatch: The monkeypatch fixture. + + Returns: + The reflex.json path and the mocked PyPI request. + """ + reflex_json = tmp_path / constants.Reflex.JSON + reflex_json.write_text(json.dumps({"project_hash": "test-project"})) + monkeypatch.setattr(prerequisites, "get_web_dir", lambda: tmp_path) + monkeypatch.setenv(environment.REFLEX_CHECK_LATEST_VERSION.name, "True") + monkeypatch.setattr(prerequisites.importlib.metadata, "version", lambda _: "1.0.0") + response = Mock() + response.json.return_value = {"info": {"version": "2.0.0"}} + request = Mock(return_value=response) + monkeypatch.setattr(prerequisites.net, "get", request) + return reflex_json, request + + +def test_check_latest_package_version_skips_cached_request(version_check_env): + """A previously recorded version check avoids another PyPI request.""" + reflex_json, request = version_check_env + contents = json.dumps({"last_version_check_datetime": "2026-09-01 12:00:00"}) + reflex_json.write_text(contents) + + prerequisites.check_latest_package_version("reflex-hosting-cli") + + request.assert_not_called() + assert reflex_json.read_text() == contents + + +@pytest.mark.parametrize("latest_version", ["1.0.0", "2.0.0"]) +def test_check_latest_package_version_caches_success( + version_check_env, latest_version: str, caplog: pytest.LogCaptureFixture +): + """A successful check is reused across packages without repeating warnings.""" + reflex_json, request = version_check_env + request.return_value.json.return_value = {"info": {"version": latest_version}} + + with caplog.at_level(logging.WARNING, logger=prerequisites.__name__): + prerequisites.check_latest_package_version("reflex") + prerequisites.check_latest_package_version("reflex-hosting-cli") + + request.assert_called_once_with("https://pypi.org/pypi/reflex/json", timeout=2) + data = json.loads(reflex_json.read_text()) + assert data["last_version_check_datetime"] + assert data["project_hash"] == "test-project" + warnings = [ + record for record in caplog.records if record.levelno == logging.WARNING + ] + assert len(warnings) == (latest_version == "2.0.0") + + +def test_check_latest_package_version_retries_failed_request(version_check_env): + """A failed PyPI request leaves the cache unset so the next call retries.""" + reflex_json, request = version_check_env + request.side_effect = [OSError("network unavailable"), request.return_value] + + prerequisites.check_latest_package_version("reflex") + assert "last_version_check_datetime" not in json.loads(reflex_json.read_text()) + + prerequisites.check_latest_package_version("reflex") + assert request.call_count == 2 + assert json.loads(reflex_json.read_text())["last_version_check_datetime"] + + +def test_check_latest_package_version_disabled( + version_check_env, monkeypatch: pytest.MonkeyPatch +): + """Disabling version checks avoids requests and cache updates.""" + reflex_json, request = version_check_env + monkeypatch.setenv(environment.REFLEX_CHECK_LATEST_VERSION.name, "False") + contents = reflex_json.read_text() + + prerequisites.check_latest_package_version("reflex") + + request.assert_not_called() + assert reflex_json.read_text() == contents + + 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) @@ -314,6 +403,8 @@ def _patch_frontend_package_manager( # inspect the install args without mocking subprocess primitives. def _stub_initial_install(primary_pm, env, frozen_lockfile): args = [primary_pm, "install", "--legacy-peer-deps"] + if not js_runtimes._is_bun_package_manager(primary_pm): + args.append("--include=dev") if frozen_lockfile and js_runtimes._is_bun_package_manager(primary_pm): args.append("--frozen-lockfile") run_package_manager( @@ -350,6 +441,119 @@ def _stub_framework_packages(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(constants.PackageJson, "OVERRIDES", {}) +@pytest.mark.parametrize("config_class", ["Config", "AppConfig"]) +def test_frontend_package_cache_fingerprint_is_stable_across_processes( + tmp_path: Path, config_class: str +): + """Python hash randomization must not invalidate unchanged dependency installs.""" + script = f""" +import hashlib +from reflex_base.config import Config +from reflex.utils.js_runtimes import _frontend_packages_cache_payload + +class AppConfig(Config): + pass + +config = {config_class}( + app_name="test_app", + api_url="https://api.example.com", + deploy_url="https://example.com", + frontend_port=3001, + frozen_lockfile=True, + _skip_plugins_checks=True, +) +payload = _frontend_packages_cache_payload({{"some-package@1.0.0"}}, config, ("npm",)) +print(hashlib.sha256(payload.encode()).hexdigest()) +""" + fingerprints = { + subprocess.check_output( + [sys.executable, "-c", script], + cwd=tmp_path, + env={**os.environ, "PYTHONHASHSEED": seed}, + text=True, + ).strip() + for seed in ("1", "2", "3") + } + + assert len(fingerprints) == 1 + + +def test_frontend_package_cache_fingerprint_keeps_config_changes(): + """Canonicalization preserves both changed values and explicitly set attributes.""" + config = Config(app_name="test_app", _skip_plugins_checks=True) + original = js_runtimes._frontend_packages_cache_payload(set(), config, ("npm",)) + config.api_url = "https://api.example.com" + changed_url = js_runtimes._frontend_packages_cache_payload(set(), config, ("npm",)) + config._non_default_attributes.add("api_url") + explicit_url = js_runtimes._frontend_packages_cache_payload(set(), config, ("npm",)) + + assert len({original, changed_url, explicit_url}) == 3 + + +def test_frontend_package_cache_fingerprint_preserves_custom_serialization(): + """An overridden Config.json keeps its existing fingerprint semantics.""" + + class AppConfig(Config): + def json(self) -> str: + return '{"_non_default_attributes": ["z", "a"]}' + + config = AppConfig(app_name="test_app", _skip_plugins_checks=True) + payload = js_runtimes._frontend_packages_cache_payload(set(), config, ("npm",)) + + assert config.json() in payload + + +@pytest.mark.parametrize( + "serialized", + [ + {"custom": "config"}, + {"_non_default_attributes": ["z", "a"]}, + ["custom"], + "custom", + ], +) +def test_frontend_package_cache_fingerprint_preserves_registered_serializer( + monkeypatch: pytest.MonkeyPatch, serialized: dict | list | str +): + """Registered Config serializers retain their complete output and ordering.""" + + class AppConfig(Config): + pass + + config = AppConfig(app_name="test_app", _skip_plugins_checks=True) + with monkeypatch.context() as registry: + registry.setitem(serializers.SERIALIZERS, AppConfig, lambda _: serialized) + serializers.get_serializer.cache_clear() + try: + expected = config.json() + payload = js_runtimes._frontend_packages_cache_payload( + set(), config, ("npm",) + ) + assert expected in payload + finally: + serializers.get_serializer.cache_clear() + + +def test_frontend_package_cache_fingerprint_preserves_registered_set_serializer( + monkeypatch: pytest.MonkeyPatch, +): + """A custom set serializer must not be mistaken for the default unordered list.""" + config = Config(app_name="test_app", _skip_plugins_checks=True) + with monkeypatch.context() as registry: + registry.setitem( + serializers.SERIALIZERS, set, lambda value: {"values": sorted(value)} + ) + serializers.get_serializer.cache_clear() + try: + expected = config.json() + payload = js_runtimes._frontend_packages_cache_payload( + set(), config, ("npm",) + ) + assert expected in payload + finally: + serializers.get_serializer.cache_clear() + + @pytest.fixture def install_packages_env( tmp_path, monkeypatch @@ -1155,6 +1359,232 @@ def test_install_frontend_packages_pins_framework_dependencies( assert "--only-missing" not in pin_dev_deps_call +@pytest.mark.parametrize("package_manager", ["bun", "npm"]) +@pytest.mark.parametrize("has_lockfile", [False, True]) +def test_install_frontend_packages_reuses_installed_framework_pins( + install_packages_env: InstallPackagesEnv, + monkeypatch: pytest.MonkeyPatch, + package_manager: str, + has_lockfile: bool, +): + """Matching framework pins need no add only after a successful initial install.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + monkeypatch.setattr(constants.PackageJson, "DEV_DEPENDENCIES", {"vite": "8.0.9"}) + env.root_package_json.write_text( + json.dumps({ + "dependencies": {"react": "19.2.5"}, + "devDependencies": {"vite": "8.0.9"}, + }) + ) + if has_lockfile: + lockfile = ( + env.root_lock + if package_manager == "bun" + else env.root_lock.parent / constants.Node.LOCKFILE_PATH + ) + lockfile.write_text("persisted-lock") + calls = _record_calls_with_pm(env, package_manager) + + env.install() + + install_calls = [call for call in calls if "install" in call] + assert len(install_calls) == int(has_lockfile) + if has_lockfile: + assert ("--frozen-lockfile" in install_calls[0]) == (package_manager == "bun") + add_calls = [call for call in calls if "add" in call] + assert len(add_calls) == (0 if has_lockfile else 2) + if not has_lockfile: + assert "vite@8.0.9" in add_calls[0] + assert "react@19.2.5" in add_calls[1] + + +def test_install_frontend_packages_updates_only_changed_framework_pins( + install_packages_env: InstallPackagesEnv, monkeypatch: pytest.MonkeyPatch +): + """Changed, absent, or misplaced framework pins still get installed.""" + env = install_packages_env + monkeypatch.setattr( + constants.PackageJson, + "DEPENDENCIES", + { + "react": "19.2.5", + "react-dom": "19.2.5", + "isbot": "5.2.2", + }, + ) + monkeypatch.setattr( + constants.PackageJson, + "DEV_DEPENDENCIES", + { + "vite": "8.0.9", + "postcss": "8.5.26", + }, + ) + env.root_lock.write_text("persisted-lock") + env.root_package_json.write_text( + json.dumps({ + "dependencies": { + "react": "19.2.5", + "react-dom": "18.0.0", + "stale": "1.0.0", + }, + "devDependencies": {"isbot": "5.2.2", "vite": "8.0.9"}, + }) + ) + calls = _record_calls(env) + + env.install() + + assert set(calls[0][3:]) == {"stale", "isbot"} + add_calls = [call for call in calls if "add" in call] + assert len(add_calls) == 2 + assert "postcss@8.5.26" in add_calls[0] + assert {"react-dom@19.2.5", "isbot@5.2.2"}.issubset(add_calls[1]) + assert all( + "react@19.2.5" not in call and "vite@8.0.9" not in call for call in add_calls + ) + + +def test_install_frontend_packages_keeps_custom_and_plugin_pin_resolution( + install_packages_env: InstallPackagesEnv, monkeypatch: pytest.MonkeyPatch +): + """Custom pins still resolve and dev requests cannot override framework deps.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + + class FakePlugin: + def get_frontend_dependencies(self): + return {"plugin-pkg@1.0.0"} + + def get_frontend_development_dependencies(self): + return {"plugin-dev@2.0.0", "react@18.0.0"} + + monkeypatch.setattr(env.config, "plugins", [FakePlugin()]) + env.root_lock.write_text("persisted-lock") + env.root_package_json.write_text( + json.dumps({ + "dependencies": { + "react": "19.2.5", + "plugin-pkg": "1.0.0", + "custom": "^3.0.0", + }, + "devDependencies": {"plugin-dev": "2.0.0"}, + }) + ) + calls = _record_calls(env) + + env.install({"custom@^3.0.0"}) + + add_calls = [call for call in calls if "add" in call] + assert len(add_calls) == 2 + assert "plugin-dev@2.0.0" in add_calls[0] + assert {"plugin-pkg@1.0.0", "custom@^3.0.0"}.issubset(add_calls[1]) + assert all(not any(arg.startswith("react@") for arg in call) for call in add_calls) + + +def test_install_frontend_packages_reconciles_overrides_with_matching_framework_pins( + install_packages_env: InstallPackagesEnv, monkeypatch: pytest.MonkeyPatch +): + """Skipping matched pins must still apply newly merged framework overrides.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + monkeypatch.setattr(constants.PackageJson, "OVERRIDES", {"postcss": "8.5.26"}) + env.root_lock.write_text("persisted-lock") + env.root_package_json.write_text(json.dumps({"dependencies": {"react": "19.2.5"}})) + calls = _record_calls(env) + + env.install() + + assert len(calls) == 2 + assert "install" in calls[0] + assert "--frozen-lockfile" in calls[0] + assert "install" in calls[1] + assert "--frozen-lockfile" not in calls[1] + assert json.loads(env.root_package_json.read_text())["overrides"] == { + "postcss": "8.5.26" + } + + +def test_install_frontend_packages_preserves_conflicting_pin_arguments( + install_packages_env: InstallPackagesEnv, monkeypatch: pytest.MonkeyPatch +): + """Explicit pins conflicting with framework pins keep the original add arguments.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + monkeypatch.setattr(constants.PackageJson, "DEV_DEPENDENCIES", {"vite": "8.0.9"}) + + class FakePlugin: + def get_frontend_dependencies(self): + return set() + + def get_frontend_development_dependencies(self): + return {"vite@7.0.0"} + + monkeypatch.setattr(env.config, "plugins", [FakePlugin()]) + env.root_lock.write_text("persisted-lock") + env.root_package_json.write_text( + json.dumps({ + "dependencies": {"react": "19.2.5"}, + "devDependencies": {"vite": "8.0.9"}, + }) + ) + calls = _record_calls(env) + + env.install({"react@18.0.0"}) + + add_calls = [call for call in calls if "add" in call] + assert len(add_calls) == 2 + assert {"vite@7.0.0", "vite@8.0.9"}.issubset(add_calls[0]) + assert {"react@18.0.0", "react@19.2.5"}.issubset(add_calls[1]) + + +def test_install_frontend_packages_matching_pins_do_not_bypass_failed_install( + install_packages_env: InstallPackagesEnv, monkeypatch: pytest.MonkeyPatch +): + """A failed frozen install propagates and never marks the dependencies cached.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + env.root_lock.write_text("persisted-lock") + env.root_package_json.write_text(json.dumps({"dependencies": {"react": "19.2.5"}})) + calls = _record_calls(env) + monkeypatch.setattr( + js_runtimes, "_run_initial_install", Mock(side_effect=SystemExit(1)) + ) + + with pytest.raises(SystemExit): + env.install() + + assert calls == [] + assert not js_runtimes._frontend_packages_cache_path().exists() + + +@pytest.mark.parametrize( + "version_spec", + ["^19.2.5", "19.x", "latest", "github:facebook/react", "file:../react"], +) +def test_install_frontend_packages_still_resolves_framework_version_overrides( + install_packages_env: InstallPackagesEnv, + monkeypatch: pytest.MonkeyPatch, + version_spec: str, +): + """Framework version overrides that are not exact pins keep their add behavior.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": version_spec}) + env.root_lock.write_text("persisted-lock") + env.root_package_json.write_text( + json.dumps({"dependencies": {"react": version_spec}}) + ) + calls = _record_calls(env) + + env.install() + + assert len(calls) == 2 + assert "install" in calls[0] + assert "add" in calls[1] + assert f"react@{version_spec}" in calls[1] + + def _record_calls_with_pm( env: InstallPackagesEnv, package_manager: str ) -> list[list[str]]: @@ -1176,6 +1606,231 @@ def run_package_manager(args, **kwargs): return calls +@pytest.mark.parametrize("package_manager", ["bun", "npm"]) +def test_install_frontend_packages_uses_package_manager_dev_flag( + install_packages_env: InstallPackagesEnv, + monkeypatch: pytest.MonkeyPatch, + package_manager: str, +): + """Npm must save development tools in devDependencies, including in production.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEV_DEPENDENCIES", {"vite": "8.0.9"}) + monkeypatch.setenv("NODE_ENV", "production") + calls = _record_calls_with_pm(env, package_manager) + + env.install() + + assert len(calls) == 1 + assert ("--save-dev" in calls[0]) == (package_manager == "npm") + assert ("-d" in calls[0]) == (package_manager == "bun") + assert ("--include=dev" in calls[0]) == (package_manager == "npm") + + +@pytest.mark.parametrize("lockfile_version", [2, 3]) +def test_install_frontend_packages_reuses_verified_npm_caret_pins( + install_packages_env: InstallPackagesEnv, + monkeypatch: pytest.MonkeyPatch, + lockfile_version: int, +): + """Npm's saved caret declarations can reuse the exact version just installed.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + monkeypatch.setattr( + constants.PackageJson, "DEV_DEPENDENCIES", {"@scope/tool": "2.0.0"} + ) + package_json = { + "dependencies": {"react": "^19.2.5"}, + "devDependencies": {"@scope/tool": "^2.0.0"}, + } + env.root_package_json.write_text(json.dumps(package_json)) + (env.root_lock.parent / constants.Node.LOCKFILE_PATH).write_text( + json.dumps({ + "lockfileVersion": lockfile_version, + "packages": { + "": package_json, + "node_modules/react": {"version": "19.2.5"}, + "node_modules/@scope/tool": {"version": "2.0.0", "name": "@scope/tool"}, + }, + }) + ) + for name, package_version in (("react", "19.2.5"), ("@scope/tool", "2.0.0")): + package_dir = env.web_dir / "node_modules" / name + package_dir.mkdir(parents=True) + (package_dir / "package.json").write_text( + json.dumps({"name": name, "version": package_version}) + ) + calls = _record_calls_with_pm(env, "npm") + + env.install() + + assert len(calls) == 1 + assert "install" in calls[0] + + npm_lock = env.root_lock.parent / constants.Node.LOCKFILE_PATH + saved_package_json = env.root_package_json.read_bytes() + saved_lock = npm_lock.read_bytes() + js_runtimes._frontend_packages_cache_path().unlink() + calls.clear() + env.install() + + assert len(calls) == 1 + assert "install" in calls[0] + assert env.root_package_json.read_bytes() == saved_package_json + assert npm_lock.read_bytes() == saved_lock + + +@pytest.mark.parametrize( + "case", + [ + "newer_version", + "wrong_section", + "alias", + "link", + "local_tarball", + "missing_entry", + "legacy", + "malformed", + "malformed_packages", + "missing_root", + "missing_manifest", + "newer_installed_version", + "aliased_manifest", + "linked_package", + "explicit_conflict", + "tilde", + "overrides_changed", + ], +) +def test_install_frontend_packages_npm_caret_pin_fallback( + install_packages_env: InstallPackagesEnv, + monkeypatch: pytest.MonkeyPatch, + case: str, +): + """Unverified npm declarations keep the explicit framework add operation.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + declaration = "~19.2.5" if case == "tilde" else "^19.2.5" + package_json = {"dependencies": {"react": declaration}} + env.root_package_json.write_text(json.dumps(package_json)) + locked_react: dict[str, object] = {"version": "19.2.5"} + lock = { + "lockfileVersion": 3, + "packages": {"": package_json, "node_modules/react": locked_react}, + } + if case == "newer_version": + locked_react["version"] = "19.3.0" + elif case == "wrong_section": + lock["packages"][""] = {"devDependencies": {"react": declaration}} + elif case == "alias": + locked_react["name"] = "different-package" + elif case == "link": + locked_react["link"] = True + elif case == "local_tarball": + locked_react["resolved"] = "file:../react.tgz" + elif case == "missing_entry": + del lock["packages"]["node_modules/react"] + elif case == "legacy": + lock["lockfileVersion"] = 1 + elif case == "malformed_packages": + lock["packages"] = [] + elif case == "missing_root": + del lock["packages"][""] + elif case == "overrides_changed": + monkeypatch.setattr(constants.PackageJson, "OVERRIDES", {"postcss": "8.5.26"}) + (env.root_lock.parent / constants.Node.LOCKFILE_PATH).write_text( + "invalid" if case == "malformed" else json.dumps(lock) + ) + package_dir = env.web_dir / "node_modules" / "react" + package_dir.mkdir(parents=True) + if case != "missing_manifest": + (package_dir / "package.json").write_text( + json.dumps({ + "name": "different-package" if case == "aliased_manifest" else "react", + "version": "19.3.0" if case == "newer_installed_version" else "19.2.5", + }) + ) + if case == "linked_package": + if constants.IS_WINDOWS: + pytest.skip("Requires directory symlinks") + linked_package = env.tmp_path / "linked-package" + package_dir.rename(linked_package) + package_dir.symlink_to(linked_package, target_is_directory=True) + calls = _record_calls_with_pm(env, "npm") + + env.install({"react@18.0.0"} if case == "explicit_conflict" else None) + + assert len(calls) == 2 + assert "install" in calls[0] + assert "add" in calls[1] + assert "react@19.2.5" in calls[1] + if case == "explicit_conflict": + assert "react@18.0.0" in calls[1] + + +def test_install_frontend_packages_reads_npm_versions_after_install( + install_packages_env: InstallPackagesEnv, monkeypatch: pytest.MonkeyPatch +): + """A newer version resolved during initial install must be repinned afterwards.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + package_json = {"dependencies": {"react": "^19.2.5"}} + env.root_package_json.write_text(json.dumps(package_json)) + lock = { + "lockfileVersion": 3, + "packages": {"": package_json, "node_modules/react": {"version": "19.2.5"}}, + } + (env.root_lock.parent / constants.Node.LOCKFILE_PATH).write_text(json.dumps(lock)) + package_dir = env.web_dir / "node_modules" / "react" + package_dir.mkdir(parents=True) + (package_dir / "package.json").write_text( + json.dumps({"name": "react", "version": "19.2.5"}) + ) + calls: list[list[str]] = [] + + def run_package_manager(args, **kwargs): + calls.append(list(args)) + if "install" in args: + lock["packages"]["node_modules/react"]["version"] = "19.3.0" + (env.web_dir / constants.Node.LOCKFILE_PATH).write_text(json.dumps(lock)) + + env.patch_pm(["npm"], run_package_manager) + env.install() + + assert len(calls) == 2 + assert "react@19.2.5" in calls[1] + + +def test_install_frontend_packages_all_npm_operations_include_dev( + install_packages_env: InstallPackagesEnv, monkeypatch: pytest.MonkeyPatch +): + """Later npm add/remove operations must not prune development tools in production.""" + env = install_packages_env + monkeypatch.setenv("NODE_ENV", "production") + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + monkeypatch.setattr(constants.PackageJson, "DEV_DEPENDENCIES", {"vite": "8.0.9"}) + env.root_package_json.write_text(json.dumps({"dependencies": {"stale": "1.0.0"}})) + calls = _record_calls_with_pm(env, "npm") + + env.install() + + assert {call[1] for call in calls} == {"remove", "add"} + assert all("--include=dev" in call for call in calls) + + +def test_run_initial_npm_install_includes_dev_in_production( + monkeypatch: pytest.MonkeyPatch, +): + """Installing a restored npm lock must include development tools for the build.""" + monkeypatch.setenv("NODE_ENV", "production") + new_process = Mock(return_value=Mock(returncode=0)) + monkeypatch.setattr(js_runtimes.processes, "new_process", new_process) + monkeypatch.setattr(js_runtimes.processes, "show_status", Mock(return_value=[])) + + js_runtimes._run_initial_install("npm", {}, frozen_lockfile=True) + + assert "--include=dev" in new_process.call_args.args[0] + + def test_install_frontend_packages_npm_skips_frozen_lockfile( install_packages_env: InstallPackagesEnv, ): diff --git a/tests/units/utils/test_build.py b/tests/units/utils/test_build.py index e12234b1e40..73956b61f34 100644 --- a/tests/units/utils/test_build.py +++ b/tests/units/utils/test_build.py @@ -4,16 +4,99 @@ import gzip import json +import zipfile from pathlib import Path import pytest import reflex_base from pytest_mock import MockerFixture +from reflex_base import constants from reflex.plugins import EmbedPlugin, Plugin from reflex.utils import build, path_ops +@pytest.mark.parametrize("suffix", [".gz", ".br", ".zst"]) +@pytest.mark.parametrize("component_name", list(constants.ComponentName)) +def test_zip_precompressed_sidecars( + tmp_path: Path, suffix: str, component_name: constants.ComponentName +): + """Skip recompression for frontend sidecars and preserve backend compression.""" + static_dir = tmp_path / "static" + static_dir.mkdir() + source = b"export const greeting = 'hello';\n" * 100 + sidecar = gzip.compress(source) + (static_dir / "app.js").write_bytes(source) + (static_dir / f"app.js{suffix}").write_bytes(sidecar) + target = tmp_path / "frontend.zip" + + build._zip( + component_name=component_name, + target=target, + root_directory=static_dir, + exclude_venv_directories=False, + ) + + with zipfile.ZipFile(target) as archive: + assert archive.read("app.js") == source + assert archive.read(f"app.js{suffix}") == sidecar + assert archive.getinfo("app.js").compress_type == zipfile.ZIP_DEFLATED + assert archive.getinfo(f"app.js{suffix}").compress_type == ( + zipfile.ZIP_STORED + if component_name == constants.ComponentName.FRONTEND + else zipfile.ZIP_DEFLATED + ) + + +def test_zip_excludes_files_and_hardlink_aliases(tmp_path: Path): + """Archive exclusions must match file identity, including hard-linked aliases.""" + root = tmp_path / "app" + root.mkdir() + (root / "app.py").write_text("print('app')") + excluded_file = root / "secret.txt" + excluded_file.write_text("secret") + (root / "alias.txt").hardlink_to(excluded_file) + excluded_dir = root / "excluded" + excluded_dir.mkdir() + (excluded_dir / "private.txt").write_text("private") + target = tmp_path / "backend.zip" + + build._zip( + component_name=constants.ComponentName.BACKEND, + target=target, + root_directory=root, + exclude_venv_directories=True, + files_to_exclude={excluded_file, excluded_dir, root / "missing"}, + ) + + with zipfile.ZipFile(target) as archive: + assert archive.namelist() == ["app.py"] + assert archive.read("app.py") == b"print('app')" + + +@pytest.mark.skipif(constants.IS_WINDOWS, reason="Requires directory symlinks") +def test_zip_excludes_directory_symlink_aliases(tmp_path: Path): + """An excluded directory must also be skipped through a symbolic link.""" + root = tmp_path / "app" + root.mkdir() + excluded_dir = root / "excluded" + excluded_dir.mkdir() + (excluded_dir / "private.txt").write_text("private") + (root / "alias").symlink_to(excluded_dir, target_is_directory=True) + target = tmp_path / "backend.zip" + + build._zip( + component_name=constants.ComponentName.BACKEND, + target=target, + root_directory=root, + exclude_venv_directories=True, + files_to_exclude={excluded_dir}, + ) + + with zipfile.ZipFile(target) as archive: + assert archive.namelist() == [] + + def test_compress_static_output_overwrites_stale_sidecars( tmp_path: Path, mocker: MockerFixture ): diff --git a/tests/units/utils/test_build_cache.py b/tests/units/utils/test_build_cache.py new file mode 100644 index 00000000000..7540ccd19d8 --- /dev/null +++ b/tests/units/utils/test_build_cache.py @@ -0,0 +1,359 @@ +"""Regression coverage for opt-in reuse of production frontend builds.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest +from pytest_mock import MockerFixture + +from reflex.plugins import Plugin +from reflex.utils import build + + +@pytest.fixture +def cached_build( + tmp_path: Path, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +): + """Create a frontend whose simulated build reads real source files. + + Returns: + The frontend directory, mutable config, and build subprocess mock. + """ + monkeypatch.setenv("REFLEX_FRONTEND_BUILD_CACHE", "true") + web = tmp_path / ".web" + (web / "app").mkdir(parents=True) + (web / "app/page.js").write_text("original") + (web / "public").mkdir() + (web / "public/style.css").write_text("body{color:red}") + (web / "node_modules/package").mkdir(parents=True) + (web / "node_modules/package/index.js").write_text("old") + (web / "package.json").write_text('{"scripts":{"export":"react-router build"}}') + (web / "bun.lock").write_text("lock") + (web / "env.json").write_text('{"EVENT":"https://first.example/_event"}') + (web / "reflex.json").write_text('{"version":"1.0.0","project_hash":42}') + runtime = tmp_path / "runtime" + runtime.write_text("runtime") + config = mocker.Mock() + config.plugins = [] + config.frontend_compression_formats = ["gzip"] + config.frontend_path = "" + mocker.patch.object(build.prerequisites, "get_web_dir", return_value=web) + mocker.patch.object(build, "get_config", return_value=config) + mocker.patch.object(build.path_ops, "get_node_path", return_value=str(runtime)) + mocker.patch.object( + build.js_runtimes, + "get_js_package_executor", + return_value=([str(runtime)], None), + ) + + def compile_frontend(*args, **kwargs): + output = web / "build/client" + output.mkdir(parents=True) + if config.frontend_path: + (output / config.frontend_path.strip("/")).mkdir(parents=True) + (output / "index.html").write_text((web / "app/page.js").read_text()) + (output / "bundle.js").write_text("compiled") + return mocker.Mock(returncode=0) + + process = mocker.patch.object( + build.processes, "new_process", side_effect=compile_frontend + ) + mocker.patch.object(build.processes, "show_progress") + mocker.patch.object(build, "_compress_static_output") + return web, config, process + + +@pytest.mark.skipif(os.name == "nt", reason="Cache uses POSIX change timestamps") +def test_unchanged_build_reuses_pristine_output(cached_build, mocker: MockerFixture): + """Reuse Vite output while executing post-build work on every invocation.""" + web, config, process = cached_build + compress = mocker.patch.object(build, "_compress_static_output") + calls = [] + + class AppendPlugin(Plugin): + def post_build(self, **context): + calls.append(len(calls) + 1) + index = context["static_dir"] / "index.html" + index.write_text(index.read_text() + f" hook{calls[-1]}") + + config.plugins = [AppendPlugin()] + config.frontend_path = "/site" + build.build() + build.build() + assert process.call_count == 1 + assert calls == [1, 2] + assert (web / "build/client/site/index.html").read_text() == "original hook2" + assert not (web / "build/client/site/site").exists() + assert compress.call_count == 2 + + +@pytest.mark.skipif(os.name == "nt", reason="Cache uses POSIX change timestamps") +def test_telemetry_timestamps_do_not_invalidate_build(cached_build): + """Only the two private telemetry timestamps may be ignored.""" + web, _, process = cached_build + build.build() + metadata = web / "reflex.json" + data = json.loads(metadata.read_text()) + data.update(last_reflex_run_datetime="later", last_version_check_datetime="later") + metadata.write_text(json.dumps(data)) + build.build() + assert process.call_count == 1 + data["version"] = "2.0.0" + metadata.write_text(json.dumps(data)) + build.build() + assert process.call_count == 2 + + +@pytest.mark.parametrize( + "name", ["app/page.js", "public/style.css", "env.json", "bun.lock", "package.json"] +) +def test_changed_build_inputs_rebuild(cached_build, name: str): + """Changes to code, assets, URLs, locks, or package scripts invalidate output.""" + web, _, process = cached_build + build.build() + target = web / name + target.write_text(target.read_text() + " ") + build.build() + assert process.call_count == 2 + + +def test_dependency_edit_with_restored_mtime_rebuilds(cached_build): + """POSIX ctime detects same-length dependency edits even if mtime is restored.""" + web, _, process = cached_build + build.build() + dependency = web / "node_modules/package/index.js" + before = dependency.stat() + dependency.write_text("new") + os.utime(dependency, ns=(before.st_atime_ns, before.st_mtime_ns)) + build.build() + assert process.call_count == 2 + + +def test_disabled_cache_forces_and_refreshes_build(cached_build, monkeypatch): + """Disabling the cache prevents an older snapshot from being reused later.""" + _, _, process = cached_build + build.build() + monkeypatch.setenv("REFLEX_FRONTEND_BUILD_CACHE", "false") + build.build() + monkeypatch.setenv("REFLEX_FRONTEND_BUILD_CACHE", "true") + build.build() + assert process.call_count == 3 + + +def test_environment_change_rebuilds(cached_build, monkeypatch): + """Build hooks may observe arbitrary environment values.""" + _, _, process = cached_build + build.build() + monkeypatch.setenv("CUSTOM_BUILD_VALUE", "new") + build.build() + assert process.call_count == 2 + + +def test_failed_build_is_retried(cached_build): + """A failed Vite run must never populate the cache.""" + _, _, process = cached_build + compile_frontend = process.side_effect + process.side_effect = None + process.return_value.returncode = 1 + with pytest.raises(SystemExit): + build.build() + process.side_effect = compile_frontend + build.build() + assert process.call_count == 2 + + +def test_failed_post_build_is_retried(cached_build): + """Post-build failure must not publish partially processed output.""" + _, config, process = cached_build + + class FailingPlugin(Plugin): + def post_build(self, **context): + msg = "plugin failed" + raise RuntimeError(msg) + + config.plugins = [FailingPlugin()] + with pytest.raises(RuntimeError, match="plugin failed"): + build.build() + config.plugins = [] + build.build() + assert process.call_count == 2 + + +@pytest.mark.skipif(os.name == "nt", reason="Cache uses POSIX change timestamps") +def test_generated_dependency_caches_do_not_invalidate(cached_build): + """Vite's transient dependency caches are excluded from tracked inputs.""" + web, _, process = cached_build + build.build() + for name in (".vite", ".vite-temp"): + (web / "node_modules" / name).mkdir() + (web / "node_modules" / name / "temporary.js").write_text("temporary") + build.build() + assert process.call_count == 1 + + +@pytest.mark.parametrize( + "mutation", ["missing", "content", "extra", "metadata", "mode"] +) +def test_damaged_snapshot_rebuilds(cached_build, mutation: str): + """A missing, modified, or malformed snapshot cannot bypass a fresh build.""" + web, _, process = cached_build + build.build() + current = web / "reflex.build-cache/current" + if os.name == "nt": + assert not current.exists() + return + bundle = current / "build/client/bundle.js" + if mutation == "missing": + bundle.unlink() + elif mutation == "content": + bundle.write_text("tampered") + elif mutation == "extra": + (current / "build/client/extra.js").write_text("unexpected") + elif mutation == "metadata": + (current / "metadata.json").write_text("[]") + else: + bundle.chmod(0o600) + build.build() + assert process.call_count == 2 + assert (web / "build/client/bundle.js").read_text() == "compiled" + + +def test_input_changed_during_build_is_not_cached(cached_build): + """An input change during Vite cannot label old output with a reusable key.""" + web, _, process = cached_build + compile_frontend = process.side_effect + + def changing_build(*args, **kwargs): + result = compile_frontend(*args, **kwargs) + (web / "app/page.js").write_text("changed during build") + return result + + process.side_effect = changing_build + build.build() + assert not (web / "reflex.build-cache/current").exists() + process.side_effect = compile_frontend + build.build() + assert process.call_count == 2 + assert (web / "build/client/index.html").read_text() == "changed during build" + + +@pytest.mark.skipif(os.name == "nt", reason="Requires symlinks") +@pytest.mark.parametrize("directory", ["app", "node_modules"]) +def test_external_symlink_bypasses_cache(cached_build, tmp_path: Path, directory: str): + """Untracked external source or dependency targets force normal builds.""" + web, _, process = cached_build + external = tmp_path / "external.js" + external.write_text("external") + (web / directory / "external.js").symlink_to(external) + build.build() + build.build() + assert process.call_count == 2 + + +@pytest.mark.skipif(os.name == "nt", reason="Requires symlinks") +def test_external_redirect_between_internal_sources_rebuilds(cached_build, tmp_path): + """Resolve intermediate links again before reusing their internal target's output.""" + web, _, process = cached_build + first = web / "app/first.js" + second = web / "app/second.js" + first.write_text("first") + second.write_text("second") + redirect = tmp_path / "redirect.js" + redirect.symlink_to(first) + source = web / "app/page.js" + source.unlink() + source.symlink_to(redirect) + + build.build() + build.build() + assert process.call_count == 1 + assert (web / "build/client/index.html").read_text() == "first" + + redirect.unlink() + redirect.symlink_to(second) + build.build() + assert process.call_count == 2 + assert (web / "build/client/index.html").read_text() == "second" + + +@pytest.mark.skipif(os.name == "nt", reason="Requires symlinks") +def test_internal_dependency_symlink_is_tracked(cached_build): + """Normal package executable links remain cacheable and their targets are tracked.""" + web, _, process = cached_build + binaries = web / "node_modules/.bin" + binaries.mkdir() + (binaries / "package").symlink_to("../package/index.js") + build.build() + build.build() + assert process.call_count == 1 + (web / "node_modules/package/index.js").write_text("new") + build.build() + assert process.call_count == 2 + + +@pytest.mark.parametrize("mutation", ["add", "remove"]) +def test_source_file_membership_invalidates(cached_build, mutation: str): + """Added and removed source files both change the input fingerprint.""" + web, _, process = cached_build + extra = web / "app/extra.js" + if mutation == "remove": + extra.write_text("extra") + build.build() + if mutation == "add": + extra.write_text("extra") + else: + extra.unlink() + build.build() + assert process.call_count == 2 + + +@pytest.mark.skipif(os.name == "nt", reason="Cache uses POSIX change timestamps") +def test_deleted_processed_output_restores_snapshot(cached_build): + """The pristine snapshot can restore output deleted after a successful build.""" + web, _, process = cached_build + build.build() + build.path_ops.rm(web / "build") + build.build() + assert process.call_count == 1 + assert (web / "build/client/index.html").read_text() == "original" + + +@pytest.mark.skipif(os.name == "nt", reason="Requires symlinks") +def test_link_into_ignored_dependency_cache_bypasses(cached_build): + """A link cannot turn an excluded transient file into an untracked input.""" + web, _, process = cached_build + generated = web / "node_modules/.cache" + generated.mkdir() + (generated / "style.css").write_text("old") + (web / "public/linked.css").symlink_to("../node_modules/.cache/style.css") + build.build() + (generated / "style.css").write_text("new") + build.build() + assert process.call_count == 2 + assert not (web / "reflex.build-cache/current").exists() + + +@pytest.mark.skipif(os.name == "nt", reason="Requires POSIX symlinks and modes") +@pytest.mark.parametrize("entry", ["directory", "current"]) +def test_cache_symlink_never_changes_external_target( + cached_build, tmp_path, monkeypatch, entry +): + """Discard cache links without chmod or removal of their external targets.""" + web, _, _ = cached_build + external = tmp_path / "external-cache" + external.mkdir(mode=0o700) + (external / "keep").write_text("keep") + previous_mode = external.stat().st_mode + directory = web / "reflex.build-cache" + if entry == "directory": + directory.symlink_to(external, target_is_directory=True) + monkeypatch.setenv("REFLEX_FRONTEND_BUILD_CACHE", "false") + else: + directory.mkdir() + (directory / "current").symlink_to(external, target_is_directory=True) + build.build() + assert external.stat().st_mode == previous_mode + assert (external / "keep").read_text() == "keep" diff --git a/tests/units/utils/test_frontend_skeleton.py b/tests/units/utils/test_frontend_skeleton.py new file mode 100644 index 00000000000..05974f13509 --- /dev/null +++ b/tests/units/utils/test_frontend_skeleton.py @@ -0,0 +1,72 @@ +"""Tests for frontend dependency manifest synchronization.""" + +import json + +import pytest + +from reflex.utils import frontend_skeleton, js_runtimes + + +@pytest.fixture +def package_files(tmp_path, monkeypatch): + """Create persisted and rendered package manifests with isolated paths. + + Returns: + The frontend directory and persisted package manifest path. + """ + web = tmp_path / ".web" + root = tmp_path / "reflex.lock" + web.mkdir() + root.mkdir() + monkeypatch.setattr(frontend_skeleton, "get_web_dir", lambda: web) + monkeypatch.setattr(js_runtimes, "get_web_dir", lambda: web) + monkeypatch.setattr( + frontend_skeleton, "get_root_lockfile_path", lambda name: root / name + ) + manifest = root / "package.json" + manifest.write_text( + json.dumps({"dependencies": {"react": "19.2.8"}, "custom": True}) + ) + (web / "package.json").write_text(frontend_skeleton._compile_package_json()) + return web, manifest + + +@pytest.mark.parametrize("sorted_keys", [False, True]) +def test_package_formatting_preserves_install_cache(package_files, sorted_keys): + """Package manager whitespace/key formatting must not invalidate installed dependencies.""" + web, _ = package_files + package = web / "package.json" + formatted = ( + json.dumps(json.loads(package.read_text()), indent=2, sort_keys=sorted_keys) + + "\n" + ) + package.write_text(formatted) + marker = web / "reflex.install_frontend_packages.cached" + marker.write_bytes(b"unchanged install cache") + before = package.stat().st_mtime_ns + + assert frontend_skeleton.sync_root_package_json_to_web() is False + js_runtimes._sync_root_lockfiles_for_frontend_install() + + assert package.read_text() == formatted + assert package.stat().st_mtime_ns == before + assert marker.read_bytes() == b"unchanged install cache" + + +@pytest.mark.parametrize("value", [False, 1, "true", [True]]) +def test_package_value_changes_invalidate(package_files, value): + """Manifest changes remain significant, including JSON boolean versus number types.""" + web, manifest = package_files + data = json.loads(manifest.read_text()) + data["custom"] = value + manifest.write_text(json.dumps(data)) + assert frontend_skeleton.sync_root_package_json_to_web() is True + assert type(json.loads((web / "package.json").read_text())["custom"]) is type(value) + + +def test_invalid_rendered_package_is_repaired(package_files): + """Malformed generated package JSON must not be treated as a formatting-only change.""" + web, _ = package_files + (web / "package.json").write_text("{bad json") + assert frontend_skeleton.sync_root_package_json_to_web() is True + assert json.loads((web / "package.json").read_text())["custom"] is True From b9230726af32d53a8dfa37f1c87c28feac39b779 Mon Sep 17 00:00:00 2001 From: Alek Petuskey <38776361+Alek99@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:32:28 -0700 Subject: [PATCH 2/6] Fix cross-platform build-cache concurrency and version checks --- docs/hosting/self-hosting.md | 20 +- .../+cache-production-frontend.performance.md | 2 +- .../+skip-cached-version-check.performance.md | 2 +- ...able-frontend-install-cache.performance.md | 1 - reflex/reflex.py | 57 +++- reflex/utils/build_cache.py | 157 +++++++-- reflex/utils/export.py | 74 ++-- reflex/utils/js_runtimes.py | 1 - reflex/utils/prerequisites.py | 18 +- tests/units/test_prerequisites.py | 209 ++++++------ tests/units/test_reflex.py | 110 ++++++ tests/units/utils/test_build_cache.py | 321 +++++++++++++++++- tests/units/utils/test_export.py | 68 +++- 13 files changed, 849 insertions(+), 191 deletions(-) delete mode 100644 news/+stable-frontend-install-cache.performance.md diff --git a/docs/hosting/self-hosting.md b/docs/hosting/self-hosting.md index 76bbea1d0df..233ea751799 100644 --- a/docs/hosting/self-hosting.md +++ b/docs/hosting/self-hosting.md @@ -105,6 +105,13 @@ It is disabled by default. On a cache hit, Reflex restores the pristine JavaScri build, then runs post-build plugins, fallback generation, compression, and frontend path processing again. Python compilation and the normal dependency checks still run. +On macOS and Linux, production exports sharing `.web` wait for one another from +compilation through ZIP creation. Production and preview startup also hold this +lock while compiling and building, even when caching is disabled. The lock file +`.web/.reflex-build.lock` remains in place between commands; do not remove it while +a command is running. Initialization, development hot reload, and unrelated tools +writing to `.web` are outside this lock, so avoid running them during an export. + Enable this option only when build output is determined by the tracked local inputs. Prerendering, Vite plugins, and custom export scripts can read remote data, the clock, or files outside `.web`; changes to those inputs require a fresh build. Run with @@ -117,10 +124,17 @@ It also verifies snapshot file contents before restoring them. Use it on a local macOS or Linux filesystem that reports file modification and change timestamps reliably; the cache is bypassed on Windows and when links lead outside tracked inputs. -Generated build output, `.react-router`, the dependency-install cache marker, and +Production exports and production/preview frontend builds sharing the same `.web` +directory wait for an exclusive workspace lock on macOS, Linux, and Windows, +including when caching is disabled. The lock covers compilation through build and +archive creation, and is released before a server starts serving. Initialization, +development hot reload, and unrelated workspace writers do not participate. + +Generated build output, `.react-router`, the build lock, the dependency-install cache marker, and the top-level `node_modules/.vite`, `.vite-temp`, and `.cache` directories are -excluded from the input fingerprint. The private `last_reflex_run_datetime` and -`last_version_check_datetime` fields in `reflex.json` are also excluded. Custom code +excluded from the input fingerprint. The private `last_reflex_run_datetime`, +`last_version_check_datetime`, and `last_version_check_attempt_datetime` fields in +`reflex.json`, including their per-package timestamp variants, are also excluded. Custom code that uses these excluded values or files to determine build output should keep the cache disabled. Cache hits can retain the earlier private timestamps embedded in bundles; the client framework uses the separately checked Reflex version value. diff --git a/news/+cache-production-frontend.performance.md b/news/+cache-production-frontend.performance.md index dd6e2d9d707..adb89a5a8af 100644 --- a/news/+cache-production-frontend.performance.md +++ b/news/+cache-production-frontend.performance.md @@ -1 +1 @@ -Add an opt-in local production frontend build cache with `REFLEX_FRONTEND_BUILD_CACHE=true` for deterministic repeat builds on macOS and Linux. Post-build hooks and compression still run; set the variable to `false` to force a fresh build and discard cached output. +Add an opt-in local production frontend build cache with `REFLEX_FRONTEND_BUILD_CACHE=true` for deterministic repeat builds on macOS and Linux. Production exports sharing a frontend directory are serialized; post-build hooks and compression still run, and setting the variable to `false` forces a fresh build and discards cached output. diff --git a/news/+skip-cached-version-check.performance.md b/news/+skip-cached-version-check.performance.md index 1ff2f0a9df7..9355a3cf44c 100644 --- a/news/+skip-cached-version-check.performance.md +++ b/news/+skip-cached-version-check.performance.md @@ -1 +1 @@ -Avoid redundant package version requests during deploy and startup when the app has already recorded a successful version check. +Handle timezone-aware version-check timestamps while preserving per-package caching and failure cooldowns. Failed HTTP responses are not recorded as successful checks. diff --git a/news/+stable-frontend-install-cache.performance.md b/news/+stable-frontend-install-cache.performance.md deleted file mode 100644 index ea02b16af45..00000000000 --- a/news/+stable-frontend-install-cache.performance.md +++ /dev/null @@ -1 +0,0 @@ -Reuse cached frontend dependency installs across CLI processes with unchanged configuration, avoiding reinstalls caused by Python's randomized set ordering. diff --git a/reflex/reflex.py b/reflex/reflex.py index aa7b7c14b1d..8a83f6b51c1 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from contextlib import nullcontext from importlib import import_module from importlib.util import find_spec from pathlib import Path @@ -437,21 +438,33 @@ def _run_preview(running_mode: constants.RunningMode, port: int, host: str): """ import atexit - from reflex.utils import build, exec, processes, telemetry + from reflex.utils import ( + build, + build_cache, + exec, + prerequisites, + processes, + telemetry, + ) config = get_config() - config._set_persistent(frontend_port=port, backend_port=port) + with ( + build_cache.frontend_build_lock(prerequisites.get_web_dir()) + if running_mode.has_frontend() + else nullcontext() + ): + config._set_persistent(frontend_port=port, backend_port=port) - # Mount the compiled frontend into the dev backend so no Vite server is needed. - environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.set( - running_mode.has_frontend() and running_mode.has_backend() - ) + # Mount the compiled frontend into the dev backend so no Vite server is needed. + environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.set( + running_mode.has_frontend() and running_mode.has_backend() + ) - if running_mode.has_frontend(): - # Compile the app and produce the initial frontend build. - _compile_app() - build.setup_frontend_prod(Path.cwd()) + if running_mode.has_frontend(): + # Compile the app and produce the initial frontend build. + _compile_app() + build.setup_frontend_prod(Path.cwd()) # Post a telemetry event. telemetry.send("run-preview") @@ -476,16 +489,28 @@ def _run_preview(running_mode: constants.RunningMode, port: int, host: str): def _run_prod(running_mode: constants.RunningMode, port: int, host: str): import atexit - from reflex.utils import build, exec, processes, telemetry + from reflex.utils import ( + build, + build_cache, + exec, + prerequisites, + processes, + telemetry, + ) config = get_config() - config._set_persistent(frontend_port=port, backend_port=port) + with ( + build_cache.frontend_build_lock(prerequisites.get_web_dir()) + if running_mode.has_frontend() + else nullcontext() + ): + config._set_persistent(frontend_port=port, backend_port=port) - if running_mode.has_frontend(): - # Get the app module. - _compile_app(avoid_dirty_check=False) - build.setup_frontend_prod(Path.cwd()) + if running_mode.has_frontend(): + # Get the app module. + _compile_app(avoid_dirty_check=False) + build.setup_frontend_prod(Path.cwd()) _skip_compile() diff --git a/reflex/utils/build_cache.py b/reflex/utils/build_cache.py index 84fffae5a9b..9a6f88fd8f2 100644 --- a/reflex/utils/build_cache.py +++ b/reflex/utils/build_cache.py @@ -2,13 +2,17 @@ from __future__ import annotations +import errno import hashlib import json import logging import os import shutil import stat +import sys import tempfile +import threading +import time from collections.abc import Iterator, Sequence from contextlib import contextmanager from pathlib import Path @@ -21,14 +25,123 @@ logger = logging.getLogger(__name__) _CACHE_DIR = "reflex.build-cache" +_LOCK_FILE = ".reflex-build.lock" _GENERATED_ROOT_ENTRIES = { _CACHE_DIR, + _LOCK_FILE, constants.Dirs.BUILD_DIR, ".react-router", "reflex.install_frontend_packages.cached", } _DEPENDENCY_CACHE_ENTRIES = {".vite", ".vite-temp", ".cache"} -_TELEMETRY_FIELDS = {"last_reflex_run_datetime", "last_version_check_datetime"} +_TELEMETRY_FIELDS = { + "last_reflex_run_datetime", + "last_version_check_datetime", + "last_version_check_attempt_datetime", +} +_VERSION_CHECK_PREFIXES = ( + "last_version_check_datetime_", + "last_version_check_attempt_datetime_", +) +_lock_state = threading.local() +_lock_descriptors: set[int] = set() + + +def _reset_build_locks_after_fork() -> None: + """Close inherited descriptors without unlocking the parent's file descriptions.""" + for descriptor in _lock_descriptors: + os.close(descriptor) + _lock_descriptors.clear() + _lock_state.paths = set() + + +if not constants.IS_WINDOWS: + os.register_at_fork(after_in_child=_reset_build_locks_after_fork) + + +def _lock_descriptor(descriptor: int, *, unlock: bool = False) -> None: + """Acquire or release the platform's exclusive file lock. + + Args: + descriptor: Open lock-file descriptor, positioned at byte zero. + unlock: Whether to release a previously acquired lock. + + Raises: + OSError: The lock operation fails for a reason other than contention. + """ + if sys.platform == "win32": + import msvcrt + + if unlock: + msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1) + return + while True: + try: + msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1) + except OSError as error: # noqa: PERF203 - retry a contended OS lock + if error.errno != errno.EACCES: + raise + # Poll every 100 ms; builds can exceed LK_LOCK's ten-second limit. + time.sleep(0.1) + else: + return + else: + import fcntl + + fcntl.flock(descriptor, fcntl.LOCK_UN if unlock else fcntl.LOCK_EX) + + +@contextmanager +def frontend_build_lock(web_dir: Path) -> Iterator[None]: + """Serialize production workspace access, including builds with caching disabled. + + Nested calls from the same thread reuse its lock. Other threads and processes + open independent descriptors and wait on the same persistent lock file. + + Args: + web_dir: Shared frontend working directory. + + Yields: + Control while this thread owns the production workspace. + + Raises: + OSError: A regular lock file cannot be opened or locked safely. + """ + owner_pid = os.getpid() + web_dir = web_dir.resolve() + held: set[Path] = getattr(_lock_state, "paths", set()) + if web_dir in held: + yield + return + web_dir.mkdir(parents=True, exist_ok=True) + lock_path = web_dir / _LOCK_FILE + descriptor = os.open( + lock_path, os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600 + ) + _lock_descriptors.add(descriptor) + try: + info = os.fstat(descriptor) + path_info = lock_path.lstat() + if ( + not stat.S_ISREG(info.st_mode) + or not stat.S_ISREG(path_info.st_mode) + or not os.path.samestat(info, path_info) + ): + msg = "Production build lock must be a regular file" + raise OSError(msg) + _lock_descriptor(descriptor) + held.add(web_dir) + _lock_state.paths = held + try: + yield + finally: + if os.getpid() == owner_pid: + held.remove(web_dir) + _lock_descriptor(descriptor, unlock=True) + finally: + if os.getpid() == owner_pid: + _lock_descriptors.discard(descriptor) + os.close(descriptor) def _is_generated(relative: Path) -> bool: @@ -143,6 +256,7 @@ def visit(directory: Path) -> None: key: value for key, value in metadata.items() if key not in _TELEMETRY_FIELDS + and not key.startswith(_VERSION_CHECK_PREFIXES) }, sort_keys=True, ).encode() @@ -195,7 +309,7 @@ def _input_digest(web_dir: Path, command: Sequence[str | Path]) -> str: info.st_ctime_ns, )) payload = [ - 1, + 2, # Discard snapshots created before production workspace locking. str(web_dir), constants.Reflex.VERSION, [str(arg) for arg in command], @@ -301,20 +415,25 @@ def frontend_build_cache( Yields: A cache handle, or None when disabled or local inputs cannot be tracked. """ - web_dir = web_dir.resolve() - directory = web_dir / _CACHE_DIR - enabled = environment.REFLEX_FRONTEND_BUILD_CACHE.get() and not constants.IS_WINDOWS - cache = None - try: - if not enabled: - # A forced fresh build must not leave an older reusable snapshot. - _remove_cache_entry(directory) - elif not directory.is_symlink(): - cache = _FrontendBuildCache(web_dir, command) - except (OSError, ValueError): - logger.debug("Frontend cache unavailable; running a fresh production build.") - try: - yield cache - finally: - if cache is not None: - cache.close() + with frontend_build_lock(web_dir): + web_dir = web_dir.resolve() + directory = web_dir / _CACHE_DIR + enabled = ( + environment.REFLEX_FRONTEND_BUILD_CACHE.get() and not constants.IS_WINDOWS + ) + cache = None + try: + if not enabled: + # A forced fresh build must not leave an older reusable snapshot. + _remove_cache_entry(directory) + elif not directory.is_symlink(): + cache = _FrontendBuildCache(web_dir, command) + except (OSError, ValueError): + logger.debug( + "Frontend cache unavailable; running a fresh production build." + ) + try: + yield cache + finally: + if cache is not None: + cache.close() diff --git a/reflex/utils/export.py b/reflex/utils/export.py index 565d0cb0915..bd0a506f4a7 100644 --- a/reflex/utils/export.py +++ b/reflex/utils/export.py @@ -11,7 +11,7 @@ from reflex_base.environment import environment from reflex_base.utils import console -from reflex.utils import build, exec, prerequisites, telemetry +from reflex.utils import build, build_cache, exec, prerequisites, telemetry logger = logging.getLogger(__name__) @@ -49,23 +49,6 @@ def export( # Set the log level. console.set_log_level(loglevel) - # Set env mode in the environment - environment.REFLEX_ENV_MODE.set(env) - - # Override the config url values if provided. - if api_url is not None: - config._set_persistent(api_url=str(api_url)) - logger.debug(f"overriding API URL: {config.api_url}") - if deploy_url is not None: - config._set_persistent(deploy_url=str(deploy_url)) - logger.debug(f"overriding deploy URL: {config.deploy_url}") - - # Show system info - exec.output_system_info() - - # Compile the app in production mode and export it. - console.rule("[bold]Compiling production app and preparing for export.") - start = time.monotonic() phase_durations: dict[str, float] = {} status = "success" @@ -80,26 +63,41 @@ def _time_phase(name: str) -> Iterator[None]: phase_durations[name] = time.monotonic() - t0 try: - if frontend: - with _time_phase("compile_duration"): - # Ensure module can be imported and app.compile() is called. - prerequisites.get_compiled_app( - prerender_routes=prerender_routes, trigger="export" - ) - with _time_phase("setup_duration"): - # Set up .web directory and install frontend dependencies. - build.setup_frontend(Path.cwd()) - with _time_phase("build_duration"): - build.build() - if zipping: - with _time_phase("zip_duration"): - build.zip_app( - frontend=frontend, - backend=backend, - zip_dest_dir=zip_dest_dir, - include_db_file=upload_db_file, - backend_excluded_dirs=backend_excluded_dirs, - ) + with build_cache.frontend_build_lock(prerequisites.get_web_dir()): + # Set env mode in the environment. + environment.REFLEX_ENV_MODE.set(env) + + # Override the config url values if provided. + if api_url is not None: + config._set_persistent(api_url=str(api_url)) + logger.debug(f"overriding API URL: {config.api_url}") + if deploy_url is not None: + config._set_persistent(deploy_url=str(deploy_url)) + logger.debug(f"overriding deploy URL: {config.deploy_url}") + + exec.output_system_info() + console.rule("[bold]Compiling production app and preparing for export.") + + if frontend: + with _time_phase("compile_duration"): + # Ensure module can be imported and app.compile() is called. + prerequisites.get_compiled_app( + prerender_routes=prerender_routes, trigger="export" + ) + with _time_phase("setup_duration"): + # Set up .web directory and install frontend dependencies. + build.setup_frontend(Path.cwd()) + with _time_phase("build_duration"): + build.build() + if zipping: + with _time_phase("zip_duration"): + build.zip_app( + frontend=frontend, + backend=backend, + zip_dest_dir=zip_dest_dir, + include_db_file=upload_db_file, + backend_excluded_dirs=backend_excluded_dirs, + ) except Exception as exc: status = "failure" detail = type(exc).__name__ diff --git a/reflex/utils/js_runtimes.py b/reflex/utils/js_runtimes.py index 9c966e30a2c..6f75192cab3 100644 --- a/reflex/utils/js_runtimes.py +++ b/reflex/utils/js_runtimes.py @@ -15,7 +15,6 @@ from reflex_base.environment import environment from reflex_base.utils.decorator import cached_procedure, once from reflex_base.utils.exceptions import SystemPackageMissingError -from reflex_base.utils.serializers import get_serializer, serialize_set from rich.markup import escape from reflex.utils import console, frontend_skeleton, net, path_ops, processes diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py index 38b64ebd5f7..b34fb2e5b05 100644 --- a/reflex/utils/prerequisites.py +++ b/reflex/utils/prerequisites.py @@ -12,7 +12,7 @@ import sys import typing import uuid -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from os import getcwd from pathlib import Path from types import ModuleType @@ -33,7 +33,9 @@ logger = logging.getLogger(__name__) -_LATEST_VERSION_CHECK_INTERVAL = timedelta(days=1) +_LATEST_VERSION_CHECK_INTERVAL = timedelta( + days=1 +) # Reuse successful checks for 24 hours. _LATEST_VERSION_CHECK_FAILURE_INTERVAL = timedelta(hours=1) _LATEST_VERSION_CHECK_DATETIME_KEY = "last_version_check_datetime" _LATEST_VERSION_CHECK_ATTEMPT_DATETIME_KEY = "last_version_check_attempt_datetime" @@ -100,13 +102,18 @@ def check_latest_package_version(package_name: str): current_version = importlib.metadata.version(package_name) url = f"https://pypi.org/pypi/{package_name}/json" response = net.get(url, timeout=2) + response.raise_for_status() latest_version = response.json()["info"]["version"] logger.debug(f"Latest version of {package_name}: {latest_version}") current_version_parsed = version.parse(current_version) latest_version_parsed = version.parse(latest_version) path_ops.update_json_file( get_web_dir() / constants.Reflex.JSON, - {_version_check_timestamp_key(package_name): str(datetime.now())}, + { + _version_check_timestamp_key(package_name): datetime.now( + timezone.utc + ).isoformat() + }, ) if current_version_parsed < latest_version_parsed: # Show a warning when the host version is older than PyPI version @@ -158,7 +165,7 @@ def get_or_set_last_reflex_version_check_datetime( return None data = json.loads(reflex_json_file.read_text()) - now = datetime.now() + now = datetime.now(timezone.utc) for key, interval in ( ( _version_check_timestamp_key(package_name), @@ -173,7 +180,8 @@ def get_or_set_last_reflex_version_check_datetime( if not isinstance(timestamp, str): continue try: - elapsed = now - datetime.fromisoformat(timestamp) + checked_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + elapsed = datetime.now(checked_at.tzinfo) - checked_at except (TypeError, ValueError): continue else: diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py index 3e41ac9d895..e9600ccee27 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -8,7 +8,7 @@ import uuid from collections.abc import Callable, Generator from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Protocol from unittest.mock import Mock @@ -18,7 +18,7 @@ from reflex_base import constants from reflex_base.config import Config from reflex_base.environment import environment -from reflex_base.utils import log, serializers +from reflex_base.utils import log from reflex_base.utils.decorator import cached_procedure from reflex.reflex import cli @@ -134,7 +134,7 @@ def test_check_latest_package_version_refreshes_expired_check( }) ) installed_version, request = _mock_pypi_versions(mocker) - before_check = datetime.now() + before_check = datetime.now(timezone.utc) with caplog.at_level("WARNING"): prerequisites.check_latest_package_version("reflex") @@ -144,7 +144,7 @@ def test_check_latest_package_version_refreshes_expired_check( ) installed_version.assert_called_once_with("reflex") request.assert_called_once_with("https://pypi.org/pypi/reflex/json", timeout=2) - assert before_check <= checked_at <= datetime.now() + assert before_check <= checked_at <= datetime.now(timezone.utc) assert caplog.messages == [ ( "Your version (1.0.0) of reflex is out of date. Upgrade to 2.0.0 " @@ -201,7 +201,7 @@ def test_check_latest_package_version_repairs_invalid_timestamp( "last_version_check_datetime" ] assert refreshed_timestamp != stored_timestamp - assert datetime.fromisoformat(refreshed_timestamp) <= datetime.now() + assert datetime.fromisoformat(refreshed_timestamp) <= datetime.now(timezone.utc) def test_check_latest_package_version_throttles_failed_request( @@ -310,6 +310,15 @@ def version_check_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(prerequisites, "get_web_dir", lambda: tmp_path) monkeypatch.setenv(environment.REFLEX_CHECK_LATEST_VERSION.name, "True") monkeypatch.setattr(prerequisites.importlib.metadata, "version", lambda _: "1.0.0") + + class FrozenDatetime(datetime): + @classmethod + def now(cls, tz=None): + if tz is None: + return cls(2026, 9, 6, 12) + return cls(2026, 9, 6, 12, tzinfo=timezone.utc).astimezone(tz) + + monkeypatch.setattr(prerequisites, "datetime", FrozenDatetime) response = Mock() response.json.return_value = {"info": {"version": "2.0.0"}} request = Mock(return_value=response) @@ -317,29 +326,116 @@ def version_check_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): return reflex_json, request -def test_check_latest_package_version_skips_cached_request(version_check_env): - """A previously recorded version check avoids another PyPI request.""" +@pytest.mark.parametrize( + "timestamp", + [ + "2026-09-06 11:00:00", + "2026-09-06T12:00:00+00:00", + "2026-09-06T13:00:00+02:00", + "2026-09-06T11:00:00Z", + "2026-09-05T12:00:01+00:00", + ], +) +def test_check_latest_package_version_skips_cached_request( + version_check_env, timestamp +): + """A check less than one day old avoids another PyPI request.""" reflex_json, request = version_check_env - contents = json.dumps({"last_version_check_datetime": "2026-09-01 12:00:00"}) + contents = json.dumps({"last_version_check_datetime": timestamp}) reflex_json.write_text(contents) - prerequisites.check_latest_package_version("reflex-hosting-cli") + prerequisites.check_latest_package_version("reflex") request.assert_not_called() assert reflex_json.read_text() == contents +@pytest.mark.parametrize( + "timestamp", + [ + "2026-09-01 12:00:00", + "2026-09-05T12:00:00+00:00", + "2026-09-05T14:00:00+02:00", + "2026-09-07 12:00:00", + "2026-09-06T12:00:01+00:00", + "2026-09-06T13:00:00-02:00", + "not-a-date", + 123, + ["2026-09-06 11:00:00"], + None, + ], +) +def test_check_latest_package_version_refreshes_stale_or_invalid_timestamp( + version_check_env, timestamp, caplog: pytest.LogCaptureFixture +): + """Expired, invalid, and future records refresh and allow a new upgrade warning.""" + reflex_json, request = version_check_env + reflex_json.write_text( + json.dumps({ + "project_hash": "test-project", + "last_version_check_datetime": timestamp, + }) + ) + + with caplog.at_level(logging.WARNING, logger=prerequisites.__name__): + prerequisites.check_latest_package_version("reflex") + prerequisites.check_latest_package_version("reflex") + + request.assert_called_once_with("https://pypi.org/pypi/reflex/json", timeout=2) + assert json.loads(reflex_json.read_text()) == { + "project_hash": "test-project", + "last_version_check_datetime": "2026-09-06T12:00:00+00:00", + "last_version_check_attempt_datetime": "2026-09-06 12:00:00+00:00", + } + assert sum(record.levelno == logging.WARNING for record in caplog.records) == 1 + + +@pytest.mark.parametrize("failure", ["network", "http", "json", "version"]) +def test_check_latest_package_version_retries_failed_refresh( + version_check_env, failure +): + """Failed refreshes preserve the old record and retry after the failure cooldown.""" + reflex_json, request = version_check_env + contents = json.dumps({"last_version_check_datetime": "2026-09-01 12:00:00"}) + reflex_json.write_text(contents) + failed_response = Mock() + failed_response.json.return_value = { + "info": {"version": "not-a-version" if failure == "version" else "2.0.0"} + } + if failure == "http": + failed_response.raise_for_status.side_effect = OSError("HTTP failure") + elif failure == "json": + failed_response.json.side_effect = ValueError("Invalid JSON") + request.side_effect = [ + OSError("network unavailable") if failure == "network" else failed_response, + request.return_value, + ] + + prerequisites.check_latest_package_version("reflex") + data = json.loads(reflex_json.read_text()) + assert data["last_version_check_datetime"] == "2026-09-01 12:00:00" + prerequisites.check_latest_package_version("reflex") + assert request.call_count == 1 + data["last_version_check_attempt_datetime"] = "2026-09-06T11:00:00+00:00" + reflex_json.write_text(json.dumps(data)) + prerequisites.check_latest_package_version("reflex") + assert request.call_count == 2 + assert json.loads(reflex_json.read_text())["last_version_check_datetime"] == ( + "2026-09-06T12:00:00+00:00" + ) + + @pytest.mark.parametrize("latest_version", ["1.0.0", "2.0.0"]) def test_check_latest_package_version_caches_success( version_check_env, latest_version: str, caplog: pytest.LogCaptureFixture ): - """A successful check is reused across packages without repeating warnings.""" + """A successful check is reused for the same package without repeating warnings.""" reflex_json, request = version_check_env request.return_value.json.return_value = {"info": {"version": latest_version}} with caplog.at_level(logging.WARNING, logger=prerequisites.__name__): prerequisites.check_latest_package_version("reflex") - prerequisites.check_latest_package_version("reflex-hosting-cli") + prerequisites.check_latest_package_version("reflex") request.assert_called_once_with("https://pypi.org/pypi/reflex/json", timeout=2) data = json.loads(reflex_json.read_text()) @@ -351,19 +447,6 @@ def test_check_latest_package_version_caches_success( assert len(warnings) == (latest_version == "2.0.0") -def test_check_latest_package_version_retries_failed_request(version_check_env): - """A failed PyPI request leaves the cache unset so the next call retries.""" - reflex_json, request = version_check_env - request.side_effect = [OSError("network unavailable"), request.return_value] - - prerequisites.check_latest_package_version("reflex") - assert "last_version_check_datetime" not in json.loads(reflex_json.read_text()) - - prerequisites.check_latest_package_version("reflex") - assert request.call_count == 2 - assert json.loads(reflex_json.read_text())["last_version_check_datetime"] - - def test_check_latest_package_version_disabled( version_check_env, monkeypatch: pytest.MonkeyPatch ): @@ -462,7 +545,7 @@ class AppConfig(Config): frozen_lockfile=True, _skip_plugins_checks=True, ) -payload = _frontend_packages_cache_payload({{"some-package@1.0.0"}}, config, ("npm",)) +payload = _frontend_packages_cache_payload({{"some-package@1.0.0"}}, set(), config.frozen_lockfile, ("npm",)) print(hashlib.sha256(payload.encode()).hexdigest()) """ fingerprints = { @@ -478,82 +561,6 @@ class AppConfig(Config): assert len(fingerprints) == 1 -def test_frontend_package_cache_fingerprint_keeps_config_changes(): - """Canonicalization preserves both changed values and explicitly set attributes.""" - config = Config(app_name="test_app", _skip_plugins_checks=True) - original = js_runtimes._frontend_packages_cache_payload(set(), config, ("npm",)) - config.api_url = "https://api.example.com" - changed_url = js_runtimes._frontend_packages_cache_payload(set(), config, ("npm",)) - config._non_default_attributes.add("api_url") - explicit_url = js_runtimes._frontend_packages_cache_payload(set(), config, ("npm",)) - - assert len({original, changed_url, explicit_url}) == 3 - - -def test_frontend_package_cache_fingerprint_preserves_custom_serialization(): - """An overridden Config.json keeps its existing fingerprint semantics.""" - - class AppConfig(Config): - def json(self) -> str: - return '{"_non_default_attributes": ["z", "a"]}' - - config = AppConfig(app_name="test_app", _skip_plugins_checks=True) - payload = js_runtimes._frontend_packages_cache_payload(set(), config, ("npm",)) - - assert config.json() in payload - - -@pytest.mark.parametrize( - "serialized", - [ - {"custom": "config"}, - {"_non_default_attributes": ["z", "a"]}, - ["custom"], - "custom", - ], -) -def test_frontend_package_cache_fingerprint_preserves_registered_serializer( - monkeypatch: pytest.MonkeyPatch, serialized: dict | list | str -): - """Registered Config serializers retain their complete output and ordering.""" - - class AppConfig(Config): - pass - - config = AppConfig(app_name="test_app", _skip_plugins_checks=True) - with monkeypatch.context() as registry: - registry.setitem(serializers.SERIALIZERS, AppConfig, lambda _: serialized) - serializers.get_serializer.cache_clear() - try: - expected = config.json() - payload = js_runtimes._frontend_packages_cache_payload( - set(), config, ("npm",) - ) - assert expected in payload - finally: - serializers.get_serializer.cache_clear() - - -def test_frontend_package_cache_fingerprint_preserves_registered_set_serializer( - monkeypatch: pytest.MonkeyPatch, -): - """A custom set serializer must not be mistaken for the default unordered list.""" - config = Config(app_name="test_app", _skip_plugins_checks=True) - with monkeypatch.context() as registry: - registry.setitem( - serializers.SERIALIZERS, set, lambda value: {"values": sorted(value)} - ) - serializers.get_serializer.cache_clear() - try: - expected = config.json() - payload = js_runtimes._frontend_packages_cache_payload( - set(), config, ("npm",) - ) - assert expected in payload - finally: - serializers.get_serializer.cache_clear() - - @pytest.fixture def install_packages_env( tmp_path, monkeypatch diff --git a/tests/units/test_reflex.py b/tests/units/test_reflex.py index bd539ab727b..db581fe8734 100644 --- a/tests/units/test_reflex.py +++ b/tests/units/test_reflex.py @@ -9,6 +9,8 @@ import click.testing import pytest +from pytest_mock import MockerFixture +from reflex_base import constants from reflex import reflex @@ -384,3 +386,111 @@ def test_init_records_version_check_after_frontend_setup( reflex._init("demo") assert events == ["frontend", "version"] + + +@pytest.fixture +def patched_production_startup(mocker: MockerFixture) -> dict: + """Patch process startup while retaining the production command sequence. + + Returns: + Mocked startup operations and the frontend lock context. + """ + config = mocker.patch("reflex.reflex.get_config", return_value=mocker.Mock()) + mocker.patch("reflex.reflex._skip_compile") + mocker.patch("atexit.register") + mocker.patch("reflex.utils.telemetry.send") + mocker.patch("reflex.utils.exec.notify_app_running") + mocker.patch("reflex.utils.exec.notify_frontend") + mount_frontend = mocker.patch( + "reflex.reflex.environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP" + ).set + return { + "config": config.return_value, + "mount_frontend": mount_frontend, + "lock": mocker.patch("reflex.utils.build_cache.frontend_build_lock"), + "compile": mocker.patch("reflex.reflex._compile_app"), + "setup": mocker.patch("reflex.utils.build.setup_frontend_prod"), + "backend_prod": mocker.patch("reflex.utils.exec.run_backend_prod"), + "backend_preview": mocker.patch("reflex.utils.exec.run_backend"), + "frontend": mocker.patch("reflex.utils.exec.run_frontend_prod"), + } + + +@pytest.mark.parametrize("runner_name", ["_run_prod", "_run_preview"]) +@pytest.mark.parametrize( + "running_mode", + [constants.RunningMode.FULLSTACK, constants.RunningMode.FRONTEND_ONLY], +) +def test_production_startup_locks_compile_and_build_before_serving( + patched_production_startup, runner_name: str, running_mode: constants.RunningMode +): + """Only compile and build hold the lock; serving starts after release.""" + context = patched_production_startup["lock"].return_value + + def require_lock(*args, **kwargs): + """Compilation and setup share the same unreleased lock.""" + context.__enter__.assert_called_once() + context.__exit__.assert_not_called() + + def require_release(*args, **kwargs): + """Serving must not retain the frontend lock for the server lifetime.""" + context.__exit__.assert_called_once_with(None, None, None) + + patched_production_startup["compile"].side_effect = require_lock + patched_production_startup["setup"].side_effect = require_lock + patched_production_startup["config"]._set_persistent.side_effect = require_lock + patched_production_startup["mount_frontend"].side_effect = require_lock + for server in ("backend_prod", "backend_preview", "frontend"): + patched_production_startup[server].side_effect = require_release + + getattr(reflex, runner_name)(running_mode, 8000, "127.0.0.1") + + patched_production_startup["lock"].assert_called_once() + patched_production_startup["setup"].assert_called_once() + patched_production_startup["config"]._set_persistent.assert_called_once_with( + frontend_port=8000, backend_port=8000 + ) + server = ( + "frontend" + if running_mode == constants.RunningMode.FRONTEND_ONLY + else "backend_prod" + if runner_name == "_run_prod" + else "backend_preview" + ) + patched_production_startup[server].assert_called_once() + + +@pytest.mark.parametrize("runner_name", ["_run_prod", "_run_preview"]) +@pytest.mark.parametrize("failing_target", ["compile", "setup"]) +def test_production_startup_releases_lock_after_failed_build( + patched_production_startup, runner_name: str, failing_target: str +): + """A startup failure releases the lock and does not start a server.""" + error = RuntimeError("build failed") + patched_production_startup[failing_target].side_effect = error + with pytest.raises(RuntimeError, match="build failed"): + getattr(reflex, runner_name)(constants.RunningMode.FULLSTACK, 8000, "127.0.0.1") + + context = patched_production_startup["lock"].return_value + context.__enter__.assert_called_once() + context.__exit__.assert_called_once() + assert context.__exit__.call_args.args[:2] == (RuntimeError, error) + for server in ("backend_prod", "backend_preview", "frontend"): + patched_production_startup[server].assert_not_called() + + +@pytest.mark.parametrize("runner_name", ["_run_prod", "_run_preview"]) +def test_backend_only_startup_does_not_lock_frontend( + patched_production_startup, runner_name: str +): + """Backend-only startup does not access the frontend working directory.""" + getattr(reflex, runner_name)(constants.RunningMode.BACKEND_ONLY, 8000, "127.0.0.1") + + patched_production_startup["lock"].assert_not_called() + patched_production_startup["compile"].assert_not_called() + patched_production_startup["setup"].assert_not_called() + patched_production_startup["config"]._set_persistent.assert_called_once_with( + frontend_port=8000, backend_port=8000 + ) + if runner_name == "_run_preview": + patched_production_startup["mount_frontend"].assert_called_once_with(False) diff --git a/tests/units/utils/test_build_cache.py b/tests/units/utils/test_build_cache.py index 7540ccd19d8..a8f471e91e7 100644 --- a/tests/units/utils/test_build_cache.py +++ b/tests/units/utils/test_build_cache.py @@ -2,15 +2,104 @@ from __future__ import annotations +import errno import json +import multiprocessing import os +import sys from pathlib import Path +from types import SimpleNamespace import pytest from pytest_mock import MockerFixture from reflex.plugins import Plugin -from reflex.utils import build +from reflex.utils import build, build_cache +from reflex.utils import export as export_utils + + +def _acquire_spawned_build_lock(web_dir, attempted, entered): + """Acquire a workspace lock from an independent interpreter. + + Args: + web_dir: Shared workspace path. + attempted: Event set before attempting acquisition. + entered: Event set after acquisition. + """ + attempted.set() + with build_cache.frontend_build_lock(web_dir): + entered.set() + + +@pytest.mark.parametrize("cache_enabled", ["true", "false"]) +def test_spawned_build_lock_serializes_with_or_without_cache( + tmp_path, monkeypatch, cache_enabled +): + """Independent processes serialize on every platform, even without caching.""" + monkeypatch.setenv("REFLEX_FRONTEND_BUILD_CACHE", cache_enabled) + context = multiprocessing.get_context("spawn") + attempted, entered = context.Event(), context.Event() + child = context.Process( + target=_acquire_spawned_build_lock, args=(tmp_path, attempted, entered) + ) + try: + with ( + build_cache.frontend_build_lock(tmp_path), + build_cache.frontend_build_lock(tmp_path / "."), + ): + child.start() + assert attempted.wait(15) + assert not entered.wait(0.3) + assert entered.wait(15) + finally: + if child.pid is not None: + child.join(15) + if child.is_alive(): + child.terminate() + child.join(5) + assert child.exitcode == 0 + + +def test_windows_lock_waits_for_contention_and_releases(tmp_path, monkeypatch, mocker): + """Windows retries contention beyond ten attempts and releases after errors.""" + locking = mocker.Mock( + side_effect=[*[OSError(errno.EACCES, "busy")] * 11, None, None] + ) + sleep = mocker.patch("time.sleep") + with ( + monkeypatch.context() as patch, + pytest.raises(RuntimeError, match="build failed"), + ): + patch.setattr(build_cache.sys, "platform", "win32") + patch.setitem( + sys.modules, + "msvcrt", + SimpleNamespace(locking=locking, LK_NBLCK=2, LK_UNLCK=0), + ) + with build_cache.frontend_build_lock(tmp_path): + assert locking.call_count == 12 + message = "build failed" + raise RuntimeError(message) + assert locking.call_count == 13 + assert locking.call_args.args[1:] == (0, 1) + assert sleep.call_count == 11 + + +def test_windows_lock_failure_stops_workspace_access(tmp_path, monkeypatch, mocker): + """Unexpected Windows lock failures propagate without entering the workspace.""" + locking = mocker.Mock(side_effect=OSError(errno.EBADF, "invalid descriptor")) + with ( + monkeypatch.context() as patch, + pytest.raises(OSError, match="invalid descriptor"), + ): + patch.setattr(build_cache.sys, "platform", "win32") + patch.setitem( + sys.modules, + "msvcrt", + SimpleNamespace(locking=locking, LK_NBLCK=2, LK_UNLCK=0), + ) + with build_cache.frontend_build_lock(tmp_path): + pytest.fail("Entered workspace without its lock") @pytest.fixture @@ -92,12 +181,18 @@ def post_build(self, **context): @pytest.mark.skipif(os.name == "nt", reason="Cache uses POSIX change timestamps") def test_telemetry_timestamps_do_not_invalidate_build(cached_build): - """Only the two private telemetry timestamps may be ignored.""" + """Private run and per-package version timestamps do not affect build output.""" web, _, process = cached_build build.build() metadata = web / "reflex.json" data = json.loads(metadata.read_text()) - data.update(last_reflex_run_datetime="later", last_version_check_datetime="later") + data.update( + last_reflex_run_datetime="later", + last_version_check_datetime="later", + last_version_check_attempt_datetime="later", + last_version_check_datetime_reflex_hosting_cli="later", + last_version_check_attempt_datetime_reflex_hosting_cli="later", + ) metadata.write_text(json.dumps(data)) build.build() assert process.call_count == 1 @@ -357,3 +452,223 @@ def test_cache_symlink_never_changes_external_target( build.build() assert external.stat().st_mode == previous_mode assert (external / "keep").read_text() == "keep" + + +@pytest.mark.skipif(os.name == "nt", reason="Cache and flock require POSIX") +@pytest.mark.parametrize("cache_modes", [(True, True), (True, False), (False, True)]) +def test_overlapping_builds_serialize(cached_build, cache_modes, mocker): + """Another process cannot replace Vite output before capture and publication.""" + web, _, process = cached_build + context = multiprocessing.get_context("fork") + first_ready = context.Event() + release_first = context.Event() + second_attempted = context.Event() + second_built = context.Event() + executor = build.js_runtimes.get_js_package_executor(raise_on_none=True)[0] + package_executor = mocker.patch.object( + build.js_runtimes, "get_js_package_executor", return_value=(executor, None) + ) + compile_frontend = process.side_effect + + def run_build(label, enabled): + os.environ["REFLEX_FRONTEND_BUILD_CACHE"] = str(enabled).lower() + package_executor.return_value = ( + [*executor, label], + None, + ) + + def compile_labeled_frontend(*args, **kwargs): + result = compile_frontend(*args, **kwargs) + (web / "build/client/index.html").write_text(label) + if label == "first": + first_ready.set() + assert release_first.wait(10) + else: + second_built.set() + return result + + process.side_effect = compile_labeled_frontend + if label == "second": + second_attempted.set() + build.build() + + first = context.Process(target=run_build, args=("first", cache_modes[0])) + second = context.Process(target=run_build, args=("second", cache_modes[1])) + first.start() + try: + assert first_ready.wait(10) + second.start() + assert second_attempted.wait(10) + assert not second_built.wait(0.3), "Second Vite replaced an active build" + finally: + release_first.set() + for worker in (first, second): + if worker.pid is not None: + worker.join(10) + if worker.is_alive(): + worker.terminate() + worker.join(5) + assert first.exitcode == second.exitcode == 0 + assert second_built.is_set() + assert (web / "build/client/index.html").read_text() == "second" + if cache_modes[1]: + package_executor.return_value = ( + [*executor, "second"], + None, + ) + build.build() + assert process.call_count == 0 + assert (web / "build/client/index.html").read_text() == "second" + else: + assert not (web / "reflex.build-cache/current").exists() + + +@pytest.mark.skipif(os.name == "nt", reason="Requires fork and flock") +def test_forked_child_does_not_inherit_lock_ownership(tmp_path): + """Nested calls are reentrant, but a forked child must wait for its parent.""" + context = multiprocessing.get_context("fork") + attempted = context.Event() + entered = context.Event() + + def acquire_in_child(): + attempted.set() + with build_cache.frontend_build_lock(tmp_path): + entered.set() + + child = context.Process(target=acquire_in_child) + try: + with ( + build_cache.frontend_build_lock(tmp_path), + build_cache.frontend_build_lock(tmp_path / "."), + ): + child.start() + assert attempted.wait(10) + assert not entered.wait(0.3) + assert entered.wait(10) + finally: + if child.pid is not None: + child.join(10) + if child.is_alive(): + child.terminate() + child.join(5) + assert child.exitcode == 0 + + +@pytest.mark.skipif(os.name == "nt", reason="Cache and flock require POSIX") +@pytest.mark.parametrize("second_cache_enabled", [True, False]) +def test_overlapping_exports_preserve_inputs_through_zip( + cached_build, tmp_path, mocker, second_cache_enabled +): + """An export waiting to archive cannot lose its inputs/output to another export.""" + web, config, _ = cached_build + context = multiprocessing.get_context("fork") + first_zipping = context.Event() + release_zip = context.Event() + second_attempted = context.Event() + second_compiled = context.Event() + first_url = "https://first.example" + second_url = "https://second.example" + mocker.patch.object(export_utils, "get_config", return_value=config) + mocker.patch.object(export_utils.exec, "output_system_info") + mocker.patch.object(export_utils.telemetry, "send") + + def set_config(**values): + for key, value in values.items(): + setattr(config, key, value) + + config._set_persistent.side_effect = set_config + + def compile_app(**kwargs): + if config.api_url == second_url: + second_compiled.set() + (web / "app/page.js").write_text(config.api_url) + + def setup_frontend(root): + (web / "env.json").write_text(json.dumps({"API_URL": config.api_url})) + + def zip_app(**kwargs): + if config.api_url == first_url: + first_zipping.set() + assert release_zip.wait(10) + destination = Path(kwargs["zip_dest_dir"]) + destination.mkdir() + (destination / "frontend.txt").write_text( + (web / "build/client/index.html").read_text() + ) + + mocker.patch.object(export_utils.prerequisites, "get_compiled_app", compile_app) + mocker.patch.object(build, "setup_frontend", setup_frontend) + mocker.patch.object(build, "zip_app", zip_app) + + def run_export(name, url, cache_enabled): + os.environ["REFLEX_FRONTEND_BUILD_CACHE"] = str(cache_enabled).lower() + if name == "second": + second_attempted.set() + export_utils.export(api_url=url, zip_dest_dir=str(tmp_path / name)) + + first = context.Process(target=run_export, args=("first", first_url, True)) + second = context.Process( + target=run_export, args=("second", second_url, second_cache_enabled) + ) + first.start() + try: + assert first_zipping.wait(10) + second.start() + assert second_attempted.wait(10) + assert not second_compiled.wait(0.3), "Another export changed active inputs" + finally: + release_zip.set() + for worker in (first, second): + if worker.pid is not None: + worker.join(10) + if worker.is_alive(): + worker.terminate() + worker.join(5) + assert first.exitcode == second.exitcode == 0 + assert (tmp_path / "first/frontend.txt").read_text() == first_url + assert (tmp_path / "second/frontend.txt").read_text() == second_url + + +@pytest.mark.skipif(os.name == "nt", reason="Requires POSIX symlinks") +def test_lock_symlink_cannot_bypass_serialization(cached_build, tmp_path): + """An unsafe lock path stops the build instead of proceeding without exclusion.""" + web, _, process = cached_build + external = tmp_path / "external-lock" + external.write_text("keep") + previous_mode = external.stat().st_mode + (web / ".reflex-build.lock").symlink_to(external) + with pytest.raises(OSError): + build.build() + assert process.call_count == 0 + assert external.read_text() == "keep" + assert external.stat().st_mode == previous_mode + + +@pytest.mark.skipif(os.name == "nt", reason="Requires flock") +def test_lock_failure_does_not_run_an_unlocked_build(cached_build, mocker): + """Lock acquisition failure must propagate before touching another build's output.""" + _, _, process = cached_build + mocker.patch("fcntl.flock", side_effect=OSError("lock unavailable")) + with pytest.raises(OSError, match="lock unavailable"): + build.build() + assert process.call_count == 0 + + +@pytest.mark.skipif(os.name == "nt", reason="Requires raw fork") +def test_forked_child_can_leave_inherited_lock_context(tmp_path): + """A child leaving its parent's context must not operate on closed descriptors.""" + pid = None + child_status = 0 + try: + with build_cache.frontend_build_lock(tmp_path): + pid = os.fork() + except OSError: + if pid != 0: + raise + child_status = 1 + finally: + if pid == 0: + os._exit(child_status) + assert pid is not None + _, status = os.waitpid(pid, 0) + assert os.waitstatus_to_exitcode(status) == 0 diff --git a/tests/units/utils/test_export.py b/tests/units/utils/test_export.py index 9776eb0c043..a34e058619f 100644 --- a/tests/units/utils/test_export.py +++ b/tests/units/utils/test_export.py @@ -16,6 +16,9 @@ def patched_export(mocker: MockerFixture) -> dict: Dict of patched mocks keyed by short name. """ return { + "frontend_build_lock": mocker.patch( + "reflex.utils.build_cache.frontend_build_lock" + ), "get_compiled_app": mocker.patch( "reflex.utils.export.prerequisites.get_compiled_app" ), @@ -27,8 +30,8 @@ def patched_export(mocker: MockerFixture) -> dict: "reflex.utils.export.exec.output_system_info" ), "env_mode_set": mocker.patch( - "reflex.utils.export.environment.REFLEX_ENV_MODE.set" - ), + "reflex.utils.export.environment.REFLEX_ENV_MODE" + ).set, "get_config": mocker.patch( "reflex.utils.export.get_config", return_value=mocker.Mock() ), @@ -126,3 +129,64 @@ def test_export_no_zip_emits_only_compile_and_build_durations(patched_export): assert isinstance(kwargs["setup_duration"], float) assert isinstance(kwargs["build_duration"], float) assert kwargs["zip_duration"] is None + + +@pytest.mark.parametrize( + ("frontend", "zipping", "phases"), + [ + (True, True, ["get_compiled_app", "setup_frontend", "build", "zip_app"]), + (True, False, ["get_compiled_app", "setup_frontend", "build"]), + (False, True, ["zip_app"]), + ], +) +def test_export_holds_frontend_lock_through_packaging( + patched_export, frontend: bool, zipping: bool, phases: list[str] +): + """Generated inputs and both archives belong to one locked export.""" + lock = patched_export["frontend_build_lock"] + context = lock.return_value + + def require_lock(*args, **kwargs): + """Require each export phase to run before the lock is released.""" + context.__enter__.assert_called_once() + context.__exit__.assert_not_called() + + for phase in phases: + patched_export[phase].side_effect = require_lock + patched_export["env_mode_set"].side_effect = require_lock + config = patched_export["get_config"].return_value + config._set_persistent.side_effect = require_lock + + export.export( + frontend=frontend, + zipping=zipping, + api_url="https://api.example.com", + deploy_url="https://app.example.com", + ) + + lock.assert_called_once_with(export.prerequisites.get_web_dir()) + context.__exit__.assert_called_once_with(None, None, None) + assert config._set_persistent.call_count == 2 + + +@pytest.mark.parametrize( + "failing_target", ["get_compiled_app", "setup_frontend", "build", "zip_app"] +) +def test_export_releases_frontend_lock_before_failure_telemetry( + patched_export, failing_target: str +): + """A failed export releases shared files before reporting its failure.""" + context = patched_export["frontend_build_lock"].return_value + error = RuntimeError("export failed") + patched_export[failing_target].side_effect = error + + def require_release(*args, **kwargs): + """Telemetry runs outside the frontend lock even on failure.""" + context.__enter__.assert_called_once() + context.__exit__.assert_called_once() + + patched_export["send"].side_effect = require_release + with pytest.raises(RuntimeError, match="export failed"): + export.export() + + assert context.__exit__.call_args.args[:2] == (RuntimeError, error) From 87ea648d795977ce38ae49a7ecf61851b73e3094 Mon Sep 17 00:00:00 2001 From: Alek Date: Tue, 8 Sep 2026 15:38:37 -0700 Subject: [PATCH 3/6] Clarify cross-platform workspace locking and version cooldowns --- docs/hosting/self-hosting.md | 19 +++++++------------ reflex/utils/prerequisites.py | 6 +++--- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/docs/hosting/self-hosting.md b/docs/hosting/self-hosting.md index 233ea751799..b835d0e1c05 100644 --- a/docs/hosting/self-hosting.md +++ b/docs/hosting/self-hosting.md @@ -105,12 +105,13 @@ It is disabled by default. On a cache hit, Reflex restores the pristine JavaScri build, then runs post-build plugins, fallback generation, compression, and frontend path processing again. Python compilation and the normal dependency checks still run. -On macOS and Linux, production exports sharing `.web` wait for one another from -compilation through ZIP creation. Production and preview startup also hold this -lock while compiling and building, even when caching is disabled. The lock file -`.web/.reflex-build.lock` remains in place between commands; do not remove it while -a command is running. Initialization, development hot reload, and unrelated tools -writing to `.web` are outside this lock, so avoid running them during an export. +On macOS, Linux, and Windows, production exports sharing `.web` wait for one +another from compilation through ZIP creation. Production and preview startup +also hold this lock while compiling and building, even when caching is disabled, +and release it before serving. The lock file `.web/.reflex-build.lock` remains in +place between commands; do not remove it while a command is running. +Initialization, development hot reload, and unrelated tools writing to `.web` are +outside this lock, so avoid running them during an export. Enable this option only when build output is determined by the tracked local inputs. Prerendering, Vite plugins, and custom export scripts can read remote data, the clock, @@ -124,12 +125,6 @@ It also verifies snapshot file contents before restoring them. Use it on a local macOS or Linux filesystem that reports file modification and change timestamps reliably; the cache is bypassed on Windows and when links lead outside tracked inputs. -Production exports and production/preview frontend builds sharing the same `.web` -directory wait for an exclusive workspace lock on macOS, Linux, and Windows, -including when caching is disabled. The lock covers compilation through build and -archive creation, and is released before a server starts serving. Initialization, -development hot reload, and unrelated workspace writers do not participate. - Generated build output, `.react-router`, the build lock, the dependency-install cache marker, and the top-level `node_modules/.vite`, `.vite-temp`, and `.cache` directories are excluded from the input fingerprint. The private `last_reflex_run_datetime`, diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py index b34fb2e5b05..48d7b1822fe 100644 --- a/reflex/utils/prerequisites.py +++ b/reflex/utils/prerequisites.py @@ -33,9 +33,9 @@ logger = logging.getLogger(__name__) -_LATEST_VERSION_CHECK_INTERVAL = timedelta( - days=1 -) # Reuse successful checks for 24 hours. +# Reuse successful checks for 24 hours. +_LATEST_VERSION_CHECK_INTERVAL = timedelta(days=1) +# Retry failed checks after one hour. _LATEST_VERSION_CHECK_FAILURE_INTERVAL = timedelta(hours=1) _LATEST_VERSION_CHECK_DATETIME_KEY = "last_version_check_datetime" _LATEST_VERSION_CHECK_ATTEMPT_DATETIME_KEY = "last_version_check_attempt_datetime" From 7ebe20fd87f3db7ad975bc5c362551b264eb4c15 Mon Sep 17 00:00:00 2001 From: Farhan Date: Sat, 19 Sep 2026 02:51:56 +0500 Subject: [PATCH 4/6] Address deploy cache review feedback --- reflex/utils/build.py | 72 ++++++--- reflex/utils/build_cache.py | 211 ++++++++++++++++++-------- reflex/utils/export.py | 8 +- reflex/utils/js_runtimes.py | 113 ++++++++------ reflex/utils/prerequisites.py | 2 +- tests/units/test_prerequisites.py | 2 +- tests/units/utils/test_build_cache.py | 15 +- tests/units/utils/test_export.py | 8 +- 8 files changed, 292 insertions(+), 139 deletions(-) diff --git a/reflex/utils/build.py b/reflex/utils/build.py index c35bff00431..a47d7c2ff7c 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -41,6 +41,41 @@ def set_env_json(): ) +def _zip_compress_type(component_name: constants.ComponentName, file: Path) -> int: + """Select compression suitable for one archive entry. + + Args: + component_name: The archive being created. + file: The source file being archived. + + Returns: + The ZIP compression type for the file. + """ + if component_name == constants.ComponentName.FRONTEND and file.suffix in { + ".gz", + ".br", + ".zst", + }: + return zipfile.ZIP_STORED + return zipfile.ZIP_DEFLATED + + +def _is_excluded_archive_path( + path: Path, excluded_file_ids: set[tuple[int, int]] +) -> bool: + """Check whether an archive path has an excluded file identity. + + Args: + path: The path being considered for the archive. + excluded_file_ids: Device and inode pairs that must be excluded. + + Returns: + Whether the path refers to an excluded file or directory. + """ + stat = path.stat() + return (stat.st_dev, stat.st_ino) in excluded_file_ids + + def _zip( *, component_name: constants.ComponentName, @@ -75,20 +110,6 @@ def _zip( stat = excluded_file.stat() excluded_file_ids.add((stat.st_dev, stat.st_ino)) - def is_excluded(path: Path) -> bool: - """Check file identity without repeatedly statting every excluded path. - - Args: - path: The file or directory to check. - - Returns: - Whether the path refers to an excluded file or directory. - """ - if not excluded_file_ids: - return False - stat = path.stat() - return (stat.st_dev, stat.st_ino) in excluded_file_ids - files_to_zip: list[Path] = [] # Traverse the root directory in a top-down manner. In this traversal order, # we can modify the dirs list in-place to remove directories we don't want to include. @@ -101,7 +122,12 @@ def is_excluded(path: Path) -> bool: subdirectory_name for subdirectory_name in subdirectories_names if subdirectory_name not in directory_names_to_exclude - and not is_excluded(directory_path / subdirectory_name) + and ( + not excluded_file_ids + or not _is_excluded_archive_path( + directory_path / subdirectory_name, excluded_file_ids + ) + ) and not subdirectory_name.startswith(".") and ( not exclude_venv_directories @@ -118,7 +144,10 @@ def is_excluded(path: Path) -> bool: files_to_zip += [ directory_path / subfile_name for subfile_name in subfiles_names - if not is_excluded(directory_path / subfile_name) + if not excluded_file_ids + or not _is_excluded_archive_path( + directory_path / subfile_name, excluded_file_ids + ) ] if globs_to_include: for glob in globs_to_include: @@ -137,15 +166,10 @@ def is_excluded(path: Path) -> bool: for file in files_to_zip: logger.debug(f"{target}: {file}", extra={"progress": progress}) progress.advance(task) - # Sidecars are already compressed for serving the frontend. - compress_type = ( - zipfile.ZIP_STORED - if component_name == constants.ComponentName.FRONTEND - and file.suffix in {".gz", ".br", ".zst"} - else zipfile.ZIP_DEFLATED - ) zipf.write( - file, file.relative_to(root_directory), compress_type=compress_type + file, + file.relative_to(root_directory), + compress_type=_zip_compress_type(component_name, file), ) diff --git a/reflex/utils/build_cache.py b/reflex/utils/build_cache.py index 9a6f88fd8f2..84634bc28bb 100644 --- a/reflex/utils/build_cache.py +++ b/reflex/utils/build_cache.py @@ -13,9 +13,10 @@ import tempfile import threading import time -from collections.abc import Iterator, Sequence +from collections.abc import Buffer, Iterator, Sequence from contextlib import contextmanager from pathlib import Path +from typing import Protocol from reflex_base import constants from reflex_base.environment import environment @@ -43,10 +44,19 @@ "last_version_check_datetime_", "last_version_check_attempt_datetime_", ) +_BUILD_ENVIRONMENT_PREFIXES = ("BUN_", "NODE_", "NPM_CONFIG_", "REFLEX_", "VITE_") +_IGNORED_BUILD_ENVIRONMENT_KEYS = {"REFLEX_LOGLEVEL"} _lock_state = threading.local() _lock_descriptors: set[int] = set() +class _Digest(Protocol): + """Protocol for the portion of a hash object used by cache fingerprints.""" + + def update(self, data: Buffer, /) -> None: + """Add data to the digest.""" + + def _reset_build_locks_after_fork() -> None: """Close inherited descriptors without unlocking the parent's file descriptions.""" for descriptor in _lock_descriptors: @@ -175,6 +185,122 @@ def _remove_cache_entry(path: Path) -> None: shutil.rmtree(path) +def _digest_symlink(digest: _Digest, root: Path, path: Path, *, inputs: bool) -> None: + """Hash a tracked symlink and validate its resolved target. + + Args: + digest: The digest receiving the symlink identity. + root: The root of the tracked tree. + path: The symlink path. + inputs: Whether this is the frontend input tree. + + Raises: + ValueError: The link loops, leaves the tree, or reaches generated output. + """ + try: + target = path.resolve(strict=True) + except RuntimeError as error: + msg = "Build input contains a symlink loop" + raise ValueError(msg) from error + if not inputs or not target.is_relative_to(root): + msg = "Build cache cannot track an external symlink" + raise ValueError(msg) + target_relative = target.relative_to(root) + if _is_generated(target_relative): + msg = "Build input links to an untracked generated directory" + raise ValueError(msg) + digest.update( + json.dumps([str(path.readlink()), target_relative.as_posix()]).encode() + ) + + +def _digest_regular_file( + digest: _Digest, path: Path, relative: Path, info: os.stat_result, *, inputs: bool +) -> None: + """Hash a file's content or installed-dependency metadata. + + Args: + digest: The digest receiving file metadata. + path: The file path. + relative: The path relative to the tracked tree. + info: The file status metadata. + inputs: Whether this is the frontend input tree. + + Raises: + ValueError: Frontend metadata is not a JSON object. + """ + if inputs and relative.parts[0] == "node_modules": + digest.update( + json.dumps([ + info.st_dev, + info.st_ino, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + ]).encode() + ) + return + if inputs and relative.as_posix() == constants.Reflex.JSON: + metadata = json.loads(path.read_text()) + if not isinstance(metadata, dict): + msg = "Frontend metadata must be an object" + raise ValueError(msg) + digest.update( + json.dumps( + { + key: value + for key, value in metadata.items() + if key not in _TELEMETRY_FIELDS + and not key.startswith(_VERSION_CHECK_PREFIXES) + }, + sort_keys=True, + ).encode() + ) + return + content_digest = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + content_digest.update(chunk) + digest.update(content_digest.digest()) + + +def _digest_entry( + digest: _Digest, + root: Path, + path: Path, + relative: Path, + info: os.stat_result, + *, + inputs: bool, +) -> Path | None: + """Hash one tree entry and return a directory that should be visited. + + Args: + digest: The digest receiving entry data. + root: The root of the tracked tree. + path: The entry path. + relative: The path relative to the tracked tree. + info: The entry status metadata. + inputs: Whether this is the frontend input tree. + + Returns: + A physical child directory to visit, if applicable. + + Raises: + ValueError: The entry is unsupported or cannot be safely tracked. + """ + if stat.S_ISLNK(info.st_mode): + _digest_symlink(digest, root, path, inputs=inputs) + return None + if stat.S_ISDIR(info.st_mode): + return path + if stat.S_ISREG(info.st_mode): + _digest_regular_file(digest, path, relative, info, inputs=inputs) + return None + msg = "Build cache only supports regular files and directories" + raise ValueError(msg) + + def _tree_digest(root: Path, *, inputs: bool = False) -> str: """Hash tracked tree entries, using change metadata for installed dependencies. @@ -209,73 +335,30 @@ def visit(directory: Path) -> None: continue info = entry.stat(follow_symlinks=False) digest.update(json.dumps([relative.as_posix(), info.st_mode]).encode()) - if stat.S_ISLNK(info.st_mode): - try: - target = path.resolve(strict=True) - except RuntimeError as error: - # Python 3.10-3.12 report symlink loops as RuntimeError. - msg = "Build input contains a symlink loop" - raise ValueError(msg) from error - if not inputs or not target.is_relative_to(root): - msg = "Build cache cannot track an external symlink" - raise ValueError(msg) - target_relative = target.relative_to(root) - if _is_generated(target_relative): - msg = "Build input links to an untracked generated directory" - raise ValueError(msg) - # An intermediate link outside the tree can redirect to another - # tracked file without changing this link or either file. - digest.update( - json.dumps([ - str(path.readlink()), - target_relative.as_posix(), - ]).encode() - ) - elif stat.S_ISDIR(info.st_mode): - visit(path) - elif stat.S_ISREG(info.st_mode): - if inputs and relative.parts[0] == "node_modules": - # ctime detects edits even when size and mtime are restored. - digest.update( - json.dumps([ - info.st_dev, - info.st_ino, - info.st_size, - info.st_mtime_ns, - info.st_ctime_ns, - ]).encode() - ) - elif inputs and relative.as_posix() == constants.Reflex.JSON: - metadata = json.loads(path.read_text()) - if not isinstance(metadata, dict): - msg = "Frontend metadata must be an object" - raise ValueError(msg) - digest.update( - json.dumps( - { - key: value - for key, value in metadata.items() - if key not in _TELEMETRY_FIELDS - and not key.startswith(_VERSION_CHECK_PREFIXES) - }, - sort_keys=True, - ).encode() - ) - else: - content_digest = hashlib.sha256() - with path.open("rb") as source: - while chunk := source.read(1024 * 1024): - content_digest.update(chunk) - digest.update(content_digest.digest()) - else: - msg = "Build cache only supports regular files and directories" - raise ValueError(msg) + if child_directory := _digest_entry( + digest, root, path, relative, info, inputs=inputs + ): + visit(child_directory) digest.update(b"\0") visit(root) return digest.hexdigest() +def _build_environment() -> list[tuple[str, str]]: + """Return environment values that can affect a Vite production build. + + Returns: + Sorted build-relevant environment key-value pairs. + """ + return sorted( + (key, value) + for key, value in os.environ.items() + if (key == "PATH" or key.startswith(_BUILD_ENVIRONMENT_PREFIXES)) + and key not in _IGNORED_BUILD_ENVIRONMENT_KEYS + ) + + def _input_digest(web_dir: Path, command: Sequence[str | Path]) -> str: """Fingerprint local build inputs and the executing runtime. @@ -309,12 +392,12 @@ def _input_digest(web_dir: Path, command: Sequence[str | Path]) -> str: info.st_ctime_ns, )) payload = [ - 2, # Discard snapshots created before production workspace locking. + 3, # Discard snapshots created before build-environment filtering. str(web_dir), constants.Reflex.VERSION, [str(arg) for arg in command], runtimes, - sorted(os.environ.items()), + _build_environment(), _tree_digest(web_dir, inputs=True), ] return hashlib.sha256(json.dumps(payload).encode()).hexdigest() diff --git a/reflex/utils/export.py b/reflex/utils/export.py index bd0a506f4a7..d3bce4f6f0b 100644 --- a/reflex/utils/export.py +++ b/reflex/utils/export.py @@ -3,7 +3,7 @@ import logging import time from collections.abc import Iterator -from contextlib import contextmanager +from contextlib import contextmanager, nullcontext from pathlib import Path from reflex_base import constants @@ -63,7 +63,11 @@ def _time_phase(name: str) -> Iterator[None]: phase_durations[name] = time.monotonic() - t0 try: - with build_cache.frontend_build_lock(prerequisites.get_web_dir()): + with ( + build_cache.frontend_build_lock(prerequisites.get_web_dir()) + if frontend + else nullcontext() + ): # Set env mode in the environment. environment.REFLEX_ENV_MODE.set(env) diff --git a/reflex/utils/js_runtimes.py b/reflex/utils/js_runtimes.py index 5b341c649a9..048ce83a04d 100644 --- a/reflex/utils/js_runtimes.py +++ b/reflex/utils/js_runtimes.py @@ -469,18 +469,56 @@ def _is_bun_package_manager(package_manager: str) -> bool: return Path(package_manager).stem.lower() == "bun" -def _npm_installed_package_sections( - declared_deps: dict[str, str], declared_dev_deps: dict[str, str] -) -> tuple[dict[str, str], dict[str, str]]: - """Verify npm's saved caret declarations against its lockfile and installed packages. +def _verified_installed_version( + name: str, declaration: str, entry: object +) -> str | None: + """Return a verified installed version for one npm caret declaration. Args: - declared_deps: Regular dependency declarations used for the install. - declared_dev_deps: Development dependency declarations used for the install. + name: The package name. + declaration: The version declared in the package manifest. + entry: The package-lock entry for the installed package. Returns: - Dependency sections with verified caret declarations replaced by their - exact installed versions. Unverified declarations remain unchanged. + The installed version when its lock and package metadata both match. + """ + if ( + not isinstance(entry, dict) + or entry.get("link") + or entry.get("name", name) != name + ): + return None + installed_version = entry.get("version") + if not isinstance(installed_version, str) or declaration != f"^{installed_version}": + return None + resolved = entry.get("resolved") + if isinstance(resolved, str) and resolved.startswith(( + "file:", + "link:", + "workspace:", + )): + return None + package_dir = get_web_dir() / "node_modules" / name + if package_dir.is_symlink(): + return None + try: + package = json.loads((package_dir / "package.json").read_text()) + except (OSError, ValueError): + return None + if ( + isinstance(package, dict) + and package.get("name") == name + and package.get("version") == installed_version + ): + return installed_version + return None + + +def _npm_lock_package_data() -> tuple[dict, dict] | None: + """Read the root and package metadata from a supported npm lockfile. + + Returns: + The root package declarations and installed package metadata, if usable. """ try: lock = json.loads( @@ -489,12 +527,31 @@ def _npm_installed_package_sections( ).read_text() ) except (OSError, ValueError): - return declared_deps, declared_dev_deps + return None if not isinstance(lock, dict) or lock.get("lockfileVersion") not in (2, 3): - return declared_deps, declared_dev_deps + return None packages = lock.get("packages") if not isinstance(packages, dict) or not isinstance(root := packages.get(""), dict): + return None + return root, packages + + +def _npm_installed_package_sections( + declared_deps: dict[str, str], declared_dev_deps: dict[str, str] +) -> tuple[dict[str, str], dict[str, str]]: + """Verify npm's saved caret declarations against its lockfile and installed packages. + + Args: + declared_deps: Regular dependency declarations used for the install. + declared_dev_deps: Development dependency declarations used for the install. + + Returns: + Dependency sections with verified caret declarations replaced by their + exact installed versions. Unverified declarations remain unchanged. + """ + if (lock_data := _npm_lock_package_data()) is None: return declared_deps, declared_dev_deps + root, packages = lock_data installed_deps, installed_dev_deps = dict(declared_deps), dict(declared_dev_deps) for section, declared, installed in ( @@ -505,40 +562,10 @@ def _npm_installed_package_sections( if not isinstance(root_declarations, dict): continue for name, declaration in declared.items(): - entry = packages.get(f"node_modules/{name}") - if ( - not isinstance(entry, dict) - or entry.get("link") - or entry.get("name", name) != name - or root_declarations.get(name) != declaration - ): - continue - installed_version = entry.get("version") - if ( - not isinstance(installed_version, str) - or declaration != f"^{installed_version}" - ): - continue - resolved = entry.get("resolved") - if isinstance(resolved, str) and resolved.startswith(( - "file:", - "link:", - "workspace:", - )): - continue - package_dir = get_web_dir() / "node_modules" / name - if package_dir.is_symlink(): - continue - # npm can ignore package-lock.json (package-lock=false or shrinkwrap). - # Check the installed package too so a stale lock cannot hide an upgrade. - try: - package = json.loads((package_dir / "package.json").read_text()) - except (OSError, ValueError): + if root_declarations.get(name) != declaration: continue - if ( - isinstance(package, dict) - and package.get("name") == name - and package.get("version") == installed_version + if installed_version := _verified_installed_version( + name, declaration, packages.get(f"node_modules/{name}") ): installed[name] = installed_version return installed_deps, installed_dev_deps diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py index 89fe52ec208..f063efb521a 100644 --- a/reflex/utils/prerequisites.py +++ b/reflex/utils/prerequisites.py @@ -189,7 +189,7 @@ def get_or_set_last_reflex_version_check_datetime( path_ops.update_json_file( reflex_json_file, - {_version_check_timestamp_key(package_name, attempt=True): str(now)}, + {_version_check_timestamp_key(package_name, attempt=True): now.isoformat()}, ) return None diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py index 8368408c99d..c577c9a5a7e 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -396,7 +396,7 @@ def test_check_latest_package_version_refreshes_stale_or_invalid_timestamp( assert json.loads(reflex_json.read_text()) == { "project_hash": "test-project", "last_version_check_datetime": "2026-09-06T12:00:00+00:00", - "last_version_check_attempt_datetime": "2026-09-06 12:00:00+00:00", + "last_version_check_attempt_datetime": "2026-09-06T12:00:00+00:00", } assert sum(record.levelno == logging.WARNING for record in caplog.records) == 1 diff --git a/tests/units/utils/test_build_cache.py b/tests/units/utils/test_build_cache.py index a8f471e91e7..104d663804d 100644 --- a/tests/units/utils/test_build_cache.py +++ b/tests/units/utils/test_build_cache.py @@ -238,15 +238,24 @@ def test_disabled_cache_forces_and_refreshes_build(cached_build, monkeypatch): assert process.call_count == 3 -def test_environment_change_rebuilds(cached_build, monkeypatch): - """Build hooks may observe arbitrary environment values.""" +def test_build_environment_change_rebuilds(cached_build, monkeypatch): + """A Vite environment value invalidates the build cache.""" _, _, process = cached_build build.build() - monkeypatch.setenv("CUSTOM_BUILD_VALUE", "new") + monkeypatch.setenv("VITE_CUSTOM_BUILD_VALUE", "new") build.build() assert process.call_count == 2 +def test_non_build_environment_change_reuses_cache(cached_build, monkeypatch): + """Unrelated shell state does not invalidate a deterministic frontend build.""" + _, _, process = cached_build + build.build() + monkeypatch.setenv("SHLVL", "999") + build.build() + assert process.call_count == 1 + + def test_failed_build_is_retried(cached_build): """A failed Vite run must never populate the cache.""" _, _, process = cached_build diff --git a/tests/units/utils/test_export.py b/tests/units/utils/test_export.py index a34e058619f..7eb8105d7ed 100644 --- a/tests/units/utils/test_export.py +++ b/tests/units/utils/test_export.py @@ -136,7 +136,6 @@ def test_export_no_zip_emits_only_compile_and_build_durations(patched_export): [ (True, True, ["get_compiled_app", "setup_frontend", "build", "zip_app"]), (True, False, ["get_compiled_app", "setup_frontend", "build"]), - (False, True, ["zip_app"]), ], ) def test_export_holds_frontend_lock_through_packaging( @@ -169,6 +168,13 @@ def require_lock(*args, **kwargs): assert config._set_persistent.call_count == 2 +def test_backend_only_export_does_not_lock_frontend(patched_export): + """A backend-only export does not create or lock a frontend workspace.""" + export.export(frontend=False) + + patched_export["frontend_build_lock"].assert_not_called() + + @pytest.mark.parametrize( "failing_target", ["get_compiled_app", "setup_frontend", "build", "zip_app"] ) From ad1c487ab4d92e5793e273328deb06fd7ebca8f7 Mon Sep 17 00:00:00 2001 From: Farhan Date: Sat, 19 Sep 2026 03:22:02 +0500 Subject: [PATCH 5/6] Restore Python 3.10 build cache compatibility --- reflex/utils/build_cache.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/reflex/utils/build_cache.py b/reflex/utils/build_cache.py index 84634bc28bb..245a8ea5276 100644 --- a/reflex/utils/build_cache.py +++ b/reflex/utils/build_cache.py @@ -13,13 +13,14 @@ import tempfile import threading import time -from collections.abc import Buffer, Iterator, Sequence +from collections.abc import Iterator, Sequence from contextlib import contextmanager from pathlib import Path from typing import Protocol from reflex_base import constants from reflex_base.environment import environment +from typing_extensions import Buffer from reflex.utils import path_ops From 888f71c5d16f4a44f0929e320067061e2b65531c Mon Sep 17 00:00:00 2001 From: Farhan Date: Sat, 19 Sep 2026 03:34:57 +0500 Subject: [PATCH 6/6] Skip frontend cache reuse test on Windows --- tests/units/utils/test_build_cache.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/units/utils/test_build_cache.py b/tests/units/utils/test_build_cache.py index 104d663804d..804abdfda97 100644 --- a/tests/units/utils/test_build_cache.py +++ b/tests/units/utils/test_build_cache.py @@ -247,6 +247,9 @@ def test_build_environment_change_rebuilds(cached_build, monkeypatch): assert process.call_count == 2 +@pytest.mark.skipif( + os.name == "nt", reason="Frontend build cache is disabled on Windows" +) def test_non_build_environment_change_reuses_cache(cached_build, monkeypatch): """Unrelated shell state does not invalidate a deterministic frontend build.""" _, _, process = cached_build