Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/assets/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ rx.image(

The `rx.asset` function provides a more flexible way to reference assets in your app. It supports both local assets (in the app's `assets/` directory) and shared assets (placed next to your Python files).

`rx.asset` appends a short content hash query parameter to the generated URL. For example, `rx.asset("logo.svg")` returns a URL like `/logo.svg?v=a1b2c3d4`, so browsers fetch a new version only when the file content changes.

#### Local Assets

Local assets are stored in the app's `assets/` directory and are referenced using `rx.asset`:
Expand Down
1 change: 1 addition & 0 deletions news/6550.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added content-hash cache busting to `rx.asset` URLs.
82 changes: 77 additions & 5 deletions reflex/assets.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Helper functions for adding assets to the app."""

import hashlib
import inspect
from pathlib import Path
from typing import TYPE_CHECKING, overload
Expand All @@ -8,6 +9,9 @@
from reflex_base.config import get_config
from reflex_base.environment import EnvironmentVariables

_HASH_CHUNK_SIZE = 1024 * 1024
_MAX_HASH_ATTEMPTS = 3

if TYPE_CHECKING:
from typing_extensions import Buffer

Expand All @@ -25,13 +29,21 @@ class AssetPathStr(str):
construction time.
"""

__slots__ = ("importable_path",)
__slots__ = ("_raw_path", "importable_path")

_raw_path: str
importable_path: str

@overload
def __new__(cls, object: object = "") -> "AssetPathStr": ...
@overload
def __new__(
cls,
object: object = "",
*,
importable_path: str | None = None,
) -> "AssetPathStr": ...
@overload
def __new__(
cls,
object: "Buffer",
Expand All @@ -44,6 +56,8 @@ def __new__(
object: object = "",
encoding: str | None = None,
errors: str | None = None,
*,
importable_path: str | None = None,
) -> "AssetPathStr":
"""Construct from an unprefixed, leading-slash asset path.

Expand All @@ -56,6 +70,7 @@ def __new__(
object: The object to stringify (str, bytes, or any object).
encoding: Encoding to decode ``object`` with when it is bytes-like.
errors: Error handler for decoding.
importable_path: Optional unversioned path to use for build-time imports.

Returns:
A new ``AssetPathStr`` instance.
Expand All @@ -72,7 +87,8 @@ def __new__(
instance = super().__new__(
cls, get_config().prepend_frontend_path(relative_path)
)
instance.importable_path = f"$/public{relative_path}"
instance._raw_path = relative_path
instance.importable_path = f"$/public{importable_path or relative_path}"
return instance

def __getnewargs__(self) -> tuple[str]:
Expand All @@ -87,7 +103,57 @@ def __getnewargs__(self) -> tuple[str]:
Returns:
A one-tuple containing the unprefixed asset path.
"""
return (self.importable_path[len("$/public") :],)
return (self._raw_path,)

def __getnewargs_ex__(self) -> tuple[tuple[str], dict[str, str]]:
"""Return constructor args and kwargs for pickle/copy reconstruction.

Returns:
Constructor args and kwargs preserving the unversioned import path.
"""
return (
(self._raw_path,),
{"importable_path": self.importable_path[len("$/public") :]},
)


def _short_content_hash(path: Path) -> str:
"""Get a short content hash for an asset file.

Args:
path: The file to hash.

Returns:
The first 8 hex characters of the file's SHA-256 hash.
"""
digest = hashlib.sha256()
for _ in range(_MAX_HASH_ATTEMPTS):
digest = hashlib.sha256()
start_stat = path.stat()
with path.open("rb") as file:
while chunk := file.read(_HASH_CHUNK_SIZE):
digest.update(chunk)
end_stat = path.stat()
if (
start_stat.st_size == end_stat.st_size
and start_stat.st_mtime_ns == end_stat.st_mtime_ns
):
break
return digest.hexdigest()[:8]


def _versioned_asset_path(relative_path: str, source_file: Path) -> AssetPathStr:
"""Create an asset URL with a content-hash query parameter.

Args:
relative_path: The unprefixed public asset path.
source_file: The source file used to calculate the content hash.

Returns:
A versioned asset URL that preserves an unversioned import path.
"""
versioned_path = f"{relative_path}?v={_short_content_hash(source_file)}"
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment on lines +154 to +155

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Hash Can Precede Copy — The stability check only covers the time spent reading source_file. The assets directory is copied into the frontend build later. If a generated asset changes after this hash is calculated but before that copy, the page keeps the old ?v= value while the public directory receives new bytes. Browsers may then reuse a cached response instead of fetching the deployed file. Hashing and copying need to use the same immutable snapshot.

return AssetPathStr(versioned_path, importable_path=relative_path)


def remove_stale_external_asset_symlinks():
Expand Down Expand Up @@ -176,7 +242,10 @@ def asset(
if not backend_only and not src_file_local.exists():
msg = f"File not found: {src_file_local}"
raise FileNotFoundError(msg)
return AssetPathStr(f"/{path}")
relative_path = f"/{path}"
if backend_only and not src_file_local.exists():
return AssetPathStr(relative_path)
return _versioned_asset_path(relative_path, src_file_local)

# Shared asset handling
# Determine the file by which the asset is exposed.
Expand Down Expand Up @@ -212,4 +281,7 @@ def asset(
dst_file.unlink()
dst_file.symlink_to(src_file_shared)

return AssetPathStr(f"/{external}/{subfolder}/{path}")
return _versioned_asset_path(
f"/{external}/{subfolder}/{path}",
src_file_shared,
)
128 changes: 125 additions & 3 deletions tests/units/assets/test_assets.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import copy
import hashlib
import io
import pickle
import shutil
from collections.abc import Generator
from dataclasses import dataclass
from pathlib import Path
from typing import cast

import pytest

Expand All @@ -11,6 +15,11 @@
from reflex.assets import AssetPathStr, remove_stale_external_asset_symlinks


def _asset_hash(path: Path) -> str:
"""Return the expected short content hash for an asset."""
return hashlib.sha256(path.read_bytes()).hexdigest()[:8]


@pytest.fixture
def mock_asset_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Create a mock asset file and patch the current working directory.
Expand All @@ -32,9 +41,14 @@ def mock_asset_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:

def test_shared_asset(mock_asset_path: Path) -> None:
"""Test shared assets."""
source_file = Path(__file__).parent / "custom_script.js"
expected_hash = _asset_hash(source_file)

# 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"
assert (
asset == f"/external/test_assets/subfolder/custom_script.js?v={expected_hash}"
)
result_file = Path(
mock_asset_path,
"assets",
Expand All @@ -50,7 +64,7 @@ def test_shared_asset(mock_asset_path: Path) -> 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"
assert asset == f"/external/test_assets/custom_script.js?v={expected_hash}"
result_file = Path(
mock_asset_path, "assets", "external", "test_assets", "custom_script.js"
)
Expand Down Expand Up @@ -107,7 +121,90 @@ def test_local_asset(custom_script_in_asset_dir: Path) -> None:

"""
asset = rx.asset("custom_script.js", shared=False)
assert asset == "/custom_script.js"
assert asset == f"/custom_script.js?v={_asset_hash(custom_script_in_asset_dir)}"


def test_local_asset_hash_changes_with_content(
custom_script_in_asset_dir: Path,
) -> None:
"""The asset URL changes when the file content changes.

Args:
custom_script_in_asset_dir: Fixture that creates a custom_script.js file in the app's assets directory.
"""
custom_script_in_asset_dir.write_text("first")
first_asset = rx.asset("custom_script.js", shared=False)

custom_script_in_asset_dir.write_text("second")
second_asset = rx.asset("custom_script.js", shared=False)

assert first_asset != second_asset
assert first_asset == (
f"/custom_script.js?v={hashlib.sha256(b'first').hexdigest()[:8]}"
)
assert second_asset == (
f"/custom_script.js?v={hashlib.sha256(b'second').hexdigest()[:8]}"
)


def test_asset_hash_reads_in_chunks(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Hashing handles assets larger than one read chunk.

Args:
tmp_path: A temporary directory provided by pytest.
monkeypatch: A pytest fixture for patching.
"""
import reflex.assets as assets_module

monkeypatch.setattr(assets_module, "_HASH_CHUNK_SIZE", 3)
asset_file = tmp_path / "large.bin"
asset_file.write_bytes(b"abcdefghi")

assert (
assets_module._short_content_hash(asset_file)
== hashlib.sha256(b"abcdefghi").hexdigest()[:8]
)


def test_asset_hash_retries_when_file_changes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Hashing retries when the file changes while it is being read.

Args:
monkeypatch: A pytest fixture for patching.
"""
import reflex.assets as assets_module

@dataclass
class _Stat:
st_size: int
st_mtime_ns: int

class _ChangingPath:
content = b"old"
stat_calls = 0

def stat(self) -> _Stat:
self.stat_calls += 1
if self.stat_calls == 2:
self.content = b"final"
return _Stat(st_size=3, st_mtime_ns=1)
return _Stat(st_size=len(self.content), st_mtime_ns=2)

def open(self, mode: str):
assert mode == "rb"
return io.BytesIO(self.content)

monkeypatch.setattr(assets_module, "_HASH_CHUNK_SIZE", 2)
changing_path = _ChangingPath()

assert (
assets_module._short_content_hash(cast(Path, changing_path))
== hashlib.sha256(b"final").hexdigest()[:8]
)


def test_asset_importable_path_local(custom_script_in_asset_dir: Path) -> None:
Expand All @@ -117,13 +214,16 @@ def test_asset_importable_path_local(custom_script_in_asset_dir: Path) -> None:
custom_script_in_asset_dir: Fixture that creates a custom_script.js file in the app's assets directory.
"""
asset = rx.asset("custom_script.js", shared=False)
assert asset == f"/custom_script.js?v={_asset_hash(custom_script_in_asset_dir)}"
assert isinstance(asset, AssetPathStr)
assert asset.importable_path == "$/public/custom_script.js"


def test_asset_importable_path_shared(mock_asset_path: Path) -> None:
"""A shared asset path exposes an `importable_path` prefixed with $/public."""
asset = rx.asset(path="custom_script.js", shared=True)
expected_hash = _asset_hash(Path(__file__).parent / "custom_script.js")
assert asset == f"/external/test_assets/custom_script.js?v={expected_hash}"
assert isinstance(asset, AssetPathStr)
assert asset.importable_path == "$/public/external/test_assets/custom_script.js"

Expand Down Expand Up @@ -190,6 +290,28 @@ def prepend_frontend_path(path: str) -> str:
assert clone.importable_path == "$/public/external/mod/file.js"


def test_versioned_asset_path_pickle_roundtrip(
custom_script_in_asset_dir: Path,
) -> None:
"""Pickle/copy round-trips preserve the versioned URL and unversioned import path.

Args:
custom_script_in_asset_dir: Fixture that creates a custom_script.js file in the app's assets directory.
"""
original = rx.asset("custom_script.js")
assert original == f"/custom_script.js?v={_asset_hash(custom_script_in_asset_dir)}"
assert original.importable_path == "$/public/custom_script.js"

for clone in (
pickle.loads(pickle.dumps(original)),
copy.copy(original),
copy.deepcopy(original),
):
assert isinstance(clone, AssetPathStr)
assert clone == original
assert clone.importable_path == original.importable_path


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 = (
Expand Down
Loading