mutts 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -6
- package/dist/chunks/{_tslib-C-cuVLvZ.js → _tslib-BgjropY9.js} +9 -1
- package/dist/chunks/_tslib-BgjropY9.js.map +1 -0
- package/dist/chunks/{_tslib-CMEnd0VE.esm.js → _tslib-Mzh1rNsX.esm.js} +9 -2
- package/dist/chunks/_tslib-Mzh1rNsX.esm.js.map +1 -0
- package/dist/chunks/{decorator-D4DU97Zg.js → decorator-DLvrD0UF.js} +42 -19
- package/dist/chunks/decorator-DLvrD0UF.js.map +1 -0
- package/dist/chunks/{decorator-GnHw1Az7.esm.js → decorator-DqiszP7i.esm.js} +42 -19
- package/dist/chunks/decorator-DqiszP7i.esm.js.map +1 -0
- package/dist/chunks/index-79Kk8D6e.esm.js +4857 -0
- package/dist/chunks/index-79Kk8D6e.esm.js.map +1 -0
- package/dist/chunks/index-GRBSx0mB.js +4908 -0
- package/dist/chunks/index-GRBSx0mB.js.map +1 -0
- package/dist/decorator.esm.js +1 -1
- package/dist/decorator.js +1 -1
- package/dist/destroyable.d.ts +1 -1
- package/dist/destroyable.esm.js +1 -1
- package/dist/destroyable.esm.js.map +1 -1
- package/dist/destroyable.js +1 -1
- package/dist/destroyable.js.map +1 -1
- package/dist/devtools/devtools.html +9 -0
- package/dist/devtools/devtools.js +5 -0
- package/dist/devtools/devtools.js.map +1 -0
- package/dist/devtools/manifest.json +8 -0
- package/dist/devtools/panel.css +72 -0
- package/dist/devtools/panel.html +31 -0
- package/dist/devtools/panel.js +13048 -0
- package/dist/devtools/panel.js.map +1 -0
- package/dist/eventful.esm.js +1 -1
- package/dist/eventful.js +1 -1
- package/dist/index.d.ts +18 -63
- package/dist/index.esm.js +4 -4
- package/dist/index.js +37 -11
- package/dist/index.js.map +1 -1
- package/dist/indexable.d.ts +187 -1
- package/dist/indexable.esm.js +197 -3
- package/dist/indexable.esm.js.map +1 -1
- package/dist/indexable.js +198 -2
- package/dist/indexable.js.map +1 -1
- package/dist/mutts.umd.js +1 -1
- package/dist/mutts.umd.js.map +1 -1
- package/dist/mutts.umd.min.js +1 -1
- package/dist/mutts.umd.min.js.map +1 -1
- package/dist/promiseChain.esm.js.map +1 -1
- package/dist/promiseChain.js.map +1 -1
- package/dist/reactive.d.ts +602 -97
- package/dist/reactive.esm.js +3 -3
- package/dist/reactive.js +32 -10
- package/dist/reactive.js.map +1 -1
- package/dist/std-decorators.esm.js +1 -1
- package/dist/std-decorators.js +1 -1
- package/docs/ai/api-reference.md +133 -0
- package/docs/ai/manual.md +105 -0
- package/docs/iterableWeak.md +646 -0
- package/docs/reactive/advanced.md +1280 -0
- package/docs/reactive/collections.md +767 -0
- package/docs/reactive/core.md +973 -0
- package/docs/reactive.md +21 -9545
- package/package.json +18 -5
- package/src/decorator.ts +266 -0
- package/src/destroyable.ts +199 -0
- package/src/eventful.ts +77 -0
- package/src/index.d.ts +9 -0
- package/src/index.ts +9 -0
- package/src/indexable.ts +484 -0
- package/src/introspection.ts +59 -0
- package/src/iterableWeak.ts +233 -0
- package/src/mixins.ts +123 -0
- package/src/promiseChain.ts +110 -0
- package/src/reactive/array.ts +414 -0
- package/src/reactive/change.ts +134 -0
- package/src/reactive/debug.ts +517 -0
- package/src/reactive/deep-touch.ts +268 -0
- package/src/reactive/deep-watch-state.ts +82 -0
- package/src/reactive/deep-watch.ts +168 -0
- package/src/reactive/effect-context.ts +94 -0
- package/src/reactive/effects.ts +1345 -0
- package/src/reactive/index.ts +76 -0
- package/src/reactive/interface.ts +223 -0
- package/src/reactive/map.ts +171 -0
- package/src/reactive/mapped.ts +130 -0
- package/src/reactive/memoize.ts +107 -0
- package/src/reactive/non-reactive-state.ts +49 -0
- package/src/reactive/non-reactive.ts +43 -0
- package/src/reactive/project.project.md +93 -0
- package/src/reactive/project.ts +335 -0
- package/src/reactive/proxy-state.ts +27 -0
- package/src/reactive/proxy.ts +289 -0
- package/src/reactive/record.ts +196 -0
- package/src/reactive/register.ts +421 -0
- package/src/reactive/set.ts +144 -0
- package/src/reactive/tracking.ts +101 -0
- package/src/reactive/types.ts +358 -0
- package/src/reactive/zone.ts +208 -0
- package/src/std-decorators.ts +217 -0
- package/src/utils.ts +117 -0
- package/dist/chunks/_tslib-C-cuVLvZ.js.map +0 -1
- package/dist/chunks/_tslib-CMEnd0VE.esm.js.map +0 -1
- package/dist/chunks/decorator-D4DU97Zg.js.map +0 -1
- package/dist/chunks/decorator-GnHw1Az7.esm.js.map +0 -1
- package/dist/chunks/index-DBScoeCX.esm.js +0 -1960
- package/dist/chunks/index-DBScoeCX.esm.js.map +0 -1
- package/dist/chunks/index-DOTmXL89.js +0 -1983
- package/dist/chunks/index-DOTmXL89.js.map +0 -1
|
@@ -0,0 +1,1280 @@
|
|
|
1
|
+
## Atomic Operations
|
|
2
|
+
|
|
3
|
+
### `atomic()` - Function Wrapper and Decorator
|
|
4
|
+
|
|
5
|
+
The `atomic` function wraps a function to batch all reactive effects triggered within it, ensuring effects run only once after the function completes. It can be used both as a function wrapper and as a decorator.
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { atomic, reactive, effect } from 'mutts/reactive'
|
|
9
|
+
|
|
10
|
+
const state = reactive({ a: 0, b: 0 })
|
|
11
|
+
|
|
12
|
+
effect(() => {
|
|
13
|
+
console.log('Values:', state.a, state.b)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
// Wrap a function for atomic execution
|
|
17
|
+
const updateBoth = atomic((a, b) => {
|
|
18
|
+
state.a = a
|
|
19
|
+
state.b = b
|
|
20
|
+
// All effects triggered by these changes are batched
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// Effect runs only once with final values
|
|
24
|
+
updateBoth(10, 20)
|
|
25
|
+
|
|
26
|
+
// atomic also works as a decorator
|
|
27
|
+
class CounterService {
|
|
28
|
+
@atomic
|
|
29
|
+
updateMultiple(newValue: number) {
|
|
30
|
+
state.a = newValue
|
|
31
|
+
this.performOtherUpdates()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
performOtherUpdates() {
|
|
35
|
+
// Changes here are also batched
|
|
36
|
+
state.b = state.a + 1
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const service = new CounterService()
|
|
41
|
+
service.updateMultiple(5) // Effect runs only once with final values
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The wrapped function preserves its signature (parameters and return value), and all effects triggered by reactive changes inside it are automatically batched.
|
|
45
|
+
|
|
46
|
+
### `addBatchCleanup()` / `defer()` - Deferring Work to Avoid Cycles
|
|
47
|
+
|
|
48
|
+
When an effect needs to perform an action that would modify state the effect depends on, this can create a reactive cycle. The `addBatchCleanup` function (also exported as `defer` for semantic clarity) allows you to defer such work until after the current batch of effects completes.
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
import { addBatchCleanup, effect, reactive } from 'mutts/reactive'
|
|
52
|
+
// or use the semantic alias:
|
|
53
|
+
// import { defer } from 'mutts/reactive'
|
|
54
|
+
|
|
55
|
+
const state = reactive({
|
|
56
|
+
items: [],
|
|
57
|
+
processedCount: 0
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
effect(() => {
|
|
61
|
+
// This effect reads state.items
|
|
62
|
+
const itemCount = state.items.length
|
|
63
|
+
|
|
64
|
+
// If we modify state.processedCount here synchronously,
|
|
65
|
+
// it could trigger this effect again → potential cycle
|
|
66
|
+
|
|
67
|
+
// Instead, defer the mutation until after this effect completes
|
|
68
|
+
addBatchCleanup(() => {
|
|
69
|
+
state.processedCount = itemCount
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
state.items.push('new item')
|
|
74
|
+
// Effect runs, deferred callback runs after, no cycle
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Common Use Case: Self-Triggering Effects**
|
|
78
|
+
|
|
79
|
+
A classic scenario is when an effect needs to create side effects that modify the state it reads:
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
class Hive {
|
|
83
|
+
advertisements = reactive({ wood: { demands: [], supplies: [] } })
|
|
84
|
+
movements = reactive([])
|
|
85
|
+
|
|
86
|
+
setupAdvertisementProcessing() {
|
|
87
|
+
effect(() => {
|
|
88
|
+
// Read advertisements
|
|
89
|
+
const ads = this.advertisements
|
|
90
|
+
|
|
91
|
+
// We need to create movements based on advertisements,
|
|
92
|
+
// but creating a movement modifies state that might trigger this effect
|
|
93
|
+
|
|
94
|
+
addBatchCleanup(() => {
|
|
95
|
+
// Deferred: runs after this effect completes
|
|
96
|
+
if (this.canMatchDemandWithSupply(ads.wood)) {
|
|
97
|
+
this.createMovement('wood', supplier, demander)
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
createMovement(good, from, to) {
|
|
104
|
+
// Modifies reactive state
|
|
105
|
+
this.movements.push({ good, from, to })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**vs. `queueMicrotask` Anti-Pattern**
|
|
111
|
+
|
|
112
|
+
Before `addBatchCleanup` was widely known, developers sometimes used `queueMicrotask` as a workaround:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
// ❌ Anti-pattern: Using queueMicrotask
|
|
116
|
+
effect(() => {
|
|
117
|
+
processAdvertisements()
|
|
118
|
+
|
|
119
|
+
queueMicrotask(() => {
|
|
120
|
+
createMovement() // Defers via event loop
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Problems with `queueMicrotask`:**
|
|
126
|
+
- ❌ **Async timing**: Relies on JavaScript event loop, not reactive system
|
|
127
|
+
- ❌ **Hard to test**: Requires `await` in tests to flush microtasks
|
|
128
|
+
- ❌ **Unclear intent**: Not obvious this is for reactive cycle avoidance
|
|
129
|
+
- ❌ **No batch coordination**: Runs after event loop, not after reactive batch
|
|
130
|
+
|
|
131
|
+
**Use `addBatchCleanup` instead:**
|
|
132
|
+
```typescript
|
|
133
|
+
// ✅ Correct: Using addBatchCleanup
|
|
134
|
+
effect(() => {
|
|
135
|
+
processAdvertisements()
|
|
136
|
+
|
|
137
|
+
addBatchCleanup(() => {
|
|
138
|
+
createMovement() // Defers to end of reactive batch
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Benefits:**
|
|
144
|
+
- ✅ **Synchronous**: Runs immediately after batch, no event loop delay
|
|
145
|
+
- ✅ **Testable**: No need for `await` in tests
|
|
146
|
+
- ✅ **Semantic**: Clear that this is deferred work within reactive system
|
|
147
|
+
- ✅ **Batch-aware**: Respects reactive batch boundaries
|
|
148
|
+
|
|
149
|
+
**Execution Timing:**
|
|
150
|
+
|
|
151
|
+
`addBatchCleanup` callbacks run:
|
|
152
|
+
1. After **all effects in the current batch** have executed
|
|
153
|
+
2. In **FIFO order** (first added, first executed)
|
|
154
|
+
3. **Before** control returns to the caller
|
|
155
|
+
4. **Synchronously** (no microtask/setTimeout delay)
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
const state = reactive({ count: 0 })
|
|
159
|
+
|
|
160
|
+
console.log('1. Starting')
|
|
161
|
+
|
|
162
|
+
effect(() => {
|
|
163
|
+
console.log('2. Effect running, count:', state.count)
|
|
164
|
+
|
|
165
|
+
addBatchCleanup(() => {
|
|
166
|
+
console.log('5. Deferred work A')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
addBatchCleanup(() => {
|
|
170
|
+
console.log('6. Deferred work B')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
console.log('3. Effect ending')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
console.log('4. After effect created')
|
|
177
|
+
// Deferred callbacks run here
|
|
178
|
+
console.log('7. All done')
|
|
179
|
+
|
|
180
|
+
// Output:
|
|
181
|
+
// 1. Starting
|
|
182
|
+
// 2. Effect running, count: 0
|
|
183
|
+
// 3. Effect ending
|
|
184
|
+
// 4. After effect created
|
|
185
|
+
// 5. Deferred work A
|
|
186
|
+
// 6. Deferred work B
|
|
187
|
+
// 7. All done
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**Outside of Batches:**
|
|
191
|
+
|
|
192
|
+
If `addBatchCleanup` is called when no batch is active, the callback executes **immediately**:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
// Not inside an effect or atomic operation
|
|
196
|
+
addBatchCleanup(() => {
|
|
197
|
+
console.log('Runs immediately')
|
|
198
|
+
})
|
|
199
|
+
// Callback has already run by this point
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Nested Batches:**
|
|
203
|
+
|
|
204
|
+
Callbacks added in nested batches are collected and run when the **outermost batch** completes:
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
effect(() => {
|
|
208
|
+
addBatchCleanup(() => console.log('Outer deferred'))
|
|
209
|
+
|
|
210
|
+
atomic(() => {
|
|
211
|
+
addBatchCleanup(() => console.log('Inner deferred'))
|
|
212
|
+
})()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
// Output:
|
|
216
|
+
// Outer deferred
|
|
217
|
+
// Inner deferred
|
|
218
|
+
// (Both run after outer batch completes)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**Error Handling:**
|
|
222
|
+
|
|
223
|
+
If a deferred callback throws an error, it's logged but doesn't stop other deferred callbacks from running:
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
effect(() => {
|
|
227
|
+
addBatchCleanup(() => {
|
|
228
|
+
throw new Error('First callback fails')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
addBatchCleanup(() => {
|
|
232
|
+
console.log('Second callback still runs')
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
// Error is logged, but second callback executes
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
**Best Practices:**
|
|
240
|
+
|
|
241
|
+
1. **Use for consequence actions**: When an effect's consequences would trigger the effect again
|
|
242
|
+
2. **Keep callbacks focused**: Each deferred callback should do one thing
|
|
243
|
+
3. **Prefer defer alias**: `defer` is more semantic than `addBatchCleanup` for deferring work
|
|
244
|
+
4. **Document why**: Add a comment explaining why deferral is needed
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
effect(() => {
|
|
248
|
+
processData()
|
|
249
|
+
|
|
250
|
+
// Defer movement creation to avoid triggering this effect again
|
|
251
|
+
// (createMovement modifies state that this effect reads)
|
|
252
|
+
defer(() => {
|
|
253
|
+
createMovement(data)
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### `biDi()` - Bidirectional Binding
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
Creates a bidirectional binding between a reactive value and a non-reactive external value (like DOM elements), automatically preventing infinite loops.
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
import { biDi, reactive } from 'mutts/reactive'
|
|
265
|
+
|
|
266
|
+
const model = reactive({ value: '' })
|
|
267
|
+
|
|
268
|
+
// Bind to a DOM element value property
|
|
269
|
+
const provide = biDi(
|
|
270
|
+
(v) => {
|
|
271
|
+
// External setter: called when reactive value changes
|
|
272
|
+
inputElement.value = v
|
|
273
|
+
},
|
|
274
|
+
() => model.value, // Reactive getter
|
|
275
|
+
(v) => model.value = v // Reactive setter
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
// Handle user input (external changes)
|
|
279
|
+
inputElement.addEventListener('input', () => {
|
|
280
|
+
provide(inputElement.value) // Updates model.value without causing loop
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// Changes to model.value also update the input element
|
|
284
|
+
model.value = 'new value' // inputElement.value is updated via biDi
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
**When to use `biDi()`:**
|
|
288
|
+
|
|
289
|
+
- **Two-way data binding**: Connect reactive state to HTML form inputs, third-party libraries, or non-reactive APIs
|
|
290
|
+
- **Prevent circular updates**: Avoid infinite loops when external and reactive changes can trigger each other
|
|
291
|
+
- **Integrate external systems**: Bridge between reactive code and legacy/external code that can't be made reactive
|
|
292
|
+
|
|
293
|
+
**How it works:**
|
|
294
|
+
|
|
295
|
+
The `biDi()` function creates an effect that syncs reactive → external changes, and returns a function that handles external → reactive changes. The returned function uses atomic operations to suppress the circular effect, preventing infinite update loops.
|
|
296
|
+
|
|
297
|
+
**Alternative syntax:**
|
|
298
|
+
|
|
299
|
+
You can pass the reactive getter/setter as a single object instead of two separate parameters:
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
const provide = biDi(
|
|
303
|
+
(v) => inputElement.value = v,
|
|
304
|
+
{ get: () => model.value, set: (v) => model.value = v }
|
|
305
|
+
)
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## Advanced Effects
|
|
309
|
+
|
|
310
|
+
### Recording Results inside Effects
|
|
311
|
+
|
|
312
|
+
If you want to capture derived values from an effect, push them from inside the effect body. Use the returned cleanup to manage resources between runs.
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
const state = reactive({ count: 0 })
|
|
316
|
+
const results: number[] = []
|
|
317
|
+
|
|
318
|
+
const stop = effect(() => {
|
|
319
|
+
results.push(state.count * 2)
|
|
320
|
+
return () => {
|
|
321
|
+
// cleanup between runs if needed
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
state.count = 5 // results: [0, 10]
|
|
326
|
+
stop()
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Effect Cleanup Functions
|
|
330
|
+
|
|
331
|
+
Effects can return cleanup functions that run before the next execution.
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
const state = reactive({ count: 0 })
|
|
335
|
+
|
|
336
|
+
effect(() => {
|
|
337
|
+
const interval = setInterval(() => {
|
|
338
|
+
console.log('Count:', state.count)
|
|
339
|
+
}, 1000)
|
|
340
|
+
|
|
341
|
+
return () => {
|
|
342
|
+
clearInterval(interval)
|
|
343
|
+
console.log('Cleaned up interval')
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
// When state.count changes, the cleanup runs first
|
|
348
|
+
state.count = 5
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Effect Lifecycle
|
|
352
|
+
|
|
353
|
+
1. **Initial Run**: Effect runs immediately when created
|
|
354
|
+
2. **Dependency Change**: When dependencies change, cleanup runs, then effect re-runs
|
|
355
|
+
3. **Manual Stop**: Calling the cleanup function stops the effect permanently
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
const state = reactive({ count: 0 })
|
|
359
|
+
|
|
360
|
+
const stop = effect(() => {
|
|
361
|
+
console.log('Effect running, count:', state.count)
|
|
362
|
+
return () => console.log('Cleanup running')
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
console.log('Effect created')
|
|
366
|
+
|
|
367
|
+
state.count = 5
|
|
368
|
+
// Output:
|
|
369
|
+
// Effect created
|
|
370
|
+
// Effect running, count: 0
|
|
371
|
+
// Cleanup running
|
|
372
|
+
// Effect running, count: 5
|
|
373
|
+
|
|
374
|
+
stop() // Effect stops permanently
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Effect Arguments
|
|
378
|
+
|
|
379
|
+
Effects can accept additional arguments that are forwarded to the effect function. This is particularly useful in loops or when you need to pass context to the effect:
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
const items = reactive([
|
|
383
|
+
{ id: 1, name: 'Item 1' },
|
|
384
|
+
{ id: 2, name: 'Item 2' },
|
|
385
|
+
{ id: 3, name: 'Item 3' }
|
|
386
|
+
])
|
|
387
|
+
|
|
388
|
+
// Create effects for each item with the item index
|
|
389
|
+
const effectCleanups: (() => void)[] = []
|
|
390
|
+
|
|
391
|
+
for (let i = 0; i < items.length; i++) {
|
|
392
|
+
const cleanup = effect((access, index) => {
|
|
393
|
+
console.log(`Item ${index}:`, items[index].name)
|
|
394
|
+
|
|
395
|
+
// The index is captured in the closure and passed as argument
|
|
396
|
+
return () => console.log(`Cleaning up effect for item ${index}`)
|
|
397
|
+
}, i)
|
|
398
|
+
|
|
399
|
+
effectCleanups.push(cleanup)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Later, clean up all effects
|
|
403
|
+
effectCleanups.forEach(cleanup => cleanup())
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### Opaque Effects (Strict Mode)
|
|
407
|
+
|
|
408
|
+
By default, the reactive system uses "Deep Touch" optimization. If a reactive property is replaced with a new object/array that is structurally similar to the old one (e.g., swapping `[1, 2]` with a new `[1, 2]`), the system may skip re-running parent effects and only notify granular child updates. This optimization avoids unnecessary re-renders but can be problematic for effects that rely on strict object identity (like `memoize` or reference checks).
|
|
409
|
+
|
|
410
|
+
You can mark an effect as "opaque" to opt-out of this optimization. Opaque effects will **always** trigger when their dependencies change identity, regardless of content similarity.
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
// Standard effect (optimized)
|
|
414
|
+
effect(() => {
|
|
415
|
+
// If state.list is replaced by a similar list, this might NOT run
|
|
416
|
+
console.log('List changed', state.list)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
// Opaque effect
|
|
420
|
+
effect(() => {
|
|
421
|
+
// This will ALWAYS run if state.list reference changes
|
|
422
|
+
console.log('List identity changed', state.list)
|
|
423
|
+
}, { opaque: true })
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
This is automatically used by `memoize` to ensure cached results are invalidated when references change.
|
|
427
|
+
|
|
428
|
+
### Watch Function
|
|
429
|
+
|
|
430
|
+
The `watch` function provides a more direct way to observe changes in reactive objects. It comes in two forms:
|
|
431
|
+
|
|
432
|
+
#### Watch with Value Function
|
|
433
|
+
|
|
434
|
+
```typescript
|
|
435
|
+
const state = reactive({ count: 0, name: 'John' })
|
|
436
|
+
|
|
437
|
+
const stop = watch(
|
|
438
|
+
() => state.count, // Value function
|
|
439
|
+
(newValue, oldValue) => {
|
|
440
|
+
console.log(`Count changed from ${oldValue} to ${newValue}`)
|
|
441
|
+
}
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
state.count = 5 // Triggers: "Count changed from 0 to 5"
|
|
445
|
+
state.name = 'Jane' // No trigger (not watching name)
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
#### Watch Object Properties
|
|
449
|
+
|
|
450
|
+
The second form of `watch` allows you to watch any property change on a reactive object:
|
|
451
|
+
|
|
452
|
+
```typescript
|
|
453
|
+
const user = reactive({
|
|
454
|
+
name: 'John',
|
|
455
|
+
age: 30,
|
|
456
|
+
email: 'john@example.com'
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
const stop = watch(
|
|
460
|
+
user, // The reactive object to watch
|
|
461
|
+
() => {
|
|
462
|
+
console.log('Any property of user changed!')
|
|
463
|
+
console.log('Current user:', user)
|
|
464
|
+
}
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
user.name = 'Jane' // Triggers the callback
|
|
468
|
+
user.age = 31 // Triggers the callback
|
|
469
|
+
user.email = 'jane@example.com' // Triggers the callback
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
#### Use Cases
|
|
473
|
+
|
|
474
|
+
**Object-level watching** is particularly useful for:
|
|
475
|
+
|
|
476
|
+
- **Form validation**: Watch all form fields for changes
|
|
477
|
+
- **Auto-save**: Save whenever any field in a document changes
|
|
478
|
+
- **Logging**: Track all changes to a state object
|
|
479
|
+
- **Dirty checking**: Detect if any property has been modified
|
|
480
|
+
|
|
481
|
+
```typescript
|
|
482
|
+
const form = reactive({
|
|
483
|
+
firstName: '',
|
|
484
|
+
lastName: '',
|
|
485
|
+
email: '',
|
|
486
|
+
isValid: false
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
const stop = watch(form, () => {
|
|
490
|
+
// Auto-save whenever any field changes
|
|
491
|
+
saveForm(form)
|
|
492
|
+
|
|
493
|
+
// Update validation status
|
|
494
|
+
form.isValid = form.firstName && form.lastName && form.email
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
// Any change to firstName, lastName, or email will trigger auto-save
|
|
498
|
+
form.firstName = 'John'
|
|
499
|
+
form.lastName = 'Doe'
|
|
500
|
+
form.email = 'john.doe@example.com'
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
#### Deep Watching
|
|
504
|
+
|
|
505
|
+
For both forms of `watch`, you can enable deep watching by passing `{ deep: true }` in the options:
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
const state = reactive({
|
|
509
|
+
user: {
|
|
510
|
+
name: 'John',
|
|
511
|
+
profile: { age: 30 }
|
|
512
|
+
}
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
// Deep watch - triggers on nested property changes
|
|
516
|
+
const stop = watch(state, () => {
|
|
517
|
+
console.log('Any nested property changed!')
|
|
518
|
+
}, { deep: true })
|
|
519
|
+
|
|
520
|
+
state.user.name = 'Jane' // Triggers
|
|
521
|
+
state.user.profile.age = 31 // Triggers (nested change)
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
**Deep Watching Behavior:**
|
|
525
|
+
|
|
526
|
+
- **Unreactive objects are skipped**: Deep watching will not traverse into objects marked as unreactive using `@unreactive` or `unreactive()`
|
|
527
|
+
- **Collections are handled specially**:
|
|
528
|
+
- **Arrays**: All elements and length changes are tracked
|
|
529
|
+
- **Sets**: All values are tracked (keys are not separate in Sets)
|
|
530
|
+
- **Maps**: All values are tracked (keys are not tracked separately)
|
|
531
|
+
- **WeakSet/WeakMap**: Cannot be deep watched (not iterable), only replacement triggers
|
|
532
|
+
- **Circular references**: Handled safely - There is also a configurable depth limit
|
|
533
|
+
- **Performance**: Deep watching has higher overhead than shallow watching
|
|
534
|
+
|
|
535
|
+
```typescript
|
|
536
|
+
// Example with collections
|
|
537
|
+
const state = reactive({
|
|
538
|
+
items: [1, 2, 3],
|
|
539
|
+
tags: new Set(['a', 'b']),
|
|
540
|
+
metadata: new Map([['key1', 'value1']])
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
const stop = watch(state, () => {
|
|
544
|
+
console.log('Collection changed')
|
|
545
|
+
}, { deep: true })
|
|
546
|
+
|
|
547
|
+
state.items.push(4) // Triggers
|
|
548
|
+
state.items[0] = 10 // Triggers
|
|
549
|
+
state.tags.add('c') // Triggers
|
|
550
|
+
state.metadata.set('key2', 'value2') // Triggers
|
|
551
|
+
|
|
552
|
+
// WeakSet/WeakMap only trigger on replacement
|
|
553
|
+
const weakSet = new WeakSet()
|
|
554
|
+
state.weakData = weakSet // Triggers (replacement)
|
|
555
|
+
// Changes to weakSet contents won't trigger (not trackable)
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
#### Cleanup
|
|
559
|
+
|
|
560
|
+
Both forms of `watch` return a cleanup function:
|
|
561
|
+
|
|
562
|
+
```typescript
|
|
563
|
+
const stop = watch(user, () => {
|
|
564
|
+
console.log('User changed')
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
// Later, stop watching
|
|
568
|
+
stop()
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
## Evolution Tracking
|
|
572
|
+
|
|
573
|
+
### Understanding Object Evolution
|
|
574
|
+
|
|
575
|
+
The reactive system tracks how objects change over time, creating an "evolution history" that you can inspect.
|
|
576
|
+
|
|
577
|
+
```typescript
|
|
578
|
+
import { getState } from './reactive'
|
|
579
|
+
|
|
580
|
+
const obj = reactive({ count: 0 })
|
|
581
|
+
let state = getState(obj)
|
|
582
|
+
|
|
583
|
+
effect(() => {
|
|
584
|
+
while ('evolution' in state) {
|
|
585
|
+
console.log('Change:', state.evolution)
|
|
586
|
+
state = state.next
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
console.log('Current count:', obj.count)
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
obj.count = 5
|
|
593
|
+
obj.count = 10
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
### `getState()` - Accessing Change History
|
|
597
|
+
|
|
598
|
+
Returns the current state object for tracking evolution.
|
|
599
|
+
|
|
600
|
+
```typescript
|
|
601
|
+
function getState(obj: any): State
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
**Returns:** A state object that does contain evolution information ( = `{}`) but will in a next evolution call if the object state has evolved.
|
|
605
|
+
|
|
606
|
+
### Evolution Types
|
|
607
|
+
|
|
608
|
+
The system tracks different types of changes:
|
|
609
|
+
|
|
610
|
+
- **`add`**: Property was added
|
|
611
|
+
- **`set`**: Property value was changed
|
|
612
|
+
- **`del`**: Property was deleted
|
|
613
|
+
- **`bunch`**: Collection operation (array methods, map/set operations)
|
|
614
|
+
|
|
615
|
+
```typescript
|
|
616
|
+
const obj = reactive({ count: 0 })
|
|
617
|
+
let state = getState(obj)
|
|
618
|
+
|
|
619
|
+
effect(() => {
|
|
620
|
+
let changes = []
|
|
621
|
+
while ('evolution' in state) {
|
|
622
|
+
changes.push(state.evolution)
|
|
623
|
+
state = state.next
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (changes.length > 0) {
|
|
627
|
+
console.log('Changes since last effect:', changes)
|
|
628
|
+
}
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
obj.count = 5 // { type: 'set', prop: 'count' }
|
|
632
|
+
obj.newProp = 'test' // { type: 'add', prop: 'newProp' }
|
|
633
|
+
delete obj.count // { type: 'del', prop: 'count' }
|
|
634
|
+
|
|
635
|
+
// Array operations
|
|
636
|
+
const array = reactive([1, 2, 3])
|
|
637
|
+
array.push(4) // { type: 'bunch', method: 'push' }
|
|
638
|
+
array.reverse() // { type: 'bunch', method: 'reverse' }
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
### Change History Patterns
|
|
642
|
+
|
|
643
|
+
Common patterns for using evolution tracking:
|
|
644
|
+
|
|
645
|
+
```typescript
|
|
646
|
+
// Pattern 1: Count changes
|
|
647
|
+
let state = getState(obj)
|
|
648
|
+
effect(() => {
|
|
649
|
+
let changes = 0
|
|
650
|
+
while ('evolution' in state) {
|
|
651
|
+
changes++
|
|
652
|
+
state = state.next
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (changes > 0) {
|
|
656
|
+
console.log(`Detected ${changes} changes`)
|
|
657
|
+
state = getState(obj) // Reset for next run
|
|
658
|
+
}
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
// Pattern 2: Filter specific changes
|
|
662
|
+
let state = getState(obj)
|
|
663
|
+
effect(() => {
|
|
664
|
+
const relevantChanges = []
|
|
665
|
+
while ('evolution' in state) {
|
|
666
|
+
if (state.evolution.type === 'set') {
|
|
667
|
+
relevantChanges.push(state.evolution)
|
|
668
|
+
}
|
|
669
|
+
state = state.next
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (relevantChanges.length > 0) {
|
|
673
|
+
console.log('Property updates:', relevantChanges)
|
|
674
|
+
state = getState(obj)
|
|
675
|
+
}
|
|
676
|
+
})
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
## Prototype Chains and Pure Objects
|
|
680
|
+
|
|
681
|
+
The reactive system intelligently handles prototype chains, distinguishing between data prototypes (which should be tracked) and class prototypes (which should not). This enables powerful patterns like using instances as prototypes and working with pure objects created with `Object.create(null)`.
|
|
682
|
+
|
|
683
|
+
### Pure Objects (Object.create(null))
|
|
684
|
+
|
|
685
|
+
Pure objects are objects created with `Object.create(null)` that have no prototype. These objects are useful for creating data structures without inheriting properties from `Object.prototype`.
|
|
686
|
+
|
|
687
|
+
```typescript
|
|
688
|
+
// Create a pure object
|
|
689
|
+
const pure = reactive(Object.create(null) as any)
|
|
690
|
+
pure.x = 1
|
|
691
|
+
pure.y = 2
|
|
692
|
+
|
|
693
|
+
effect(() => {
|
|
694
|
+
console.log(`Pure object: x=${pure.x}, y=${pure.y}`)
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
pure.x = 10 // Triggers effect
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
### Prototype Chain Dependency Tracking
|
|
701
|
+
|
|
702
|
+
When accessing a property that doesn't exist on an object but exists in its prototype chain, the system automatically tracks dependencies on both the object and the prototype where the property is defined.
|
|
703
|
+
|
|
704
|
+
```typescript
|
|
705
|
+
// Create parent with properties
|
|
706
|
+
const parent = reactive({ shared: 'value' })
|
|
707
|
+
|
|
708
|
+
// Create child that inherits from parent
|
|
709
|
+
const child = reactive(Object.create(parent))
|
|
710
|
+
|
|
711
|
+
effect(() => {
|
|
712
|
+
// Accesses 'shared' from parent through prototype chain
|
|
713
|
+
console.log(child.shared) // Tracks both child.shared AND parent.shared
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
// Changing parent property triggers the effect
|
|
717
|
+
parent.shared = 'new value' // Effect runs again
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
### Data Prototypes vs Class Prototypes
|
|
721
|
+
|
|
722
|
+
The system distinguishes between:
|
|
723
|
+
|
|
724
|
+
1. **Data Prototypes**: Objects used as prototypes that don't have their own `constructor` property
|
|
725
|
+
- `Object.create({})` - Plain object as prototype
|
|
726
|
+
- `Object.create(instance)` - Reactive instance as prototype
|
|
727
|
+
- Pure object chains
|
|
728
|
+
|
|
729
|
+
2. **Class Prototypes**: Class prototypes that have their own `constructor` property
|
|
730
|
+
- `Object.create(MyClass.prototype)` - Class prototype
|
|
731
|
+
- These are **not tracked** (we only care about data changes, not method overrides)
|
|
732
|
+
|
|
733
|
+
### Pure Object Prototype Chains
|
|
734
|
+
|
|
735
|
+
You can create chains of pure objects for efficient data structures:
|
|
736
|
+
|
|
737
|
+
```typescript
|
|
738
|
+
// Create root pure object
|
|
739
|
+
const root = reactive(Object.create(null) as any)
|
|
740
|
+
root.baseValue = 1
|
|
741
|
+
|
|
742
|
+
// Create child that inherits from root
|
|
743
|
+
const child = reactive(Object.create(root))
|
|
744
|
+
child.derivedValue = 2
|
|
745
|
+
|
|
746
|
+
// Create grandchild
|
|
747
|
+
const grandchild = reactive(Object.create(child))
|
|
748
|
+
|
|
749
|
+
effect(() => {
|
|
750
|
+
// Accesses baseValue from root through the chain
|
|
751
|
+
console.log(grandchild.baseValue) // 1
|
|
752
|
+
console.log(grandchild.derivedValue) // 2
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
root.baseValue = 10 // Triggers effect
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
### Using Instances as Prototypes
|
|
759
|
+
|
|
760
|
+
A powerful pattern is using reactive instances as prototypes. This allows sharing reactive state across multiple objects:
|
|
761
|
+
|
|
762
|
+
```typescript
|
|
763
|
+
// Create a shared instance with reactive state
|
|
764
|
+
const sharedState = reactive({ count: 0, name: 'Shared' })
|
|
765
|
+
|
|
766
|
+
// Create multiple objects that share this instance as prototype
|
|
767
|
+
const obj1 = reactive(Object.create(sharedState))
|
|
768
|
+
const obj2 = reactive(Object.create(sharedState))
|
|
769
|
+
|
|
770
|
+
effect(() => {
|
|
771
|
+
// obj1 accesses count from sharedState prototype
|
|
772
|
+
console.log(`obj1.count: ${obj1.count}`)
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
effect(() => {
|
|
776
|
+
// obj2 accesses name from sharedState prototype
|
|
777
|
+
console.log(`obj2.name: ${obj2.name}`)
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
// Changing shared state triggers both effects
|
|
781
|
+
sharedState.count = 5 // Triggers first effect
|
|
782
|
+
sharedState.name = 'New' // Triggers second effect
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
### Shadowing in Prototype Chains
|
|
786
|
+
|
|
787
|
+
When an object defines its own property that exists in the prototype, it "shadows" the prototype property. The system only tracks the shadowing property, not the prototype property:
|
|
788
|
+
|
|
789
|
+
```typescript
|
|
790
|
+
const parent = reactive({ value: 'parent' })
|
|
791
|
+
const child = reactive(Object.create(parent))
|
|
792
|
+
child.value = 'child' // Shadows parent.value
|
|
793
|
+
|
|
794
|
+
effect(() => {
|
|
795
|
+
console.log(child.value) // Tracks child.value, not parent.value
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
parent.value = 'parent-changed' // Does NOT trigger effect (shadowed)
|
|
799
|
+
child.value = 'child-changed' // Triggers effect
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
### Multi-Level Chains with Mixed Types
|
|
803
|
+
|
|
804
|
+
You can create complex chains mixing pure objects and normal objects:
|
|
805
|
+
|
|
806
|
+
```typescript
|
|
807
|
+
// Pure object root
|
|
808
|
+
const root = reactive(Object.create(null) as any)
|
|
809
|
+
root.a = 1
|
|
810
|
+
|
|
811
|
+
// Normal object with pure parent
|
|
812
|
+
const mid = reactive(Object.create(root))
|
|
813
|
+
mid.b = 2
|
|
814
|
+
|
|
815
|
+
// Pure object with normal parent
|
|
816
|
+
const leaf = reactive(Object.create(mid))
|
|
817
|
+
|
|
818
|
+
effect(() => {
|
|
819
|
+
console.log(leaf.a) // From root
|
|
820
|
+
console.log(leaf.b) // From mid
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
root.a = 10 // Triggers effect
|
|
824
|
+
mid.b = 20 // Triggers effect
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
### Deep Touch with Prototype Chains
|
|
828
|
+
|
|
829
|
+
When recursive touch is performed on objects with prototype chains, the system compares both own properties and prototype chain properties. This ensures that changes to prototype-level properties are properly detected:
|
|
830
|
+
|
|
831
|
+
```typescript
|
|
832
|
+
const ProtoA = reactive({ x: 1, y: 2 })
|
|
833
|
+
const ProtoB = reactive({ x: 10, y: 20 })
|
|
834
|
+
|
|
835
|
+
const A = reactive(Object.create(ProtoA))
|
|
836
|
+
const B = reactive(Object.create(ProtoB))
|
|
837
|
+
|
|
838
|
+
const C = reactive({ something: A })
|
|
839
|
+
|
|
840
|
+
effect(() => {
|
|
841
|
+
const val = C.something
|
|
842
|
+
effect(() => {
|
|
843
|
+
// Accesses x through prototype chain
|
|
844
|
+
const nested = val.x
|
|
845
|
+
})
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
// Deep touch replacement - compares prototype chains
|
|
849
|
+
C.something = B
|
|
850
|
+
// Nested effect runs because ProtoB.x differs from ProtoA.x
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
### Best Practices
|
|
854
|
+
|
|
855
|
+
1. **Use pure objects for data-only structures**: Pure objects are ideal when you want to avoid inheriting methods from `Object.prototype`.
|
|
856
|
+
|
|
857
|
+
2. **Use instance prototypes for shared state**: Creating multiple objects with the same instance as prototype is an efficient way to share reactive state.
|
|
858
|
+
|
|
859
|
+
3. **Avoid class prototypes in data chains**: Don't use class prototypes (`MyClass.prototype`) in data prototype chains - the system won't track them.
|
|
860
|
+
|
|
861
|
+
4. **Be mindful of shadowing**: Remember that properties defined on an object shadow prototype properties, so changes to the prototype won't trigger effects on the shadowing property.
|
|
862
|
+
|
|
863
|
+
## Recursive Touching
|
|
864
|
+
|
|
865
|
+
When you replace a reactive object with another object that shares the same prototype (including `null`-prototype objects and class instances), the system performs a **recursive touch** instead of notifying watchers as if the entire branch changed. This means:
|
|
866
|
+
|
|
867
|
+
- Watchers attached to the container are *not* re-fired if the container's prototype did not change (this avoids unnecessary parent effect re-runs).
|
|
868
|
+
- Watchers attached to nested properties are re-evaluated only for keys that actually changed (added, removed, or whose values differ).
|
|
869
|
+
- For arrays, the behaviour stays index-oriented: replacing an element at index `i` fires a touch for that index (and `length` if needed) rather than diffing the element recursively. This preserves reorder detection.
|
|
870
|
+
- Prototype chain properties are compared when both objects have prototype chains, ensuring changes to prototype-level properties are detected.
|
|
871
|
+
|
|
872
|
+
**Integration with Prototype Chains:**
|
|
873
|
+
|
|
874
|
+
When comparing objects with prototype chains during recursive touch, the system:
|
|
875
|
+
- Compares own properties of both objects
|
|
876
|
+
- Walks and compares prototype chains (for data prototypes only, not class prototypes)
|
|
877
|
+
- Generates notifications for properties that differ at any level of the prototype chain
|
|
878
|
+
- Applies origin filtering to ensure only effects that came through the replacement property are notified
|
|
879
|
+
|
|
880
|
+
### Example
|
|
881
|
+
|
|
882
|
+
```typescript
|
|
883
|
+
const state = reactive({
|
|
884
|
+
nested: {
|
|
885
|
+
title: 'Hello',
|
|
886
|
+
meta: { views: 0 },
|
|
887
|
+
},
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
const containerWatcher = jest.fn()
|
|
891
|
+
const titleWatcher = jest.fn()
|
|
892
|
+
const viewsWatcher = jest.fn()
|
|
893
|
+
|
|
894
|
+
effect(()=> containerWatcher(state.nested))
|
|
895
|
+
effect(()=> titleWatcher(state.nested.title))
|
|
896
|
+
effect(()=> viewsWatcher(state.nested.meta.views))
|
|
897
|
+
|
|
898
|
+
// Replace nested with another object using the same shape/prototype
|
|
899
|
+
state.nested = {
|
|
900
|
+
title: 'Hello world',
|
|
901
|
+
meta: { views: 10 },
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// containerWatcher runs only once (initial run)
|
|
905
|
+
// Deep touch avoids parent effects when only sub-properties change
|
|
906
|
+
expect(containerWatcher).toHaveBeenCalledTimes(1)
|
|
907
|
+
|
|
908
|
+
// titleWatcher reacts to the changed title
|
|
909
|
+
expect(titleWatcher).toHaveBeenCalledTimes(2)
|
|
910
|
+
|
|
911
|
+
// viewsWatcher reacts because the nested meta object changed recursively
|
|
912
|
+
expect(viewsWatcher).toHaveBeenCalledTimes(2)
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
This behaviour keeps container-level watchers stable while still delivering fine-grained updates to nested effects—ideal when you replace data structures with freshly fetched objects that share the same prototype.
|
|
916
|
+
|
|
917
|
+
### Origin Filtering
|
|
918
|
+
|
|
919
|
+
When recursive touch is triggered (e.g., `C.something = A` replaced with `C.something = B`), the system applies **origin filtering** to ensure only effects that came through the replacement property are notified:
|
|
920
|
+
|
|
921
|
+
```typescript
|
|
922
|
+
const A = reactive({ x: 1, y: 2 })
|
|
923
|
+
const B = reactive({ x: 10, y: 20 })
|
|
924
|
+
const C = reactive({ something: A })
|
|
925
|
+
const D = reactive({ other: A })
|
|
926
|
+
|
|
927
|
+
let effect1Runs = 0
|
|
928
|
+
let effect2Runs = 0
|
|
929
|
+
|
|
930
|
+
// Effect1 depends on C.something
|
|
931
|
+
effect(() => {
|
|
932
|
+
effect1Runs++
|
|
933
|
+
const val = C.something
|
|
934
|
+
effect(() => {
|
|
935
|
+
// Nested effect accesses A.x through C.something
|
|
936
|
+
const nested = A.x
|
|
937
|
+
})
|
|
938
|
+
})
|
|
939
|
+
|
|
940
|
+
// Effect2 depends on A.x directly (not through C.something)
|
|
941
|
+
effect(() => {
|
|
942
|
+
effect2Runs++
|
|
943
|
+
const val = A.x
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
// Replace C.something
|
|
947
|
+
C.something = B
|
|
948
|
+
|
|
949
|
+
// Effect1's nested effect runs (it came through C.something)
|
|
950
|
+
// Effect2 does NOT run (it doesn't depend on C.something)
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
**Key Points:**
|
|
954
|
+
- Effects that depend only on the container property (e.g., `C.something`) do **not** run
|
|
955
|
+
- Only nested effects that accessed properties through the container are notified
|
|
956
|
+
- Independent effects on the replaced object (e.g., direct dependency on `A.x`) are filtered out
|
|
957
|
+
- This minimizes unnecessary re-computations while keeping data consistent
|
|
958
|
+
|
|
959
|
+
**Note:** This recursive touching behavior can be disabled globally by setting `reactiveOptions.recursiveTouching = false`. When disabled, all object replacements will trigger parent effects regardless of prototype matching.
|
|
960
|
+
|
|
961
|
+
### Why Not Deep Watching?
|
|
962
|
+
|
|
963
|
+
You might wonder why deep watching is implemented but not recommended for most use cases. The answer lies in understanding the tradeoffs:
|
|
964
|
+
|
|
965
|
+
#### The Deep Watching Approach
|
|
966
|
+
|
|
967
|
+
Traditional deep watching automatically tracks all nested properties:
|
|
968
|
+
|
|
969
|
+
```typescript
|
|
970
|
+
const state = reactive({ user: { profile: { name: 'John' } } })
|
|
971
|
+
|
|
972
|
+
watch(state, () => {
|
|
973
|
+
console.log('Something changed')
|
|
974
|
+
}, { deep: true })
|
|
975
|
+
|
|
976
|
+
state.user.profile.name = 'Jane' // Triggers watch
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
**Problems with deep watching:**
|
|
980
|
+
- **Performance overhead**: Every nested property access creates a dependency
|
|
981
|
+
- **Hidden dependencies**: Unclear which deep properties triggered the effect
|
|
982
|
+
- **Circular reference hazards**: Requires depth limits and visited tracking
|
|
983
|
+
- **Memory overhead**: Back-references for all nested objects
|
|
984
|
+
- **Over-notification**: Triggers when you might not care about a change
|
|
985
|
+
|
|
986
|
+
#### The Recursive Touch Approach
|
|
987
|
+
|
|
988
|
+
With recursive touching, you get fine-grained change detection without the overhead:
|
|
989
|
+
|
|
990
|
+
```typescript
|
|
991
|
+
const state = reactive({ user: { profile: { name: 'John' } } })
|
|
992
|
+
|
|
993
|
+
// Replace the entire user object with fresh data
|
|
994
|
+
state.user = fetchUser() // Only notifies if actual values changed
|
|
995
|
+
|
|
996
|
+
// Or explicitly track what you need
|
|
997
|
+
effect(() => {
|
|
998
|
+
console.log(state.user.profile.name) // Only tracks this specific path
|
|
999
|
+
})
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
**Benefits:**
|
|
1003
|
+
- **Explicit dependencies**: You see exactly what properties you depend on
|
|
1004
|
+
- **Better performance**: Only tracks properties you actually read
|
|
1005
|
+
- **Automatic deduplication**: Reading `obj.toJSON()` marks all properties as used
|
|
1006
|
+
- **No circular reference issues**: Only touches what you explicitly access
|
|
1007
|
+
- **Clearer intent**: Your code shows what data matters
|
|
1008
|
+
|
|
1009
|
+
#### When to Use Each
|
|
1010
|
+
|
|
1011
|
+
**Use recursive touching (default) when:**
|
|
1012
|
+
- Building UI frameworks (most changes come from fresh data)
|
|
1013
|
+
- Working with fetched/stale data patterns
|
|
1014
|
+
- You want explicit dependency tracking
|
|
1015
|
+
- Performance matters
|
|
1016
|
+
|
|
1017
|
+
**Use deep watching when:**
|
|
1018
|
+
- You truly need to know about ANY change anywhere
|
|
1019
|
+
- Debugging complex state changes
|
|
1020
|
+
- Legacy integration where you can't change access patterns
|
|
1021
|
+
- You explicitly don't care about which property changed
|
|
1022
|
+
|
|
1023
|
+
#### Practical Example: Saving Objects
|
|
1024
|
+
|
|
1025
|
+
Deep watching makes everything reactive:
|
|
1026
|
+
|
|
1027
|
+
```typescript
|
|
1028
|
+
// ❌ Deep watching - tracks too much
|
|
1029
|
+
const form = reactive({
|
|
1030
|
+
fields: { name: '', email: '' },
|
|
1031
|
+
meta: { lastSaved: Date.now() }
|
|
1032
|
+
})
|
|
1033
|
+
watch(form, saveToServer, { deep: true })
|
|
1034
|
+
form.meta.lastSaved = Date.now() // Unnecessary save triggered
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
Recursive touching with explicit tracking:
|
|
1038
|
+
|
|
1039
|
+
```typescript
|
|
1040
|
+
// ✅ Explicit - only saves when form fields change
|
|
1041
|
+
const form = reactive({
|
|
1042
|
+
fields: { name: '', email: '' },
|
|
1043
|
+
meta: { lastSaved: Date.now() }
|
|
1044
|
+
})
|
|
1045
|
+
effect(() => {
|
|
1046
|
+
// Read the nested properties to track them
|
|
1047
|
+
const name = form.fields.name
|
|
1048
|
+
const email = form.fields.email
|
|
1049
|
+
saveToServer({ name, email })
|
|
1050
|
+
})
|
|
1051
|
+
form.meta.lastSaved = Date.now() // No save triggered
|
|
1052
|
+
form.fields.name = 'John' // Triggers save
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
Or if you need to serialize the whole form:
|
|
1056
|
+
|
|
1057
|
+
```typescript
|
|
1058
|
+
// ✅ If you serialize, all properties are tracked automatically
|
|
1059
|
+
effect(() => {
|
|
1060
|
+
const data = JSON.stringify(form.fields) // Marks all fields as used
|
|
1061
|
+
saveToServer(data)
|
|
1062
|
+
})
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
**Bottom line:** Recursive touching gives you the granular control you need without deep watching's overhead, making it ideal for modern reactive applications.
|
|
1066
|
+
|
|
1067
|
+
## Memoization
|
|
1068
|
+
|
|
1069
|
+
### `memoize()`
|
|
1070
|
+
|
|
1071
|
+
`memoize(fn, maxArgs?)` caches the result of `fn` based on the identity of its arguments. All arguments must be WeakMap-compatible values (non-null objects, arrays, or symbols). Passing a primitive throws immediately to prevent inconsistent caching.
|
|
1072
|
+
|
|
1073
|
+
**Signature**
|
|
1074
|
+
|
|
1075
|
+
```typescript
|
|
1076
|
+
import { memoize } from 'mutts/reactive'
|
|
1077
|
+
|
|
1078
|
+
type Memoizable = object | any[] | symbol
|
|
1079
|
+
|
|
1080
|
+
function memoize<Result>(
|
|
1081
|
+
fn: (...args: Memoizable[]) => Result,
|
|
1082
|
+
maxArgs?: number
|
|
1083
|
+
): (...args: Memoizable[]) => Result
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
**Parameters**
|
|
1087
|
+
|
|
1088
|
+
- `fn`: compute function executed inside an effect. Any reactive reads it performs are tracked; when they change the cached value is discarded and recomputed on the next call.
|
|
1089
|
+
- `maxArgs` (optional): number of leading arguments to keep. Arguments beyond this index are ignored for caching **and** will not be forwarded to `fn`.
|
|
1090
|
+
|
|
1091
|
+
**Behaviour**
|
|
1092
|
+
|
|
1093
|
+
- Memoized wrappers are deduplicated per original function. Calling `memoize` multiple times with the same function returns the same memoized wrapper.
|
|
1094
|
+
- Cache entries live in nested `WeakMap`s keyed by each argument. When the last reference to a key disappears, the corresponding cache entry is eligible for garbage collection.
|
|
1095
|
+
- When dependencies touched inside `fn` change, the cache entry is removed and a `{ type: 'invalidate' }` touch is emitted.
|
|
1096
|
+
- Because caching relies on object identity, reuse parameter objects instead of recreating literals for every call.
|
|
1097
|
+
|
|
1098
|
+
**Basic usage**
|
|
1099
|
+
|
|
1100
|
+
```typescript
|
|
1101
|
+
import { effect, memoize, reactive } from 'mutts/reactive'
|
|
1102
|
+
|
|
1103
|
+
const source = reactive({ value: 1 })
|
|
1104
|
+
const args = { node: source }
|
|
1105
|
+
|
|
1106
|
+
const double = memoize(({ node }: { node: typeof source }) => node.value * 2)
|
|
1107
|
+
|
|
1108
|
+
effect(() => {
|
|
1109
|
+
console.log(double(args))
|
|
1110
|
+
})
|
|
1111
|
+
|
|
1112
|
+
source.value = 5
|
|
1113
|
+
// -> cache invalidated, effect re-runs, memoized recomputes to 10
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
**Multiple arguments**
|
|
1117
|
+
|
|
1118
|
+
```typescript
|
|
1119
|
+
const a = reactive({ value: 1 })
|
|
1120
|
+
const b = reactive({ value: 2 })
|
|
1121
|
+
|
|
1122
|
+
const sum = memoize((left: typeof a, right: typeof b) => left.value + right.value)
|
|
1123
|
+
|
|
1124
|
+
console.log(sum(a, b)) // 3
|
|
1125
|
+
b.value = 3
|
|
1126
|
+
console.log(sum(a, b)) // 4 (cache entry invalidated for the same argument pair)
|
|
1127
|
+
```
|
|
1128
|
+
|
|
1129
|
+
**Limiting argument arity with `maxArgs`**
|
|
1130
|
+
|
|
1131
|
+
```typescript
|
|
1132
|
+
const describe = memoize(
|
|
1133
|
+
(node: { id: string }, locale: { language: string }) => `${node.id}:${locale.language}`,
|
|
1134
|
+
1
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
describe({ id: 'x' } as any, { language: 'en' }) // locale is ignored, only the first argument is cached
|
|
1138
|
+
```
|
|
1139
|
+
|
|
1140
|
+
Use `maxArgs` when the memoized function should only consider the first _n_ arguments. Subsequent arguments are ignored and not forwarded to `fn`.
|
|
1141
|
+
|
|
1142
|
+
### Decorator usage
|
|
1143
|
+
|
|
1144
|
+
Apply `@memoize` to class getters or methods to share the same cache semantics. Getters cache per instance; methods cache per instance and argument tuple.
|
|
1145
|
+
|
|
1146
|
+
```typescript
|
|
1147
|
+
import { memoize, reactive } from 'mutts/reactive'
|
|
1148
|
+
|
|
1149
|
+
class Example {
|
|
1150
|
+
state = reactive({ count: 0 })
|
|
1151
|
+
|
|
1152
|
+
@memoize
|
|
1153
|
+
get doubled() {
|
|
1154
|
+
return this.state.count * 2
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
@memoize
|
|
1158
|
+
total(a: { value: number }, b: { value: number }) {
|
|
1159
|
+
return a.value + b.value + this.state.count
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
### Working with `mapped()`
|
|
1165
|
+
|
|
1166
|
+
Pair `memoize()` with [`mapped()`](#mapped) to keep derived array elements stable across reorders (see [Identity-preserving mapped arrays](#identity-preserving-mapped-arrays)). Memoizing the mapper ensures each input object is processed at most once and its derived state persists while the input reference lives.
|
|
1167
|
+
|
|
1168
|
+
## Debugging and Development
|
|
1169
|
+
|
|
1170
|
+
### Cycle Detection
|
|
1171
|
+
|
|
1172
|
+
The reactive system automatically detects circular dependencies between effects. When one effect triggers another effect that eventually triggers the first effect again, a cycle is detected.
|
|
1173
|
+
|
|
1174
|
+
#### Cycle Detection Behavior
|
|
1175
|
+
|
|
1176
|
+
By default, cycles are detected and an error is thrown. You can configure the behavior using `reactiveOptions.cycleHandling`:
|
|
1177
|
+
|
|
1178
|
+
```typescript
|
|
1179
|
+
import { reactiveOptions } from 'mutts/reactive'
|
|
1180
|
+
|
|
1181
|
+
// Options: 'throw' (default), 'warn', or 'break'
|
|
1182
|
+
reactiveOptions.cycleHandling = 'warn' // Warn instead of throwing
|
|
1183
|
+
reactiveOptions.cycleHandling = 'break' // Silently break the cycle
|
|
1184
|
+
```
|
|
1185
|
+
|
|
1186
|
+
**Cycle handling modes:**
|
|
1187
|
+
|
|
1188
|
+
- **`'throw'`** (default): Throws a `ReactiveError` with a detailed cycle path when a cycle is detected
|
|
1189
|
+
- **`'warn'`**: Logs a warning message with the cycle path but continues execution (breaks the cycle)
|
|
1190
|
+
- **`'break'`**: Silently breaks the cycle without any message
|
|
1191
|
+
- **`'strict'`**: Checks the graph BEFORE execution. Prevents infinite loops at the source (higher overhead).
|
|
1192
|
+
|
|
1193
|
+
#### Cycle Error Messages
|
|
1194
|
+
|
|
1195
|
+
When a cycle is detected, the error message includes the full cycle path showing which effects form the cycle:
|
|
1196
|
+
|
|
1197
|
+
```typescript
|
|
1198
|
+
const state = reactive({ a: 0, b: 0, c: 0 })
|
|
1199
|
+
|
|
1200
|
+
effect(() => {
|
|
1201
|
+
state.b = state.a + 1 // Effect A
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
effect(() => {
|
|
1205
|
+
state.c = state.b + 1 // Effect B
|
|
1206
|
+
})
|
|
1207
|
+
|
|
1208
|
+
// This will throw with a detailed cycle path
|
|
1209
|
+
try {
|
|
1210
|
+
effect(() => {
|
|
1211
|
+
state.a = state.c + 1 // Effect C - creates cycle: A → B → C → A
|
|
1212
|
+
})
|
|
1213
|
+
} catch (e) {
|
|
1214
|
+
console.error(e.message)
|
|
1215
|
+
// "[reactive] Cycle detected: effectA → effectB → effectC → effectA"
|
|
1216
|
+
}
|
|
1217
|
+
```
|
|
1218
|
+
|
|
1219
|
+
The cycle path shows the sequence of effects that form the circular dependency, making it easier to identify and fix the issue.
|
|
1220
|
+
|
|
1221
|
+
#### Common Cycle Patterns
|
|
1222
|
+
|
|
1223
|
+
**Direct cycle:**
|
|
1224
|
+
```typescript
|
|
1225
|
+
const state = reactive({ a: 0, b: 0 })
|
|
1226
|
+
|
|
1227
|
+
effect(() => {
|
|
1228
|
+
state.b = state.a + 1 // Effect A
|
|
1229
|
+
})
|
|
1230
|
+
|
|
1231
|
+
effect(() => {
|
|
1232
|
+
state.a = state.b + 1 // Effect B - creates cycle: A → B → A
|
|
1233
|
+
})
|
|
1234
|
+
```
|
|
1235
|
+
|
|
1236
|
+
**Indirect cycle:**
|
|
1237
|
+
```typescript
|
|
1238
|
+
const state = reactive({ a: 0, b: 0, c: 0 })
|
|
1239
|
+
|
|
1240
|
+
effect(() => {
|
|
1241
|
+
state.b = state.a + 1 // Effect A
|
|
1242
|
+
})
|
|
1243
|
+
|
|
1244
|
+
effect(() => {
|
|
1245
|
+
state.c = state.b + 1 // Effect B
|
|
1246
|
+
})
|
|
1247
|
+
|
|
1248
|
+
effect(() => {
|
|
1249
|
+
state.a = state.c + 1 // Effect C - creates cycle: A → B → C → A
|
|
1250
|
+
})
|
|
1251
|
+
```
|
|
1252
|
+
|
|
1253
|
+
#### Preventing Cycles
|
|
1254
|
+
|
|
1255
|
+
To avoid cycles, consider:
|
|
1256
|
+
|
|
1257
|
+
1. **Separate read and write effects**: Don't have an effect that both reads and writes the same reactive properties
|
|
1258
|
+
2. **Use `untracked()`**: For operations that shouldn't create dependencies
|
|
1259
|
+
3. **Use `atomic()`**: To batch operations and prevent intermediate triggers
|
|
1260
|
+
4. **Restructure logic**: Break circular dependencies by introducing intermediate state or computed values
|
|
1261
|
+
|
|
1262
|
+
**Example - Using `untracked()` to break cycles:**
|
|
1263
|
+
```typescript
|
|
1264
|
+
const state = reactive({ count: 0 })
|
|
1265
|
+
|
|
1266
|
+
effect(() => {
|
|
1267
|
+
// Read count
|
|
1268
|
+
const current = state.count
|
|
1269
|
+
|
|
1270
|
+
// Write to count without creating a dependency cycle
|
|
1271
|
+
untracked(() => {
|
|
1272
|
+
if (current < 10) {
|
|
1273
|
+
state.count = current + 1
|
|
1274
|
+
}
|
|
1275
|
+
})
|
|
1276
|
+
})
|
|
1277
|
+
```
|
|
1278
|
+
|
|
1279
|
+
**Note:** Self-loops (an effect reading and writing the same property, like `obj.prop++`) are automatically ignored and do not create dependency relationships or cycles.
|
|
1280
|
+
|