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
12 changes: 9 additions & 3 deletions reflex/ivars/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
VarValueError,
)
from reflex.utils.format import format_state_name
from reflex.utils.types import GenericType, get_origin
from reflex.utils.types import GenericType, Self, get_origin
from reflex.vars import (
REPLACED_NAMES,
Var,
Expand Down Expand Up @@ -1467,7 +1467,7 @@ def __init__(
object.__setattr__(self, "_fget", fget)

@override
def _replace(self, merge_var_data=None, **kwargs: Any) -> ImmutableComputedVar:
def _replace(self, merge_var_data=None, **kwargs: Any) -> Self:
"""Replace the attributes of the ComputedVar.

Args:
Expand Down Expand Up @@ -1499,7 +1499,7 @@ def _replace(self, merge_var_data=None, **kwargs: Any) -> ImmutableComputedVar:
unexpected_kwargs = ", ".join(kwargs.keys())
raise TypeError(f"Unexpected keyword arguments: {unexpected_kwargs}")

return ImmutableComputedVar(**field_values)
return type(self)(**field_values)

@property
def _cache_attr(self) -> str:
Expand Down Expand Up @@ -1773,6 +1773,12 @@ def fget(self) -> Callable[[BaseState], RETURN_TYPE]:
return self._fget


class DynamicRouteVar(ImmutableComputedVar[Union[str, List[str]]]):
"""A ComputedVar that represents a dynamic route."""

pass


if TYPE_CHECKING:
BASE_STATE = TypeVar("BASE_STATE", bound=BaseState)

Expand Down
41 changes: 36 additions & 5 deletions reflex/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

from reflex.config import get_config
from reflex.ivars.base import (
DynamicRouteVar,
ImmutableComputedVar,
ImmutableVar,
immutable_computed_var,
Expand All @@ -60,7 +61,11 @@
fix_events,
)
from reflex.utils import console, format, path_ops, prerequisites, types
from reflex.utils.exceptions import ImmutableStateError, LockExpiredError
from reflex.utils.exceptions import (
DynamicRouteArgShadowsStateVar,
ImmutableStateError,
LockExpiredError,
)
from reflex.utils.exec import is_testing_env
from reflex.utils.serializers import SerializedType, serialize, serializer
from reflex.utils.types import override
Expand Down Expand Up @@ -1023,17 +1028,19 @@ def setup_dynamic_args(cls, args: dict[str, str]):
if not args:
return

cls._check_overwritten_dynamic_args(list(args.keys()))

def argsingle_factory(param):
def inner_func(self) -> str:
return self.router.page.params.get(param, "")

return ImmutableComputedVar(fget=inner_func, cache=True)
return DynamicRouteVar(fget=inner_func, cache=True)

def arglist_factory(param):
def inner_func(self) -> List:
def inner_func(self) -> List[str]:
return self.router.page.params.get(param, [])

return ImmutableComputedVar(fget=inner_func, cache=True)
return DynamicRouteVar(fget=inner_func, cache=True)

for param, value in args.items():
if value == constants.RouteArgType.SINGLE:
Expand All @@ -1044,12 +1051,36 @@ def inner_func(self) -> List:
continue
# to allow passing as a prop, evade python frozen rules (bad practice)
object.__setattr__(func, "_var_name", param)
cls.vars[param] = cls.computed_vars[param] = func._var_set_state(cls) # type: ignore
# cls.vars[param] = cls.computed_vars[param] = func._var_set_state(cls) # type: ignore
cls.vars[param] = cls.computed_vars[param] = func._replace(
_var_data=VarData.from_state(cls)
)
setattr(cls, param, func)

# Reinitialize dependency tracking dicts.
cls._init_var_dependency_dicts()

@classmethod
def _check_overwritten_dynamic_args(cls, args: list[str]):
"""Check if dynamic args are shadowing existing vars. Recursively checks all child states.

Args:
args: a dict of args

Raises:
DynamicRouteArgShadowsStateVar: If a dynamic arg is shadowing an existing var.
"""
for arg in args:
if (
arg in cls.computed_vars
and not isinstance(cls.computed_vars[arg], DynamicRouteVar)
) or arg in cls.base_vars:
raise DynamicRouteArgShadowsStateVar(
f"Dynamic route arg '{arg}' is shadowing an existing var in {cls.__module__}.{cls.__name__}"
)
for substate in cls.get_substates():
substate._check_overwritten_dynamic_args(args)

def __getattribute__(self, name: str) -> Any:
"""Get the state var.

Expand Down
4 changes: 4 additions & 0 deletions reflex/utils/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,7 @@ class EventHandlerArgMismatch(ReflexError, TypeError):

class EventFnArgMismatch(ReflexError, TypeError):
"""Raised when the number of args accepted by a lambda differs from that provided by the event trigger."""


class DynamicRouteArgShadowsStateVar(ReflexError, NameError):
"""Raised when a dynamic route arg shadows a state var."""
5 changes: 5 additions & 0 deletions reflex/utils/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ def override(func: Callable) -> Callable:
"_was_touched",
}

if sys.version_info >= (3, 11):
from typing import Self as Self
else:
from typing_extensions import Self as Self


class Unset:
"""A class to represent an unset value.
Expand Down
59 changes: 56 additions & 3 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ class EmptyState(BaseState):


@pytest.fixture
def index_page():
def index_page() -> ComponentCallable:
"""An index page.

Returns:
Expand All @@ -83,7 +83,7 @@ def index():


@pytest.fixture
def about_page():
def about_page() -> ComponentCallable:
"""An about page.

Returns:
Expand Down Expand Up @@ -919,9 +919,62 @@ def comp_dynamic(self) -> str:
on_load_internal = OnLoadInternalState.on_load_internal.fn


def test_dynamic_arg_shadow(
index_page: ComponentCallable,
windows_platform: bool,
token: str,
app_module_mock: unittest.mock.Mock,
mocker,
):
"""Create app with dynamic route var and try to add a page with a dynamic arg that shadows a state var.

Args:
index_page: The index page.
windows_platform: Whether the system is windows.
token: a Token.
app_module_mock: Mocked app module.
mocker: pytest mocker object.
"""
arg_name = "counter"
route = f"/test/[{arg_name}]"
if windows_platform:
route.lstrip("/").replace("/", "\\")
Comment thread
benedikt-bartscher marked this conversation as resolved.
app = app_module_mock.app = App(state=DynamicState)
assert app.state is not None
with pytest.raises(NameError):
app.add_page(index_page, route=route, on_load=DynamicState.on_load) # type: ignore


def test_multiple_dynamic_args(
index_page: ComponentCallable,
windows_platform: bool,
token: str,
app_module_mock: unittest.mock.Mock,
mocker,
):
"""Create app with multiple dynamic route vars with the same name.

Args:
index_page: The index page.
windows_platform: Whether the system is windows.
token: a Token.
app_module_mock: Mocked app module.
mocker: pytest mocker object.
"""
arg_name = "my_arg"
route = f"/test/[{arg_name}]"
route2 = f"/test2/[{arg_name}]"
if windows_platform:
route = route.lstrip("/").replace("/", "\\")
route2 = route2.lstrip("/").replace("/", "\\")
app = app_module_mock.app = App(state=EmptyState)
app.add_page(index_page, route=route)
app.add_page(index_page, route=route2)


@pytest.mark.asyncio
async def test_dynamic_route_var_route_change_completed_on_load(
index_page,
index_page: ComponentCallable,
windows_platform: bool,
token: str,
app_module_mock: unittest.mock.Mock,
Expand Down