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,767 @@
|
|
|
1
|
+
## Collections
|
|
2
|
+
|
|
3
|
+
### `ReactiveMap`
|
|
4
|
+
|
|
5
|
+
A reactive wrapper around JavaScript's `Map` class.
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
const map = reactive(new Map([['key1', 'value1']]))
|
|
9
|
+
|
|
10
|
+
effect(() => {
|
|
11
|
+
console.log('Map size:', map.size)
|
|
12
|
+
console.log('Has key1:', map.has('key1'))
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
map.set('key2', 'value2') // Triggers effect
|
|
16
|
+
map.delete('key1') // Triggers effect
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**Features:**
|
|
20
|
+
- Tracks `size` changes
|
|
21
|
+
- Tracks individual key operations
|
|
22
|
+
- Tracks collection-wide operations via `allProps`
|
|
23
|
+
|
|
24
|
+
### `ReactiveWeakMap`
|
|
25
|
+
|
|
26
|
+
A reactive wrapper around JavaScript's `WeakMap` class.
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
const weakMap = reactive(new WeakMap())
|
|
30
|
+
const key = { id: 1 }
|
|
31
|
+
|
|
32
|
+
effect(() => {
|
|
33
|
+
console.log('Has key:', weakMap.has(key))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
weakMap.set(key, 'value') // Triggers effect
|
|
37
|
+
weakMap.delete(key) // Triggers effect
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Features:**
|
|
41
|
+
- Only tracks individual key operations
|
|
42
|
+
- No `size` tracking (WeakMap limitation)
|
|
43
|
+
- No collection-wide operations
|
|
44
|
+
|
|
45
|
+
### `ReactiveSet`
|
|
46
|
+
|
|
47
|
+
A reactive wrapper around JavaScript's `Set` class.
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
const set = reactive(new Set([1, 2, 3]))
|
|
51
|
+
|
|
52
|
+
effect(() => {
|
|
53
|
+
console.log('Set size:', set.size)
|
|
54
|
+
console.log('Has 1:', set.has(1))
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
set.add(4) // Triggers effect
|
|
58
|
+
set.delete(1) // Triggers effect
|
|
59
|
+
set.clear() // Triggers effect
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Features:**
|
|
63
|
+
- Tracks `size` changes
|
|
64
|
+
- Tracks individual value operations
|
|
65
|
+
- Tracks collection-wide operations
|
|
66
|
+
|
|
67
|
+
### `ReactiveWeakSet`
|
|
68
|
+
|
|
69
|
+
A reactive wrapper around JavaScript's `WeakSet` class.
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
const weakSet = reactive(new WeakSet())
|
|
73
|
+
const obj = { id: 1 }
|
|
74
|
+
|
|
75
|
+
effect(() => {
|
|
76
|
+
console.log('Has obj:', weakSet.has(obj))
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
weakSet.add(obj) // Triggers effect
|
|
80
|
+
weakSet.delete(obj) // Triggers effect
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Collection-Specific Reactivity
|
|
84
|
+
|
|
85
|
+
Collections provide different levels of reactivity:
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
const map = reactive(new Map())
|
|
89
|
+
|
|
90
|
+
// Size tracking
|
|
91
|
+
effect(() => {
|
|
92
|
+
console.log('Map size:', map.size)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// Individual key tracking
|
|
96
|
+
effect(() => {
|
|
97
|
+
console.log('Value for key1:', map.get('key1'))
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// Collection-wide tracking
|
|
101
|
+
effect(() => {
|
|
102
|
+
for (const [key, value] of map) {
|
|
103
|
+
// This effect depends on allProps
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Operations trigger different effects
|
|
108
|
+
map.set('key1', 'value1') // Triggers size and key1 effects
|
|
109
|
+
map.set('key2', 'value2') // Triggers size and allProps effects
|
|
110
|
+
map.delete('key1') // Triggers size, key1, and allProps effects
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### `ReactiveArray`
|
|
114
|
+
|
|
115
|
+
A reactive wrapper around JavaScript's `Array` class with full array method support.
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
const array = reactive([1, 2, 3])
|
|
119
|
+
|
|
120
|
+
effect(() => {
|
|
121
|
+
console.log('Array length:', array.length)
|
|
122
|
+
console.log('First element:', array[0])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
array.push(4) // Triggers effect
|
|
126
|
+
array[0] = 10 // Triggers effect
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Features:**
|
|
130
|
+
- Tracks `length` changes
|
|
131
|
+
- Tracks individual index operations
|
|
132
|
+
- Tracks collection-wide operations via `allProps`
|
|
133
|
+
- Supports all array methods with proper reactivity
|
|
134
|
+
|
|
135
|
+
### Array Methods
|
|
136
|
+
|
|
137
|
+
All standard array methods are supported with reactivity:
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
const array = reactive([1, 2, 3])
|
|
141
|
+
|
|
142
|
+
// Mutator methods
|
|
143
|
+
array.push(4) // Triggers length and allProps effects
|
|
144
|
+
array.pop() // Triggers length and allProps effects
|
|
145
|
+
array.shift() // Triggers length and allProps effects
|
|
146
|
+
array.unshift(0) // Triggers length and allProps effects
|
|
147
|
+
array.splice(1, 1, 10) // Triggers length and allProps effects
|
|
148
|
+
array.reverse() // Triggers allProps effects
|
|
149
|
+
array.sort() // Triggers allProps effects
|
|
150
|
+
array.fill(0) // Triggers allProps effects
|
|
151
|
+
array.copyWithin(0, 2) // Triggers allProps effects
|
|
152
|
+
|
|
153
|
+
// Accessor methods (immutable)
|
|
154
|
+
const reversed = array.toReversed()
|
|
155
|
+
const sorted = array.toSorted()
|
|
156
|
+
const spliced = array.toSpliced(1, 1)
|
|
157
|
+
const withNew = array.with(0, 100)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Index Access
|
|
161
|
+
|
|
162
|
+
ReactiveArray supports both positive and negative index access:
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
const array = reactive([1, 2, 3, 4, 5])
|
|
166
|
+
|
|
167
|
+
effect(() => {
|
|
168
|
+
console.log('First element:', array[0])
|
|
169
|
+
console.log('Last element:', array.at(-1))
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
array[0] = 10 // Triggers effect
|
|
173
|
+
array[4] = 50 // Triggers effect
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Length Reactivity
|
|
177
|
+
|
|
178
|
+
The `length` property is fully reactive:
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
const array = reactive([1, 2, 3])
|
|
182
|
+
|
|
183
|
+
effect(() => {
|
|
184
|
+
console.log('Array length:', array.length)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
array.push(4) // Triggers effect
|
|
188
|
+
array.length = 2 // Triggers effect
|
|
189
|
+
array[5] = 10 // Triggers effect (expands array)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Array Evolution Tracking
|
|
193
|
+
|
|
194
|
+
Array operations generate specific evolution events:
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
const array = reactive([1, 2, 3])
|
|
198
|
+
let state = getState(array)
|
|
199
|
+
|
|
200
|
+
effect(() => {
|
|
201
|
+
while ('evolution' in state) {
|
|
202
|
+
console.log('Array change:', state.evolution)
|
|
203
|
+
state = state.next
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
array.push(4) // { type: 'bunch', method: 'push' }
|
|
208
|
+
array[0] = 10 // { type: 'set', prop: 0 }
|
|
209
|
+
array[5] = 20 // { type: 'add', prop: 5 }
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Array-Specific Reactivity Patterns
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
const array = reactive([1, 2, 3])
|
|
216
|
+
|
|
217
|
+
// Track specific indices
|
|
218
|
+
effect(() => {
|
|
219
|
+
console.log('First two elements:', array[0], array[1])
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// Track length changes
|
|
223
|
+
effect(() => {
|
|
224
|
+
console.log('Array size changed to:', array.length)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// Track all elements (via iteration)
|
|
228
|
+
effect(() => {
|
|
229
|
+
for (const item of array) {
|
|
230
|
+
// This effect depends on allProps
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// Track specific array methods
|
|
235
|
+
effect(() => {
|
|
236
|
+
const lastElement = array.at(-1)
|
|
237
|
+
console.log('Last element:', lastElement)
|
|
238
|
+
})
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Performance Considerations
|
|
242
|
+
|
|
243
|
+
ReactiveArray is optimized for common array operations:
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
// Efficient: Direct index access
|
|
247
|
+
effect(() => {
|
|
248
|
+
console.log(array[0]) // Only tracks index 0
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// Efficient: Length tracking
|
|
252
|
+
effect(() => {
|
|
253
|
+
console.log(array.length) // Only tracks length
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
// Less efficient: Iteration tracks all elements
|
|
257
|
+
effect(() => {
|
|
258
|
+
array.forEach(item => console.log(item)) // Tracks allProps
|
|
259
|
+
})
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### `Register`
|
|
263
|
+
|
|
264
|
+
`Register` is an ordered, array-like collection that keeps a stable mapping between keys and values. It is useful when you need array semantics (indexable access, ordering, iteration) but also require identity preservation by key—ideal for UI lists keyed by IDs or when you want to memoise entries across reorders.
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
import { Register } from 'mutts/reactive'
|
|
268
|
+
|
|
269
|
+
// Create a register where the key comes from the `id` field
|
|
270
|
+
const list = new Register(({id}: { id: number }) => id, [
|
|
271
|
+
{ id: 1, label: 'Alpha' },
|
|
272
|
+
{ id: 2, label: 'Bravo' },
|
|
273
|
+
])
|
|
274
|
+
|
|
275
|
+
effect(() => {
|
|
276
|
+
console.log('Length:', list.length)
|
|
277
|
+
console.log('First label:', list[0]?.label)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// Push uses the key function to keep identities stable
|
|
281
|
+
list.push({ id: 3, label: 'Charlie' })
|
|
282
|
+
|
|
283
|
+
// Replacing with the same key updates watchers without creating a new identity
|
|
284
|
+
list[0] = { id: 1, label: 'Alpha (updated)' }
|
|
285
|
+
|
|
286
|
+
// Access by key
|
|
287
|
+
const second = list.get(2) // { id: 2, label: 'Bravo' }
|
|
288
|
+
|
|
289
|
+
// Duplicate keys share value identity
|
|
290
|
+
list.push({ id: 2, label: 'Bravo (new data)' })
|
|
291
|
+
console.log(list[1] === list[2]) // true
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
**Highlights:**
|
|
295
|
+
|
|
296
|
+
- Fully indexable (`list[0]`, `list.at(-1)`, `list.length`, iteration, etc.) thanks to the shared `Indexable` infrastructure.
|
|
297
|
+
- Complete array surface forwarding (`map`, `filter`, `reduce`, `concat`, `reverse`, `sort`, `fill`, `copyWithin`, and more) with reactivity preserved.
|
|
298
|
+
- Stable key/value map under the hood allows quick lookups via `get()`, `hasKey()`, and `indexOfKey()`.
|
|
299
|
+
- When the same key appears multiple times, all slots reference the same underlying value instance, making deduplication and memoisation straightforward.
|
|
300
|
+
- Reordering operations emit index-level touches so list reactivity remains predictable in rendered UIs.
|
|
301
|
+
|
|
302
|
+
### Register-specific API (beyond Array)
|
|
303
|
+
|
|
304
|
+
The `Register` exposes additional methods and behaviors that standard arrays do not have:
|
|
305
|
+
|
|
306
|
+
- `get(key)` / `set(key, value)`
|
|
307
|
+
- `get(key: K): T | undefined` returns the latest value for a key.
|
|
308
|
+
- `set(key: K, value: T): void` updates the value for an existing key (no-op if key absent).
|
|
309
|
+
- Example:
|
|
310
|
+
```typescript
|
|
311
|
+
list.set(2, { id: 2, label: 'Bravo (updated)' })
|
|
312
|
+
const v = list.get(2)
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
- `hasKey(key)` / `indexOfKey(key)`
|
|
316
|
+
- `hasKey(key: K): boolean` whether the key is present in any slot.
|
|
317
|
+
- `indexOfKey(key: K): number` first index at which the key appears, or `-1`.
|
|
318
|
+
|
|
319
|
+
- `remove(key)` / `removeAt(index)`
|
|
320
|
+
- `remove(key: K): void` removes all occurrences of `key` from the register.
|
|
321
|
+
- `removeAt(index: number): T | undefined` removes a single slot by index and returns its value.
|
|
322
|
+
|
|
323
|
+
- `keep(predicate)`
|
|
324
|
+
- `keep(predicate: (value: T) => boolean): void` keeps only items for which the predicate returns `true`; items for which it returns `false` are removed. The predicate is evaluated once per distinct key; duplicate keys follow the same decision.
|
|
325
|
+
|
|
326
|
+
- `update(...values)`
|
|
327
|
+
- `update(...values: T[]): void` updates existing entries by their key; ignores values whose key is not yet present.
|
|
328
|
+
|
|
329
|
+
- `upsert(insert, ...values)`
|
|
330
|
+
- `upsert(insert: (value: T) => void, ...values: T[]): void` updates by key when present, otherwise calls `insert(value)` so you can decide how to insert (e.g. `push`, `unshift`, or `splice`).
|
|
331
|
+
- Example:
|
|
332
|
+
```typescript
|
|
333
|
+
list.upsert(v => list.push(v), { id: 4, label: 'Delta' }, { id: 2, label: 'Bravo (again)' })
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
- `entries()`
|
|
337
|
+
- Iterates `[number, value]` pairs in index order: `IterableIterator<[number, T | undefined]>`.
|
|
338
|
+
|
|
339
|
+
- `keys` / `values`
|
|
340
|
+
- `keys: ArrayIterator<number>` provides the index iterator (mirrors `Array#keys()`).
|
|
341
|
+
- `values: IterableIterator<T>` provides an iterator of values (same as default iteration).
|
|
342
|
+
|
|
343
|
+
- `clear()`
|
|
344
|
+
- Removes all entries and disposes internal key-tracking effects.
|
|
345
|
+
|
|
346
|
+
- `toArray()` / `toString()`
|
|
347
|
+
- `toArray(): T[]` materializes the current values into a plain array.
|
|
348
|
+
- `toString(): string` returns a concise description like `[Register length=3]`.
|
|
349
|
+
|
|
350
|
+
Notes:
|
|
351
|
+
- Direct length modification via `list.length = n` is not supported; use `splice` instead.
|
|
352
|
+
- Assigning to an index (`list[i] = value`) uses the key function to bind that slot to `value`’s key.
|
|
353
|
+
|
|
354
|
+
## Class Reactivity
|
|
355
|
+
## Array Mapping
|
|
356
|
+
|
|
357
|
+
### `mapped()`
|
|
358
|
+
|
|
359
|
+
Creates a reactive array by mapping over an input array. The mapper receives the current item value, its index, and the previous mapped value for that index.
|
|
360
|
+
|
|
361
|
+
```typescript
|
|
362
|
+
import { mapped, reactive } from 'mutts/reactive'
|
|
363
|
+
|
|
364
|
+
const input = reactive([1, 2, 3])
|
|
365
|
+
const doubles = mapped(input, (value, index, oldValue) => value * 2)
|
|
366
|
+
|
|
367
|
+
console.log(doubles) // [2, 4, 6]
|
|
368
|
+
|
|
369
|
+
// When input changes, the mapped output updates in place
|
|
370
|
+
input.push(4)
|
|
371
|
+
console.log(doubles) // [2, 4, 6, 8]
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
**Mapper signature:**
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
(value: T, index: number, oldValue?: U) => U
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
- **value**: current element from the input array
|
|
381
|
+
- **index**: current element index
|
|
382
|
+
- **oldValue**: previously computed value at the same index (useful for incremental updates)
|
|
383
|
+
|
|
384
|
+
**Key features:**
|
|
385
|
+
|
|
386
|
+
- **Live reactivity**: Output array updates when the input array changes (push/pop/splice/assignments).
|
|
387
|
+
- **Granular recompute**: Only indices that change are recomputed; `oldValue` enables incremental updates.
|
|
388
|
+
- **Simple contract**: Mapper works directly with `(value, index, oldValue)` and can freely return reactive objects.
|
|
389
|
+
|
|
390
|
+
**Performance characteristics:**
|
|
391
|
+
|
|
392
|
+
```typescript
|
|
393
|
+
const users = reactive([
|
|
394
|
+
{ name: 'John', age: 30 },
|
|
395
|
+
{ name: 'Jane', age: 25 }
|
|
396
|
+
])
|
|
397
|
+
|
|
398
|
+
let computeCount = 0
|
|
399
|
+
const processedUsers = mapped(users, (user) => {
|
|
400
|
+
computeCount++
|
|
401
|
+
return `${user.name} (${user.age})`
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
console.log(computeCount) // 2 (initial computation)
|
|
405
|
+
|
|
406
|
+
// Modify one user - only that index recomputes
|
|
407
|
+
users[0].age = 31
|
|
408
|
+
console.log(processedUsers[0]) // "John (31)"
|
|
409
|
+
console.log(computeCount) // 3
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
**Advanced usage:**
|
|
413
|
+
|
|
414
|
+
```typescript
|
|
415
|
+
const orders = reactive([
|
|
416
|
+
{ items: [{ price: 10 }, { price: 20 }] },
|
|
417
|
+
{ items: [{ price: 15 }] }
|
|
418
|
+
])
|
|
419
|
+
|
|
420
|
+
const orderTotals = mapped(orders, (order) => (
|
|
421
|
+
order.items.reduce((sum, item) => sum + item.price, 0)
|
|
422
|
+
))
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
### Identity-preserving mapped arrays
|
|
426
|
+
|
|
427
|
+
Combine `mapped()` with [`memoize()`](#memoize) when you need to reuse mapped results for the same input identity. The memoized mapper runs at most once per input object, even when the source array is reordered, and it can host additional reactive state that should survive reordering.
|
|
428
|
+
|
|
429
|
+
```typescript
|
|
430
|
+
import { effect, mapped, memoize, reactive } from 'mutts/reactive'
|
|
431
|
+
|
|
432
|
+
const inputs = reactive([{ name: 'John' }, { name: 'Jane' }])
|
|
433
|
+
|
|
434
|
+
const memoizedCard = memoize((user: { name: string }) => {
|
|
435
|
+
const view: { name?: string; setName(next: string): void } = {
|
|
436
|
+
setName(next) {
|
|
437
|
+
user.name = next
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
effect(() => {
|
|
442
|
+
view.name = user.name.toUpperCase()
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
return view
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
const cards = mapped(inputs, (user) => memoizedCard(user))
|
|
449
|
+
|
|
450
|
+
cards[0].setName('Johnny')
|
|
451
|
+
console.log(cards[0].name) // 'JOHNNY'
|
|
452
|
+
|
|
453
|
+
// Reorder: cached output follows the original object
|
|
454
|
+
const first = inputs.shift()!
|
|
455
|
+
inputs.push(first)
|
|
456
|
+
console.log(cards[1].name) // still 'JOHNNY'
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
Use this pattern when:
|
|
460
|
+
|
|
461
|
+
- You are mapping an array of reactive objects and want to keep derived objects stable across reorders.
|
|
462
|
+
- The mapper returns objects with internal state or nested effects that should survive reordering.
|
|
463
|
+
- You prefer to share memoized helpers across multiple mapped arrays.
|
|
464
|
+
|
|
465
|
+
## Projection
|
|
466
|
+
|
|
467
|
+
### `project()`
|
|
468
|
+
|
|
469
|
+
`project()` provides a unified API for transforming reactive collections (arrays, records, and maps) into new reactive collections. Each source entry gets its own reactive effect that recomputes only when that specific entry changes, enabling granular updates perfect for rendering pipelines.
|
|
470
|
+
|
|
471
|
+
**Note:** `project()` is the modern replacement for `mapped()`. It offers the same per-entry reactivity benefits but works across all collection types with a consistent API.
|
|
472
|
+
|
|
473
|
+
#### Basic Usage
|
|
474
|
+
|
|
475
|
+
```typescript
|
|
476
|
+
import { cleanup, project, reactive } from 'mutts/reactive'
|
|
477
|
+
|
|
478
|
+
// Arrays
|
|
479
|
+
const users = reactive([{ name: 'John', age: 30 }, { name: 'Jane', age: 25 }])
|
|
480
|
+
const names = project.array(users, ({ get }) => get()?.name.toUpperCase() ?? '')
|
|
481
|
+
|
|
482
|
+
console.log(names) // ['JOHN', 'JANE']
|
|
483
|
+
|
|
484
|
+
users[0].name = 'Johnny'
|
|
485
|
+
console.log(names[0]) // 'JOHNNY' - only index 0 recomputed
|
|
486
|
+
|
|
487
|
+
// Records
|
|
488
|
+
const scores = reactive({ math: 90, science: 85 })
|
|
489
|
+
const grades = project.record(scores, ({ get }) => {
|
|
490
|
+
const score = get()
|
|
491
|
+
return score >= 90 ? 'A' : score >= 80 ? 'B' : 'C'
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
console.log(grades.math) // 'A'
|
|
495
|
+
scores.math = 88
|
|
496
|
+
console.log(grades.math) // 'B' - only math key recomputed
|
|
497
|
+
|
|
498
|
+
// Maps
|
|
499
|
+
const inventory = reactive(new Map([
|
|
500
|
+
['apples', { count: 10 }],
|
|
501
|
+
['oranges', { count: 5 }]
|
|
502
|
+
]))
|
|
503
|
+
const totals = project.map(inventory, ({ get }) => get()?.count ?? 0)
|
|
504
|
+
|
|
505
|
+
console.log(totals.get('apples')) // 10
|
|
506
|
+
inventory.get('apples')!.count = 15
|
|
507
|
+
console.log(totals.get('apples')) // 15 - only 'apples' key recomputed
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
#### Automatic Type Selection
|
|
511
|
+
|
|
512
|
+
You can use `project()` directly and it will automatically select the appropriate helper based on the source type:
|
|
513
|
+
|
|
514
|
+
```typescript
|
|
515
|
+
// Automatically uses project.array
|
|
516
|
+
const doubled = project([1, 2, 3], ({ get }) => get() * 2)
|
|
517
|
+
|
|
518
|
+
// Automatically uses project.record
|
|
519
|
+
const upper = project({ a: 'hello', b: 'world' }, ({ get }) => get()?.toUpperCase() ?? '')
|
|
520
|
+
|
|
521
|
+
// Automatically uses project.map
|
|
522
|
+
const counts = project(new Map([['x', 1], ['y', 2]]), ({ get }) => get() * 2)
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
#### Access Object
|
|
526
|
+
|
|
527
|
+
The callback receives a `ProjectAccess` object with:
|
|
528
|
+
|
|
529
|
+
- **`get()`**: Function that returns the current source value for this key/index
|
|
530
|
+
- **`set(value)`**: Function to update the source value (if the source is mutable)
|
|
531
|
+
- **`key`**: The current key or index
|
|
532
|
+
- **`source`**: Reference to the original source collection
|
|
533
|
+
- **`old`**: Previously computed result for this entry (undefined on first run)
|
|
534
|
+
- **`value`**: Computed property that mirrors `get()` (for convenience)
|
|
535
|
+
|
|
536
|
+
```typescript
|
|
537
|
+
const transformed = project.array(items, (access) => {
|
|
538
|
+
// Access the source value
|
|
539
|
+
const item = access.get()
|
|
540
|
+
|
|
541
|
+
// Access the key/index
|
|
542
|
+
console.log(`Processing index ${access.key}`)
|
|
543
|
+
|
|
544
|
+
// Leverage previous result
|
|
545
|
+
console.log(`Previous result: ${access.old}`)
|
|
546
|
+
|
|
547
|
+
// Transform and return
|
|
548
|
+
return item.value * 2
|
|
549
|
+
})
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
#### Per-Entry Reactivity
|
|
553
|
+
|
|
554
|
+
Each entry in the source collection gets its own reactive effect. When only one entry changes, only that entry's projection recomputes:
|
|
555
|
+
|
|
556
|
+
```typescript
|
|
557
|
+
const users = reactive([
|
|
558
|
+
{ id: 1, name: 'John', score: 100 },
|
|
559
|
+
{ id: 2, name: 'Jane', score: 200 },
|
|
560
|
+
{ id: 3, name: 'Bob', score: 150 }
|
|
561
|
+
])
|
|
562
|
+
|
|
563
|
+
let computeCount = 0
|
|
564
|
+
const summaries = project.array(users, ({ get }) => {
|
|
565
|
+
computeCount++
|
|
566
|
+
const user = get()
|
|
567
|
+
return `${user.name}: ${user.score}`
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
console.log(computeCount) // 3 (initial computation)
|
|
571
|
+
|
|
572
|
+
// Modify only the first user
|
|
573
|
+
users[0].score = 150
|
|
574
|
+
console.log(summaries[0]) // 'John: 150'
|
|
575
|
+
console.log(computeCount) // 4 (only index 0 recomputed)
|
|
576
|
+
|
|
577
|
+
// Add a new user
|
|
578
|
+
users.push({ id: 4, name: 'Alice', score: 175 })
|
|
579
|
+
console.log(computeCount) // 5 (only new index 3 computed)
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
#### Key Addition and Removal
|
|
583
|
+
|
|
584
|
+
`project()` automatically handles keys being added or removed from the source:
|
|
585
|
+
|
|
586
|
+
```typescript
|
|
587
|
+
const source = reactive({ a: 1, b: 2 })
|
|
588
|
+
const doubled = project.record(source, ({ get }) => get() * 2)
|
|
589
|
+
|
|
590
|
+
console.log(doubled.a) // 2
|
|
591
|
+
console.log(doubled.b) // 4
|
|
592
|
+
|
|
593
|
+
// Add new key
|
|
594
|
+
source.c = 3
|
|
595
|
+
console.log(doubled.c) // 6 (automatically computed)
|
|
596
|
+
|
|
597
|
+
// Remove key
|
|
598
|
+
delete source.a
|
|
599
|
+
console.log('a' in doubled) // false (automatically removed)
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
#### Cleanup
|
|
603
|
+
|
|
604
|
+
The returned object includes a `cleanup` symbol that stops all reactive effects:
|
|
605
|
+
|
|
606
|
+
```typescript
|
|
607
|
+
const result = project.array(items, ({ get }) => get() * 2)
|
|
608
|
+
|
|
609
|
+
// Later, when done
|
|
610
|
+
result[cleanup]() // Stops all effects and cleans up
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
#### Use Cases
|
|
614
|
+
|
|
615
|
+
- **Rendering Lists**: Transform data models into view models for JSX/HTML rendering, with only changed items recomputing
|
|
616
|
+
- **Derived Collections**: Create computed views of source data that stay in sync
|
|
617
|
+
- **Data Transformation**: Convert between collection types while maintaining reactivity
|
|
618
|
+
- **Performance Optimization**: Avoid full recomputation when only a few entries change
|
|
619
|
+
|
|
620
|
+
#### Comparison with `mapped()`
|
|
621
|
+
|
|
622
|
+
`project()` is designed to eventually replace `mapped()`. Key differences:
|
|
623
|
+
|
|
624
|
+
- **Unified API**: Works with arrays, records, and maps (vs. `mapped()` only for arrays)
|
|
625
|
+
- **Access Pattern**: Uses an access object with `get()`/`set()` instead of direct value/index parameters
|
|
626
|
+
- **Automatic Target Creation**: Creates its own reactive target container (no need to provide a base target)
|
|
627
|
+
- **Consistent Behavior**: Same per-entry reactivity model across all collection types
|
|
628
|
+
|
|
629
|
+
For new code, prefer `project()` over `mapped()`. Existing `mapped()` code will continue to work, but consider migrating for better consistency and future features.
|
|
630
|
+
|
|
631
|
+
## Record Organization
|
|
632
|
+
|
|
633
|
+
### `organized()`
|
|
634
|
+
|
|
635
|
+
`organized()` is the record companion to [`mapped()`](#mapped). Instead of iterating over numeric indices, it reacts to property additions, updates, and deletions on any `Record<PropertyKey, T>` (plain objects, dictionaries, even reactive proxies) and lets you build **whatever target structure you need**—a new record, nested buckets, a `Map`, or a more elaborate object with metadata.
|
|
636
|
+
|
|
637
|
+
```typescript
|
|
638
|
+
import { cleanup, organized, reactive } from 'mutts/reactive'
|
|
639
|
+
|
|
640
|
+
const source = reactive<Record<string, number>>({ apples: 1, oranges: 2 })
|
|
641
|
+
|
|
642
|
+
const doubled = organized(source, (access, target) => {
|
|
643
|
+
target[access.key] = access.get() * 2
|
|
644
|
+
return () => delete target[access.key] // optional cleanup per key
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
console.log(doubled.apples) // 2
|
|
648
|
+
|
|
649
|
+
source.oranges = 5
|
|
650
|
+
console.log(doubled.oranges) // 10
|
|
651
|
+
|
|
652
|
+
delete source.apples
|
|
653
|
+
doubled[cleanup]() // run all remaining key cleanups (here deletes oranges)
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
#### Signature
|
|
657
|
+
|
|
658
|
+
```typescript
|
|
659
|
+
function organized<
|
|
660
|
+
Source extends Record<PropertyKey, any>,
|
|
661
|
+
Target extends object = Record<PropertyKey, any>
|
|
662
|
+
>(
|
|
663
|
+
source: Source,
|
|
664
|
+
apply: (access: OrganizedAccess<Source, keyof Source>, target: Target) => ScopedCallback | void,
|
|
665
|
+
baseTarget?: Target
|
|
666
|
+
): Target & { [cleanup]: ScopedCallback }
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
- **source**: Any object with consistent value type. If it is not already reactive, `organized()` wraps it transparently.
|
|
670
|
+
- **apply**: Called once per own property. It receives the *stable* `target` object plus an accessor describing the property:
|
|
671
|
+
- `access.key` → the original property key
|
|
672
|
+
- `access.get()` / `access.set(value)` → always respect source getters/setters
|
|
673
|
+
- `access.value` → convenience getter/setter backed by the same logic
|
|
674
|
+
Return a cleanup to dispose per-key resources (event handlers, nested effects, bucket entries, …).
|
|
675
|
+
- **baseTarget** *(optional)*: Provide an initial object (e.g. `{ buckets: {}, cleanups: new Map() }`). It becomes reactive and is returned.
|
|
676
|
+
- **return value**: The same `target`, augmented with the `[cleanup]` symbol. Call `target[cleanup]()` to stop all per-key effects and run the stored cleanups.
|
|
677
|
+
|
|
678
|
+
Under the hood there is:
|
|
679
|
+
|
|
680
|
+
- One effect watching `Reflect.ownKeys(source)` (similar to how `mapped()` tracks `length`)
|
|
681
|
+
- A child effect per key that re-runs whenever that key’s value changes, automatically reusing and replacing the cleanup you returned.
|
|
682
|
+
- Automatic disposal when keys disappear or when `target[cleanup]()` is invoked.
|
|
683
|
+
|
|
684
|
+
#### Re-creating `mapped`-style records
|
|
685
|
+
|
|
686
|
+
```typescript
|
|
687
|
+
const metrics = reactive({ success: 3, errors: 1 })
|
|
688
|
+
|
|
689
|
+
const readable = organized(metrics, (access, target) => {
|
|
690
|
+
target[access.key] = `${String(access.key)}: ${access.get()}`
|
|
691
|
+
return () => delete target[access.key]
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
console.log(readable.success) // "success: 3"
|
|
695
|
+
metrics.errors = 4
|
|
696
|
+
console.log(readable.errors) // "errors: 4"
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
#### Partitioning into buckets
|
|
700
|
+
|
|
701
|
+
`organized()` also covers the “partition” helper use case: classify properties into groups while keeping leftovers around.
|
|
702
|
+
|
|
703
|
+
```typescript
|
|
704
|
+
const props = reactive({
|
|
705
|
+
'if:visible': true,
|
|
706
|
+
'onClick': () => console.log('click'),
|
|
707
|
+
'class:warning': true,
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
type Buckets = {
|
|
711
|
+
events: Record<string, Function>
|
|
712
|
+
classes: Record<string, boolean>
|
|
713
|
+
plain: Record<string, unknown>
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const buckets = organized(
|
|
717
|
+
props,
|
|
718
|
+
(access, target) => {
|
|
719
|
+
const match = String(access.key).match(/^([^:]+):(.+)$/)
|
|
720
|
+
if (!match) {
|
|
721
|
+
target.plain[String(access.key)] = access.get()
|
|
722
|
+
return () => delete target.plain[String(access.key)]
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const [, group, name] = match
|
|
726
|
+
if (group === 'if') {
|
|
727
|
+
target.plain[name] = access.get()
|
|
728
|
+
return () => delete target.plain[name]
|
|
729
|
+
}
|
|
730
|
+
if (group === 'class') {
|
|
731
|
+
target.classes[name] = Boolean(access.get())
|
|
732
|
+
return () => delete target.classes[name]
|
|
733
|
+
}
|
|
734
|
+
if (group === 'on') {
|
|
735
|
+
target.events[name] = access.get() as Function
|
|
736
|
+
return () => delete target.events[name]
|
|
737
|
+
}
|
|
738
|
+
},
|
|
739
|
+
{ events: {}, classes: {}, plain: {} } satisfies Buckets
|
|
740
|
+
)
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
Every cleanup removes the entry it created, keeping each bucket in sync with the current source props. This is the same pattern you can use to build a `partitioned()` helper or to manage “mounted” cleanup callbacks keyed by property.
|
|
744
|
+
|
|
745
|
+
#### Feeding other data structures
|
|
746
|
+
|
|
747
|
+
Because the target can be anything, you can build `Map`s, arrays of keys, or richer objects:
|
|
748
|
+
|
|
749
|
+
```typescript
|
|
750
|
+
const registry = organized(
|
|
751
|
+
reactive({ foo: 1 }),
|
|
752
|
+
(access, target) => {
|
|
753
|
+
target.entries.set(access.key, access.get())
|
|
754
|
+
target.allKeys.add(access.key)
|
|
755
|
+
return () => {
|
|
756
|
+
target.entries.delete(access.key)
|
|
757
|
+
target.allKeys.delete(access.key)
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
{ entries: new Map<PropertyKey, number>(), allKeys: new Set<PropertyKey>() }
|
|
761
|
+
)
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
This flexibility makes `organized()` a good base for higher-level utilities such as `mappedKeys`, `partitioned`, or “group by” helpers: implement the logic once inside `apply`, export the tailored function, and reuse the same underlying reactive infrastructure.
|
|
765
|
+
|
|
766
|
+
> **Tip:** If you only need a simple per-key transform with the same shape, return a new record and skip custom `baseTarget`. When you need buckets, metadata, or parallel cleanup tracking, seed `baseTarget` with the structures you plan to mutate.
|
|
767
|
+
|