Skip to content

Commit abcfdf8

Browse files
feat: detect mutation to values of reactive vars (#595)
* feat: detect mutation to values of reactive vars A common source of error is mutation of values in reactive vars. The reactive var cannot notice a change in its value (e.g. a list) if the list is mutated in place. A user can be mislead that it is working correctly, when another reactive variable triggers a rerender, teaching bad habits. Detecting mutations in Python without using proxies can be done by making a deepcopy of the object, and comparing the reference to the deepcopy. This comes at the cost of performance and memory usage, therefore we should enabled this by default in development mode, so app builders are aware of mistakes while developing. In production mode we can disable the mutation checks and behave as before. * refactor: mutation detection is now a decorator class * refactor: simplify initial value mutation detection * chore: clean up mutation detection * feat: improve mutation detection performance By using `sys._getframe` when possible, we should make quite substantial performance gains over using inspect * fix: None not handled properly in mutation detection --------- Co-authored-by: Iisakki Rotko <iisakki.rotko@gmail.com>
1 parent 998e53f commit abcfdf8

File tree

8 files changed

+488
-44
lines changed

8 files changed

+488
-44
lines changed

solara/_stores.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import copy
2+
import dataclasses
3+
import inspect
4+
from typing import Callable, ContextManager, Generic, Optional, Union, cast
5+
import warnings
6+
from .toestand import ValueBase, KernelStore, S, _find_outside_solara_frame
7+
import solara.util
8+
9+
10+
class _PublicValueNotSet:
11+
pass
12+
13+
14+
@dataclasses.dataclass
15+
class StoreValue(Generic[S]):
16+
private: S # the internal private value, should never be mutated
17+
public: Union[S, _PublicValueNotSet] # this is the value that is exposed in .get(), it is a deep copy of private
18+
get_traceback: Optional[inspect.Traceback]
19+
set_value: Optional[S] # the value that was set using .set(..), we deepcopy this to set private
20+
set_traceback: Optional[inspect.Traceback]
21+
22+
23+
class MutateDetectorStore(ValueBase[S]):
24+
def __init__(self, store: KernelStore[StoreValue[S]], equals=solara.util.equals_extra):
25+
self._storage = store
26+
self._enabled = True
27+
super().__init__(equals=equals)
28+
29+
@property
30+
def lock(self):
31+
return self._storage.lock
32+
33+
def get(self) -> S:
34+
self.check_mutations()
35+
self._ensure_public_exists()
36+
value = self._storage.get()
37+
# value.public is of type Optional[S], so it's tempting to check for None here,
38+
# but S could include None as a valid value, so best we can do is cast
39+
public_value = cast(S, value.public)
40+
return public_value
41+
42+
def peek(self) -> S:
43+
"""Return the value without automatically subscribing to listeners."""
44+
self.check_mutations()
45+
store_value = self._storage.peek()
46+
self._ensure_public_exists()
47+
public_value = cast(S, store_value.public)
48+
return public_value
49+
50+
def set(self, value: S):
51+
self.check_mutations()
52+
self._ensure_public_exists()
53+
private = copy.deepcopy(value)
54+
self._check_equals(private, value)
55+
frame = _find_outside_solara_frame()
56+
if frame is not None:
57+
frame_info = inspect.getframeinfo(frame)
58+
else:
59+
frame_info = None
60+
store_value = StoreValue(private=private, public=_PublicValueNotSet(), get_traceback=None, set_value=value, set_traceback=frame_info)
61+
self._storage.set(store_value)
62+
63+
def check_mutations(self):
64+
self._storage._check_mutation()
65+
if not self._enabled:
66+
return
67+
store_value = self._storage.peek()
68+
if not isinstance(store_value.public, _PublicValueNotSet) and not self.equals(store_value.public, store_value.private):
69+
tb = store_value.get_traceback
70+
# TODO: make the error message as elaborate as below
71+
msg = (
72+
f"Reactive variable was read when it had the value of {store_value.private!r}, but was later mutated to {store_value.public!r}.\n"
73+
"Mutation should not be done on the value of a reactive variable, as in production mode we would be unable to track changes.\n"
74+
)
75+
if tb:
76+
if tb.code_context:
77+
code = tb.code_context[0]
78+
else:
79+
code = "<No code context available>"
80+
msg += f"The last value was read in the following code:\n" f"{tb.filename}:{tb.lineno}\n" f"{code}"
81+
raise ValueError(msg)
82+
elif store_value.set_value is not None and not self.equals(store_value.set_value, store_value.private):
83+
tb = store_value.set_traceback
84+
msg = f"""Reactive variable was set with a value of {store_value.private!r}, but was later mutated mutated to {store_value.set_value!r}.
85+
86+
Mutation should not be done on the value of a reactive variable, as in production mode we would be unable to track changes.
87+
88+
Bad:
89+
mylist = reactive([]]
90+
some_values = [1, 2, 3]
91+
mylist.value = some_values # you give solara a reference to your list
92+
some_values.append(4) # but later mutate it (solara cannot detect this change, so a render will not be triggered)
93+
# if later on a re-render happens for a different reason, you will read of the mutated list.
94+
95+
Good (if you want the reactive variable to be updated):
96+
mylist = reactive([]]
97+
some_values = [1, 2, 3]
98+
mylist.value = some_values
99+
mylist.value = some_values + [4]
100+
101+
Good (if you want to keep mutating your own list):
102+
mylist = reactive([]]
103+
some_values = [1, 2, 3]
104+
mylist.value = some_values.copy() # this gives solara a copy of the list
105+
some_values.append(4) # you are free to mutate your own list, solara will not see this
106+
107+
"""
108+
if tb:
109+
if tb.code_context:
110+
code = tb.code_context[0]
111+
else:
112+
code = "<No code context available>"
113+
msg += "The last time the value was set was at:\n" f"{tb.filename}:{tb.lineno}\n" f"{code}"
114+
raise ValueError(msg)
115+
116+
def _ensure_public_exists(self):
117+
store_value = self._storage.peek()
118+
if isinstance(store_value.public, _PublicValueNotSet):
119+
with self.lock:
120+
if isinstance(store_value.public, _PublicValueNotSet):
121+
frame = _find_outside_solara_frame()
122+
if frame is not None:
123+
frame_info = inspect.getframeinfo(frame)
124+
else:
125+
frame_info = None
126+
store_value.public = copy.deepcopy(store_value.private)
127+
self._check_equals(store_value.public, store_value.private)
128+
store_value.get_traceback = frame_info
129+
130+
def _check_equals(self, a: S, b: S):
131+
if not self._enabled:
132+
return
133+
if not self.equals(a, b):
134+
frame = _find_outside_solara_frame()
135+
if frame is not None:
136+
frame_info = inspect.getframeinfo(frame)
137+
else:
138+
frame_info = None
139+
140+
warn = """The equals function for this reactive value returned False when comparing a deepcopy to itself.
141+
142+
This reactive variable will not be able to detect mutations correctly, and is therefore disabled.
143+
144+
To avoid this warning, and to ensure that mutation detection works correctly, please provide a better equals function to the reactive variable.
145+
A good choice for dataframes and numpy arrays might be solara.util.equals_pickle, which will also attempt to compare the pickled values of the objects.
146+
147+
Example:
148+
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
149+
reactive_df = solara.reactive(df, equals=solara.util.equals_pickle)
150+
"""
151+
tb = frame_info
152+
if tb:
153+
if tb.code_context:
154+
code = tb.code_context[0]
155+
else:
156+
code = "<No code context available>"
157+
warn += "This warning was triggered from:\n" f"{tb.filename}:{tb.lineno}\n" f"{code}"
158+
warnings.warn(warn)
159+
self._enabled = False
160+
161+
def subscribe(self, listener: Callable[[S], None], scope: Optional[ContextManager] = None):
162+
def listener_wrapper(new: StoreValue[S], previous: StoreValue[S]):
163+
self._ensure_public_exists()
164+
assert new.public is not None
165+
assert previous.public is not None
166+
previous_value = previous.set_value if previous.set_value is not None else previous.private
167+
new_value = new.set_value
168+
assert new_value is not None
169+
if not self.equals(new_value, previous_value):
170+
listener(new_value)
171+
172+
return self._storage.subscribe_change(listener_wrapper, scope=scope)
173+
174+
def subscribe_change(self, listener: Callable[[S, S], None], scope: Optional[ContextManager] = None):
175+
def listener_wrapper(new: StoreValue[S], previous: StoreValue[S]):
176+
self._ensure_public_exists()
177+
assert new.public is not None
178+
assert previous.public is not None
179+
previous_value = previous.set_value if previous.set_value is not None else previous.private
180+
new_value = new.set_value
181+
assert new_value is not None
182+
if not self.equals(new_value, previous_value):
183+
listener(new_value, previous_value)
184+
185+
return self._storage.subscribe_change(listener_wrapper, scope=scope)

solara/hooks/use_reactive.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Callable, Optional, TypeVar, Union
1+
from typing import Any, Callable, Optional, TypeVar, Union
22

33
import solara
44

@@ -8,6 +8,7 @@
88
def use_reactive(
99
value: Union[T, solara.Reactive[T]],
1010
on_change: Optional[Callable[[T], None]] = None,
11+
equals: Callable[[Any, Any], bool] = solara.util.equals_extra,
1112
) -> solara.Reactive[T]:
1213
"""Creates a reactive variable with the a local component scope.
1314
@@ -44,6 +45,12 @@ def Page():
4445
* on_change (Optional[Callable[[T], None]]): An optional callback function
4546
that will be called when the reactive variable's value changes.
4647
48+
* equals: A function that returns True if two values are considered equal, and False otherwise.
49+
The default function is `solara.util.equals`, which performs a deep comparison of the two values
50+
and is more forgiving than the default `==` operator.
51+
You can provide a custom function if you need to define a different notion of equality.
52+
53+
4754
Returns:
4855
solara.Reactive[T]: A reactive variable with the specified initial value
4956
or the provided reactive variable.

solara/reactive.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
from typing import TypeVar
1+
from typing import Any, Callable, TypeVar
22

33
from solara.toestand import Reactive
4+
import solara.util
45

56
__all__ = ["reactive", "Reactive"]
67

78
T = TypeVar("T")
89

910

10-
def reactive(value: T) -> Reactive[T]:
11+
def reactive(value: T, equals: Callable[[Any, Any], bool] = solara.util.equals_extra) -> Reactive[T]:
1112
"""Creates a new Reactive object with the given initial value.
1213
1314
Reactive objects are mostly used to manage global or application-wide state in
@@ -35,6 +36,11 @@ def reactive(value: T) -> Reactive[T]:
3536
3637
Args:
3738
value (T): The initial value of the reactive variable.
39+
equals: A function that returns True if two values are considered equal, and False otherwise.
40+
The default function is `solara.util.equals`, which performs a deep comparison of the two values
41+
and is more forgiving than the default `==` operator.
42+
You can provide a custom function if you need to define a different notion of equality.
43+
3844
3945
Returns:
4046
Reactive[T]: A new Reactive object with the specified initial value.
@@ -90,4 +96,4 @@ def Page():
9096
Whenever the counter value changes, `CounterDisplay` automatically updates to display the new value.
9197
9298
"""
93-
return Reactive(value)
99+
return Reactive(value, equals=equals)

solara/settings.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,23 @@ class Config:
6161
env_file = ".env"
6262

6363

64+
class Storage(BaseSettings):
65+
mutation_detection: Optional[bool] = None # True/False, or None to auto determine
66+
factory: str = "solara.toestand.default_storage"
67+
68+
def get_factory(self):
69+
return solara.util.import_item(self.factory)
70+
71+
class Config:
72+
env_prefix = "solara_storage_"
73+
case_sensitive = False
74+
env_file = ".env"
75+
76+
6477
assets: Assets = Assets()
6578
cache: Cache = Cache()
6679
main = MainSettings()
80+
storage = Storage()
6781

6882
if main.check_hooks not in ["off", "warn", "raise"]:
6983
raise ValueError(f"Invalid value for check_hooks: {main.check_hooks}, expected one of ['off', 'warn', 'raise']")

0 commit comments

Comments
 (0)