Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
29 changes: 29 additions & 0 deletions reflex/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
87 changes: 80 additions & 7 deletions tests/units/assets/test_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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(
Expand All @@ -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()
Expand All @@ -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()
Loading