diff --git a/news/7566.bugfix.md b/news/7566.bugfix.md new file mode 100644 index 00000000000..8afa2131d89 --- /dev/null +++ b/news/7566.bugfix.md @@ -0,0 +1 @@ +Avoid rerunning npm installs during source-only reloads when npm omits an empty `devDependencies` field from `package.json`. diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index f0b894b9251..b5b0f1b6061 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -19,6 +19,8 @@ logger = logging.getLogger(__name__) +_DEV_DEPENDENCIES_KEY = "devDependencies" + def initialize_gitignore( gitignore_file: Path = constants.GitIgnore.FILE, @@ -368,7 +370,12 @@ def sync_root_package_json_to_web() -> bool: output_path = get_web_lockfile_path(constants.PackageJson.PATH) rendered = _compile_package_json() - if _read_package_json_object(output_path) == json.loads(rendered): + existing_package_json = _read_package_json_object(output_path) + rendered_package_json = json.loads(rendered) + for package_json in (existing_package_json, rendered_package_json): + if package_json.get(_DEV_DEPENDENCIES_KEY) == {}: + package_json.pop(_DEV_DEPENDENCIES_KEY) + if existing_package_json == rendered_package_json: return False changed = output_path.exists() diff --git a/tests/units/utils/test_frontend_skeleton.py b/tests/units/utils/test_frontend_skeleton.py index 4c269a3f697..aab07bc36c6 100644 --- a/tests/units/utils/test_frontend_skeleton.py +++ b/tests/units/utils/test_frontend_skeleton.py @@ -67,6 +67,25 @@ def test_sync_root_package_json_ignores_formatting( assert package_json_path.stat().st_mtime_ns == original_mtime +def test_sync_root_package_json_ignores_empty_dev_dependencies( + package_json_path: Path, +): + """An absent and empty devDependencies field are equivalent for npm. + + Args: + package_json_path: The rendered package.json path. + """ + package_json = json.loads(package_json_path.read_text()) + package_json.pop("devDependencies", None) + package_json_path.write_text(json.dumps(package_json, indent=2)) + original_content = package_json_path.read_text() + original_mtime = package_json_path.stat().st_mtime_ns + + assert frontend_skeleton.sync_root_package_json_to_web() is False + assert package_json_path.read_text() == original_content + assert package_json_path.stat().st_mtime_ns == original_mtime + + @pytest.mark.parametrize( ("field", "value"), [ diff --git a/tests/units/utils/test_js_runtimes.py b/tests/units/utils/test_js_runtimes.py index 36badb1ee1b..068e3ed9b96 100644 --- a/tests/units/utils/test_js_runtimes.py +++ b/tests/units/utils/test_js_runtimes.py @@ -1,11 +1,13 @@ """Tests for reflex.utils.js_runtimes install gating.""" +import json from pathlib import Path from unittest import mock import pytest +from reflex_base import constants -from reflex.utils import js_runtimes +from reflex.utils import frontend_skeleton, js_runtimes BUN_PATH = Path("/usr/bin/bun") NPM_PATH = Path("/usr/bin/npm") @@ -89,6 +91,44 @@ def _patch_unsupported_node(monkeypatch) -> None: monkeypatch.setattr(js_runtimes, "get_node_version", lambda: None) +def test_npm_empty_dev_dependencies_preserves_frontend_install_cache( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """An npm manifest omitting empty devDependencies must not invalidate installs. + + Args: + tmp_path: The temporary app directory. + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.chdir(tmp_path) + web_dir = tmp_path / constants.Dirs.WEB + web_dir.mkdir() + monkeypatch.setattr(frontend_skeleton, "get_web_dir", lambda: web_dir) + monkeypatch.setattr(js_runtimes, "get_web_dir", lambda: web_dir) + + root_package_json = frontend_skeleton.get_root_lockfile_path( + constants.PackageJson.PATH + ) + root_package_json.parent.mkdir() + root_package_json.write_text(json.dumps({"dependencies": {"test-pkg": "1.0.0"}})) + + npm_package_json = json.loads(frontend_skeleton._compile_package_json()) + npm_package_json.pop("devDependencies", None) + web_package_json = frontend_skeleton.get_web_lockfile_path( + constants.PackageJson.PATH + ) + web_package_json.write_text(json.dumps(npm_package_json, indent=2)) + + cache_file = js_runtimes._frontend_packages_cache_path() + cache_file.touch() + original_content = web_package_json.read_text() + + js_runtimes._sync_root_lockfiles_for_frontend_install() + + assert cache_file.exists() + assert web_package_json.read_text() == original_content + + def test_install_never_starts_with_unsupported_node(monkeypatch, install_mocks, caplog): """A failed node-version preflight must leave no lockfile side effects.