diff --git a/reflex/app.py b/reflex/app.py index 6245a9f0d1d..38918170f70 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -615,8 +615,13 @@ def __call__(self) -> ASGIApp: Returns: The backend api. """ + from reflex.assets import remove_stale_external_asset_symlinks from reflex.vars.base import GLOBAL_CACHE + # Clean up stale symlinks in assets/external/ before compiling, so that + # rx.asset(shared=True) symlink re-creation doesn't trigger further reloads. + remove_stale_external_asset_symlinks() + self._compile(prerender_routes=should_prerender_routes()) config = get_config() diff --git a/reflex/assets.py b/reflex/assets.py index 500cf9408ea..b6797262dff 100644 --- a/reflex/assets.py +++ b/reflex/assets.py @@ -7,6 +7,35 @@ from reflex.environment import EnvironmentVariables +def remove_stale_external_asset_symlinks(): + """Remove broken symlinks and empty directories in assets/external/. + + When a Python module directory that uses rx.asset(shared=True) is renamed + or deleted, stale symlinks remain in assets/external/ pointing to the old + path. This cleanup prevents issues with file watchers detecting symlink + re-creation during import. + """ + external_dir = ( + Path.cwd() / constants.Dirs.APP_ASSETS / constants.Dirs.EXTERNAL_APP_ASSETS + ) + if not external_dir.exists(): + return + + # Remove broken symlinks. + broken = [ + p + for p in external_dir.rglob("*") + if p.is_symlink() and not p.resolve().exists() + ] + for path in broken: + path.unlink() + + # Remove empty directories left behind (deepest first). + for dirpath in sorted(external_dir.rglob("*"), reverse=True): + if dirpath.is_dir() and not dirpath.is_symlink() and not any(dirpath.iterdir()): + dirpath.rmdir() + + def asset( path: str, shared: bool = False, diff --git a/tests/units/assets/test_assets.py b/tests/units/assets/test_assets.py index 74d0e42fd08..6ccc0e523e9 100644 --- a/tests/units/assets/test_assets.py +++ b/tests/units/assets/test_assets.py @@ -6,15 +6,40 @@ import reflex as rx import reflex.constants as constants +from reflex.assets import remove_stale_external_asset_symlinks -def test_shared_asset() -> None: +@pytest.fixture +def mock_asset_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Create a mock asset file and patch the current working directory. + + Args: + tmp_path: A temporary directory provided by pytest. + monkeypatch: A pytest fixture for patching. + + Returns: + The path to a tmp cwd that will be used for assets. + """ + # Create a temporary directory to act as the current working directory. + mock_cwd = tmp_path / "mock_asset_path" + mock_cwd.mkdir() + monkeypatch.chdir(mock_cwd) + + return mock_cwd + + +def test_shared_asset(mock_asset_path: Path) -> None: """Test shared assets.""" # The asset function copies a file to the app's external assets directory. asset = rx.asset(path="custom_script.js", shared=True, subfolder="subfolder") assert asset == "/external/test_assets/subfolder/custom_script.js" result_file = Path( - Path.cwd(), "assets/external/test_assets/subfolder/custom_script.js" + mock_asset_path, + "assets", + "external", + "test_assets", + "subfolder", + "custom_script.js", ) assert result_file.exists() @@ -24,17 +49,19 @@ def test_shared_asset() -> None: # Test the asset function without a subfolder. asset = rx.asset(path="custom_script.js", shared=True) assert asset == "/external/test_assets/custom_script.js" - result_file = Path(Path.cwd(), "assets/external/test_assets/custom_script.js") + result_file = Path( + mock_asset_path, "assets", "external", "test_assets", "custom_script.js" + ) assert result_file.exists() # clean up - shutil.rmtree(Path.cwd() / "assets/external") + shutil.rmtree(Path(mock_asset_path) / "assets" / "external") with pytest.raises(FileNotFoundError): asset = rx.asset("non_existent_file.js") # Nothing is done to assets when file does not exist. - assert not Path(Path.cwd() / "assets/external").exists() + assert not Path(mock_asset_path / "assets" / "external").exists() @pytest.mark.parametrize( @@ -56,13 +83,13 @@ def test_invalid_assets(path: str, shared: bool) -> None: @pytest.fixture -def custom_script_in_asset_dir() -> Generator[Path, None, None]: +def custom_script_in_asset_dir(mock_asset_path: Path) -> Generator[Path, None, None]: """Create a custom_script.js file in the app's assets directory. Yields: The path to the custom_script.js file. """ - asset_dir = Path.cwd() / constants.Dirs.APP_ASSETS + asset_dir = mock_asset_path / constants.Dirs.APP_ASSETS asset_dir.mkdir(exist_ok=True) path = asset_dir / "custom_script.js" path.touch() @@ -79,3 +106,49 @@ def test_local_asset(custom_script_in_asset_dir: Path) -> None: """ asset = rx.asset("custom_script.js", shared=False) assert asset == "/custom_script.js" + + +def test_remove_stale_external_asset_symlinks(mock_asset_path: Path) -> None: + """Test that stale symlinks and empty dirs in assets/external/ are cleaned up.""" + external_dir = ( + mock_asset_path / constants.Dirs.APP_ASSETS / constants.Dirs.EXTERNAL_APP_ASSETS + ) + + # Set up: create a subdirectory with a broken symlink. + stale_dir = external_dir / "old_module" / "subpkg" + stale_dir.mkdir(parents=True, exist_ok=True) + stale_symlink = stale_dir / "missing_file.js" + stale_symlink.symlink_to("/nonexistent/path/missing_file.js") + assert stale_symlink.is_symlink() + assert not stale_symlink.resolve().exists() + + # Also create a valid symlink that should be preserved. + valid_dir = external_dir / "valid_module" + valid_dir.mkdir(parents=True, exist_ok=True) + valid_target = Path(__file__).parent / "custom_script.js" + valid_symlink = valid_dir / "custom_script.js" + valid_symlink.symlink_to(valid_target) + assert valid_symlink.is_symlink() + assert valid_symlink.resolve().exists() + + remove_stale_external_asset_symlinks() + + # Broken symlink and its empty parent dirs should be removed. + assert not stale_symlink.exists() + assert not stale_symlink.is_symlink() + assert not stale_dir.exists() + assert not (external_dir / "old_module").exists() + + # Valid symlink should be preserved. + assert valid_symlink.is_symlink() + assert valid_symlink.resolve().exists() + + +def test_remove_stale_symlinks_no_external_dir(mock_asset_path: Path) -> None: + """Test that cleanup is a no-op when assets/external/ doesn't exist.""" + external_dir = ( + mock_asset_path / constants.Dirs.APP_ASSETS / constants.Dirs.EXTERNAL_APP_ASSETS + ) + assert not external_dir.exists() + # Should not raise. + remove_stale_external_asset_symlinks()