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.
Files changed (59) hide show
  1. package/README.md +5 -2
  2. package/dist/browser.cjs +237 -2596
  3. package/dist/browser.cjs.map +1 -1
  4. package/dist/browser.d.ts +1407 -2
  5. package/dist/browser.dev.cjs +44 -43
  6. package/dist/browser.dev.cjs.map +1 -1
  7. package/dist/browser.dev.d.ts +2 -2
  8. package/dist/browser.dev.esm.js +2 -2
  9. package/dist/browser.esm.js +3 -3
  10. package/dist/chunks/index-CAdnMJev.cjs +2735 -0
  11. package/dist/chunks/index-CAdnMJev.cjs.map +1 -0
  12. package/dist/chunks/{index-Sf74wXTV.esm.js → index-XsYTUhHx.esm.js} +200 -77
  13. package/dist/chunks/index-XsYTUhHx.esm.js.map +1 -0
  14. package/dist/chunks/{async-node-3PrbVAbB.cjs → node-DrrphEPf.cjs} +4 -4
  15. package/dist/chunks/node-DrrphEPf.cjs.map +1 -0
  16. package/dist/chunks/{node-Bo7WU5S2.esm.js → node-NEZvVo4M.esm.js} +2 -2
  17. package/dist/chunks/{node-Bo7WU5S2.esm.js.map → node-NEZvVo4M.esm.js.map} +1 -1
  18. package/dist/chunks/{proxy-D2C49sXH.esm.js → proxy-BtmPFjSr.esm.js} +307 -66
  19. package/dist/chunks/proxy-BtmPFjSr.esm.js.map +1 -0
  20. package/dist/chunks/{proxy-Cc79Lrzj.cjs → proxy-DBHj3kGK.cjs} +341 -70
  21. package/dist/chunks/proxy-DBHj3kGK.cjs.map +1 -0
  22. package/dist/debug.cjs +537 -167
  23. package/dist/debug.cjs.map +1 -1
  24. package/dist/debug.d.ts +96 -80
  25. package/dist/debug.esm.js +533 -166
  26. package/dist/debug.esm.js.map +1 -1
  27. package/dist/devtools/panel.js.map +1 -1
  28. package/dist/mutts.umd.js +508 -140
  29. package/dist/mutts.umd.js.map +1 -1
  30. package/dist/mutts.umd.min.js +1 -1
  31. package/dist/mutts.umd.min.js.map +1 -1
  32. package/dist/node.cjs +44 -42
  33. package/dist/node.cjs.map +1 -1
  34. package/dist/node.d.ts +2 -2
  35. package/dist/node.dev.cjs +44 -42
  36. package/dist/node.dev.cjs.map +1 -1
  37. package/dist/node.dev.d.ts +2 -2
  38. package/dist/node.dev.esm.js +3 -3
  39. package/dist/node.esm.js +3 -3
  40. package/dist/{types-Bx2PhORg.d.ts → types.d.ts} +12 -0
  41. package/docs/ai/api-reference.md +102 -12
  42. package/docs/ai/manual.md +60 -24
  43. package/docs/debug-getReason.md +161 -0
  44. package/docs/flavored.md +98 -1
  45. package/docs/reactive/advanced.md +15 -2
  46. package/docs/reactive/attend.md +32 -0
  47. package/docs/reactive/core.md +40 -6
  48. package/docs/reactive/debugging.md +25 -2
  49. package/docs/reactive.md +2 -0
  50. package/package.json +2 -3
  51. package/dist/chunks/async-browser-Dgr5CreQ.cjs +0 -218
  52. package/dist/chunks/async-browser-Dgr5CreQ.cjs.map +0 -1
  53. package/dist/chunks/async-core-CRLKP3l-.cjs +0 -29
  54. package/dist/chunks/async-core-CRLKP3l-.cjs.map +0 -1
  55. package/dist/chunks/async-node-3PrbVAbB.cjs.map +0 -1
  56. package/dist/chunks/index-Sf74wXTV.esm.js.map +0 -1
  57. package/dist/chunks/proxy-Cc79Lrzj.cjs.map +0 -1
  58. package/dist/chunks/proxy-D2C49sXH.esm.js.map +0 -1
  59. 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 | `project(arr, fn)` — per-entry effects, not `.map()` |
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, why,
26
- cleanedBy, organized,
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 `project()` for per-entry reactivity.
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 = project(items, ({ get }) => get().value * 2)
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) }` — `reaction` = false on first run, true after. `tracked` restores context in async. `ascend` tracks in parent effect.
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, () => {}, { deep: true }) // deep (expensive)
238
+ watch.deep(state, () => {}) // deep (expensive)
239
+ watch.immediate(() => state.count, (newVal, oldVal) => {}) // immediate first call
235
240
  ```
236
241
 
237
- ### 3.10 cleanedBy() — Attach cleanup to objects
242
+ `watch` has 3 useful shapes:
238
243
 
239
- ```ts
240
- const obj = cleanedBy({ data: [] }, () => console.log('cleaned up'))
241
- obj[cleanup]() // triggers cleanup
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, { timeout: 5000 }) // rejects after 5s
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 project() — Per-entry reactive map
302
+ ### 5.1 morph() — Per-entry reactive map
284
303
 
285
304
  ```ts
286
- const names = project(users, ({ get }) => get().name.toUpperCase())
305
+ const names = morph(users, (user) => user.name.toUpperCase())
287
306
  // names[0] recomputes ONLY when users[0] changes
288
- // Variants: project.record(), project.map() auto-dispatches by source type
307
+ // Use morph.pure(...) when the callback has no reactive reads
289
308
  ```
290
309
 
291
- **Access object**: `{ get(), set(v), key, source, old, value }`
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.6 lift() — Sync a computed array/object
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 `project()`/effects; use `memoize` for parameterized caching or lazy evaluation.
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`, `project`) for internal logic. Events only for DOM/external APIs.
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
- | `cleanedBy(obj, fn)` for cleanup | manual `obj[cleanup] = fn` |
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 active = lift(() => items.filter(x => x.active))
1174
- // active is a reactive array project() or effects on active[i] work fine
1180
+ const filtered = lift(() => items.filter(x => x.active))
1181
+ // Element-wise diffonly 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**:
@@ -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`.
@@ -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
- // Create a named effect
769
+ // Legacy named effect
752
770
  effect.named('counter-effect')(() => {
753
771
  console.log('Count:', state.count)
754
772
  })
755
773
 
756
- // Named effects can also be combined with other options
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
- **Benefits of named effects:**
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
- // Named opaque effect
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