mutts 1.0.11 → 1.0.13
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 +5 -2
- package/dist/browser.cjs +237 -2596
- package/dist/browser.cjs.map +1 -1
- package/dist/browser.d.ts +1407 -2
- package/dist/browser.dev.cjs +44 -43
- package/dist/browser.dev.cjs.map +1 -1
- package/dist/browser.dev.d.ts +2 -2
- package/dist/browser.dev.esm.js +2 -2
- package/dist/browser.esm.js +3 -3
- package/dist/chunks/index-CAdnMJev.cjs +2735 -0
- package/dist/chunks/index-CAdnMJev.cjs.map +1 -0
- package/dist/chunks/{index-Sf74wXTV.esm.js → index-XsYTUhHx.esm.js} +200 -77
- package/dist/chunks/index-XsYTUhHx.esm.js.map +1 -0
- package/dist/chunks/{async-node-3PrbVAbB.cjs → node-DrrphEPf.cjs} +4 -4
- package/dist/chunks/node-DrrphEPf.cjs.map +1 -0
- package/dist/chunks/{node-Bo7WU5S2.esm.js → node-NEZvVo4M.esm.js} +2 -2
- package/dist/chunks/{node-Bo7WU5S2.esm.js.map → node-NEZvVo4M.esm.js.map} +1 -1
- package/dist/chunks/{proxy-D2C49sXH.esm.js → proxy-BtmPFjSr.esm.js} +307 -66
- package/dist/chunks/proxy-BtmPFjSr.esm.js.map +1 -0
- package/dist/chunks/{proxy-Cc79Lrzj.cjs → proxy-DBHj3kGK.cjs} +341 -70
- package/dist/chunks/proxy-DBHj3kGK.cjs.map +1 -0
- package/dist/debug.cjs +537 -167
- package/dist/debug.cjs.map +1 -1
- package/dist/debug.d.ts +96 -80
- package/dist/debug.esm.js +533 -166
- package/dist/debug.esm.js.map +1 -1
- package/dist/devtools/panel.js.map +1 -1
- package/dist/mutts.umd.js +508 -140
- 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/node.cjs +44 -42
- package/dist/node.cjs.map +1 -1
- package/dist/node.d.ts +2 -2
- package/dist/node.dev.cjs +44 -42
- package/dist/node.dev.cjs.map +1 -1
- package/dist/node.dev.d.ts +2 -2
- package/dist/node.dev.esm.js +3 -3
- package/dist/node.esm.js +3 -3
- package/dist/{types-Bx2PhORg.d.ts → types.d.ts} +12 -0
- package/docs/ai/api-reference.md +102 -12
- package/docs/ai/manual.md +60 -24
- package/docs/debug-getReason.md +161 -0
- package/docs/flavored.md +98 -1
- package/docs/reactive/advanced.md +15 -2
- package/docs/reactive/attend.md +32 -0
- package/docs/reactive/core.md +40 -6
- package/docs/reactive/debugging.md +25 -2
- package/docs/reactive.md +2 -0
- package/package.json +2 -3
- package/dist/chunks/async-browser-Dgr5CreQ.cjs +0 -218
- package/dist/chunks/async-browser-Dgr5CreQ.cjs.map +0 -1
- package/dist/chunks/async-core-CRLKP3l-.cjs +0 -29
- package/dist/chunks/async-core-CRLKP3l-.cjs.map +0 -1
- package/dist/chunks/async-node-3PrbVAbB.cjs.map +0 -1
- package/dist/chunks/index-Sf74wXTV.esm.js.map +0 -1
- package/dist/chunks/proxy-Cc79Lrzj.cjs.map +0 -1
- package/dist/chunks/proxy-D2C49sXH.esm.js.map +0 -1
- package/dist/index.d.ts +0 -1322
package/docs/ai/manual.md
CHANGED
|
@@ -13,7 +13,7 @@ Proxy-based **fine-grained reactivity** — changes propagate synchronously thro
|
|
|
13
13
|
| State | `reactive(obj)` — proxy wraps, tracks reads/writes |
|
|
14
14
|
| Reactions | `effect(() => {})` — auto-tracks deps, re-runs on change |
|
|
15
15
|
| Computed | `memoize(fn)` — cached, invalidates on tracked deps |
|
|
16
|
-
| Collections | `
|
|
16
|
+
| Collections | `morph(arr, fn)` — per-entry reactive transform |
|
|
17
17
|
| Async context | `Zone` + `asyncZone` — propagates across await |
|
|
18
18
|
| Batching | `atomic(() => {})` wraps, `atom(() => {})` runs immediately — fire effects once after all mutations |
|
|
19
19
|
| Cleanup | Return fn from effect — runs before re-run/disposal |
|
|
@@ -22,11 +22,11 @@ Proxy-based **fine-grained reactivity** — changes propagate synchronously thro
|
|
|
22
22
|
|
|
23
23
|
```ts
|
|
24
24
|
import { reactive, effect, memoize, morph, attend, lift, cleanup,
|
|
25
|
-
atom, atomic, defer, untracked, unreactive, watch, when, biDi, caught,
|
|
26
|
-
|
|
25
|
+
atom, atomic, defer, untracked, unreactive, watch, when, biDi, caught,
|
|
26
|
+
organized, organize, resource, root,
|
|
27
27
|
Zone, asyncZone, ZoneHistory, ZoneAggregator,
|
|
28
28
|
decorator, mixin, Eventful, Destroyable, flavored, Indexable, chainPromise,
|
|
29
|
-
reactiveOptions, isReactive, unwrap, getState
|
|
29
|
+
reactiveOptions, isReactive, unwrap, getState, ReactiveBase
|
|
30
30
|
} from 'mutts'
|
|
31
31
|
```
|
|
32
32
|
|
|
@@ -107,14 +107,14 @@ array.splice(0) // also works — explicit clear
|
|
|
107
107
|
```
|
|
108
108
|
|
|
109
109
|
### TRAP 6: Using .map() for reactive transforms
|
|
110
|
-
`.map()` is static — full rebuild on any change. Use `
|
|
110
|
+
`.map()` is static — full rebuild on any change. Use `morph()` for per-entry reactivity.
|
|
111
111
|
|
|
112
112
|
```ts
|
|
113
113
|
// BAD — full rebuild
|
|
114
114
|
const doubled = items.map(x => x.value * 2)
|
|
115
115
|
|
|
116
116
|
// GOOD — per-entry effects
|
|
117
|
-
const doubled =
|
|
117
|
+
const doubled = morph(items, (value) => value * 2)
|
|
118
118
|
```
|
|
119
119
|
|
|
120
120
|
### TRAP 7: Deep watch overhead
|
|
@@ -164,7 +164,11 @@ state.count++ // triggers
|
|
|
164
164
|
stop() // disposes
|
|
165
165
|
```
|
|
166
166
|
|
|
167
|
-
- **Access object**: `{ reaction, tracked(fn), ascend(fn)
|
|
167
|
+
- **Access object**: `{ reaction, tracked(fn), ascend(fn), signal }`
|
|
168
|
+
- `reaction` is `false` on first run, then `true` or a `CleanupReason` on re-runs.
|
|
169
|
+
- `tracked` restores effect tracking in async or escaped callbacks.
|
|
170
|
+
- `ascend` records dependencies on the parent effect context.
|
|
171
|
+
- `signal` aborts when the effect is cleaned up or invalidated.
|
|
168
172
|
- **Parent-child**: effects inside effects are children — parent disposal cascades.
|
|
169
173
|
- **GC**: unreferenced top-level effects may GC — store `stop` to keep alive.
|
|
170
174
|
- **Modifiers**: `effect.opaque(() => {})` (identity-only), `effect.named('x')(() => {})` (debug label). Chainable.
|
|
@@ -231,16 +235,15 @@ input.addEventListener('input', () => provide(input.value))
|
|
|
231
235
|
```ts
|
|
232
236
|
watch(() => state.count, (newVal, oldVal) => {}) // specific derivation
|
|
233
237
|
watch(state, () => {}) // any property
|
|
234
|
-
watch(state, () => {}
|
|
238
|
+
watch.deep(state, () => {}) // deep (expensive)
|
|
239
|
+
watch.immediate(() => state.count, (newVal, oldVal) => {}) // immediate first call
|
|
235
240
|
```
|
|
236
241
|
|
|
237
|
-
|
|
242
|
+
`watch` has 3 useful shapes:
|
|
238
243
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
// Chains: if obj already has a cleanup, both run
|
|
243
|
-
```
|
|
244
|
+
- `watch(() => value, callback)`
|
|
245
|
+
- `watch.deep(() => object, callback)`
|
|
246
|
+
- `watch(object, callback)`
|
|
244
247
|
|
|
245
248
|
### 3.11 Lazy computed values
|
|
246
249
|
|
|
@@ -258,9 +261,25 @@ effect(() => console.log(d.value)) // recomputes immediately, .value is reactive
|
|
|
258
261
|
|
|
259
262
|
```ts
|
|
260
263
|
await when(() => state.loaded) // resolves when truthy
|
|
261
|
-
await when(() => state.ready,
|
|
264
|
+
await when(() => state.ready, 5000) // rejects after 5s
|
|
262
265
|
```
|
|
263
266
|
|
|
267
|
+
### 3.13 resource() — Async reactive state
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
const user = resource(async () => fetchUser(state.userId), { initialValue: null })
|
|
271
|
+
|
|
272
|
+
effect(() => {
|
|
273
|
+
if (user.loading) return
|
|
274
|
+
console.log(user.value)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
user.reload()
|
|
278
|
+
await user.promise
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`resource()` returns `{ value, loading, error, latest, reload(), promise }`.
|
|
282
|
+
|
|
264
283
|
---
|
|
265
284
|
|
|
266
285
|
## 4. COLLECTIONS
|
|
@@ -280,15 +299,22 @@ Array methods (`push`, `splice`, `sort`, etc.) all trigger reactivity.
|
|
|
280
299
|
|
|
281
300
|
All transforms return reactive results. Cleanup via `result[cleanup]()`.
|
|
282
301
|
|
|
283
|
-
### 5.1
|
|
302
|
+
### 5.1 morph() — Per-entry reactive map
|
|
284
303
|
|
|
285
304
|
```ts
|
|
286
|
-
const names =
|
|
305
|
+
const names = morph(users, (user) => user.name.toUpperCase())
|
|
287
306
|
// names[0] recomputes ONLY when users[0] changes
|
|
288
|
-
//
|
|
307
|
+
// Use morph.pure(...) when the callback has no reactive reads
|
|
289
308
|
```
|
|
290
309
|
|
|
291
|
-
|
|
310
|
+
For arrays the callback is `(value, access?) => mapped`.
|
|
311
|
+
For Maps and records it also receives the key.
|
|
312
|
+
|
|
313
|
+
The mapper can also be captioned, even though it is the second argument:
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
const names = morph`user:names`(users, (user) => user.name.toUpperCase())
|
|
317
|
+
```
|
|
292
318
|
|
|
293
319
|
### 5.2 attend() — Per-key lifecycle
|
|
294
320
|
|
|
@@ -297,9 +323,17 @@ attend(reactiveRecord, (key) => {
|
|
|
297
323
|
console.log(`${key} = ${reactiveRecord[key]}`)
|
|
298
324
|
return () => console.log(`cleanup: ${key}`) // disposed when key disappears
|
|
299
325
|
})
|
|
300
|
-
// Works with: arrays, Maps, Sets, or raw () => Iterable<Key>
|
|
301
326
|
```
|
|
302
327
|
|
|
328
|
+
`attend` also supports callback captions on its second argument:
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
attend`record:entries`(reactiveRecord, (key) => {
|
|
332
|
+
console.log(`${key} = ${reactiveRecord[key]}`)
|
|
333
|
+
})
|
|
334
|
+
```
|
|
335
|
+
Works with: arrays, Maps, Sets, or raw `() => Iterable<Key>`.
|
|
336
|
+
|
|
303
337
|
### 5.3 organized() — Per-key record transform
|
|
304
338
|
|
|
305
339
|
```ts
|
|
@@ -309,7 +343,7 @@ const doubled = organized(source, (access, target) => {
|
|
|
309
343
|
})
|
|
310
344
|
```
|
|
311
345
|
|
|
312
|
-
### 5.
|
|
346
|
+
### 5.4 lift() — Sync a computed array/object
|
|
313
347
|
|
|
314
348
|
```ts
|
|
315
349
|
const filtered = lift(() => items.filter(x => x.active))
|
|
@@ -318,7 +352,7 @@ const filtered = lift(() => items.filter(x => x.active))
|
|
|
318
352
|
|
|
319
353
|
**lift vs deep touching**: Deep touching handles `state.items = newArray` (replacement diffs). `lift` is for **derived collections** (filter/map/reshape) where there's no single assignment — the output is recomputed from scratch.
|
|
320
354
|
|
|
321
|
-
**lift vs memoize**: `memoize` is lazy (invalidate → recompute on next read), returns raw values, keyed by args. `lift` is eager (recompute immediately), returns a **stable reactive proxy** with per-element diffing. Use `lift` for derived collections consumed by
|
|
355
|
+
**lift vs memoize**: `memoize` is lazy (invalidate → recompute on next read), returns raw values, keyed by args. `lift` is eager (recompute immediately), returns a **stable reactive proxy** with per-element diffing. Use `lift` for derived collections consumed by effects; use `memoize` for parameterized caching or lazy evaluation.
|
|
322
356
|
|
|
323
357
|
---
|
|
324
358
|
|
|
@@ -387,6 +421,8 @@ const graph = buildReactivityGraph()
|
|
|
387
421
|
|
|
388
422
|
**ReactiveError.debugInfo**: `causalChain`, `creationStack`, `cycle` (for CYCLE_DETECTED).
|
|
389
423
|
|
|
424
|
+
`onEffectThrow` is still exported as a deprecated alias of `caught`.
|
|
425
|
+
|
|
390
426
|
---
|
|
391
427
|
|
|
392
428
|
## 9. ZONES (Async Context)
|
|
@@ -484,7 +520,7 @@ const theme = await chainPromise(api.getUser('123')).getProfile().getSettings().
|
|
|
484
520
|
## 11. PHILOSOPHY
|
|
485
521
|
|
|
486
522
|
- **Affirmative state**: Declare `Y = f(X)`. Don't say "when X changes, update Y".
|
|
487
|
-
- **Events are legacy**: Use reactive derivations (`effect`, `memoize`, `
|
|
523
|
+
- **Events are legacy**: Use reactive derivations (`effect`, `memoize`, `morph`) for internal logic. Events only for DOM/external APIs.
|
|
488
524
|
- **Cleanup ≠ undo**: Cleanup releases subscriptions, does NOT undo side effects.
|
|
489
525
|
|
|
490
526
|
---
|
|
@@ -504,5 +540,5 @@ const theme = await chainPromise(api.getUser('123')).getProfile().getSettings().
|
|
|
504
540
|
| `isReactive(obj)` to check | `obj._mutts_isReactive` |
|
|
505
541
|
| Store `stop = effect(...)` | letting effect GC unintentionally |
|
|
506
542
|
| `import { x } from 'mutts'` | `import { x } from 'mutts/reactive'` (no subpaths) |
|
|
507
|
-
| `
|
|
543
|
+
| `caught(handler)` for effect-local error handling | ad-hoc try/catch around reactive graph edges |
|
|
508
544
|
| `memoize(() => a + b)` for computed | `effect` + manual state sync |
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# `__MUTTS_DEBUG__.getReason()`
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The `getReason()` function allows you to access the reason why the current effect is being executed from within the effect itself. This is useful for debugging and understanding what triggered an effect to re-run.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import * as mutts from 'mutts'
|
|
11
|
+
|
|
12
|
+
// DevTools are automatically enabled in development mode
|
|
13
|
+
const debug = (globalThis as any).__MUTTS_DEBUG__
|
|
14
|
+
|
|
15
|
+
const state = mutts.reactive({ count: 0 })
|
|
16
|
+
|
|
17
|
+
mutts.effect(() => {
|
|
18
|
+
const reason = debug.getReason()
|
|
19
|
+
|
|
20
|
+
if (!reason) {
|
|
21
|
+
console.log('First run - no reason')
|
|
22
|
+
} else if (reason.type === 'propChange') {
|
|
23
|
+
console.log(`Re-run due to ${reason.triggers.length} property changes:`)
|
|
24
|
+
for (const trigger of reason.triggers) {
|
|
25
|
+
console.log(` - ${trigger.evolution.type} on ${trigger.evolution.prop}`)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log('Count:', state.count)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Trigger changes
|
|
33
|
+
state.count = 1 // Shows propChange reason
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Return Value
|
|
37
|
+
|
|
38
|
+
Returns `undefined` or a `CleanupReason` object:
|
|
39
|
+
|
|
40
|
+
### `undefined`
|
|
41
|
+
- Returned on the first run of an effect
|
|
42
|
+
- No cleanup/reason is available for initial execution
|
|
43
|
+
|
|
44
|
+
### `CleanupReason` types
|
|
45
|
+
|
|
46
|
+
#### `{ type: 'propChange', triggers: PropTrigger[] }`
|
|
47
|
+
The effect was re-run because one or more reactive properties changed.
|
|
48
|
+
|
|
49
|
+
- `triggers`: Array of property changes that triggered this re-run
|
|
50
|
+
- `obj`: The reactive object that changed
|
|
51
|
+
- `evolution`: Details about what changed
|
|
52
|
+
- `type`: `'set' | 'del' | 'add' | 'invalidate' | 'bunch'`
|
|
53
|
+
- `prop`: The property that changed (or method name for 'bunch')
|
|
54
|
+
- `dependency`: Stack trace from when the dependency was created (if lineage tracking enabled)
|
|
55
|
+
- `touch`: Stack trace from when the property was modified (if lineage tracking enabled)
|
|
56
|
+
|
|
57
|
+
#### `{ type: 'stopped', detail?: string }`
|
|
58
|
+
The effect was explicitly stopped via its cleanup function.
|
|
59
|
+
|
|
60
|
+
#### `{ type: 'gc' }`
|
|
61
|
+
The effect was cleaned up by garbage collection.
|
|
62
|
+
|
|
63
|
+
#### `{ type: 'error', error: unknown }`
|
|
64
|
+
The effect is being re-run due to an error in a previous run.
|
|
65
|
+
|
|
66
|
+
#### `{ type: 'lineage', parent: CleanupReason }`
|
|
67
|
+
A parent effect was cleaned up, causing this child effect to also be cleaned up.
|
|
68
|
+
|
|
69
|
+
#### `{ type: 'invalidate', cause: CleanupReason }`
|
|
70
|
+
The effect was invalidated for some other reason.
|
|
71
|
+
|
|
72
|
+
#### `{ type: 'multiple', reasons: CleanupReason[] }`
|
|
73
|
+
Multiple reasons combined (rare, usually from complex cleanup scenarios).
|
|
74
|
+
|
|
75
|
+
## Examples
|
|
76
|
+
|
|
77
|
+
### Debugging Multiple Dependencies
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
const state = mutts.reactive({
|
|
81
|
+
user: { name: 'John' },
|
|
82
|
+
posts: [{ title: 'Hello' }]
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
mutts.effect(() => {
|
|
86
|
+
const reason = debug.getReason()
|
|
87
|
+
|
|
88
|
+
if (reason?.type === 'propChange') {
|
|
89
|
+
console.log(`Effect triggered by ${reason.triggers.length} changes:`)
|
|
90
|
+
reason.triggers.forEach(trigger => {
|
|
91
|
+
if (trigger.evolution.type === 'set') {
|
|
92
|
+
console.log(` Property '${trigger.evolution.prop}' changed`)
|
|
93
|
+
} else if (trigger.evolution.type === 'add') {
|
|
94
|
+
console.log(` Property '${trigger.evolution.prop}' added`)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log(`User: ${state.user.name}, Posts: ${state.posts.length}`)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// Multiple simultaneous changes
|
|
103
|
+
mutts.untracked(() => {
|
|
104
|
+
state.user.name = 'Jane'
|
|
105
|
+
state.posts.push({ title: 'New Post' })
|
|
106
|
+
})
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Conditional Logic Based on Reason
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
mutts.effect(() => {
|
|
113
|
+
const reason = debug.getReason()
|
|
114
|
+
|
|
115
|
+
if (!reason) {
|
|
116
|
+
// First run - expensive initialization
|
|
117
|
+
console.log('Initializing...')
|
|
118
|
+
// setup expensive resources
|
|
119
|
+
} else if (reason.type === 'propChange') {
|
|
120
|
+
// Re-run - can optimize based on what changed
|
|
121
|
+
const userChanged = reason.triggers.some(t =>
|
|
122
|
+
t.evolution.prop === 'name'
|
|
123
|
+
)
|
|
124
|
+
if (userChanged) {
|
|
125
|
+
console.log('User name changed - updating UI')
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log(`User: ${state.user.name}`)
|
|
130
|
+
})
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Notes
|
|
134
|
+
|
|
135
|
+
- The reason is only available during the effect's execution
|
|
136
|
+
- After the effect completes, the reason is cleared
|
|
137
|
+
- The reason reflects why the *previous* run was cleaned up, not why the current run started
|
|
138
|
+
- In TypeScript, you can type the return value as:
|
|
139
|
+
```typescript
|
|
140
|
+
type CleanupReason = import('mutts').CleanupReason
|
|
141
|
+
const reason: CleanupReason | undefined = debug.getReason()
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Configuration
|
|
145
|
+
|
|
146
|
+
Reason gathering is controlled by `options.introspection.gatherReasons`:
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
import { reactiveOptions } from 'mutts'
|
|
150
|
+
|
|
151
|
+
// Disable reason gathering for production (performance)
|
|
152
|
+
reactiveOptions.introspection = null
|
|
153
|
+
|
|
154
|
+
// Or customize what lineage information is captured
|
|
155
|
+
reactiveOptions.introspection = {
|
|
156
|
+
gatherReasons: { lineages: 'touch' }, // 'none' | 'touch' | 'dependency' | 'both'
|
|
157
|
+
logErrors: true,
|
|
158
|
+
enableHistory: true,
|
|
159
|
+
historySize: 50,
|
|
160
|
+
}
|
|
161
|
+
```
|
package/docs/flavored.md
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
# Flavored Functions
|
|
1
|
+
# Flavored and Captioned Functions
|
|
2
2
|
|
|
3
3
|
The `flavored` utility creates extensible functions with chainable property modifiers. It enables a fluent API where properties return specialized variants of the base function.
|
|
4
4
|
|
|
5
|
+
The `captioned` utility is a sibling concept for callback-oriented APIs. It adds a tagged-template call form that can attach a runtime caption to one callback argument before delegating to the base function.
|
|
6
|
+
|
|
5
7
|
## Overview
|
|
6
8
|
|
|
7
9
|
Flavored functions allow you to:
|
|
@@ -10,6 +12,13 @@ Flavored functions allow you to:
|
|
|
10
12
|
- Use either automatic option merging (`flavorOptions`) or custom argument transformation (`createFlavor`)
|
|
11
13
|
- Return hand-made functions for complete control (the generic case)
|
|
12
14
|
|
|
15
|
+
Captioned functions allow you to:
|
|
16
|
+
- Keep the normal callback-first call form
|
|
17
|
+
- Add a tagged-template call form like `` effect`render:${id}`(fn) ``
|
|
18
|
+
- Warn when a callback-first API receives an anonymous callback without a caption
|
|
19
|
+
- Preserve captioning across flavored variants created with `createFlavor` or `flavorOptions`
|
|
20
|
+
- Target callbacks that are not in argument position `0`
|
|
21
|
+
|
|
13
22
|
## Basic Usage
|
|
14
23
|
|
|
15
24
|
### Creating a Flavored Function
|
|
@@ -62,6 +71,57 @@ effect.named('myEffect')(fn) // With name option
|
|
|
62
71
|
effect.opaque.named('x')(fn) // Chained
|
|
63
72
|
```
|
|
64
73
|
|
|
74
|
+
### `captioned(fn, options?)`
|
|
75
|
+
|
|
76
|
+
Creates a callback-oriented function that also accepts a tagged-template call form. The caption is applied to the configured callback argument before the base function runs.
|
|
77
|
+
|
|
78
|
+
**Use when:** Your API takes a callback argument and you want ergonomic call-site naming without turning naming itself into a flavor.
|
|
79
|
+
|
|
80
|
+
**Parameters:**
|
|
81
|
+
- `fn` - The callback-first base function
|
|
82
|
+
- `options.callbackIndex` - Which argument should be treated as the callback to rename/warn about. Defaults to `0`
|
|
83
|
+
- `options.name` - Human-readable label used in warning messages
|
|
84
|
+
- `options.rename` - Optional function to customize how the caption is applied to the callback
|
|
85
|
+
- `options.warn` - Optional warning sink for anonymous uncaptained callbacks
|
|
86
|
+
- `options.shouldWarnAnonymous` - Optional predicate to suppress warnings for specific argument shapes
|
|
87
|
+
|
|
88
|
+
**Example:**
|
|
89
|
+
```typescript
|
|
90
|
+
import { captioned } from 'mutts'
|
|
91
|
+
|
|
92
|
+
const run = captioned(
|
|
93
|
+
(callback: () => void) => {
|
|
94
|
+
callback()
|
|
95
|
+
},
|
|
96
|
+
{ name: 'run' }
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
run(function namedTask() {})
|
|
100
|
+
run`task:${42}`(() => {})
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The two call forms are:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
run(callback)
|
|
107
|
+
run`caption`(callback)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
If `run(callback)` receives an anonymous callback, `captioned` may warn depending on its `shouldWarnAnonymous` policy.
|
|
111
|
+
|
|
112
|
+
You can also target callbacks that are not the first argument:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
const attendLike = captioned(
|
|
116
|
+
(source: string[], callback: (value: string) => void) => {
|
|
117
|
+
for (const value of source) callback(value)
|
|
118
|
+
},
|
|
119
|
+
{ name: 'attendLike', callbackIndex: 1 }
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
attendLike`items`(['a', 'b'], () => {})
|
|
123
|
+
```
|
|
124
|
+
|
|
65
125
|
### `flavorOptions(fn, defaultOptions)`
|
|
66
126
|
|
|
67
127
|
Creates a flavored variant that merges options with the last argument.
|
|
@@ -192,6 +252,43 @@ calculator.multiply(3, 4) // 12
|
|
|
192
252
|
calculator.double()(3, 4) // 14 (6 + 8)
|
|
193
253
|
```
|
|
194
254
|
|
|
255
|
+
## Combining `flavored` and `captioned`
|
|
256
|
+
|
|
257
|
+
They solve different problems:
|
|
258
|
+
|
|
259
|
+
- `flavored` changes how a function is configured via chainable properties
|
|
260
|
+
- `captioned` changes how a callback-first function can be called
|
|
261
|
+
|
|
262
|
+
They compose naturally:
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
const watch = captioned(
|
|
266
|
+
flavored(baseWatch, {
|
|
267
|
+
get immediate() {
|
|
268
|
+
return flavorOptions(this, { immediate: true })
|
|
269
|
+
}
|
|
270
|
+
}),
|
|
271
|
+
{ name: 'watch' }
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
watch(() => state.count, changed)
|
|
275
|
+
watch`counter:watch`(() => state.count, changed)
|
|
276
|
+
watch.immediate`counter:watch`(() => state.count, changed)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
For callback arguments in another position, use `callbackIndex`:
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
const attend = captioned(baseAttend, {
|
|
283
|
+
name: 'attend',
|
|
284
|
+
callbackIndex: 1,
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
attend`entries`(source, (key) => {
|
|
288
|
+
console.log(key)
|
|
289
|
+
})
|
|
290
|
+
```
|
|
291
|
+
|
|
195
292
|
## TypeScript Considerations
|
|
196
293
|
|
|
197
294
|
Flavored functions use proxies and require type assertions for complex chaining scenarios:
|
|
@@ -526,6 +526,13 @@ watch.immediate.deep(() => state.nested, (v) => {
|
|
|
526
526
|
})
|
|
527
527
|
```
|
|
528
528
|
|
|
529
|
+
As a callback-first API, `watch` also supports tagged-template captions:
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
watch`count:watch`(() => state.count, (v) => console.log(v))
|
|
533
|
+
watch.immediate`count:watch`(() => state.count, (v) => console.log(v))
|
|
534
|
+
```
|
|
535
|
+
|
|
529
536
|
These flavors are a shorthand for passing options:
|
|
530
537
|
- `watch.immediate(...)` is equivalent to `watch(..., { immediate: true })`
|
|
531
538
|
- `watch.deep(...)` is equivalent to `watch(..., { deep: true })`
|
|
@@ -1170,8 +1177,14 @@ effect(() => console.log(profile.displayName)) // tracks .displayName only
|
|
|
1170
1177
|
|
|
1171
1178
|
**Derived filtered collection**:
|
|
1172
1179
|
```typescript
|
|
1173
|
-
const
|
|
1174
|
-
//
|
|
1180
|
+
const filtered = lift(() => items.filter(x => x.active))
|
|
1181
|
+
// Element-wise diff — only changed elements sync, not full rebuild
|
|
1182
|
+
```
|
|
1183
|
+
|
|
1184
|
+
`lift` also supports the same tagged-template caption form:
|
|
1185
|
+
|
|
1186
|
+
```ts
|
|
1187
|
+
const filtered = lift`active:items`(() => items.filter(x => x.active))
|
|
1175
1188
|
```
|
|
1176
1189
|
|
|
1177
1190
|
**Per-element transform**:
|
package/docs/reactive/attend.md
CHANGED
|
@@ -9,6 +9,7 @@ The `attend` utility reactively iterates over the entries of a collection, runni
|
|
|
9
9
|
- **Creates** an inner effect for each key, via `ascend`.
|
|
10
10
|
- **Disposes** the inner effect when the key is removed from the collection.
|
|
11
11
|
- Allows the callback to return a **cleanup function** (like a regular effect closer).
|
|
12
|
+
- Supports tagged-template captions on its callback argument.
|
|
12
13
|
|
|
13
14
|
This is the foundational lifecycle primitive that `organized` is built on.
|
|
14
15
|
|
|
@@ -33,6 +34,18 @@ function attend<S extends Record<PropertyKey, any>>(source: S, callback: (key: k
|
|
|
33
34
|
- **`source`** or **`enumerate`**: Either a collection (array, record, Map, Set) or a callback returning an `Iterable<Key>`. The enumeration runs inside the outer effect, so reactive reads (e.g. `source.length`, `Object.keys(source)`) are tracked automatically.
|
|
34
35
|
- **`callback`**: Called per key inside an inner effect. May return a cleanup function that runs when the key is removed or before the inner effect re-executes.
|
|
35
36
|
|
|
37
|
+
### Captioned callback form
|
|
38
|
+
|
|
39
|
+
Unlike `effect` or `lift`, `attend` receives its callback as the **second** argument. It still supports tagged-template captioning:
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
attend`entries`(config, (key) => {
|
|
43
|
+
console.log(`${key} = ${config[key]}`)
|
|
44
|
+
})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The caption is applied to the callback argument and contributes to the runtime names of the inner per-key effects.
|
|
48
|
+
|
|
36
49
|
### Returns
|
|
37
50
|
|
|
38
51
|
A `ScopedCallback` that tears down all inner effects and the outer effect.
|
|
@@ -67,6 +80,14 @@ stop()
|
|
|
67
80
|
// Disposes everything
|
|
68
81
|
```
|
|
69
82
|
|
|
83
|
+
The same record form also works with a caption:
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
const stop = attend`config:entries`(config, (key) => {
|
|
87
|
+
console.log(`${key} = ${config[key]}`)
|
|
88
|
+
})
|
|
89
|
+
```
|
|
90
|
+
|
|
70
91
|
### Array
|
|
71
92
|
|
|
72
93
|
```typescript
|
|
@@ -123,6 +144,17 @@ attend(
|
|
|
123
144
|
)
|
|
124
145
|
```
|
|
125
146
|
|
|
147
|
+
And likewise with a caption:
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
attend`ownKeys`(
|
|
151
|
+
() => Reflect.ownKeys(source),
|
|
152
|
+
(key) => {
|
|
153
|
+
console.log(key, source[key])
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
```
|
|
157
|
+
|
|
126
158
|
## How it Works
|
|
127
159
|
|
|
128
160
|
1. An **outer effect** calls `enumerate()` (or derives it from the collection type), collecting the current keys into a `Set`.
|
package/docs/reactive/core.md
CHANGED
|
@@ -242,6 +242,18 @@ function effect(
|
|
|
242
242
|
|
|
243
243
|
**Returns:** A cleanup function to stop the effect
|
|
244
244
|
|
|
245
|
+
**Captioned call form:**
|
|
246
|
+
|
|
247
|
+
`effect` also supports a tagged-template naming form for callback-first calls:
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
effect`counter:main`(() => {
|
|
251
|
+
console.log(state.count)
|
|
252
|
+
})
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
This is the preferred way to attach a runtime/debug name to a new effect.
|
|
256
|
+
|
|
245
257
|
**Example:**
|
|
246
258
|
|
|
247
259
|
```typescript
|
|
@@ -264,6 +276,8 @@ state.mood = 'surprised' // Does not trigger the effect
|
|
|
264
276
|
cleanup() // Stops the effect
|
|
265
277
|
```
|
|
266
278
|
|
|
279
|
+
If you use the plain `effect(fn)` form with an anonymous callback, `mutts` may warn and suggest either a named function or the tagged-template caption form.
|
|
280
|
+
|
|
267
281
|
You can also branch on the `reaction` flag to separate initialisation logic from update logic:
|
|
268
282
|
|
|
269
283
|
```typescript
|
|
@@ -647,6 +661,8 @@ const stopOuter = effect(() => {
|
|
|
647
661
|
|
|
648
662
|
The `untracked()` function allows you to run code without tracking dependencies, which can be useful for creating effects or performing operations that shouldn't be part of the current effect's dependency graph.
|
|
649
663
|
|
|
664
|
+
`untracked` is captioned. When you use it as a reactive execution root, prefer the template form so debug output and chained `CleanupReason.external` frames stay descriptive.
|
|
665
|
+
|
|
650
666
|
```typescript
|
|
651
667
|
import { effect, untracked, reactive } from 'mutts'
|
|
652
668
|
|
|
@@ -661,7 +677,7 @@ effect(() => {
|
|
|
661
677
|
// Create an inner effect without tracking the creation under the outer effect
|
|
662
678
|
let stopInner: (() => void) | undefined
|
|
663
679
|
|
|
664
|
-
untracked(() => {
|
|
680
|
+
untracked`outer:inner-effect`(() => {
|
|
665
681
|
stopInner = effect(() => {
|
|
666
682
|
state.b
|
|
667
683
|
})
|
|
@@ -739,6 +755,8 @@ item.data = { value: 30 } // Triggers BOTH effects
|
|
|
739
755
|
|
|
740
756
|
#### `.named(name)`
|
|
741
757
|
|
|
758
|
+
**Obsolete:** prefer `` effect`name`(fn) `` for new code.
|
|
759
|
+
|
|
742
760
|
Creates a named effect for easier debugging and profiling. The name appears in DevTools and debug logs.
|
|
743
761
|
|
|
744
762
|
```typescript
|
|
@@ -748,18 +766,26 @@ const state = reactive({
|
|
|
748
766
|
count: 0
|
|
749
767
|
})
|
|
750
768
|
|
|
751
|
-
//
|
|
769
|
+
// Legacy named effect
|
|
752
770
|
effect.named('counter-effect')(() => {
|
|
753
771
|
console.log('Count:', state.count)
|
|
754
772
|
})
|
|
755
773
|
|
|
756
|
-
//
|
|
774
|
+
// Legacy named effects can also be combined with other options
|
|
757
775
|
effect.named('data-loader').opaque(() => {
|
|
758
776
|
console.log('Loading data...')
|
|
759
777
|
})
|
|
760
778
|
```
|
|
761
779
|
|
|
762
|
-
|
|
780
|
+
For new code, prefer:
|
|
781
|
+
|
|
782
|
+
```typescript
|
|
783
|
+
effect`counter-effect`(() => {
|
|
784
|
+
console.log('Count:', state.count)
|
|
785
|
+
})
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
**Benefits of captioned/named effects:**
|
|
763
789
|
- Easier identification in DevTools
|
|
764
790
|
- Better stack traces during debugging
|
|
765
791
|
- Helpful for performance profiling
|
|
@@ -769,7 +795,7 @@ effect.named('data-loader').opaque(() => {
|
|
|
769
795
|
Modifiers can be chained in any order:
|
|
770
796
|
|
|
771
797
|
```typescript
|
|
772
|
-
//
|
|
798
|
+
// Legacy named opaque effect
|
|
773
799
|
effect.named('my-effect').opaque(() => {
|
|
774
800
|
// Effect code
|
|
775
801
|
})
|
|
@@ -783,7 +809,7 @@ effect.opaque.named('my-effect')(() => {
|
|
|
783
809
|
Note: The modifiers return new effect functions with the options pre-applied, so they can be stored and reused:
|
|
784
810
|
|
|
785
811
|
```typescript
|
|
786
|
-
// Create a reusable named effect factory
|
|
812
|
+
// Create a reusable legacy named effect factory
|
|
787
813
|
const createDataEffect = effect.named('data-layer')
|
|
788
814
|
|
|
789
815
|
createDataEffect(() => {
|
|
@@ -795,6 +821,14 @@ createDataEffect(() => {
|
|
|
795
821
|
})
|
|
796
822
|
```
|
|
797
823
|
|
|
824
|
+
For single call sites, the tagged-template form is usually shorter:
|
|
825
|
+
|
|
826
|
+
```typescript
|
|
827
|
+
effect`data-layer`(() => {
|
|
828
|
+
console.log('Effect 1')
|
|
829
|
+
})
|
|
830
|
+
```
|
|
831
|
+
|
|
798
832
|
## Class Reactivity
|
|
799
833
|
|
|
800
834
|
### `@reactive` Decorator
|