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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

seems like a useless extra initialization; just keep the one in the loop

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]
Comment on lines +141 to +142

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
break
return digest.hexdigest()[:8]
return digest.hexdigest()[:8]
console.warning(f"{path} was modified {_MAX_HASH_ATTEMPTS} times while calculating hash")
return str(time.time())

we should consider doing something reasonable when we can't get a stable hash, like using the compile timestamp as the hash rather than always returning the last digest, which might cause the browser to load a previous cached version. at least if we take the timestamp, the browser will reload the asset once per compile, which is probably good enough for most uses.



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 thread
masenf marked this conversation as resolved.
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