Skip to content

Commit 0a99318

Browse files
committed
Implement trigger_ref to force-notify watchers of a proxy (closes #123)
Modeled after Vue's triggerRef: after making deep mutations to a value nested inside a shallow proxy (which bypass the proxy and are therefore invisible to observ), trigger_ref(proxy) notifies the proxy's dep and all live keydeps, as if its first level was written to. Works on any proxy; all views of the same target share their deps, so triggering one view notifies watchers on all of them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAdkw8VAYycyoPWjFf2WD7
1 parent 8390b03 commit 0a99318

5 files changed

Lines changed: 172 additions & 1 deletion

File tree

docs/guide/readonly-shallow.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,25 @@ This is an escape hatch for performance-sensitive cases: when a value is a large
5050

5151
`shallow_readonly()` combines both behaviors: a read-only view where only first-level reads are tracked.
5252

53+
## Force-triggering with `trigger_ref`
54+
55+
Sometimes you *do* mutate a value nested inside a shallow proxy in place — for example when the structure is too large to replace wholesale. Since those mutations bypass the proxy, observ cannot see them. `trigger_ref()` (named after [Vue's `triggerRef`](https://vuejs.org/api/reactivity-advanced.html#triggerref)) force-notifies the watchers that depend on a proxy, as if its first level was written to:
56+
57+
```python
58+
from observ import shallow_reactive, trigger_ref, watch_effect
59+
60+
state = shallow_reactive({"big": {"huge": [...]}})
61+
62+
watcher = watch_effect(lambda: render(state["big"]))
63+
64+
state["big"]["huge"].append(item) # NOT tracked: deep mutation
65+
trigger_ref(state) # force: re-runs the effect
66+
```
67+
68+
Watchers re-evaluate their watched function; whether a `watch()` *callback* then fires follows the normal rules: watchers on a container value (or with `deep=True`) always fire, while a watcher on a plain value only fires when that value actually differs from the previous evaluation.
69+
70+
`trigger_ref` accepts any proxy (it works on the result of `ref()` and `reactive()` too). All proxies for the same target share their bookkeeping, so triggering one view notifies the watchers on all of them.
71+
5372
## Overview
5473

5574
| Function | Writable | Deep |

docs/reference/api.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Everything documented on this page is available from the top-level `observ` pack
44

55
```python
66
from observ import (
7-
reactive, readonly, shallow_reactive, shallow_readonly, ref, to_raw,
7+
reactive, readonly, shallow_reactive, shallow_readonly, ref, to_raw, trigger_ref,
88
computed, watch, watch_effect, Watcher,
99
init, loop_factory, scheduler,
1010
)
@@ -46,6 +46,8 @@ Combination of `shallow_reactive` and `readonly`.
4646

4747
::: observ.proxy.ref
4848

49+
::: observ.proxy.trigger_ref
50+
4951
::: observ.proxy.to_raw
5052

5153
## Watching state

observ/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
shallow_reactive,
1414
shallow_readonly,
1515
to_raw,
16+
trigger_ref,
1617
)
1718
from .scheduler import scheduler
1819
from .watcher import Watcher, computed, watch, watch_effect

observ/proxy.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,29 @@ def shallow_readonly(target: T) -> T:
164164
return proxy(target, readonly=True, shallow=True)
165165

166166

167+
def trigger_ref(target: Any) -> None:
168+
"""
169+
Force-notify the watchers that depend on the given proxy, as if
170+
its first level was written to. This is typically used together
171+
with a shallow proxy (`shallow_reactive`, `shallow_readonly`),
172+
after making deep mutations to a value nested inside it — those
173+
mutations bypass the proxy, so observ cannot see them by itself.
174+
"""
175+
if not isinstance(target, Proxy):
176+
raise TypeError(
177+
"trigger_ref() expects a proxy "
178+
"(e.g. the result of ref, reactive or shallow_reactive)"
179+
)
180+
dep = target.__dep__
181+
keydeps = dep.keydeps
182+
if keydeps is not None:
183+
# Notifying may run sync watchers, which can release keydeps
184+
# and thereby mutate the (weak) mapping, so iterate a snapshot
185+
for keydep in list(keydeps.values()):
186+
keydep.notify()
187+
dep.notify()
188+
189+
167190
def to_raw(target: Proxy[T] | T) -> T:
168191
"""
169192
Returns a raw object from which any trace of proxy has been replaced

tests/test_trigger_ref.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
from unittest.mock import Mock
2+
3+
import pytest
4+
5+
from observ import (
6+
computed,
7+
readonly,
8+
shallow_reactive,
9+
trigger_ref,
10+
watch,
11+
watch_effect,
12+
)
13+
14+
15+
def test_trigger_ref_shallow_key_watcher():
16+
state = shallow_reactive({"value": {"count": 0}})
17+
watcher = watch(lambda: state["value"], Mock(), sync=True)
18+
19+
# Deep mutations bypass a shallow proxy, so no watcher fires
20+
state["value"]["count"] = 1
21+
watcher.callback.assert_not_called()
22+
23+
trigger_ref(state)
24+
watcher.callback.assert_called_once()
25+
26+
27+
def test_trigger_ref_notifies_dep_and_keydeps():
28+
state = shallow_reactive({"items": [1]})
29+
keyed = watch(lambda: state["items"], Mock(), sync=True)
30+
container = watch(lambda: dict(state.items()), Mock(), sync=True)
31+
32+
trigger_ref(state)
33+
34+
keyed.callback.assert_called_once()
35+
container.callback.assert_called_once()
36+
37+
38+
def test_trigger_ref_watch_effect():
39+
state = shallow_reactive({"value": [1, 2]})
40+
lengths = []
41+
watcher = watch_effect(lambda: lengths.append(len(state["value"])), sync=True)
42+
43+
assert lengths == [2]
44+
45+
state["value"].append(3)
46+
assert lengths == [2]
47+
48+
trigger_ref(state)
49+
assert lengths == [2, 3]
50+
51+
assert watcher.active
52+
53+
54+
def test_trigger_ref_computed():
55+
state = shallow_reactive({"data": {"count": 1}})
56+
57+
@computed
58+
def doubled():
59+
return state["data"]["count"] * 2
60+
61+
assert doubled() == 2
62+
63+
state["data"]["count"] = 3
64+
# The computed doesn't know its (shallow) dependency changed
65+
assert doubled() == 2
66+
67+
trigger_ref(state)
68+
assert doubled() == 6
69+
70+
71+
def test_trigger_ref_shallow_list():
72+
items = shallow_reactive([{"a": 0}])
73+
watcher = watch(lambda: items[0], Mock(), sync=True)
74+
75+
items[0]["a"] = 1
76+
watcher.callback.assert_not_called()
77+
78+
trigger_ref(items)
79+
watcher.callback.assert_called_once()
80+
81+
82+
def test_trigger_ref_through_readonly_view():
83+
state = shallow_reactive({"data": {"x": 0}})
84+
view = readonly(state)
85+
watcher = watch(lambda: view["data"], Mock(), sync=True)
86+
87+
state["data"]["x"] = 1
88+
watcher.callback.assert_not_called()
89+
90+
# All proxies for the same target share their deps, so triggering
91+
# any view notifies watchers on all of them
92+
trigger_ref(view)
93+
watcher.callback.assert_called_once()
94+
95+
96+
def test_trigger_ref_plain_value_unchanged():
97+
# Callbacks that watch a plain (non-container) value only fire
98+
# when that value actually differs from the previous evaluation
99+
state = shallow_reactive({"value": 0})
100+
watcher = watch(lambda: state["value"], Mock(), sync=True)
101+
102+
trigger_ref(state)
103+
watcher.callback.assert_not_called()
104+
105+
# But the watched function is re-evaluated, so effects do re-run
106+
reads = []
107+
effect = watch_effect(lambda: reads.append(state["value"]), sync=True)
108+
assert reads == [0]
109+
trigger_ref(state)
110+
assert reads == [0, 0]
111+
112+
assert effect.active
113+
114+
115+
def test_trigger_ref_requires_proxy():
116+
with pytest.raises(TypeError):
117+
trigger_ref({"value": 1})
118+
119+
with pytest.raises(TypeError):
120+
trigger_ref(None)
121+
122+
123+
def test_trigger_ref_no_watchers():
124+
# Triggering a proxy nobody watches is a no-op
125+
state = shallow_reactive({"value": 1})
126+
trigger_ref(state)

0 commit comments

Comments
 (0)