mutts 1.0.12 → 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 (55) hide show
  1. package/README.md +5 -2
  2. package/dist/browser.cjs +7 -3
  3. package/dist/browser.cjs.map +1 -1
  4. package/dist/browser.d.ts +1407 -2
  5. package/dist/browser.dev.cjs +7 -3
  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-yK0HVxHv.cjs → index-CAdnMJev.cjs} +202 -79
  11. package/dist/chunks/index-CAdnMJev.cjs.map +1 -0
  12. package/dist/chunks/{index-BUop6B2U.esm.js → index-XsYTUhHx.esm.js} +200 -77
  13. package/dist/chunks/index-XsYTUhHx.esm.js.map +1 -0
  14. package/dist/chunks/{node-Dd0esp5F.cjs → node-DrrphEPf.cjs} +2 -2
  15. package/dist/chunks/{node-Dd0esp5F.cjs.map → node-DrrphEPf.cjs.map} +1 -1
  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-BvM4yewA.cjs → proxy-DBHj3kGK.cjs} +313 -66
  21. package/dist/chunks/proxy-DBHj3kGK.cjs.map +1 -0
  22. package/dist/debug.cjs +537 -166
  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 +8 -4
  33. package/dist/node.cjs.map +1 -1
  34. package/dist/node.d.ts +2 -2
  35. package/dist/node.dev.cjs +8 -4
  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/index-BUop6B2U.esm.js.map +0 -1
  52. package/dist/chunks/index-yK0HVxHv.cjs.map +0 -1
  53. package/dist/chunks/proxy-BvM4yewA.cjs.map +0 -1
  54. package/dist/chunks/proxy-D2C49sXH.esm.js.map +0 -1
  55. package/dist/index.d.ts +0 -1322
@@ -139,13 +139,10 @@ function isOwnAccessor(obj, prop) {
139
139
  return !!(opd?.get || opd?.set);
140
140
  }
141
141
  /**
142
- * Deeply compares two values.
143
- * For objects, compares prototypes with === and then own properties recursively.
144
- * Uses a cache to handle circular references.
145
- * @param a - First value
146
- * @param b - Second value
147
- * @param cache - Map for circular reference protection (internal use)
148
- * @returns True if values are deeply equal
142
+ * Symbol used to provide custom comparison logic for an object.
143
+ */
144
+ const CompareSymbol = Symbol.for('mutts.compare');
145
+ /**
149
146
  */
150
147
  function deepCompare(a, b, cache = new Map()) {
151
148
  if (a === b)
@@ -153,6 +150,13 @@ function deepCompare(a, b, cache = new Map()) {
153
150
  if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
154
151
  return a === b;
155
152
  }
153
+ // Custom comparison support
154
+ if (typeof a[CompareSymbol] === 'function') {
155
+ return a[CompareSymbol](b, (x, y) => deepCompare(x, y, cache));
156
+ }
157
+ if (typeof b[CompareSymbol] === 'function') {
158
+ return b[CompareSymbol](a, (x, y) => deepCompare(x, y, cache));
159
+ }
156
160
  // Prototype check
157
161
  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b))
158
162
  return false;
@@ -271,8 +275,9 @@ function named(name, fn) {
271
275
  });
272
276
  return fn;
273
277
  }
274
- const _mode = (typeof process !== 'undefined' && process.env?.NODE_ENV) ||
275
- (typeof ({ url: (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('chunks/proxy-BvM4yewA.cjs', document.baseURI).href)) }) !== 'undefined' && undefined?.MODE) ||
278
+ const runtimeGlobals = globalThis;
279
+ const _mode = runtimeGlobals.process?.env?.NODE_ENV ||
280
+ (typeof ({ url: (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('chunks/proxy-DBHj3kGK.cjs', document.baseURI).href)) }) !== 'undefined' && undefined?.MODE) ||
276
281
  'production';
277
282
  const isDev = _mode === 'development';
278
283
  const isProd = _mode === 'production';
@@ -447,6 +452,93 @@ const decorator = (description) => {
447
452
  * flavoredGreet.loud('World') // "HELLO, WORLD!"
448
453
  * ```
449
454
  */
455
+ const captionedOptionsSymbol = Symbol('mutts.captioned.options');
456
+ function isTemplateStringsArray(value) {
457
+ return (Array.isArray(value) &&
458
+ Object.hasOwn(value, 'raw') &&
459
+ Array.isArray(value.raw));
460
+ }
461
+ function renderTemplate(strings, values) {
462
+ let result = strings[0] ?? '';
463
+ for (let i = 0; i < values.length; i++)
464
+ result += String(values[i]) + (strings[i + 1] ?? '');
465
+ return result;
466
+ }
467
+ function renameCallback(caption, callback) {
468
+ Object.defineProperty(callback, 'name', {
469
+ value: caption,
470
+ writable: false,
471
+ configurable: true,
472
+ });
473
+ return callback;
474
+ }
475
+ function isAnonymousCallback(callback) {
476
+ return !callback.name || callback.name === 'anonymous';
477
+ }
478
+ /**
479
+ * Wraps a callback-first function so it also accepts a tagged-template call form.
480
+ *
481
+ * The template caption is applied to one callback argument before the base
482
+ * function runs. By default, `captioned` targets the first argument, but
483
+ * `callbackIndex` can point to any callback position.
484
+ *
485
+ * This is intended for APIs such as `effect`, `lift`, or `watch` where naming
486
+ * is useful but should remain separate from the flavor system.
487
+ *
488
+ * Plain calls still work:
489
+ * `run(callback)`
490
+ *
491
+ * Captioned calls add a runtime name to the first callback:
492
+ * `` run`task:${id}`(callback) ``
493
+ *
494
+ * Anonymous uncaptioned callbacks may trigger a warning depending on
495
+ * `shouldWarnAnonymous`.
496
+ */
497
+ function captioned(fn, options = {}) {
498
+ const settings = {
499
+ callbackIndex: options.callbackIndex ?? 0,
500
+ name: options.name ?? (fn.name || 'callback'),
501
+ rename: options.rename ?? ((caption, callback) => renameCallback(caption, callback)),
502
+ // biome-ignore lint/suspicious/noConsole: This is the whole point here
503
+ warn: options.warn ?? ((message) => console.warn(message)),
504
+ shouldWarnAnonymous: options.shouldWarnAnonymous,
505
+ };
506
+ fn[captionedOptionsSymbol] = settings;
507
+ return new Proxy(fn, {
508
+ get(target, prop, receiver) {
509
+ if (prop === captionedOptionsSymbol)
510
+ return settings;
511
+ return Reflect.get(target, prop, receiver);
512
+ },
513
+ apply(target, thisArg, args) {
514
+ if (isTemplateStringsArray(args[0])) {
515
+ const caption = renderTemplate(args[0], args.slice(1));
516
+ return function captionedCall(...callArgs) {
517
+ const callback = callArgs[settings.callbackIndex];
518
+ if (typeof callback !== 'function')
519
+ throw new TypeError(`${settings.name} template calls require a callback at argument index ${settings.callbackIndex}`);
520
+ const nextArgs = [...callArgs];
521
+ nextArgs[settings.callbackIndex] = settings.rename(caption, callback);
522
+ return Reflect.apply(target, this, nextArgs);
523
+ };
524
+ }
525
+ const callback = args[settings.callbackIndex];
526
+ if (typeof callback === 'function' && isAnonymousCallback(callback)) {
527
+ const shouldWarn = settings.shouldWarnAnonymous?.(callback, args) ?? true;
528
+ if (shouldWarn)
529
+ settings.warn(`${settings.name}: anonymous callback detected. Use template syntax for automatic naming:\n` +
530
+ ` Current: ${settings.name}(() => { ... })\n` +
531
+ ` Fix: ${settings.name}\`descriptive-name\`(() => { ... })\n` +
532
+ `The captioned system uses the template literal as the effect name for better debugging.`);
533
+ }
534
+ return Reflect.apply(target, thisArg, args);
535
+ },
536
+ });
537
+ }
538
+ function inheritCaption(source, target) {
539
+ const settings = source[captionedOptionsSymbol];
540
+ return settings ? captioned(target, settings) : target;
541
+ }
450
542
  /**
451
543
  * Creates a flavored (extensible) version of a function with chainable property modifiers.
452
544
  */
@@ -479,7 +571,7 @@ function createFlavor(fn, transform, name) {
479
571
  };
480
572
  if (name)
481
573
  named(name, fct);
482
- return flavored(fct, fn.flavors || {});
574
+ return flavored(inheritCaption(fn, fct), fn.flavors || {});
483
575
  }
484
576
  /**
485
577
  * Creates a new flavored function that merges options objects at a specific index.
@@ -512,7 +604,7 @@ function flavorOptions(fn, defaultOptions, opts = {}) {
512
604
  // Preserve arity and options track
513
605
  Object.defineProperty(fct, 'length', { value: fn.length });
514
606
  fct.optionsIndex = targetIndex;
515
- return flavored(fct, fn.flavors || {});
607
+ return flavored(inheritCaption(fn, fct), fn.flavors || {});
516
608
  }
517
609
 
518
610
  /// <reference lib="esnext.collection" />
@@ -1106,6 +1198,7 @@ function resetRegistry() {
1106
1198
  * @returns The marked function
1107
1199
  */
1108
1200
  function markWithRoot(fn, root) {
1201
+ const marked = fn;
1109
1202
  // Check for collision
1110
1203
  const existingRef = reverseRoots.get(root);
1111
1204
  const existing = existingRef?.deref();
@@ -1120,8 +1213,8 @@ function markWithRoot(fn, root) {
1120
1213
  // (Last writer wins for the check)
1121
1214
  reverseRoots.set(root, new WeakRef(fn));
1122
1215
  // Store root mapping as symbol property on the function
1123
- fn[rootFunctionSymbol] = getRoot(root);
1124
- return fn;
1216
+ marked[rootFunctionSymbol] = getRoot(root);
1217
+ return marked;
1125
1218
  }
1126
1219
  /**
1127
1220
  * Gets the root function of a function for effect tracking
@@ -1141,11 +1234,30 @@ function getRoot(fn) {
1141
1234
  const effectHistory = tag('effectHistory', new ZoneHistory());
1142
1235
  tag('effectHistory.present', effectHistory.present);
1143
1236
  asyncZone.add(effectHistory);
1237
+ const externalReason = tag('externalReason', new Zone());
1238
+ asyncZone.add(externalReason);
1144
1239
  /**
1145
1240
  * Aggregator for zones that need to be tracked along effects.
1146
1241
  * ie. in each effect, the active zone of the given zoning will be the one active at effect's definition
1147
1242
  */
1148
1243
  const effectAggregator = tag('effectAggregator', new ZoneAggregator(effectHistory.present));
1244
+ effectAggregator.add(externalReason);
1245
+ function chainExternalReason(reason) {
1246
+ const external = externalReason.active;
1247
+ if (!external)
1248
+ return reason;
1249
+ if (!reason)
1250
+ return external;
1251
+ let current = reason;
1252
+ while (current) {
1253
+ if (current.type === 'external' &&
1254
+ external.type === 'external' &&
1255
+ current.detail === external.detail)
1256
+ return reason;
1257
+ current = current.chain;
1258
+ }
1259
+ return { ...reason, chain: chainExternalReason(reason.chain) };
1260
+ }
1149
1261
  function isRunning(effect) {
1150
1262
  const root = getRoot(effect);
1151
1263
  return effectHistory.some((e) => getRoot(e) === root);
@@ -1153,6 +1265,35 @@ function isRunning(effect) {
1153
1265
  function getActiveEffect() {
1154
1266
  return effectHistory.present.active;
1155
1267
  }
1268
+ /**
1269
+ * Captures the current effect context so that deferred code can later
1270
+ * create child effects parented to this point in the effect tree.
1271
+ *
1272
+ * @returns An opaque token to pass to `withEffectContext()`
1273
+ *
1274
+ * @example
1275
+ * ```ts
1276
+ * const ctx = effectContext() // inside an effect or root()
1277
+ * // later, in a deferred callback:
1278
+ * withEffectContext(ctx, () => {
1279
+ * effect(() => { /* child of the captured context *​/ })
1280
+ * })
1281
+ * ```
1282
+ */
1283
+ function effectContext() {
1284
+ return effectHistory.active;
1285
+ }
1286
+ /**
1287
+ * Runs `fn` within a previously captured effect context.
1288
+ * Any effects created inside `fn` become children of the captured parent.
1289
+ *
1290
+ * @param ctx - The context token from `effectContext()`, or `undefined` for root context
1291
+ * @param fn - The function to execute within the restored context
1292
+ * @returns The return value of `fn`
1293
+ */
1294
+ function withEffectContext(ctx, fn) {
1295
+ return effectHistory.with(ctx, fn);
1296
+ }
1156
1297
  const cleanups = new WeakMap();
1157
1298
  /**
1158
1299
  * Attach cleanup dependencies to an object. When `unlink(obj)` is called,
@@ -1180,7 +1321,7 @@ const cleanups = new WeakMap();
1180
1321
  function link(obj, ...cleanupFns) {
1181
1322
  const set = cleanups.get(obj);
1182
1323
  if (!set)
1183
- cleanups.set(obj, new Set(cleanupFns.filter(Boolean)));
1324
+ cleanups.set(obj, new Set(cleanupFns.filter((fn) => fn !== undefined)));
1184
1325
  else
1185
1326
  for (const fn of cleanupFns)
1186
1327
  if (fn)
@@ -1246,18 +1387,61 @@ function formatCleanupReason(reason, depth = 0) {
1246
1387
  parts.push(',');
1247
1388
  parts.push(...formatTrigger(reason.triggers[i]));
1248
1389
  }
1390
+ if (reason.chain) {
1391
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1392
+ }
1393
+ return parts;
1394
+ }
1395
+ case 'stopped': {
1396
+ const parts = [`${indent}stopped`];
1397
+ if (reason.detail)
1398
+ parts.push(`(${reason.detail})`);
1399
+ if (reason.chain) {
1400
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1401
+ }
1402
+ return parts;
1403
+ }
1404
+ case 'external': {
1405
+ const parts = [`${indent}external:`, reason.detail];
1406
+ if (reason.chain) {
1407
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1408
+ }
1409
+ return parts;
1410
+ }
1411
+ case 'gc': {
1412
+ const parts = [`${indent}gc`];
1413
+ if (reason.chain) {
1414
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1415
+ }
1416
+ return parts;
1417
+ }
1418
+ case 'error': {
1419
+ const parts = [`${indent}error:`, reason.error];
1420
+ if (reason.chain) {
1421
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1422
+ }
1423
+ return parts;
1424
+ }
1425
+ case 'lineage': {
1426
+ const parts = [
1427
+ `${indent}lineage ←\n`,
1428
+ ...formatCleanupReason(reason.parent, depth + 1),
1429
+ ];
1430
+ if (reason.chain) {
1431
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1432
+ }
1433
+ return parts;
1434
+ }
1435
+ case 'invalidate': {
1436
+ const parts = [
1437
+ `${indent}invalidate ←\n`,
1438
+ ...formatCleanupReason(reason.cause, depth + 1),
1439
+ ];
1440
+ if (reason.chain) {
1441
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1442
+ }
1249
1443
  return parts;
1250
1444
  }
1251
- case 'stopped':
1252
- return [`${indent}stopped`];
1253
- case 'gc':
1254
- return [`${indent}gc`];
1255
- case 'error':
1256
- return [`${indent}error:`, reason.error];
1257
- case 'lineage':
1258
- return [`${indent}lineage ←\n`, ...formatCleanupReason(reason.parent, depth + 1)];
1259
- case 'invalidate':
1260
- return [`${indent}invalidate ←\n`, ...formatCleanupReason(reason.cause, depth + 1)];
1261
1445
  case 'multiple': {
1262
1446
  const parts = [];
1263
1447
  for (let i = 0; i < reason.reasons.length; i++) {
@@ -1265,6 +1449,9 @@ function formatCleanupReason(reason, depth = 0) {
1265
1449
  parts.push('\n');
1266
1450
  parts.push(...formatCleanupReason(reason.reasons[i], depth));
1267
1451
  }
1452
+ if (reason.chain) {
1453
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1454
+ }
1268
1455
  return parts;
1269
1456
  }
1270
1457
  }
@@ -1465,6 +1652,8 @@ const options = {
1465
1652
  asyncMode: 'cancel',
1466
1653
  // biome-ignore lint/suspicious/noConsole: This is the whole point here
1467
1654
  warn: (...args) => console.warn(...args),
1655
+ // biome-ignore lint/suspicious/noConsole: This is the whole point here
1656
+ error: (...args) => console.error(...args),
1468
1657
  /**
1469
1658
  * Introspection and debug aids. Set to `null` to disable all debug overhead in production.
1470
1659
  *
@@ -1594,7 +1783,7 @@ function dependant(obj, prop = allProps) {
1594
1783
  return;
1595
1784
  const node = getEffectNode(currentActiveEffect);
1596
1785
  if ('dependencyHook' in node) {
1597
- node.dependencyHook(obj, prop);
1786
+ node.dependencyHook?.(obj, prop);
1598
1787
  }
1599
1788
  let objectWatchers = exports.watchers.get(obj);
1600
1789
  if (!objectWatchers) {
@@ -1660,6 +1849,9 @@ function formatRoots(roots, limit = 20) {
1660
1849
  const end = names.slice(-10);
1661
1850
  return `${start.join(' → ')} ... (${names.length - 15} more) ... ${end.join(' → ')}`;
1662
1851
  }
1852
+ function externalReasonFrom(fn) {
1853
+ return fn.name ? { type: 'external', detail: fn.name } : undefined;
1854
+ }
1663
1855
  // Nested map structure for efficient counting and batch cleanup
1664
1856
  // batchId -> effect root -> obj -> prop -> count
1665
1857
  let activationRegistry;
@@ -2102,6 +2294,14 @@ function addToBatch(effect, caller, immediate, reason) {
2102
2294
  // Build reason from pending triggers if not provided
2103
2295
  if (!reason && node.pendingTriggers) {
2104
2296
  reason = { type: 'propChange', triggers: node.pendingTriggers };
2297
+ // Add chain: if this is being triggered from another effect, get its reason
2298
+ if (caller) {
2299
+ const callerNode = getEffectNode(caller);
2300
+ if (callerNode.currentReason) {
2301
+ reason.chain = callerNode.currentReason;
2302
+ }
2303
+ }
2304
+ reason = chainExternalReason(reason);
2105
2305
  }
2106
2306
  node.pendingTriggers = undefined;
2107
2307
  if (reason) {
@@ -2370,7 +2570,7 @@ function executeNext(effectuatedRoots) {
2370
2570
  }
2371
2571
  // Track which sub-effects have been executed to prevent infinite loops
2372
2572
  // These are all the effects triggered under `activeEffect` and all their sub-effects
2373
- function batch(effect, immediate) {
2573
+ function batch(effect, immediate, caller) {
2374
2574
  if (broken) {
2375
2575
  throw new ReactiveError('[reactive] Reactive system is broken after an unrecoverable error. Call reset() to recover.', { code: exports.ReactiveErrorCode.BrokenEffects });
2376
2576
  }
@@ -2386,11 +2586,12 @@ function batch(effect, immediate) {
2386
2586
  optionCall('beginChain', roots);
2387
2587
  }
2388
2588
  // TODO: Consider this has been produced but was useless - it might be more correct ?const caller = executingStack.length > 0 ? getActiveEffect() : undefined
2389
- const caller = getActiveEffect();
2589
+ const activeCaller = getActiveEffect();
2590
+ const callerToUse = caller || activeCaller;
2390
2591
  // Optimization: If nested and NOT immediate, just join the existing batch
2391
2592
  if (!isNewBatch && !immediate) {
2392
2593
  for (let i = 0; i < effect.length; i++) {
2393
- addToBatch(effect[i], caller);
2594
+ addToBatch(effect[i], callerToUse);
2394
2595
  }
2395
2596
  return;
2396
2597
  }
@@ -2429,7 +2630,7 @@ function batch(effect, immediate) {
2429
2630
  else {
2430
2631
  // Add initial effects to batch and compute dependencies
2431
2632
  for (let i = 0; i < effect.length; i++) {
2432
- addToBatch(effect[i], caller, false);
2633
+ addToBatch(effect[i], callerToUse, false);
2433
2634
  }
2434
2635
  computeAllInDegrees(currentBatch);
2435
2636
  }
@@ -2483,6 +2684,11 @@ function batch(effect, immediate) {
2483
2684
  success = true;
2484
2685
  return firstReturn.value;
2485
2686
  }
2687
+ catch (error) {
2688
+ if (batchStack.length === 1)
2689
+ optionCall('error', '[reactive] Root batch failure before broken state:', error);
2690
+ throw error;
2691
+ }
2486
2692
  finally {
2487
2693
  if (!success && batchStack.length === 1) {
2488
2694
  broken = true;
@@ -2583,7 +2789,7 @@ const fr = new FinalizationRegistry((f) => f());
2583
2789
  * @param options - Options for effect execution
2584
2790
  * @returns A cleanup function to stop the effect
2585
2791
  */
2586
- const effect = named(effectMarker.leave, flavored(function effect(fn, effectOptions = {}) {
2792
+ const effect = captioned(named(effectMarker.leave, flavored(function effect(fn, effectOptions = {}) {
2587
2793
  if (effectOptions?.name)
2588
2794
  Object.defineProperty(fn, 'name', { value: effectOptions.name });
2589
2795
  // Use per-effect asyncMode or fall back to global option
@@ -2596,7 +2802,10 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2596
2802
  const prevCleanup = node.cleanup;
2597
2803
  node.cleanup = undefined;
2598
2804
  try {
2599
- untracked(() => prevCleanup(node.nextReason || { type: 'stopped' }));
2805
+ untracked `effect:cleanup`(() => prevCleanup(chainExternalReason(node.nextReason || {
2806
+ type: 'stopped',
2807
+ chain: node.currentReason,
2808
+ })));
2600
2809
  }
2601
2810
  catch (error) {
2602
2811
  // If we want to report them, we could use options.warn or similar
@@ -2629,6 +2838,9 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2629
2838
  }
2630
2839
  // Set reaction reason for the upcoming run
2631
2840
  access.reaction = node.nextReason || access.reaction;
2841
+ node.currentReason =
2842
+ node.nextReason ||
2843
+ (access.reaction && access.reaction !== true ? access.reaction : undefined);
2632
2844
  node.nextReason = undefined;
2633
2845
  optionCall('enter', getRoot(fn));
2634
2846
  optionCall('effectRun', getRoot(fn), access.reaction);
@@ -2691,7 +2903,8 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2691
2903
  // This ensures that when we cancel, the original promise's .catch() handlers are triggered
2692
2904
  // We do this by rejecting the race promise, which makes the original promise chain see the rejection
2693
2905
  // through the zone-wrapped .then()/.catch() handlers
2694
- runningPromise = runningPromise.catch((error) => {
2906
+ runningPromise = runningPromise
2907
+ .catch((error) => {
2695
2908
  // Propagate async errors to the effect's error handler
2696
2909
  // This ensures onEffectThrow handlers are triggered for async errors
2697
2910
  if (error !== cancelError) {
@@ -2699,6 +2912,10 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2699
2912
  }
2700
2913
  // If thrower didn't throw (handled), we absorb the error.
2701
2914
  // If thrower threw (unhandled), it propagates as a new unhandled rejection, which is correct.
2915
+ })
2916
+ .finally(() => {
2917
+ // Clear currentReason when async effect completes
2918
+ node.currentReason = undefined;
2702
2919
  });
2703
2920
  }
2704
2921
  else {
@@ -2709,7 +2926,13 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2709
2926
  catch (error) {
2710
2927
  debugHooks.decorateError(error, runEffect);
2711
2928
  // catcher:self`
2712
- errorToThrow = error;
2929
+ errorToThrow = error instanceof Error ? error : new Error(String(error));
2930
+ }
2931
+ finally {
2932
+ // Clear currentReason for synchronous effects
2933
+ if (!runningPromise) {
2934
+ node.currentReason = undefined;
2935
+ }
2713
2936
  }
2714
2937
  // Create cleanup function for next run
2715
2938
  node.cleanup = (reason) => {
@@ -2740,8 +2963,11 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2740
2963
  const childReason = reason
2741
2964
  ? reason.type === 'lineage'
2742
2965
  ? reason
2743
- : { type: 'lineage', parent: reason }
2744
- : { type: 'stopped' };
2966
+ : { type: 'lineage', parent: reason, chain: node.currentReason }
2967
+ : (chainExternalReason({ type: 'stopped', chain: node.currentReason }) ?? {
2968
+ type: 'stopped',
2969
+ chain: node.currentReason,
2970
+ });
2745
2971
  for (const childCleanup of children)
2746
2972
  childCleanup(childReason);
2747
2973
  delete node.children;
@@ -2754,7 +2980,7 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2754
2980
  const node = getEffectNode(runEffect);
2755
2981
  if (debugHooks.isDevtoolsEnabled()) {
2756
2982
  const stack = debugHooks.captureStack(); // Robustly skips internal mutts frames
2757
- if (Array.isArray(stack) && stack.length > 0) {
2983
+ if (stack) {
2758
2984
  node.creationStack = stack;
2759
2985
  }
2760
2986
  }
@@ -2814,7 +3040,7 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2814
3040
  runningPromise = null;
2815
3041
  }
2816
3042
  try {
2817
- node.cleanup?.(reason || { type: 'stopped' });
3043
+ node.cleanup?.(chainExternalReason(reason || { type: 'stopped', chain: node.currentReason }));
2818
3044
  }
2819
3045
  catch (error) {
2820
3046
  // Cleanup errors should basically be ignored or at least not stop the world
@@ -2857,30 +3083,35 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2857
3083
  named(name) {
2858
3084
  return flavorOptions(this, { name }, { name: 'named' });
2859
3085
  },
2860
- }));
2861
- /**
2862
- * Executes a function without tracking dependencies but maintains parent cleanup relationship
2863
- * Effects created inside will still be cleaned up when the parent effect is destroyed
2864
- * @param fn - The function to execute
2865
- */
2866
- function untracked(fn) {
2867
- return effectHistory.present.root(fn);
2868
- }
3086
+ })), {
3087
+ name: 'effect',
3088
+ warn: (message) => options.warn(`[reactive] ${message}`),
3089
+ shouldWarnAnonymous: (_callback, args) => !(args[1] && typeof args[1] === 'object' && 'name' in args[1]),
3090
+ });
3091
+ const untracked = captioned(function untracked(fn) {
3092
+ const external = externalReasonFrom(fn);
3093
+ return external
3094
+ ? externalReason.with(external, () => effectHistory.present.root(fn))
3095
+ : effectHistory.present.root(fn);
3096
+ });
2869
3097
  /**
2870
3098
  * Executes a function from a virgin/root context - no parent effect, no tracking
2871
3099
  * Creates completely independent effects that won't be cleaned up by any parent
2872
3100
  * @param fn - The function to execute
2873
3101
  */
2874
- function root(fn) {
2875
- return effectHistory.root(fn);
2876
- }
3102
+ const root = captioned(function root(fn) {
3103
+ const external = externalReasonFrom(fn);
3104
+ return external
3105
+ ? externalReason.with(external, () => effectHistory.root(fn))
3106
+ : effectHistory.root(fn);
3107
+ });
2877
3108
  function biDi(received, get, set) {
2878
3109
  if (typeof get !== 'function') {
2879
3110
  set = get.set;
2880
3111
  get = get.get;
2881
3112
  }
2882
3113
  let programmaticallySetValue = Symbol();
2883
- effect.named('biDi')(markWithRoot(() => {
3114
+ effect `biDi`(markWithRoot(() => {
2884
3115
  const newValue = get();
2885
3116
  const pValue = programmaticallySetValue;
2886
3117
  programmaticallySetValue = Symbol();
@@ -3031,7 +3262,8 @@ function collectEffects(obj, evolution, effects, objectWatchers, ...keyChains) {
3031
3262
  const deps = objectWatchers.get(key);
3032
3263
  if (deps) {
3033
3264
  // Make sure `some.prop++` does not keep a dependency to `some.props`
3034
- deps.delete(sourceEffect);
3265
+ if (sourceEffect)
3266
+ deps.delete(sourceEffect);
3035
3267
  for (const effect of deps) {
3036
3268
  const runningChain = isRunning(effect);
3037
3269
  if (runningChain) {
@@ -3077,6 +3309,7 @@ function touched(obj, evolution, props) {
3077
3309
  else
3078
3310
  collectEffects(obj, evolution, effects, objectWatchers, objectWatchers.keys());
3079
3311
  const triggers = Array.from(effects.keys());
3312
+ const sourceEffect = getActiveEffect();
3080
3313
  optionCall('touched', obj, evolution, props, triggers);
3081
3314
  // Store pending triggers for CleanupReason before batching
3082
3315
  if (options.introspection?.gatherReasons) {
@@ -3098,7 +3331,7 @@ function touched(obj, evolution, props) {
3098
3331
  });
3099
3332
  }
3100
3333
  }
3101
- batch(triggers);
3334
+ batch(triggers, undefined, sourceEffect);
3102
3335
  }
3103
3336
  // Bubble up changes if this object has deep watchers
3104
3337
  if (objectsWithDeepWatchers.has(obj)) {
@@ -3172,7 +3405,7 @@ function touchedOpaque(obj, evolution, prop) {
3172
3405
  }
3173
3406
  if (effects.size > 0) {
3174
3407
  optionCall('touched', obj, evolution, [prop], Array.from(effects));
3175
- batch(Array.from(effects));
3408
+ batch(Array.from(effects), undefined, sourceEffect);
3176
3409
  }
3177
3410
  }
3178
3411
 
@@ -3194,9 +3427,10 @@ function addUnreactiveProps(proto, set) {
3194
3427
  return proto;
3195
3428
  }
3196
3429
  // Merge sets
3197
- set = proto[unreactiveProperties] = new Set(proto[unreactiveProperties]);
3430
+ const merged = new Set(existing);
3431
+ proto[unreactiveProperties] = merged;
3198
3432
  for (const p of set)
3199
- existing.add(p);
3433
+ merged.add(p);
3200
3434
  }
3201
3435
  // If no set, mark as fully unreactive, otherwise create set
3202
3436
  else
@@ -3295,7 +3529,7 @@ function notifyPropertyChange(targetObj, prop, oldValue, newValue, hadProperty)
3295
3529
  const origin = { obj: unwrappedObj, prop };
3296
3530
  // Deep touch: only notify nested property changes with origin filtering
3297
3531
  // Don't notify direct property change - the whole point is to avoid parent effects re-running
3298
- const changes = untracked(() => recursiveTouch(oldValue, newValue, new WeakMap(), [], origin));
3532
+ const changes = untracked `deepTouch:recursive`(() => recursiveTouch(oldValue, newValue, new WeakMap(), [], origin));
3299
3533
  // When deep touch found no child differences, the object identity still changed.
3300
3534
  // Migrate watchers from old → new so the dependency chain is preserved.
3301
3535
  if (changes.length === 0) {
@@ -3521,6 +3755,17 @@ const subsRegister = new WeakMap();
3521
3755
  // Internal untracked flag for setter/getter operations - only used when testing oldValue while setting a value
3522
3756
  // TODO: `touched` trigger also compares to old value and should use the internalUntracked flag
3523
3757
  let internalUntracked = false;
3758
+ function wrapReactiveValue(obj, prop, value) {
3759
+ if (!isReactive(value) && typeof value === 'object' && value !== null) {
3760
+ const reactiveValue = reactiveObject(value);
3761
+ // Only create back-references if this object needs them
3762
+ if (needsBackReferences(obj)) {
3763
+ addBackReference(reactiveValue, obj, prop);
3764
+ }
3765
+ return reactiveValue;
3766
+ }
3767
+ return value;
3768
+ }
3524
3769
  const reactiveHandlers = {
3525
3770
  [Symbol.toStringTag]: 'MutTs Reactive',
3526
3771
  get(obj, prop, receiver) {
@@ -3548,6 +3793,10 @@ const reactiveHandlers = {
3548
3793
  // Symbols: fast-path — no reactivity tracking
3549
3794
  if (typeof prop === 'symbol' || prop === 'constructor' || isUnreactiveProp(obj, prop))
3550
3795
  return FoolProof.get(obj, prop, receiver);
3796
+ if (!getActiveEffect()) {
3797
+ const value = (subsRegister.get(obj)?.get || FoolProof.get)(obj, prop, receiver);
3798
+ return wrapReactiveValue(obj, prop, value);
3799
+ }
3551
3800
  // Check if property exists using a trap-free walk to avoid triggering
3552
3801
  // the has-trap cascade on prototype chains of reactive proxies.
3553
3802
  const isOwnProp = Object.hasOwn(obj, prop);
@@ -3587,15 +3836,7 @@ const reactiveHandlers = {
3587
3836
  // For arrays, use FoolProof.get (Indexer path) for numeric index reactivity.
3588
3837
  // For all other objects, inline Reflect.get directly (skips 3 function calls).
3589
3838
  const value = (subsRegister.get(obj)?.get || FoolProof.get)(obj, prop, receiver);
3590
- if (!isReactive(value) && typeof value === 'object' && value !== null) {
3591
- const reactiveValue = reactiveObject(value);
3592
- // Only create back-references if this object needs them
3593
- if (needsBackReferences(obj)) {
3594
- addBackReference(reactiveValue, obj, prop);
3595
- }
3596
- return reactiveValue;
3597
- }
3598
- return value;
3839
+ return wrapReactiveValue(obj, prop, value);
3599
3840
  },
3600
3841
  set(obj, prop, value, receiver) {
3601
3842
  const unwrapped = unwrap(receiver);
@@ -3772,6 +4013,7 @@ const reactive = decorator({
3772
4013
  });
3773
4014
 
3774
4015
  exports.AZone = AZone;
4016
+ exports.CompareSymbol = CompareSymbol;
3775
4017
  exports.DecoratorError = DecoratorError;
3776
4018
  exports.FoolProof = FoolProof;
3777
4019
  exports.IterableWeakMap = IterableWeakMap;
@@ -3795,8 +4037,10 @@ exports.atom = atom;
3795
4037
  exports.atomic = atomic;
3796
4038
  exports.batch = batch;
3797
4039
  exports.biDi = biDi;
4040
+ exports.captioned = captioned;
3798
4041
  exports.captured = captured;
3799
4042
  exports.caught = caught;
4043
+ exports.chainExternalReason = chainExternalReason;
3800
4044
  exports.contentRef = contentRef;
3801
4045
  exports.createFlavor = createFlavor;
3802
4046
  exports.debugPreset = debugPreset;
@@ -3808,6 +4052,7 @@ exports.dependant = dependant;
3808
4052
  exports.devPreset = devPreset;
3809
4053
  exports.effect = effect;
3810
4054
  exports.effectAggregator = effectAggregator;
4055
+ exports.effectContext = effectContext;
3811
4056
  exports.effectHistory = effectHistory;
3812
4057
  exports.effectMarker = effectMarker;
3813
4058
  exports.effectToDeepWatchedObjects = effectToDeepWatchedObjects;
@@ -3820,6 +4065,7 @@ exports.getEffectNode = getEffectNode;
3820
4065
  exports.getRoot = getRoot;
3821
4066
  exports.getState = getState;
3822
4067
  exports.hooks = hooks;
4068
+ exports.inheritCaption = inheritCaption;
3823
4069
  exports.isConstructor = isConstructor;
3824
4070
  exports.isDev = isDev;
3825
4071
  exports.isNonReactive = isNonReactive;
@@ -3857,6 +4103,7 @@ exports.unlink = unlink;
3857
4103
  exports.unreactiveProperties = unreactiveProperties;
3858
4104
  exports.untracked = untracked;
3859
4105
  exports.unwrap = unwrap;
4106
+ exports.withEffectContext = withEffectContext;
3860
4107
  exports.wrapProtos = wrapProtos;
3861
4108
  exports.zip = zip;
3862
- //# sourceMappingURL=proxy-BvM4yewA.cjs.map
4109
+ //# sourceMappingURL=proxy-DBHj3kGK.cjs.map