From fbe69a7dc41788a82b5cc3c60768458cb6aab059 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Mon, 20 Apr 2026 15:05:14 -0700 Subject: [PATCH 1/2] Expose `.importable_path` on the value returned from rx.asset This allows components to reference the internal path in the `.web` directory for use with JS imports at compile time. --- reflex/assets.py | 77 +++++++++++++++++++++++++++++-- tests/units/assets/test_assets.py | 49 +++++++++++++++++++- 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/reflex/assets.py b/reflex/assets.py index 8dd29fb5e5f..72d9472e16d 100644 --- a/reflex/assets.py +++ b/reflex/assets.py @@ -2,11 +2,79 @@ import inspect from pathlib import Path +from typing import TYPE_CHECKING, overload from reflex_base import constants from reflex_base.config import get_config from reflex_base.environment import EnvironmentVariables +if TYPE_CHECKING: + from typing_extensions import Buffer + + +class AssetPathStr(str): + """The relative URL to an asset, with a build-time importable variant. + + Returned by :func:`asset`. The string value is the asset URL with the + configured ``frontend_path`` prepended; :attr:`importable_path` is the + same asset prefixed with ``$/public`` so the asset can be referenced by + a component ``library`` or module import at build time. + + The constructor signature mirrors :class:`str`: the input is interpreted + as the unprefixed asset path and both forms are derived from it at + construction time. + """ + + __slots__ = ("importable_path",) + + importable_path: str + + @overload + def __new__(cls, object: object = "") -> "AssetPathStr": ... + @overload + def __new__( + cls, + object: "Buffer", + encoding: str = "utf-8", + errors: str = "strict", + ) -> "AssetPathStr": ... + + def __new__( + cls, + object: object = "", + encoding: str | None = None, + errors: str | None = None, + ) -> "AssetPathStr": + """Construct from an unprefixed, leading-slash asset path. + + Args/semantics mirror :class:`str`. The resulting string is interpreted + as the asset path (e.g. ``"/external/mod/file.js"``); the + frontend-prefixed URL is stored as the ``AssetPathStr`` value and + ``$/public`` + ``relative_path`` as :attr:`importable_path`. + + Args: + 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. + + Returns: + A new ``AssetPathStr`` instance. + """ + if encoding is None and errors is None: + relative_path = str.__new__(str, object) + else: + relative_path = str.__new__( + str, + object, # pyright: ignore[reportArgumentType] + "utf-8" if encoding is None else encoding, + "strict" if errors is None else errors, + ) + instance = super().__new__( + cls, get_config().prepend_frontend_path(relative_path) + ) + instance.importable_path = f"$/public{relative_path}" + return instance + def remove_stale_external_asset_symlinks(): """Remove broken symlinks and empty directories in assets/external/. @@ -42,7 +110,7 @@ def asset( shared: bool = False, subfolder: str | None = None, _stack_level: int = 1, -) -> str: +) -> AssetPathStr: """Add an asset to the app, either shared as a symlink or local. Shared/External/Library assets: @@ -74,7 +142,8 @@ def asset( increase this number for each helper function in the stack. Returns: - The relative URL to the asset. + The relative URL to the asset, with an ``importable_path`` property + for use as a build-time module reference. Raises: FileNotFoundError: If the file does not exist. @@ -93,7 +162,7 @@ def asset( if not backend_only and not src_file_local.exists(): msg = f"File not found: {src_file_local}" raise FileNotFoundError(msg) - return get_config().prepend_frontend_path(f"/{path}") + return AssetPathStr(f"/{path}") # Shared asset handling # Determine the file by which the asset is exposed. @@ -129,4 +198,4 @@ def asset( dst_file.unlink() dst_file.symlink_to(src_file_shared) - return get_config().prepend_frontend_path(f"/{external}/{subfolder}/{path}") + return AssetPathStr(f"/{external}/{subfolder}/{path}") diff --git a/tests/units/assets/test_assets.py b/tests/units/assets/test_assets.py index 6ccc0e523e9..858ec63db13 100644 --- a/tests/units/assets/test_assets.py +++ b/tests/units/assets/test_assets.py @@ -6,7 +6,7 @@ import reflex as rx import reflex.constants as constants -from reflex.assets import remove_stale_external_asset_symlinks +from reflex.assets import AssetPathStr, remove_stale_external_asset_symlinks @pytest.fixture @@ -108,6 +108,53 @@ def test_local_asset(custom_script_in_asset_dir: Path) -> None: assert asset == "/custom_script.js" +def test_asset_importable_path_local(custom_script_in_asset_dir: Path) -> None: + """A local asset path exposes an `importable_path` prefixed with $/public. + + Args: + 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 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) + assert isinstance(asset, AssetPathStr) + assert asset.importable_path == "$/public/external/test_assets/custom_script.js" + + +def test_asset_importable_path_with_frontend_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """With frontend_path configured, str value is prefixed but importable_path is not. + + Args: + monkeypatch: A pytest fixture for patching. + """ + import reflex.assets as assets_module + + class _StubConfig: + frontend_path = "/my-app" + + @staticmethod + def prepend_frontend_path(path: str) -> str: + return f"/my-app{path}" if path.startswith("/") else path + + monkeypatch.setattr(assets_module, "get_config", lambda: _StubConfig) + + asset = AssetPathStr("/external/mod/custom_script.js") + assert asset == "/my-app/external/mod/custom_script.js" + assert asset.importable_path == "$/public/external/mod/custom_script.js" + + # Bytes + encoding form (matches str() signature) also works. + asset_from_bytes = AssetPathStr(b"/external/mod/file.js", "utf-8") + assert asset_from_bytes == "/my-app/external/mod/file.js" + assert asset_from_bytes.importable_path == "$/public/external/mod/file.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 = ( From 358ef7070ef48c5b1fd9dc339ff1d62cb3091ba2 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Mon, 20 Apr 2026 15:19:07 -0700 Subject: [PATCH 2/2] handle picklability of AssetPathStr --- reflex/assets.py | 14 +++++++++++++ tests/units/assets/test_assets.py | 35 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/reflex/assets.py b/reflex/assets.py index 72d9472e16d..06e4e739b9a 100644 --- a/reflex/assets.py +++ b/reflex/assets.py @@ -75,6 +75,20 @@ def __new__( instance.importable_path = f"$/public{relative_path}" return instance + def __getnewargs__(self) -> tuple[str]: + """Return the unprefixed path for pickle/copy reconstruction. + + Python's default ``str`` pickle path would feed the frontend-prefixed + value back into :meth:`__new__`, double-applying the prefix and + losing the :attr:`importable_path` slot. Returning the raw path + (recovered by stripping the ``$/public`` prefix) lets ``__new__`` + rebuild both forms correctly. + + Returns: + A one-tuple containing the unprefixed asset path. + """ + return (self.importable_path[len("$/public") :],) + def remove_stale_external_asset_symlinks(): """Remove broken symlinks and empty directories in assets/external/. diff --git a/tests/units/assets/test_assets.py b/tests/units/assets/test_assets.py index 858ec63db13..5baa030653f 100644 --- a/tests/units/assets/test_assets.py +++ b/tests/units/assets/test_assets.py @@ -1,3 +1,5 @@ +import copy +import pickle import shutil from collections.abc import Generator from pathlib import Path @@ -155,6 +157,39 @@ def prepend_frontend_path(path: str) -> str: assert asset_from_bytes.importable_path == "$/public/external/mod/file.js" +def test_asset_path_pickle_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None: + """Pickle/copy round-trips must not double-apply the frontend prefix. + + Regression test for https://github.com/reflex-dev/reflex/pull/6348#discussion_r3113958087. + + Args: + monkeypatch: A pytest fixture for patching. + """ + import reflex.assets as assets_module + + class _StubConfig: + frontend_path = "/my-app" + + @staticmethod + def prepend_frontend_path(path: str) -> str: + return f"/my-app{path}" if path.startswith("/") else path + + monkeypatch.setattr(assets_module, "get_config", lambda: _StubConfig) + + original = AssetPathStr("/external/mod/file.js") + assert original == "/my-app/external/mod/file.js" + assert original.importable_path == "$/public/external/mod/file.js" + + for clone in ( + pickle.loads(pickle.dumps(original)), + copy.copy(original), + copy.deepcopy(original), + ): + assert isinstance(clone, AssetPathStr) + assert clone == "/my-app/external/mod/file.js" + assert clone.importable_path == "$/public/external/mod/file.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 = (