Skip to content

Commit 63bf1b8

Browse files
Dynamic route vars silently shadow all other vars (#3805)
* fix dynamic route vars silently shadow all other vars * add test * fix: allow multiple dynamic routes with the same arg * add test for multiple dynamic args with the same name * avoid side-effects with DynamicState tests * fix dynamic route integration test which shadowed a var * fix darglint * refactor to DynamicRouteVar * old typing stuff again * from typing_extensions import Self try to keep typing backward compatible with older releases we support * Raise a specific exception when encountering dynamic route arg shadowing --------- Co-authored-by: Masen Furer <m_github@0x26.net>
1 parent 95631ff commit 63bf1b8

File tree

5 files changed

+110
-11
lines changed

5 files changed

+110
-11
lines changed

reflex/ivars/base.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
VarValueError,
4444
)
4545
from reflex.utils.format import format_state_name
46-
from reflex.utils.types import GenericType, get_origin
46+
from reflex.utils.types import GenericType, Self, get_origin
4747
from reflex.vars import (
4848
REPLACED_NAMES,
4949
Var,
@@ -1467,7 +1467,7 @@ def __init__(
14671467
object.__setattr__(self, "_fget", fget)
14681468

14691469
@override
1470-
def _replace(self, merge_var_data=None, **kwargs: Any) -> ImmutableComputedVar:
1470+
def _replace(self, merge_var_data=None, **kwargs: Any) -> Self:
14711471
"""Replace the attributes of the ComputedVar.
14721472
14731473
Args:
@@ -1499,7 +1499,7 @@ def _replace(self, merge_var_data=None, **kwargs: Any) -> ImmutableComputedVar:
14991499
unexpected_kwargs = ", ".join(kwargs.keys())
15001500
raise TypeError(f"Unexpected keyword arguments: {unexpected_kwargs}")
15011501

1502-
return ImmutableComputedVar(**field_values)
1502+
return type(self)(**field_values)
15031503

15041504
@property
15051505
def _cache_attr(self) -> str:
@@ -1773,6 +1773,12 @@ def fget(self) -> Callable[[BaseState], RETURN_TYPE]:
17731773
return self._fget
17741774

17751775

1776+
class DynamicRouteVar(ImmutableComputedVar[Union[str, List[str]]]):
1777+
"""A ComputedVar that represents a dynamic route."""
1778+
1779+
pass
1780+
1781+
17761782
if TYPE_CHECKING:
17771783
BASE_STATE = TypeVar("BASE_STATE", bound=BaseState)
17781784

reflex/state.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
from reflex.config import get_config
3737
from reflex.ivars.base import (
38+
DynamicRouteVar,
3839
ImmutableComputedVar,
3940
ImmutableVar,
4041
immutable_computed_var,
@@ -60,7 +61,11 @@
6061
fix_events,
6162
)
6263
from reflex.utils import console, format, path_ops, prerequisites, types
63-
from reflex.utils.exceptions import ImmutableStateError, LockExpiredError
64+
from reflex.utils.exceptions import (
65+
DynamicRouteArgShadowsStateVar,
66+
ImmutableStateError,
67+
LockExpiredError,
68+
)
6469
from reflex.utils.exec import is_testing_env
6570
from reflex.utils.serializers import SerializedType, serialize, serializer
6671
from reflex.utils.types import override
@@ -1023,17 +1028,19 @@ def setup_dynamic_args(cls, args: dict[str, str]):
10231028
if not args:
10241029
return
10251030

1031+
cls._check_overwritten_dynamic_args(list(args.keys()))
1032+
10261033
def argsingle_factory(param):
10271034
def inner_func(self) -> str:
10281035
return self.router.page.params.get(param, "")
10291036

1030-
return ImmutableComputedVar(fget=inner_func, cache=True)
1037+
return DynamicRouteVar(fget=inner_func, cache=True)
10311038

10321039
def arglist_factory(param):
1033-
def inner_func(self) -> List:
1040+
def inner_func(self) -> List[str]:
10341041
return self.router.page.params.get(param, [])
10351042

1036-
return ImmutableComputedVar(fget=inner_func, cache=True)
1043+
return DynamicRouteVar(fget=inner_func, cache=True)
10371044

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

10501060
# Reinitialize dependency tracking dicts.
10511061
cls._init_var_dependency_dicts()
10521062

1063+
@classmethod
1064+
def _check_overwritten_dynamic_args(cls, args: list[str]):
1065+
"""Check if dynamic args are shadowing existing vars. Recursively checks all child states.
1066+
1067+
Args:
1068+
args: a dict of args
1069+
1070+
Raises:
1071+
DynamicRouteArgShadowsStateVar: If a dynamic arg is shadowing an existing var.
1072+
"""
1073+
for arg in args:
1074+
if (
1075+
arg in cls.computed_vars
1076+
and not isinstance(cls.computed_vars[arg], DynamicRouteVar)
1077+
) or arg in cls.base_vars:
1078+
raise DynamicRouteArgShadowsStateVar(
1079+
f"Dynamic route arg '{arg}' is shadowing an existing var in {cls.__module__}.{cls.__name__}"
1080+
)
1081+
for substate in cls.get_substates():
1082+
substate._check_overwritten_dynamic_args(args)
1083+
10531084
def __getattribute__(self, name: str) -> Any:
10541085
"""Get the state var.
10551086

reflex/utils/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,7 @@ class EventHandlerArgMismatch(ReflexError, TypeError):
8787

8888
class EventFnArgMismatch(ReflexError, TypeError):
8989
"""Raised when the number of args accepted by a lambda differs from that provided by the event trigger."""
90+
91+
92+
class DynamicRouteArgShadowsStateVar(ReflexError, NameError):
93+
"""Raised when a dynamic route arg shadows a state var."""

reflex/utils/types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ def override(func: Callable) -> Callable:
111111
"_was_touched",
112112
}
113113

114+
if sys.version_info >= (3, 11):
115+
from typing import Self as Self
116+
else:
117+
from typing_extensions import Self as Self
118+
114119

115120
class Unset:
116121
"""A class to represent an unset value.

tests/test_app.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ class EmptyState(BaseState):
6969

7070

7171
@pytest.fixture
72-
def index_page():
72+
def index_page() -> ComponentCallable:
7373
"""An index page.
7474
7575
Returns:
@@ -83,7 +83,7 @@ def index():
8383

8484

8585
@pytest.fixture
86-
def about_page():
86+
def about_page() -> ComponentCallable:
8787
"""An about page.
8888
8989
Returns:
@@ -919,9 +919,62 @@ def comp_dynamic(self) -> str:
919919
on_load_internal = OnLoadInternalState.on_load_internal.fn
920920

921921

922+
def test_dynamic_arg_shadow(
923+
index_page: ComponentCallable,
924+
windows_platform: bool,
925+
token: str,
926+
app_module_mock: unittest.mock.Mock,
927+
mocker,
928+
):
929+
"""Create app with dynamic route var and try to add a page with a dynamic arg that shadows a state var.
930+
931+
Args:
932+
index_page: The index page.
933+
windows_platform: Whether the system is windows.
934+
token: a Token.
935+
app_module_mock: Mocked app module.
936+
mocker: pytest mocker object.
937+
"""
938+
arg_name = "counter"
939+
route = f"/test/[{arg_name}]"
940+
if windows_platform:
941+
route.lstrip("/").replace("/", "\\")
942+
app = app_module_mock.app = App(state=DynamicState)
943+
assert app.state is not None
944+
with pytest.raises(NameError):
945+
app.add_page(index_page, route=route, on_load=DynamicState.on_load) # type: ignore
946+
947+
948+
def test_multiple_dynamic_args(
949+
index_page: ComponentCallable,
950+
windows_platform: bool,
951+
token: str,
952+
app_module_mock: unittest.mock.Mock,
953+
mocker,
954+
):
955+
"""Create app with multiple dynamic route vars with the same name.
956+
957+
Args:
958+
index_page: The index page.
959+
windows_platform: Whether the system is windows.
960+
token: a Token.
961+
app_module_mock: Mocked app module.
962+
mocker: pytest mocker object.
963+
"""
964+
arg_name = "my_arg"
965+
route = f"/test/[{arg_name}]"
966+
route2 = f"/test2/[{arg_name}]"
967+
if windows_platform:
968+
route = route.lstrip("/").replace("/", "\\")
969+
route2 = route2.lstrip("/").replace("/", "\\")
970+
app = app_module_mock.app = App(state=EmptyState)
971+
app.add_page(index_page, route=route)
972+
app.add_page(index_page, route=route2)
973+
974+
922975
@pytest.mark.asyncio
923976
async def test_dynamic_route_var_route_change_completed_on_load(
924-
index_page,
977+
index_page: ComponentCallable,
925978
windows_platform: bool,
926979
token: str,
927980
app_module_mock: unittest.mock.Mock,

0 commit comments

Comments
 (0)