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
@@ -136,13 +136,10 @@ function isOwnAccessor(obj, prop) {
136
136
  return !!(opd?.get || opd?.set);
137
137
  }
138
138
  /**
139
- * Deeply compares two values.
140
- * For objects, compares prototypes with === and then own properties recursively.
141
- * Uses a cache to handle circular references.
142
- * @param a - First value
143
- * @param b - Second value
144
- * @param cache - Map for circular reference protection (internal use)
145
- * @returns True if values are deeply equal
139
+ * Symbol used to provide custom comparison logic for an object.
140
+ */
141
+ const CompareSymbol = Symbol.for('mutts.compare');
142
+ /**
146
143
  */
147
144
  function deepCompare(a, b, cache = new Map()) {
148
145
  if (a === b)
@@ -150,6 +147,13 @@ function deepCompare(a, b, cache = new Map()) {
150
147
  if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
151
148
  return a === b;
152
149
  }
150
+ // Custom comparison support
151
+ if (typeof a[CompareSymbol] === 'function') {
152
+ return a[CompareSymbol](b, (x, y) => deepCompare(x, y, cache));
153
+ }
154
+ if (typeof b[CompareSymbol] === 'function') {
155
+ return b[CompareSymbol](a, (x, y) => deepCompare(x, y, cache));
156
+ }
153
157
  // Prototype check
154
158
  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b))
155
159
  return false;
@@ -268,7 +272,8 @@ function named(name, fn) {
268
272
  });
269
273
  return fn;
270
274
  }
271
- const _mode = (typeof process !== 'undefined' && process.env?.NODE_ENV) ||
275
+ const runtimeGlobals = globalThis;
276
+ const _mode = runtimeGlobals.process?.env?.NODE_ENV ||
272
277
  (typeof import.meta !== 'undefined' && import.meta.env?.MODE) ||
273
278
  'production';
274
279
  const isDev = _mode === 'development';
@@ -444,6 +449,93 @@ const decorator = (description) => {
444
449
  * flavoredGreet.loud('World') // "HELLO, WORLD!"
445
450
  * ```
446
451
  */
452
+ const captionedOptionsSymbol = Symbol('mutts.captioned.options');
453
+ function isTemplateStringsArray(value) {
454
+ return (Array.isArray(value) &&
455
+ Object.hasOwn(value, 'raw') &&
456
+ Array.isArray(value.raw));
457
+ }
458
+ function renderTemplate(strings, values) {
459
+ let result = strings[0] ?? '';
460
+ for (let i = 0; i < values.length; i++)
461
+ result += String(values[i]) + (strings[i + 1] ?? '');
462
+ return result;
463
+ }
464
+ function renameCallback(caption, callback) {
465
+ Object.defineProperty(callback, 'name', {
466
+ value: caption,
467
+ writable: false,
468
+ configurable: true,
469
+ });
470
+ return callback;
471
+ }
472
+ function isAnonymousCallback(callback) {
473
+ return !callback.name || callback.name === 'anonymous';
474
+ }
475
+ /**
476
+ * Wraps a callback-first function so it also accepts a tagged-template call form.
477
+ *
478
+ * The template caption is applied to one callback argument before the base
479
+ * function runs. By default, `captioned` targets the first argument, but
480
+ * `callbackIndex` can point to any callback position.
481
+ *
482
+ * This is intended for APIs such as `effect`, `lift`, or `watch` where naming
483
+ * is useful but should remain separate from the flavor system.
484
+ *
485
+ * Plain calls still work:
486
+ * `run(callback)`
487
+ *
488
+ * Captioned calls add a runtime name to the first callback:
489
+ * `` run`task:${id}`(callback) ``
490
+ *
491
+ * Anonymous uncaptioned callbacks may trigger a warning depending on
492
+ * `shouldWarnAnonymous`.
493
+ */
494
+ function captioned(fn, options = {}) {
495
+ const settings = {
496
+ callbackIndex: options.callbackIndex ?? 0,
497
+ name: options.name ?? (fn.name || 'callback'),
498
+ rename: options.rename ?? ((caption, callback) => renameCallback(caption, callback)),
499
+ // biome-ignore lint/suspicious/noConsole: This is the whole point here
500
+ warn: options.warn ?? ((message) => console.warn(message)),
501
+ shouldWarnAnonymous: options.shouldWarnAnonymous,
502
+ };
503
+ fn[captionedOptionsSymbol] = settings;
504
+ return new Proxy(fn, {
505
+ get(target, prop, receiver) {
506
+ if (prop === captionedOptionsSymbol)
507
+ return settings;
508
+ return Reflect.get(target, prop, receiver);
509
+ },
510
+ apply(target, thisArg, args) {
511
+ if (isTemplateStringsArray(args[0])) {
512
+ const caption = renderTemplate(args[0], args.slice(1));
513
+ return function captionedCall(...callArgs) {
514
+ const callback = callArgs[settings.callbackIndex];
515
+ if (typeof callback !== 'function')
516
+ throw new TypeError(`${settings.name} template calls require a callback at argument index ${settings.callbackIndex}`);
517
+ const nextArgs = [...callArgs];
518
+ nextArgs[settings.callbackIndex] = settings.rename(caption, callback);
519
+ return Reflect.apply(target, this, nextArgs);
520
+ };
521
+ }
522
+ const callback = args[settings.callbackIndex];
523
+ if (typeof callback === 'function' && isAnonymousCallback(callback)) {
524
+ const shouldWarn = settings.shouldWarnAnonymous?.(callback, args) ?? true;
525
+ if (shouldWarn)
526
+ settings.warn(`${settings.name}: anonymous callback detected. Use template syntax for automatic naming:\n` +
527
+ ` Current: ${settings.name}(() => { ... })\n` +
528
+ ` Fix: ${settings.name}\`descriptive-name\`(() => { ... })\n` +
529
+ `The captioned system uses the template literal as the effect name for better debugging.`);
530
+ }
531
+ return Reflect.apply(target, thisArg, args);
532
+ },
533
+ });
534
+ }
535
+ function inheritCaption(source, target) {
536
+ const settings = source[captionedOptionsSymbol];
537
+ return settings ? captioned(target, settings) : target;
538
+ }
447
539
  /**
448
540
  * Creates a flavored (extensible) version of a function with chainable property modifiers.
449
541
  */
@@ -476,7 +568,7 @@ function createFlavor(fn, transform, name) {
476
568
  };
477
569
  if (name)
478
570
  named(name, fct);
479
- return flavored(fct, fn.flavors || {});
571
+ return flavored(inheritCaption(fn, fct), fn.flavors || {});
480
572
  }
481
573
  /**
482
574
  * Creates a new flavored function that merges options objects at a specific index.
@@ -509,7 +601,7 @@ function flavorOptions(fn, defaultOptions, opts = {}) {
509
601
  // Preserve arity and options track
510
602
  Object.defineProperty(fct, 'length', { value: fn.length });
511
603
  fct.optionsIndex = targetIndex;
512
- return flavored(fct, fn.flavors || {});
604
+ return flavored(inheritCaption(fn, fct), fn.flavors || {});
513
605
  }
514
606
 
515
607
  /// <reference lib="esnext.collection" />
@@ -1103,6 +1195,7 @@ function resetRegistry() {
1103
1195
  * @returns The marked function
1104
1196
  */
1105
1197
  function markWithRoot(fn, root) {
1198
+ const marked = fn;
1106
1199
  // Check for collision
1107
1200
  const existingRef = reverseRoots.get(root);
1108
1201
  const existing = existingRef?.deref();
@@ -1117,8 +1210,8 @@ function markWithRoot(fn, root) {
1117
1210
  // (Last writer wins for the check)
1118
1211
  reverseRoots.set(root, new WeakRef(fn));
1119
1212
  // Store root mapping as symbol property on the function
1120
- fn[rootFunctionSymbol] = getRoot(root);
1121
- return fn;
1213
+ marked[rootFunctionSymbol] = getRoot(root);
1214
+ return marked;
1122
1215
  }
1123
1216
  /**
1124
1217
  * Gets the root function of a function for effect tracking
@@ -1138,11 +1231,30 @@ function getRoot(fn) {
1138
1231
  const effectHistory = tag('effectHistory', new ZoneHistory());
1139
1232
  tag('effectHistory.present', effectHistory.present);
1140
1233
  asyncZone.add(effectHistory);
1234
+ const externalReason = tag('externalReason', new Zone());
1235
+ asyncZone.add(externalReason);
1141
1236
  /**
1142
1237
  * Aggregator for zones that need to be tracked along effects.
1143
1238
  * ie. in each effect, the active zone of the given zoning will be the one active at effect's definition
1144
1239
  */
1145
1240
  const effectAggregator = tag('effectAggregator', new ZoneAggregator(effectHistory.present));
1241
+ effectAggregator.add(externalReason);
1242
+ function chainExternalReason(reason) {
1243
+ const external = externalReason.active;
1244
+ if (!external)
1245
+ return reason;
1246
+ if (!reason)
1247
+ return external;
1248
+ let current = reason;
1249
+ while (current) {
1250
+ if (current.type === 'external' &&
1251
+ external.type === 'external' &&
1252
+ current.detail === external.detail)
1253
+ return reason;
1254
+ current = current.chain;
1255
+ }
1256
+ return { ...reason, chain: chainExternalReason(reason.chain) };
1257
+ }
1146
1258
  function isRunning(effect) {
1147
1259
  const root = getRoot(effect);
1148
1260
  return effectHistory.some((e) => getRoot(e) === root);
@@ -1150,6 +1262,35 @@ function isRunning(effect) {
1150
1262
  function getActiveEffect() {
1151
1263
  return effectHistory.present.active;
1152
1264
  }
1265
+ /**
1266
+ * Captures the current effect context so that deferred code can later
1267
+ * create child effects parented to this point in the effect tree.
1268
+ *
1269
+ * @returns An opaque token to pass to `withEffectContext()`
1270
+ *
1271
+ * @example
1272
+ * ```ts
1273
+ * const ctx = effectContext() // inside an effect or root()
1274
+ * // later, in a deferred callback:
1275
+ * withEffectContext(ctx, () => {
1276
+ * effect(() => { /* child of the captured context *​/ })
1277
+ * })
1278
+ * ```
1279
+ */
1280
+ function effectContext() {
1281
+ return effectHistory.active;
1282
+ }
1283
+ /**
1284
+ * Runs `fn` within a previously captured effect context.
1285
+ * Any effects created inside `fn` become children of the captured parent.
1286
+ *
1287
+ * @param ctx - The context token from `effectContext()`, or `undefined` for root context
1288
+ * @param fn - The function to execute within the restored context
1289
+ * @returns The return value of `fn`
1290
+ */
1291
+ function withEffectContext(ctx, fn) {
1292
+ return effectHistory.with(ctx, fn);
1293
+ }
1153
1294
  const cleanups = new WeakMap();
1154
1295
  /**
1155
1296
  * Attach cleanup dependencies to an object. When `unlink(obj)` is called,
@@ -1177,7 +1318,7 @@ const cleanups = new WeakMap();
1177
1318
  function link(obj, ...cleanupFns) {
1178
1319
  const set = cleanups.get(obj);
1179
1320
  if (!set)
1180
- cleanups.set(obj, new Set(cleanupFns.filter(Boolean)));
1321
+ cleanups.set(obj, new Set(cleanupFns.filter((fn) => fn !== undefined)));
1181
1322
  else
1182
1323
  for (const fn of cleanupFns)
1183
1324
  if (fn)
@@ -1243,18 +1384,61 @@ function formatCleanupReason(reason, depth = 0) {
1243
1384
  parts.push(',');
1244
1385
  parts.push(...formatTrigger(reason.triggers[i]));
1245
1386
  }
1387
+ if (reason.chain) {
1388
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1389
+ }
1390
+ return parts;
1391
+ }
1392
+ case 'stopped': {
1393
+ const parts = [`${indent}stopped`];
1394
+ if (reason.detail)
1395
+ parts.push(`(${reason.detail})`);
1396
+ if (reason.chain) {
1397
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1398
+ }
1399
+ return parts;
1400
+ }
1401
+ case 'external': {
1402
+ const parts = [`${indent}external:`, reason.detail];
1403
+ if (reason.chain) {
1404
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1405
+ }
1406
+ return parts;
1407
+ }
1408
+ case 'gc': {
1409
+ const parts = [`${indent}gc`];
1410
+ if (reason.chain) {
1411
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1412
+ }
1413
+ return parts;
1414
+ }
1415
+ case 'error': {
1416
+ const parts = [`${indent}error:`, reason.error];
1417
+ if (reason.chain) {
1418
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1419
+ }
1420
+ return parts;
1421
+ }
1422
+ case 'lineage': {
1423
+ const parts = [
1424
+ `${indent}lineage ←\n`,
1425
+ ...formatCleanupReason(reason.parent, depth + 1),
1426
+ ];
1427
+ if (reason.chain) {
1428
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1429
+ }
1430
+ return parts;
1431
+ }
1432
+ case 'invalidate': {
1433
+ const parts = [
1434
+ `${indent}invalidate ←\n`,
1435
+ ...formatCleanupReason(reason.cause, depth + 1),
1436
+ ];
1437
+ if (reason.chain) {
1438
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1439
+ }
1246
1440
  return parts;
1247
1441
  }
1248
- case 'stopped':
1249
- return [`${indent}stopped`];
1250
- case 'gc':
1251
- return [`${indent}gc`];
1252
- case 'error':
1253
- return [`${indent}error:`, reason.error];
1254
- case 'lineage':
1255
- return [`${indent}lineage ←\n`, ...formatCleanupReason(reason.parent, depth + 1)];
1256
- case 'invalidate':
1257
- return [`${indent}invalidate ←\n`, ...formatCleanupReason(reason.cause, depth + 1)];
1258
1442
  case 'multiple': {
1259
1443
  const parts = [];
1260
1444
  for (let i = 0; i < reason.reasons.length; i++) {
@@ -1262,6 +1446,9 @@ function formatCleanupReason(reason, depth = 0) {
1262
1446
  parts.push('\n');
1263
1447
  parts.push(...formatCleanupReason(reason.reasons[i], depth));
1264
1448
  }
1449
+ if (reason.chain) {
1450
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1451
+ }
1265
1452
  return parts;
1266
1453
  }
1267
1454
  }
@@ -1462,6 +1649,8 @@ const options = {
1462
1649
  asyncMode: 'cancel',
1463
1650
  // biome-ignore lint/suspicious/noConsole: This is the whole point here
1464
1651
  warn: (...args) => console.warn(...args),
1652
+ // biome-ignore lint/suspicious/noConsole: This is the whole point here
1653
+ error: (...args) => console.error(...args),
1465
1654
  /**
1466
1655
  * Introspection and debug aids. Set to `null` to disable all debug overhead in production.
1467
1656
  *
@@ -1591,7 +1780,7 @@ function dependant(obj, prop = allProps) {
1591
1780
  return;
1592
1781
  const node = getEffectNode(currentActiveEffect);
1593
1782
  if ('dependencyHook' in node) {
1594
- node.dependencyHook(obj, prop);
1783
+ node.dependencyHook?.(obj, prop);
1595
1784
  }
1596
1785
  let objectWatchers = watchers.get(obj);
1597
1786
  if (!objectWatchers) {
@@ -1657,6 +1846,9 @@ function formatRoots(roots, limit = 20) {
1657
1846
  const end = names.slice(-10);
1658
1847
  return `${start.join(' → ')} ... (${names.length - 15} more) ... ${end.join(' → ')}`;
1659
1848
  }
1849
+ function externalReasonFrom(fn) {
1850
+ return fn.name ? { type: 'external', detail: fn.name } : undefined;
1851
+ }
1660
1852
  // Nested map structure for efficient counting and batch cleanup
1661
1853
  // batchId -> effect root -> obj -> prop -> count
1662
1854
  let activationRegistry;
@@ -2099,6 +2291,14 @@ function addToBatch(effect, caller, immediate, reason) {
2099
2291
  // Build reason from pending triggers if not provided
2100
2292
  if (!reason && node.pendingTriggers) {
2101
2293
  reason = { type: 'propChange', triggers: node.pendingTriggers };
2294
+ // Add chain: if this is being triggered from another effect, get its reason
2295
+ if (caller) {
2296
+ const callerNode = getEffectNode(caller);
2297
+ if (callerNode.currentReason) {
2298
+ reason.chain = callerNode.currentReason;
2299
+ }
2300
+ }
2301
+ reason = chainExternalReason(reason);
2102
2302
  }
2103
2303
  node.pendingTriggers = undefined;
2104
2304
  if (reason) {
@@ -2367,7 +2567,7 @@ function executeNext(effectuatedRoots) {
2367
2567
  }
2368
2568
  // Track which sub-effects have been executed to prevent infinite loops
2369
2569
  // These are all the effects triggered under `activeEffect` and all their sub-effects
2370
- function batch(effect, immediate) {
2570
+ function batch(effect, immediate, caller) {
2371
2571
  if (broken) {
2372
2572
  throw new ReactiveError('[reactive] Reactive system is broken after an unrecoverable error. Call reset() to recover.', { code: ReactiveErrorCode.BrokenEffects });
2373
2573
  }
@@ -2383,11 +2583,12 @@ function batch(effect, immediate) {
2383
2583
  optionCall('beginChain', roots);
2384
2584
  }
2385
2585
  // TODO: Consider this has been produced but was useless - it might be more correct ?const caller = executingStack.length > 0 ? getActiveEffect() : undefined
2386
- const caller = getActiveEffect();
2586
+ const activeCaller = getActiveEffect();
2587
+ const callerToUse = caller || activeCaller;
2387
2588
  // Optimization: If nested and NOT immediate, just join the existing batch
2388
2589
  if (!isNewBatch && !immediate) {
2389
2590
  for (let i = 0; i < effect.length; i++) {
2390
- addToBatch(effect[i], caller);
2591
+ addToBatch(effect[i], callerToUse);
2391
2592
  }
2392
2593
  return;
2393
2594
  }
@@ -2426,7 +2627,7 @@ function batch(effect, immediate) {
2426
2627
  else {
2427
2628
  // Add initial effects to batch and compute dependencies
2428
2629
  for (let i = 0; i < effect.length; i++) {
2429
- addToBatch(effect[i], caller, false);
2630
+ addToBatch(effect[i], callerToUse, false);
2430
2631
  }
2431
2632
  computeAllInDegrees(currentBatch);
2432
2633
  }
@@ -2480,6 +2681,11 @@ function batch(effect, immediate) {
2480
2681
  success = true;
2481
2682
  return firstReturn.value;
2482
2683
  }
2684
+ catch (error) {
2685
+ if (batchStack.length === 1)
2686
+ optionCall('error', '[reactive] Root batch failure before broken state:', error);
2687
+ throw error;
2688
+ }
2483
2689
  finally {
2484
2690
  if (!success && batchStack.length === 1) {
2485
2691
  broken = true;
@@ -2580,7 +2786,7 @@ const fr = new FinalizationRegistry((f) => f());
2580
2786
  * @param options - Options for effect execution
2581
2787
  * @returns A cleanup function to stop the effect
2582
2788
  */
2583
- const effect = named(effectMarker.leave, flavored(function effect(fn, effectOptions = {}) {
2789
+ const effect = captioned(named(effectMarker.leave, flavored(function effect(fn, effectOptions = {}) {
2584
2790
  if (effectOptions?.name)
2585
2791
  Object.defineProperty(fn, 'name', { value: effectOptions.name });
2586
2792
  // Use per-effect asyncMode or fall back to global option
@@ -2593,7 +2799,10 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2593
2799
  const prevCleanup = node.cleanup;
2594
2800
  node.cleanup = undefined;
2595
2801
  try {
2596
- untracked(() => prevCleanup(node.nextReason || { type: 'stopped' }));
2802
+ untracked `effect:cleanup`(() => prevCleanup(chainExternalReason(node.nextReason || {
2803
+ type: 'stopped',
2804
+ chain: node.currentReason,
2805
+ })));
2597
2806
  }
2598
2807
  catch (error) {
2599
2808
  // If we want to report them, we could use options.warn or similar
@@ -2626,6 +2835,9 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2626
2835
  }
2627
2836
  // Set reaction reason for the upcoming run
2628
2837
  access.reaction = node.nextReason || access.reaction;
2838
+ node.currentReason =
2839
+ node.nextReason ||
2840
+ (access.reaction && access.reaction !== true ? access.reaction : undefined);
2629
2841
  node.nextReason = undefined;
2630
2842
  optionCall('enter', getRoot(fn));
2631
2843
  optionCall('effectRun', getRoot(fn), access.reaction);
@@ -2688,7 +2900,8 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2688
2900
  // This ensures that when we cancel, the original promise's .catch() handlers are triggered
2689
2901
  // We do this by rejecting the race promise, which makes the original promise chain see the rejection
2690
2902
  // through the zone-wrapped .then()/.catch() handlers
2691
- runningPromise = runningPromise.catch((error) => {
2903
+ runningPromise = runningPromise
2904
+ .catch((error) => {
2692
2905
  // Propagate async errors to the effect's error handler
2693
2906
  // This ensures onEffectThrow handlers are triggered for async errors
2694
2907
  if (error !== cancelError) {
@@ -2696,6 +2909,10 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2696
2909
  }
2697
2910
  // If thrower didn't throw (handled), we absorb the error.
2698
2911
  // If thrower threw (unhandled), it propagates as a new unhandled rejection, which is correct.
2912
+ })
2913
+ .finally(() => {
2914
+ // Clear currentReason when async effect completes
2915
+ node.currentReason = undefined;
2699
2916
  });
2700
2917
  }
2701
2918
  else {
@@ -2706,7 +2923,13 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2706
2923
  catch (error) {
2707
2924
  debugHooks.decorateError(error, runEffect);
2708
2925
  // catcher:self`
2709
- errorToThrow = error;
2926
+ errorToThrow = error instanceof Error ? error : new Error(String(error));
2927
+ }
2928
+ finally {
2929
+ // Clear currentReason for synchronous effects
2930
+ if (!runningPromise) {
2931
+ node.currentReason = undefined;
2932
+ }
2710
2933
  }
2711
2934
  // Create cleanup function for next run
2712
2935
  node.cleanup = (reason) => {
@@ -2737,8 +2960,11 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2737
2960
  const childReason = reason
2738
2961
  ? reason.type === 'lineage'
2739
2962
  ? reason
2740
- : { type: 'lineage', parent: reason }
2741
- : { type: 'stopped' };
2963
+ : { type: 'lineage', parent: reason, chain: node.currentReason }
2964
+ : (chainExternalReason({ type: 'stopped', chain: node.currentReason }) ?? {
2965
+ type: 'stopped',
2966
+ chain: node.currentReason,
2967
+ });
2742
2968
  for (const childCleanup of children)
2743
2969
  childCleanup(childReason);
2744
2970
  delete node.children;
@@ -2751,7 +2977,7 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2751
2977
  const node = getEffectNode(runEffect);
2752
2978
  if (debugHooks.isDevtoolsEnabled()) {
2753
2979
  const stack = debugHooks.captureStack(); // Robustly skips internal mutts frames
2754
- if (Array.isArray(stack) && stack.length > 0) {
2980
+ if (stack) {
2755
2981
  node.creationStack = stack;
2756
2982
  }
2757
2983
  }
@@ -2811,7 +3037,7 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2811
3037
  runningPromise = null;
2812
3038
  }
2813
3039
  try {
2814
- node.cleanup?.(reason || { type: 'stopped' });
3040
+ node.cleanup?.(chainExternalReason(reason || { type: 'stopped', chain: node.currentReason }));
2815
3041
  }
2816
3042
  catch (error) {
2817
3043
  // Cleanup errors should basically be ignored or at least not stop the world
@@ -2854,30 +3080,35 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2854
3080
  named(name) {
2855
3081
  return flavorOptions(this, { name }, { name: 'named' });
2856
3082
  },
2857
- }));
2858
- /**
2859
- * Executes a function without tracking dependencies but maintains parent cleanup relationship
2860
- * Effects created inside will still be cleaned up when the parent effect is destroyed
2861
- * @param fn - The function to execute
2862
- */
2863
- function untracked(fn) {
2864
- return effectHistory.present.root(fn);
2865
- }
3083
+ })), {
3084
+ name: 'effect',
3085
+ warn: (message) => options.warn(`[reactive] ${message}`),
3086
+ shouldWarnAnonymous: (_callback, args) => !(args[1] && typeof args[1] === 'object' && 'name' in args[1]),
3087
+ });
3088
+ const untracked = captioned(function untracked(fn) {
3089
+ const external = externalReasonFrom(fn);
3090
+ return external
3091
+ ? externalReason.with(external, () => effectHistory.present.root(fn))
3092
+ : effectHistory.present.root(fn);
3093
+ });
2866
3094
  /**
2867
3095
  * Executes a function from a virgin/root context - no parent effect, no tracking
2868
3096
  * Creates completely independent effects that won't be cleaned up by any parent
2869
3097
  * @param fn - The function to execute
2870
3098
  */
2871
- function root(fn) {
2872
- return effectHistory.root(fn);
2873
- }
3099
+ const root = captioned(function root(fn) {
3100
+ const external = externalReasonFrom(fn);
3101
+ return external
3102
+ ? externalReason.with(external, () => effectHistory.root(fn))
3103
+ : effectHistory.root(fn);
3104
+ });
2874
3105
  function biDi(received, get, set) {
2875
3106
  if (typeof get !== 'function') {
2876
3107
  set = get.set;
2877
3108
  get = get.get;
2878
3109
  }
2879
3110
  let programmaticallySetValue = Symbol();
2880
- effect.named('biDi')(markWithRoot(() => {
3111
+ effect `biDi`(markWithRoot(() => {
2881
3112
  const newValue = get();
2882
3113
  const pValue = programmaticallySetValue;
2883
3114
  programmaticallySetValue = Symbol();
@@ -3028,7 +3259,8 @@ function collectEffects(obj, evolution, effects, objectWatchers, ...keyChains) {
3028
3259
  const deps = objectWatchers.get(key);
3029
3260
  if (deps) {
3030
3261
  // Make sure `some.prop++` does not keep a dependency to `some.props`
3031
- deps.delete(sourceEffect);
3262
+ if (sourceEffect)
3263
+ deps.delete(sourceEffect);
3032
3264
  for (const effect of deps) {
3033
3265
  const runningChain = isRunning(effect);
3034
3266
  if (runningChain) {
@@ -3074,6 +3306,7 @@ function touched(obj, evolution, props) {
3074
3306
  else
3075
3307
  collectEffects(obj, evolution, effects, objectWatchers, objectWatchers.keys());
3076
3308
  const triggers = Array.from(effects.keys());
3309
+ const sourceEffect = getActiveEffect();
3077
3310
  optionCall('touched', obj, evolution, props, triggers);
3078
3311
  // Store pending triggers for CleanupReason before batching
3079
3312
  if (options.introspection?.gatherReasons) {
@@ -3095,7 +3328,7 @@ function touched(obj, evolution, props) {
3095
3328
  });
3096
3329
  }
3097
3330
  }
3098
- batch(triggers);
3331
+ batch(triggers, undefined, sourceEffect);
3099
3332
  }
3100
3333
  // Bubble up changes if this object has deep watchers
3101
3334
  if (objectsWithDeepWatchers.has(obj)) {
@@ -3169,7 +3402,7 @@ function touchedOpaque(obj, evolution, prop) {
3169
3402
  }
3170
3403
  if (effects.size > 0) {
3171
3404
  optionCall('touched', obj, evolution, [prop], Array.from(effects));
3172
- batch(Array.from(effects));
3405
+ batch(Array.from(effects), undefined, sourceEffect);
3173
3406
  }
3174
3407
  }
3175
3408
 
@@ -3191,9 +3424,10 @@ function addUnreactiveProps(proto, set) {
3191
3424
  return proto;
3192
3425
  }
3193
3426
  // Merge sets
3194
- set = proto[unreactiveProperties] = new Set(proto[unreactiveProperties]);
3427
+ const merged = new Set(existing);
3428
+ proto[unreactiveProperties] = merged;
3195
3429
  for (const p of set)
3196
- existing.add(p);
3430
+ merged.add(p);
3197
3431
  }
3198
3432
  // If no set, mark as fully unreactive, otherwise create set
3199
3433
  else
@@ -3292,7 +3526,7 @@ function notifyPropertyChange(targetObj, prop, oldValue, newValue, hadProperty)
3292
3526
  const origin = { obj: unwrappedObj, prop };
3293
3527
  // Deep touch: only notify nested property changes with origin filtering
3294
3528
  // Don't notify direct property change - the whole point is to avoid parent effects re-running
3295
- const changes = untracked(() => recursiveTouch(oldValue, newValue, new WeakMap(), [], origin));
3529
+ const changes = untracked `deepTouch:recursive`(() => recursiveTouch(oldValue, newValue, new WeakMap(), [], origin));
3296
3530
  // When deep touch found no child differences, the object identity still changed.
3297
3531
  // Migrate watchers from old → new so the dependency chain is preserved.
3298
3532
  if (changes.length === 0) {
@@ -3518,6 +3752,17 @@ const subsRegister = new WeakMap();
3518
3752
  // Internal untracked flag for setter/getter operations - only used when testing oldValue while setting a value
3519
3753
  // TODO: `touched` trigger also compares to old value and should use the internalUntracked flag
3520
3754
  let internalUntracked = false;
3755
+ function wrapReactiveValue(obj, prop, value) {
3756
+ if (!isReactive(value) && typeof value === 'object' && value !== null) {
3757
+ const reactiveValue = reactiveObject(value);
3758
+ // Only create back-references if this object needs them
3759
+ if (needsBackReferences(obj)) {
3760
+ addBackReference(reactiveValue, obj, prop);
3761
+ }
3762
+ return reactiveValue;
3763
+ }
3764
+ return value;
3765
+ }
3521
3766
  const reactiveHandlers = {
3522
3767
  [Symbol.toStringTag]: 'MutTs Reactive',
3523
3768
  get(obj, prop, receiver) {
@@ -3545,6 +3790,10 @@ const reactiveHandlers = {
3545
3790
  // Symbols: fast-path — no reactivity tracking
3546
3791
  if (typeof prop === 'symbol' || prop === 'constructor' || isUnreactiveProp(obj, prop))
3547
3792
  return FoolProof.get(obj, prop, receiver);
3793
+ if (!getActiveEffect()) {
3794
+ const value = (subsRegister.get(obj)?.get || FoolProof.get)(obj, prop, receiver);
3795
+ return wrapReactiveValue(obj, prop, value);
3796
+ }
3548
3797
  // Check if property exists using a trap-free walk to avoid triggering
3549
3798
  // the has-trap cascade on prototype chains of reactive proxies.
3550
3799
  const isOwnProp = Object.hasOwn(obj, prop);
@@ -3584,15 +3833,7 @@ const reactiveHandlers = {
3584
3833
  // For arrays, use FoolProof.get (Indexer path) for numeric index reactivity.
3585
3834
  // For all other objects, inline Reflect.get directly (skips 3 function calls).
3586
3835
  const value = (subsRegister.get(obj)?.get || FoolProof.get)(obj, prop, receiver);
3587
- if (!isReactive(value) && typeof value === 'object' && value !== null) {
3588
- const reactiveValue = reactiveObject(value);
3589
- // Only create back-references if this object needs them
3590
- if (needsBackReferences(obj)) {
3591
- addBackReference(reactiveValue, obj, prop);
3592
- }
3593
- return reactiveValue;
3594
- }
3595
- return value;
3836
+ return wrapReactiveValue(obj, prop, value);
3596
3837
  },
3597
3838
  set(obj, prop, value, receiver) {
3598
3839
  const unwrapped = unwrap(receiver);
@@ -3768,5 +4009,5 @@ const reactive = decorator({
3768
4009
  default: reactiveObject,
3769
4010
  });
3770
4011
 
3771
- export { reactive as $, AZone as A, flavored as B, formatCleanupReason as C, DecoratorError as D, getActivationLog as E, getActiveEffect as F, getState as G, hooks as H, IterableWeakMap as I, isConstructor as J, isDev as K, isNonReactive as L, isObject as M, isProd as N, isReactive as O, isTest as P, legacyDecorator as Q, ReactiveBase as R, link as S, mixin as T, modernDecorator as U, named as V, objectToProxy as W, onEffectThrow as X, prodPreset as Y, Zone as Z, proxyToObject as _, IterableWeakSet as a, options as a0, reset as a1, root as a2, tag as a3, touched as a4, touched1 as a5, unlink as a6, untracked as a7, unwrap as a8, zip as a9, allProps as aA, dependant as aa, keysOf as ab, markWithRoot as ac, objectsWithDeepWatchers as ad, effectToDeepWatchedObjects as ae, deepWatchers as af, registerDeepWatcher as ag, rootFunctionSymbol as ah, getRoot as ai, optionCall as aj, FoolProof as ak, effectHistory as al, unreactiveProperties as am, __runInitializers as an, __esDecorate as ao, batch as ap, contentRef as aq, notifyPropertyChange as ar, metaProtos as as, wrapProtos as at, objectParents as au, watchers as av, effectToReactiveObjects as aw, effectMarker as ax, getEffectNode as ay, setDebugHooks as az, ReactiveError as b, ReactiveErrorCode as c, ZoneAggregator as d, ZoneHistory as e, addBatchCleanup as f, addUnreactiveProps as g, arrayEquals as h, assertUntracked as i, asyncHook as j, asyncHooks as k, asyncZone as l, atom as m, atomic as n, biDi as o, captured as p, caught as q, createFlavor as r, debugPreset as s, decorator as t, deepCompare as u, defer as v, devPreset as w, effect as x, effectAggregator as y, flavorOptions as z };
3772
- //# sourceMappingURL=proxy-D2C49sXH.esm.js.map
4012
+ export { objectToProxy as $, AZone as A, effectContext as B, CompareSymbol as C, DecoratorError as D, flavorOptions as E, flavored as F, formatCleanupReason as G, getActivationLog as H, IterableWeakMap as I, getActiveEffect as J, getState as K, hooks as L, inheritCaption as M, isConstructor as N, isDev as O, isNonReactive as P, isObject as Q, ReactiveBase as R, isProd as S, isReactive as T, isTest as U, legacyDecorator as V, link as W, mixin as X, modernDecorator as Y, Zone as Z, named as _, IterableWeakSet as a, onEffectThrow as a0, prodPreset as a1, proxyToObject as a2, reactive as a3, options as a4, reset as a5, root as a6, tag as a7, touched as a8, touched1 as a9, wrapProtos as aA, objectParents as aB, watchers as aC, effectToReactiveObjects as aD, effectMarker as aE, setDebugHooks as aF, allProps as aG, unlink as aa, untracked as ab, unwrap as ac, withEffectContext as ad, zip as ae, markWithRoot as af, dependant as ag, getEffectNode as ah, chainExternalReason as ai, keysOf as aj, objectsWithDeepWatchers as ak, effectToDeepWatchedObjects as al, deepWatchers as am, registerDeepWatcher as an, rootFunctionSymbol as ao, getRoot as ap, optionCall as aq, FoolProof as ar, effectHistory as as, unreactiveProperties as at, __runInitializers as au, __esDecorate as av, batch as aw, contentRef as ax, notifyPropertyChange as ay, metaProtos as az, ReactiveError as b, ReactiveErrorCode as c, ZoneAggregator as d, ZoneHistory as e, addBatchCleanup as f, addUnreactiveProps as g, arrayEquals as h, assertUntracked as i, asyncHook as j, asyncHooks as k, asyncZone as l, atom as m, atomic as n, biDi as o, captioned as p, captured as q, caught as r, createFlavor as s, debugPreset as t, decorator as u, deepCompare as v, defer as w, devPreset as x, effect as y, effectAggregator as z };
4013
+ //# sourceMappingURL=proxy-BtmPFjSr.esm.js.map