Repository: reflex-dev/reflex

Issue:
[REF-3239] Var dependency bug
**Describe the bug**
Vars dependencies are not invalidated correctly

**To Reproduce**
```python
import reflex as rx


class State(rx.State):
    @rx.var(cache=True)
    def id(self) -> str:
        return self.router.page.params.get("id", "")

    @rx.var(cache=True)
    def title(self) -> str:
        return f"Page {self.id}"


def index() -> rx.Component:
    return rx.container(
        rx.text(f"id: {State.id}"),
        rx.text(f"title: {State.title}"),
    )


app = rx.App()
app.add_page(index, route="/[id]")
```
- `reflex run` your app
- go to [http://localhost:3000/hi](http://localhost:3000/hi)
- change url to [http://localhost:3000/hello](http://localhost:3000/hello)
- the pages displays "id: hello" and "title: hi"

**Expected behavior**
the pages displays "id: hello" and "title: hello"


**Specifics (please complete the following information):**
 - Python Version: 3.12.4
 - Reflex Version: main
 - OS: arch

<sub>[REF-3239](https://linear.app/reflex-dev/issue/REF-3239/var-dependency-bug)</sub>


Discussion:
try this:

```
class State(rx.State):
    @rx.cached_var
    def id(self) -> str:
        return self.router.page.params.get("id", "")

    @rx.var
    def title(self) -> str:
        return f"Page {self.id}"
```

@alexlausz you must be trolling. You are suggesting to disable var caching, but this issue is about a bug in var caching.
It's like telling someone who has an issue with cruise control in their car to just disable it and control the speed manually.
This is important, because many var trees root in router.
The example from the issue description was actually affected by shadowing (#3805 revealed it).  `id` was overwritten by the uncached dynamically created route var.  
Cached vars depending on `BaseState.router` do not work at all.



Patch:
diff --git a/reflex/state.py b/reflex/state.py
index c94adb39d3c..bd35fced7e5 100644
--- a/reflex/state.py
+++ b/reflex/state.py
@@ -591,6 +591,9 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs):
             cls.event_handlers[name] = handler
             setattr(cls, name, handler)
 
+        # Initialize per-class var dependency tracking.
+        cls._computed_var_dependencies = defaultdict(set)
+        cls._substate_var_dependencies = defaultdict(set)
         cls._init_var_dependency_dicts()
 
     @staticmethod
@@ -660,10 +663,6 @@ def _init_var_dependency_dicts(cls):
         Additional updates tracking dicts for vars and substates that always
         need to be recomputed.
         """
-        # Initialize per-class var dependency tracking.
-        cls._computed_var_dependencies = defaultdict(set)
-        cls._substate_var_dependencies = defaultdict(set)
-
         inherited_vars = set(cls.inherited_vars).union(
             set(cls.inherited_backend_vars),
         )
@@ -1013,20 +1012,20 @@ def setup_dynamic_args(cls, args: dict[str, str]):
         Args:
             args: a dict of args
         """
+        if not args:
+            return
 
         def argsingle_factory(param):
-            @ComputedVar
             def inner_func(self) -> str:
                 return self.router.page.params.get(param, "")
 
-            return inner_func
+            return ComputedVar(fget=inner_func, cache=True)
 
         def arglist_factory(param):
-            @ComputedVar
             def inner_func(self) -> List:
                 return self.router.page.params.get(param, [])
 
-            return inner_func
+            return ComputedVar(fget=inner_func, cache=True)
 
         for param, value in args.items():
             if value == constants.RouteArgType.SINGLE:
@@ -1040,8 +1039,8 @@ def inner_func(self) -> List:
             cls.vars[param] = cls.computed_vars[param] = func._var_set_state(cls)  # type: ignore
             setattr(cls, param, func)
 
-            # Reinitialize dependency tracking dicts.
-            cls._init_var_dependency_dicts()
+        # Reinitialize dependency tracking dicts.
+        cls._init_var_dependency_dicts()
 
     def __getattribute__(self, name: str) -> Any:
         """Get the state var.
@@ -3607,5 +3606,7 @@ def reload_state_module(
         if subclass.__module__ == module and module is not None:
             state.class_subclasses.remove(subclass)
             state._always_dirty_substates.discard(subclass.get_name())
-    state._init_var_dependency_dicts()
+            state._computed_var_dependencies = defaultdict(set)
+            state._substate_var_dependencies = defaultdict(set)
+            state._init_var_dependency_dicts()
     state.get_class_substate.cache_clear()


Test patch:
diff --git a/tests/test_app.py b/tests/test_app.py
index f933149f166..ab7b50bdf2b 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -906,7 +906,7 @@ def on_counter(self):
         """Increment the counter var."""
         self.counter = self.counter + 1
 
-    @computed_var
+    @computed_var(cache=True)
     def comp_dynamic(self) -> str:
         """A computed var that depends on the dynamic var.
 
@@ -1049,9 +1049,6 @@ def _dynamic_state_event(name, val, **kwargs):
         assert on_load_update == StateUpdate(
             delta={
                 state.get_name(): {
-                    # These computed vars _shouldn't_ be here, because they didn't change
-                    arg_name: exp_val,
-                    f"comp_{arg_name}": exp_val,
                     "loaded": exp_index + 1,
                 },
             },
@@ -1073,9 +1070,6 @@ def _dynamic_state_event(name, val, **kwargs):
         assert on_set_is_hydrated_update == StateUpdate(
             delta={
                 state.get_name(): {
-                    # These computed vars _shouldn't_ be here, because they didn't change
-                    arg_name: exp_val,
-                    f"comp_{arg_name}": exp_val,
                     "is_hydrated": True,
                 },
             },
@@ -1097,9 +1091,6 @@ def _dynamic_state_event(name, val, **kwargs):
         assert update == StateUpdate(
             delta={
                 state.get_name(): {
-                    # These computed vars _shouldn't_ be here, because they didn't change
-                    f"comp_{arg_name}": exp_val,
-                    arg_name: exp_val,
                     "counter": exp_index + 1,
                 }
             },
diff --git a/tests/test_state.py b/tests/test_state.py
index ba8fc592f33..1f1693156fc 100644
--- a/tests/test_state.py
+++ b/tests/test_state.py
@@ -649,7 +649,12 @@ def test_set_dirty_var(test_state):
     assert test_state.dirty_vars == set()
 
 
-def test_set_dirty_substate(test_state, child_state, child_state2, grandchild_state):
+def test_set_dirty_substate(
+    test_state: TestState,
+    child_state: ChildState,
+    child_state2: ChildState2,
+    grandchild_state: GrandchildState,
+):
     """Test changing substate vars marks the value as dirty.
 
     Args:
@@ -3077,6 +3082,51 @@ def bar(self) -> str:
     assert C1._potentially_dirty_substates() == set()
 
 
+def test_router_var_dep() -> None:
+    """Test that router var dependencies are correctly tracked."""
+
+    class RouterVarParentState(State):
+        """A parent state for testing router var dependency."""
+
+        pass
+
+    class RouterVarDepState(RouterVarParentState):
+        """A state with a router var dependency."""
+
+        @rx.var(cache=True)
+        def foo(self) -> str:
+            return self.router.page.params.get("foo", "")
+
+    foo = RouterVarDepState.computed_vars["foo"]
+    State._init_var_dependency_dicts()
+
+    assert foo._deps(objclass=RouterVarDepState) == {"router"}
+    assert RouterVarParentState._potentially_dirty_substates() == {RouterVarDepState}
+    assert RouterVarParentState._substate_var_dependencies == {
+        "router": {RouterVarDepState.get_name()}
+    }
+    assert RouterVarDepState._computed_var_dependencies == {
+        "router": {"foo"},
+    }
+
+    rx_state = State()
+    parent_state = RouterVarParentState()
+    state = RouterVarDepState()
+
+    # link states
+    rx_state.substates = {RouterVarParentState.get_name(): parent_state}
+    parent_state.parent_state = rx_state
+    state.parent_state = parent_state
+    parent_state.substates = {RouterVarDepState.get_name(): state}
+
+    assert state.dirty_vars == set()
+
+    # Reassign router var
+    state.router = state.router
+    assert state.dirty_vars == {"foo", "router"}
+    assert parent_state.dirty_substates == {RouterVarDepState.get_name()}
+
+
 @pytest.mark.asyncio
 async def test_setvar(mock_app: rx.App, token: str):
     """Test that setvar works correctly.