mutts 1.0.12 → 1.0.14

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 (61) hide show
  1. package/BROWSER_ASYNC_POLYFILL.md +79 -0
  2. package/README.md +7 -4
  3. package/dist/browser.cjs +150 -27
  4. package/dist/browser.cjs.map +1 -1
  5. package/dist/browser.d.ts +1440 -2
  6. package/dist/browser.dev.cjs +17 -3
  7. package/dist/browser.dev.cjs.map +1 -1
  8. package/dist/browser.dev.d.ts +2 -2
  9. package/dist/browser.dev.esm.js +2 -2
  10. package/dist/browser.esm.js +137 -28
  11. package/dist/browser.esm.js.map +1 -1
  12. package/dist/chunks/{index-yK0HVxHv.cjs → index-BnTNC9eC.cjs} +347 -156
  13. package/dist/chunks/index-BnTNC9eC.cjs.map +1 -0
  14. package/dist/chunks/{index-BUop6B2U.esm.js → index-CAWVZL7P.esm.js} +345 -154
  15. package/dist/chunks/index-CAWVZL7P.esm.js.map +1 -0
  16. package/dist/chunks/node-Df_5r_WA.cjs +187 -0
  17. package/dist/chunks/node-Df_5r_WA.cjs.map +1 -0
  18. package/dist/chunks/node-DuIduHw3.esm.js +185 -0
  19. package/dist/chunks/node-DuIduHw3.esm.js.map +1 -0
  20. package/dist/chunks/{proxy-D2C49sXH.esm.js → proxy-C2lnvvbx.esm.js} +943 -272
  21. package/dist/chunks/proxy-C2lnvvbx.esm.js.map +1 -0
  22. package/dist/chunks/{proxy-BvM4yewA.cjs → proxy-HA_QQnd5.cjs} +959 -273
  23. package/dist/chunks/proxy-HA_QQnd5.cjs.map +1 -0
  24. package/dist/debug.cjs +571 -173
  25. package/dist/debug.cjs.map +1 -1
  26. package/dist/debug.d.ts +96 -80
  27. package/dist/debug.esm.js +567 -173
  28. package/dist/debug.esm.js.map +1 -1
  29. package/dist/devtools/panel.js.map +1 -1
  30. package/dist/mutts.umd.js +4351 -3366
  31. package/dist/mutts.umd.js.map +1 -1
  32. package/dist/mutts.umd.min.js +1 -1
  33. package/dist/mutts.umd.min.js.map +1 -1
  34. package/dist/node.cjs +18 -4
  35. package/dist/node.cjs.map +1 -1
  36. package/dist/node.d.ts +2 -2
  37. package/dist/node.dev.cjs +18 -4
  38. package/dist/node.dev.cjs.map +1 -1
  39. package/dist/node.dev.d.ts +2 -2
  40. package/dist/node.dev.esm.js +3 -3
  41. package/dist/node.esm.js +3 -3
  42. package/dist/{types-Bx2PhORg.d.ts → types.d.ts} +42 -15
  43. package/docs/ai/api-reference.md +105 -13
  44. package/docs/ai/manual.md +77 -29
  45. package/docs/debug-getReason.md +161 -0
  46. package/docs/flavored.md +98 -1
  47. package/docs/reactive/advanced.md +184 -12
  48. package/docs/reactive/attend.md +32 -0
  49. package/docs/reactive/core.md +40 -6
  50. package/docs/reactive/debugging.md +40 -15
  51. package/docs/reactive.md +4 -1
  52. package/package.json +13 -9
  53. package/dist/chunks/index-BUop6B2U.esm.js.map +0 -1
  54. package/dist/chunks/index-yK0HVxHv.cjs.map +0 -1
  55. package/dist/chunks/node-Bo7WU5S2.esm.js +0 -96
  56. package/dist/chunks/node-Bo7WU5S2.esm.js.map +0 -1
  57. package/dist/chunks/node-Dd0esp5F.cjs +0 -98
  58. package/dist/chunks/node-Dd0esp5F.cjs.map +0 -1
  59. package/dist/chunks/proxy-BvM4yewA.cjs.map +0 -1
  60. package/dist/chunks/proxy-D2C49sXH.esm.js.map +0 -1
  61. package/dist/index.d.ts +0 -1322
@@ -1,26 +1,3 @@
1
- // Queue for hooks registered before the environment is ready (circular dependency fix)
2
- const hooks = new Set();
3
- const asyncHooks = {
4
- addHook(hook) {
5
- hooks.add(hook);
6
- return () => hooks.delete(hook);
7
- },
8
- /**
9
- * [Hack] Sanitize a promise (or value) to prevent context leaks.
10
- * Default: Identity function.
11
- * Browser: Uses Macrotask wrapping to break microtask chains.
12
- */
13
- sanitizePromise(p) {
14
- return p;
15
- },
16
- };
17
- /**
18
- * Register a hook that will be called whenever an asynchronous operation is initiated.
19
- * The hook should return a restorer function which will be called just before the async callback runs.
20
- * That restorer should in turn return an undoer function which will be called just after the async callback finishes.
21
- */
22
- const asyncHook = (hook) => asyncHooks.addHook(hook);
23
-
24
1
  /**
25
2
  * Yields tuples containing elements from each input array, stopping at the longest array length
26
3
  * @param args - Arrays to zip together
@@ -136,13 +113,10 @@ function isOwnAccessor(obj, prop) {
136
113
  return !!(opd?.get || opd?.set);
137
114
  }
138
115
  /**
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
116
+ * Symbol used to provide custom comparison logic for an object.
117
+ */
118
+ const CompareSymbol = Symbol.for('mutts.compare');
119
+ /**
146
120
  */
147
121
  function deepCompare(a, b, cache = new Map()) {
148
122
  if (a === b)
@@ -150,6 +124,13 @@ function deepCompare(a, b, cache = new Map()) {
150
124
  if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
151
125
  return a === b;
152
126
  }
127
+ // Custom comparison support
128
+ if (typeof a[CompareSymbol] === 'function') {
129
+ return a[CompareSymbol](b, (x, y) => deepCompare(x, y, cache));
130
+ }
131
+ if (typeof b[CompareSymbol] === 'function') {
132
+ return b[CompareSymbol](a, (x, y) => deepCompare(x, y, cache));
133
+ }
153
134
  // Prototype check
154
135
  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b))
155
136
  return false;
@@ -268,7 +249,8 @@ function named(name, fn) {
268
249
  });
269
250
  return fn;
270
251
  }
271
- const _mode = (typeof process !== 'undefined' && process.env?.NODE_ENV) ||
252
+ const runtimeGlobals = globalThis;
253
+ const _mode = runtimeGlobals.process?.env?.NODE_ENV ||
272
254
  (typeof import.meta !== 'undefined' && import.meta.env?.MODE) ||
273
255
  'production';
274
256
  const isDev = _mode === 'development';
@@ -444,6 +426,93 @@ const decorator = (description) => {
444
426
  * flavoredGreet.loud('World') // "HELLO, WORLD!"
445
427
  * ```
446
428
  */
429
+ const captionedOptionsSymbol = Symbol('mutts.captioned.options');
430
+ function isTemplateStringsArray(value) {
431
+ return (Array.isArray(value) &&
432
+ Object.hasOwn(value, 'raw') &&
433
+ Array.isArray(value.raw));
434
+ }
435
+ function renderTemplate(strings, values) {
436
+ let result = strings[0] ?? '';
437
+ for (let i = 0; i < values.length; i++)
438
+ result += String(values[i]) + (strings[i + 1] ?? '');
439
+ return result;
440
+ }
441
+ function renameCallback(caption, callback) {
442
+ Object.defineProperty(callback, 'name', {
443
+ value: caption,
444
+ writable: false,
445
+ configurable: true,
446
+ });
447
+ return callback;
448
+ }
449
+ function isAnonymousCallback(callback) {
450
+ return !callback.name || callback.name === 'anonymous';
451
+ }
452
+ /**
453
+ * Wraps a callback-first function so it also accepts a tagged-template call form.
454
+ *
455
+ * The template caption is applied to one callback argument before the base
456
+ * function runs. By default, `captioned` targets the first argument, but
457
+ * `callbackIndex` can point to any callback position.
458
+ *
459
+ * This is intended for APIs such as `effect`, `lift`, or `watch` where naming
460
+ * is useful but should remain separate from the flavor system.
461
+ *
462
+ * Plain calls still work:
463
+ * `run(callback)`
464
+ *
465
+ * Captioned calls add a runtime name to the first callback:
466
+ * `` run`task:${id}`(callback) ``
467
+ *
468
+ * Anonymous uncaptioned callbacks may trigger a warning depending on
469
+ * `shouldWarnAnonymous`.
470
+ */
471
+ function captioned(fn, options = {}) {
472
+ const settings = {
473
+ callbackIndex: options.callbackIndex ?? 0,
474
+ name: options.name ?? (fn.name || 'callback'),
475
+ rename: options.rename ?? ((caption, callback) => renameCallback(caption, callback)),
476
+ // biome-ignore lint/suspicious/noConsole: This is the whole point here
477
+ warn: options.warn ?? ((message) => console.warn(message)),
478
+ shouldWarnAnonymous: options.shouldWarnAnonymous,
479
+ };
480
+ fn[captionedOptionsSymbol] = settings;
481
+ return new Proxy(fn, {
482
+ get(target, prop, receiver) {
483
+ if (prop === captionedOptionsSymbol)
484
+ return settings;
485
+ return Reflect.get(target, prop, receiver);
486
+ },
487
+ apply(target, thisArg, args) {
488
+ if (isTemplateStringsArray(args[0])) {
489
+ const caption = renderTemplate(args[0], args.slice(1));
490
+ return function captionedCall(...callArgs) {
491
+ const callback = callArgs[settings.callbackIndex];
492
+ if (typeof callback !== 'function')
493
+ throw new TypeError(`${settings.name} template calls require a callback at argument index ${settings.callbackIndex}`);
494
+ const nextArgs = [...callArgs];
495
+ nextArgs[settings.callbackIndex] = settings.rename(caption, callback);
496
+ return Reflect.apply(target, this, nextArgs);
497
+ };
498
+ }
499
+ const callback = args[settings.callbackIndex];
500
+ if (typeof callback === 'function' && isAnonymousCallback(callback)) {
501
+ const shouldWarn = settings.shouldWarnAnonymous?.(callback, args) ?? true;
502
+ if (shouldWarn)
503
+ settings.warn(`${settings.name}: anonymous callback detected. Use template syntax for automatic naming:\n` +
504
+ ` Current: ${settings.name}(() => { ... })\n` +
505
+ ` Fix: ${settings.name}\`descriptive-name\`(() => { ... })\n` +
506
+ `The captioned system uses the template literal as the effect name for better debugging.`);
507
+ }
508
+ return Reflect.apply(target, thisArg, args);
509
+ },
510
+ });
511
+ }
512
+ function inheritCaption(source, target) {
513
+ const settings = source[captionedOptionsSymbol];
514
+ return settings ? captioned(target, settings) : target;
515
+ }
447
516
  /**
448
517
  * Creates a flavored (extensible) version of a function with chainable property modifiers.
449
518
  */
@@ -476,7 +545,7 @@ function createFlavor(fn, transform, name) {
476
545
  };
477
546
  if (name)
478
547
  named(name, fct);
479
- return flavored(fct, fn.flavors || {});
548
+ return flavored(inheritCaption(fn, fct), fn.flavors || {});
480
549
  }
481
550
  /**
482
551
  * Creates a new flavored function that merges options objects at a specific index.
@@ -509,7 +578,7 @@ function flavorOptions(fn, defaultOptions, opts = {}) {
509
578
  // Preserve arity and options track
510
579
  Object.defineProperty(fct, 'length', { value: fn.length });
511
580
  fct.optionsIndex = targetIndex;
512
- return flavored(fct, fn.flavors || {});
581
+ return flavored(inheritCaption(fn, fct), fn.flavors || {});
513
582
  }
514
583
 
515
584
  /// <reference lib="esnext.collection" />
@@ -690,7 +759,13 @@ class IterableWeakSet {
690
759
  [Symbol.iterator]() {
691
760
  return this.keys();
692
761
  }
693
- union(other) {
762
+ union(other, ...sets) {
763
+ if (sets.length > 0) {
764
+ for (const set of [other, ...sets])
765
+ for (const value of set)
766
+ this.add(value);
767
+ return this;
768
+ }
694
769
  const others = {
695
770
  [Symbol.iterator]() {
696
771
  return other.keys();
@@ -762,100 +837,6 @@ class IterableWeakSet {
762
837
  }
763
838
  _b = Symbol.toStringTag;
764
839
 
765
- /**
766
- * Creates a mixin that can be used both as a class (extends) and as a function (mixin)
767
- *
768
- * This function supports:
769
- * - Using mixins as base classes: `class MyClass extends MyMixin`
770
- * - Using mixins as functions: `class MyClass extends MyMixin(SomeBase)`
771
- * - Composing mixins: `const Composed = MixinA(MixinB)`
772
- * - Type-safe property inference for all patterns
773
- *
774
- * @param mixinFunction - The function that creates the mixin
775
- * @param unwrapFunction - Optional function to unwrap reactive objects for method calls
776
- * @returns A mixin that can be used both as a class and as a function
777
- */
778
- function mixin(mixinFunction, unwrapFunction) {
779
- /**
780
- * Cache for mixin results to ensure the same base class always returns the same mixed class
781
- */
782
- const mixinCache = new WeakMap();
783
- // Apply the mixin to Object as the base class
784
- const MixedBase = mixinFunction(Object);
785
- mixinCache.set(Object, MixedBase);
786
- // Create the proxy that handles both constructor and function calls
787
- return new Proxy(MixedBase, {
788
- // Handle `MixinClass(SomeBase)` - use as mixin function
789
- apply(_target, _thisArg, args) {
790
- if (args.length === 0) {
791
- throw new Error('Mixin requires a base class');
792
- }
793
- const baseClass = args[0];
794
- if (typeof baseClass !== 'function') {
795
- throw new Error('Mixin requires a constructor function');
796
- }
797
- // Check if it's a valid constructor or a mixin
798
- if (!isConstructor(baseClass) &&
799
- !(baseClass && typeof baseClass === 'function' && baseClass.prototype)) {
800
- throw new Error('Mixin requires a valid constructor');
801
- }
802
- // Check cache first
803
- const cached = mixinCache.get(baseClass);
804
- if (cached) {
805
- return cached;
806
- }
807
- let usedBase = baseClass;
808
- if (unwrapFunction) {
809
- // Create a proxied base class that handles method unwrapping
810
- const ProxiedBaseClass = class extends baseClass {
811
- };
812
- // Proxy the prototype methods to handle unwrapping
813
- const originalPrototype = baseClass.prototype;
814
- const proxiedPrototype = new Proxy(originalPrototype, {
815
- get(target, prop, receiver) {
816
- const value = FoolProof.get(target, prop, receiver);
817
- // Only wrap methods that are likely to access private fields
818
- // Skip symbols and special properties that the reactive system needs
819
- if (typeof value === 'function' &&
820
- typeof prop === 'string' &&
821
- !['constructor', 'toString', 'valueOf'].includes(prop)) {
822
- // Return a wrapped version that uses unwrapped context
823
- return function (...args) {
824
- // Use the unwrapping function if provided, otherwise use this
825
- const context = unwrapFunction(this);
826
- return value.apply(context, args);
827
- };
828
- }
829
- return value;
830
- },
831
- });
832
- // Set the proxied prototype
833
- Object.setPrototypeOf(ProxiedBaseClass.prototype, proxiedPrototype);
834
- usedBase = ProxiedBaseClass;
835
- }
836
- // Create the mixed class using the proxied base class
837
- const mixedClass = mixinFunction(usedBase);
838
- // Cache the result
839
- mixinCache.set(baseClass, mixedClass);
840
- return mixedClass;
841
- },
842
- });
843
- }
844
-
845
- const debugHooks = {
846
- isDevtoolsEnabled: () => false,
847
- registerEffect: () => { },
848
- getTriggerChain: () => [],
849
- captureStack: () => [],
850
- captureLineage: () => new Error().stack,
851
- formatStack: (stack) => [stack],
852
- recordTriggerLink: () => { },
853
- decorateError: () => { },
854
- };
855
- function setDebugHooks(hooks) {
856
- Object.assign(debugHooks, hooks);
857
- }
858
-
859
840
  /******************************************************************************
860
841
  Copyright (c) Microsoft Corporation.
861
842
 
@@ -918,6 +899,29 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr
918
899
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
919
900
  };
920
901
 
902
+ // Queue for hooks registered before the environment is ready (circular dependency fix)
903
+ const hooks = new Set();
904
+ const asyncHooks = {
905
+ addHook(hook) {
906
+ hooks.add(hook);
907
+ return () => hooks.delete(hook);
908
+ },
909
+ /**
910
+ * [Hack] Sanitize a promise (or value) to prevent context leaks.
911
+ * Default: Identity function.
912
+ * Browser: Uses Macrotask wrapping to break microtask chains.
913
+ */
914
+ sanitizePromise(p) {
915
+ return p;
916
+ },
917
+ };
918
+ /**
919
+ * Register a hook that will be called whenever an asynchronous operation is initiated.
920
+ * The hook should return a restorer function which will be called just before the async callback runs.
921
+ * That restorer should in turn return an undoer function which will be called just after the async callback finishes.
922
+ */
923
+ const asyncHook = (hook) => asyncHooks.addHook(hook);
924
+
921
925
  var _ZoneAggregator_zones;
922
926
  function isu(z) {
923
927
  return z;
@@ -1103,6 +1107,7 @@ function resetRegistry() {
1103
1107
  * @returns The marked function
1104
1108
  */
1105
1109
  function markWithRoot(fn, root) {
1110
+ const marked = fn;
1106
1111
  // Check for collision
1107
1112
  const existingRef = reverseRoots.get(root);
1108
1113
  const existing = existingRef?.deref();
@@ -1117,8 +1122,8 @@ function markWithRoot(fn, root) {
1117
1122
  // (Last writer wins for the check)
1118
1123
  reverseRoots.set(root, new WeakRef(fn));
1119
1124
  // Store root mapping as symbol property on the function
1120
- fn[rootFunctionSymbol] = getRoot(root);
1121
- return fn;
1125
+ marked[rootFunctionSymbol] = getRoot(root);
1126
+ return marked;
1122
1127
  }
1123
1128
  /**
1124
1129
  * Gets the root function of a function for effect tracking
@@ -1138,11 +1143,30 @@ function getRoot(fn) {
1138
1143
  const effectHistory = tag('effectHistory', new ZoneHistory());
1139
1144
  tag('effectHistory.present', effectHistory.present);
1140
1145
  asyncZone.add(effectHistory);
1146
+ const externalReason = tag('externalReason', new Zone());
1147
+ asyncZone.add(externalReason);
1141
1148
  /**
1142
1149
  * Aggregator for zones that need to be tracked along effects.
1143
1150
  * ie. in each effect, the active zone of the given zoning will be the one active at effect's definition
1144
1151
  */
1145
1152
  const effectAggregator = tag('effectAggregator', new ZoneAggregator(effectHistory.present));
1153
+ effectAggregator.add(externalReason);
1154
+ function chainExternalReason(reason) {
1155
+ const external = externalReason.active;
1156
+ if (!external)
1157
+ return reason;
1158
+ if (!reason)
1159
+ return external;
1160
+ let current = reason;
1161
+ while (current) {
1162
+ if (current.type === 'external' &&
1163
+ external.type === 'external' &&
1164
+ current.detail === external.detail)
1165
+ return reason;
1166
+ current = current.chain;
1167
+ }
1168
+ return { ...reason, chain: chainExternalReason(reason.chain) };
1169
+ }
1146
1170
  function isRunning(effect) {
1147
1171
  const root = getRoot(effect);
1148
1172
  return effectHistory.some((e) => getRoot(e) === root);
@@ -1150,6 +1174,35 @@ function isRunning(effect) {
1150
1174
  function getActiveEffect() {
1151
1175
  return effectHistory.present.active;
1152
1176
  }
1177
+ /**
1178
+ * Captures the current effect context so that deferred code can later
1179
+ * create child effects parented to this point in the effect tree.
1180
+ *
1181
+ * @returns An opaque token to pass to `withEffectContext()`
1182
+ *
1183
+ * @example
1184
+ * ```ts
1185
+ * const ctx = effectContext() // inside an effect or root()
1186
+ * // later, in a deferred callback:
1187
+ * withEffectContext(ctx, () => {
1188
+ * effect(() => { /* child of the captured context *​/ })
1189
+ * })
1190
+ * ```
1191
+ */
1192
+ function effectContext() {
1193
+ return effectHistory.active;
1194
+ }
1195
+ /**
1196
+ * Runs `fn` within a previously captured effect context.
1197
+ * Any effects created inside `fn` become children of the captured parent.
1198
+ *
1199
+ * @param ctx - The context token from `effectContext()`, or `undefined` for root context
1200
+ * @param fn - The function to execute within the restored context
1201
+ * @returns The return value of `fn`
1202
+ */
1203
+ function withEffectContext(ctx, fn) {
1204
+ return effectHistory.with(ctx, fn);
1205
+ }
1153
1206
  const cleanups = new WeakMap();
1154
1207
  /**
1155
1208
  * Attach cleanup dependencies to an object. When `unlink(obj)` is called,
@@ -1177,7 +1230,7 @@ const cleanups = new WeakMap();
1177
1230
  function link(obj, ...cleanupFns) {
1178
1231
  const set = cleanups.get(obj);
1179
1232
  if (!set)
1180
- cleanups.set(obj, new Set(cleanupFns.filter(Boolean)));
1233
+ cleanups.set(obj, new Set(cleanupFns.filter((fn) => fn !== undefined)));
1181
1234
  else
1182
1235
  for (const fn of cleanupFns)
1183
1236
  if (fn)
@@ -1204,6 +1257,85 @@ function unlink(obj, reason) {
1204
1257
  }
1205
1258
  }
1206
1259
 
1260
+ function extractRawStack(error = new Error()) {
1261
+ if (typeof error === 'string')
1262
+ return error;
1263
+ if (error && typeof error === 'object' && 'stack' in error) {
1264
+ const stack = error.stack;
1265
+ return typeof stack === 'string' ? stack : undefined;
1266
+ }
1267
+ return undefined;
1268
+ }
1269
+ function trimStack(stack) {
1270
+ const raw = extractRawStack(stack);
1271
+ if (!raw)
1272
+ return [];
1273
+ const lines = raw
1274
+ .split('\n')
1275
+ .map((line) => line.trim())
1276
+ .filter(Boolean);
1277
+ if (lines[0]?.startsWith('Error'))
1278
+ lines.shift();
1279
+ while (lines[0] &&
1280
+ (lines[0].includes('captureLineage') ||
1281
+ lines[0].includes('captureDeferredLineage') ||
1282
+ lines[0].includes('debug-hooks.ts')))
1283
+ lines.shift();
1284
+ return lines;
1285
+ }
1286
+ function digestDeferredLineage(lineage) {
1287
+ if (lineage.segments)
1288
+ return lineage.segments;
1289
+ const segments = [];
1290
+ let effect = lineage.effect;
1291
+ let stack = trimStack(lineage.stack);
1292
+ if (!effect) {
1293
+ lineage.segments = [{ effectName: 'root', stack }];
1294
+ return lineage.segments;
1295
+ }
1296
+ while (effect) {
1297
+ const root = getRoot(effect);
1298
+ segments.push({
1299
+ effectName: root.name || 'anonymous',
1300
+ stack,
1301
+ });
1302
+ const node = getEffectNode(effect);
1303
+ effect = node.parent;
1304
+ stack = trimStack(node.creationStack);
1305
+ }
1306
+ if (stack.length)
1307
+ segments.push({ effectName: 'root', stack });
1308
+ lineage.segments = segments;
1309
+ return segments;
1310
+ }
1311
+ function formatDeferredLineage(lineage) {
1312
+ return digestDeferredLineage(lineage)
1313
+ .map((segment) => [`${segment.effectName}:`, ...segment.stack.map((line) => ` ${line}`)].join('\n'))
1314
+ .join('\n');
1315
+ }
1316
+ function captureDeferredLineage(effect = getActiveEffect(), stack = new Error()) {
1317
+ return {
1318
+ effect,
1319
+ stack,
1320
+ toString() {
1321
+ return formatDeferredLineage(this);
1322
+ },
1323
+ };
1324
+ }
1325
+ const debugHooks = {
1326
+ isDevtoolsEnabled: () => false,
1327
+ registerEffect: () => { },
1328
+ getTriggerChain: () => [],
1329
+ captureStack: (error) => extractRawStack(error ?? new Error()),
1330
+ captureLineage: captureDeferredLineage,
1331
+ formatStack: (stack) => [stack],
1332
+ recordTriggerLink: () => { },
1333
+ decorateError: () => { },
1334
+ };
1335
+ function setDebugHooks(hooks) {
1336
+ Object.assign(debugHooks, hooks);
1337
+ }
1338
+
1207
1339
  const effectMarker = {
1208
1340
  enter: 'effect:enter',
1209
1341
  leave: 'effect:leave',
@@ -1243,18 +1375,61 @@ function formatCleanupReason(reason, depth = 0) {
1243
1375
  parts.push(',');
1244
1376
  parts.push(...formatTrigger(reason.triggers[i]));
1245
1377
  }
1378
+ if (reason.chain) {
1379
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1380
+ }
1381
+ return parts;
1382
+ }
1383
+ case 'stopped': {
1384
+ const parts = [`${indent}stopped`];
1385
+ if (reason.detail)
1386
+ parts.push(`(${reason.detail})`);
1387
+ if (reason.chain) {
1388
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1389
+ }
1390
+ return parts;
1391
+ }
1392
+ case 'external': {
1393
+ const parts = [`${indent}external:`, reason.detail];
1394
+ if (reason.chain) {
1395
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1396
+ }
1397
+ return parts;
1398
+ }
1399
+ case 'gc': {
1400
+ const parts = [`${indent}gc`];
1401
+ if (reason.chain) {
1402
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1403
+ }
1404
+ return parts;
1405
+ }
1406
+ case 'error': {
1407
+ const parts = [`${indent}error:`, reason.error];
1408
+ if (reason.chain) {
1409
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1410
+ }
1411
+ return parts;
1412
+ }
1413
+ case 'lineage': {
1414
+ const parts = [
1415
+ `${indent}lineage ←\n`,
1416
+ ...formatCleanupReason(reason.parent, depth + 1),
1417
+ ];
1418
+ if (reason.chain) {
1419
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1420
+ }
1421
+ return parts;
1422
+ }
1423
+ case 'invalidate': {
1424
+ const parts = [
1425
+ `${indent}invalidate ←\n`,
1426
+ ...formatCleanupReason(reason.cause, depth + 1),
1427
+ ];
1428
+ if (reason.chain) {
1429
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1430
+ }
1246
1431
  return parts;
1247
1432
  }
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
1433
  case 'multiple': {
1259
1434
  const parts = [];
1260
1435
  for (let i = 0; i < reason.reasons.length; i++) {
@@ -1262,6 +1437,9 @@ function formatCleanupReason(reason, depth = 0) {
1262
1437
  parts.push('\n');
1263
1438
  parts.push(...formatCleanupReason(reason.reasons[i], depth));
1264
1439
  }
1440
+ if (reason.chain) {
1441
+ parts.push('\n', ...formatCleanupReason(reason.chain, depth));
1442
+ }
1265
1443
  return parts;
1266
1444
  }
1267
1445
  }
@@ -1308,6 +1486,17 @@ class ReactiveError extends Error {
1308
1486
  return this.debugInfo?.cause;
1309
1487
  }
1310
1488
  }
1489
+ function normalizeSchedulerMode(mode) {
1490
+ switch (mode) {
1491
+ case 'production':
1492
+ return 'raw';
1493
+ case 'development':
1494
+ return 'ordered';
1495
+ default:
1496
+ return mode;
1497
+ }
1498
+ }
1499
+ let schedulerMode = 'ordered';
1311
1500
  // biome-ignore-start lint/correctness/noUnusedFunctionParameters: Interface declaration with empty defaults
1312
1501
  /**
1313
1502
  * Global options for the reactive system
@@ -1398,22 +1587,42 @@ const options = {
1398
1587
  */
1399
1588
  onMemoizationDiscrepancy: undefined,
1400
1589
  /**
1401
- * How to handle cycles detected in effect batches.
1590
+ * Effect scheduler mode.
1402
1591
  *
1403
- * - `'production'` (Default): High-performance mode. Disables dependency graph maintenance and
1404
- * Topological Sorting in favor of a simple FIFO queue. Use this for trustworthy, acyclic UI code.
1405
- * Cycle detection is heuristic (uses maxEffectChain execution counts).
1592
+ * - `'ordered'` (Default): maintains the causal effect graph so effects that are already
1593
+ * queued together can run in dependency order. It also preserves parent/child effect
1594
+ * lifecycle ordering and catches cycles eagerly when edges are created.
1406
1595
  *
1407
- * - `'development'`: Maintains direct dependency graph for early cycle detection during edge creation.
1408
- * Catches cycles before effects execute via DFS check when adding edges. Throws immediately with
1409
- * basic path information. Good balance of debugging help with moderate overhead.
1596
+ * - `'raw'`: fastest FIFO scheduler. It does not maintain the effect graph.
1597
+ * Cycle detection is heuristic, using maxEffectChain execution counts.
1410
1598
  *
1411
- * - `'debug'`: Full diagnostic mode with transitive closures and topological sorting.
1412
- * Provides detailed cycle path reporting. Highest overhead but most informative for bug hunting.
1599
+ * - `'debug'`: ordered scheduling plus the most detailed graph diagnostics. Highest overhead,
1600
+ * best for investigation.
1413
1601
  *
1414
- * @default 'production'
1602
+ * @default 'ordered'
1415
1603
  */
1416
- cycleHandling: 'development',
1604
+ get scheduler() {
1605
+ return schedulerMode;
1606
+ },
1607
+ set scheduler(mode) {
1608
+ schedulerMode = mode;
1609
+ },
1610
+ /**
1611
+ * @deprecated Use `scheduler` instead.
1612
+ *
1613
+ * Backward-compatible alias for older names:
1614
+ * - `'production'` maps to `scheduler = 'raw'`
1615
+ * - `'development'` maps to `scheduler = 'ordered'`
1616
+ * - `'debug'` maps to `scheduler = 'debug'`
1617
+ *
1618
+ * The new names describe scheduler behavior rather than runtime environment.
1619
+ */
1620
+ get cycleHandling() {
1621
+ return schedulerMode;
1622
+ },
1623
+ set cycleHandling(mode) {
1624
+ schedulerMode = normalizeSchedulerMode(mode);
1625
+ },
1417
1626
  /**
1418
1627
  * Internal flag used by memoization discrepancy detector to avoid counting calls in tests
1419
1628
  * @warning Do not modify this flag manually, this flag is given by the engine
@@ -1462,6 +1671,8 @@ const options = {
1462
1671
  asyncMode: 'cancel',
1463
1672
  // biome-ignore lint/suspicious/noConsole: This is the whole point here
1464
1673
  warn: (...args) => console.warn(...args),
1674
+ // biome-ignore lint/suspicious/noConsole: This is the whole point here
1675
+ error: (...args) => console.error(...args),
1465
1676
  /**
1466
1677
  * Introspection and debug aids. Set to `null` to disable all debug overhead in production.
1467
1678
  *
@@ -1501,14 +1712,14 @@ function optionCall(name, ...args) {
1501
1712
  /** Production preset: no introspection, heuristic cycle detection, minimal overhead */
1502
1713
  const prodPreset = {
1503
1714
  maxEffectReaction: 'throw',
1504
- cycleHandling: 'production',
1715
+ scheduler: 'raw',
1505
1716
  introspection: null,
1506
1717
  onMemoizationDiscrepancy: undefined,
1507
1718
  };
1508
- /** Development preset (default): introspection on, early cycle detection, warnings */
1719
+ /** Development preset: introspection on, early cycle detection, warnings */
1509
1720
  const devPreset = {
1510
1721
  maxEffectReaction: 'warn',
1511
- cycleHandling: 'development',
1722
+ scheduler: 'ordered',
1512
1723
  introspection: {
1513
1724
  gatherReasons: { lineages: 'touch' },
1514
1725
  logErrors: true,
@@ -1520,7 +1731,7 @@ const devPreset = {
1520
1731
  /** Debug preset: full diagnostics, throws on violations, rich lineage capture */
1521
1732
  const debugPreset = {
1522
1733
  maxEffectReaction: 'debug',
1523
- cycleHandling: 'debug',
1734
+ scheduler: 'debug',
1524
1735
  introspection: {
1525
1736
  gatherReasons: { lineages: 'both' },
1526
1737
  logErrors: true,
@@ -1543,6 +1754,7 @@ function unwrap(obj) {
1543
1754
  return obj;
1544
1755
  return proxyToObject.get(obj) || obj;
1545
1756
  }
1757
+ const toRaw = unwrap;
1546
1758
  function isReactive(obj) {
1547
1759
  return proxyToObject.has(obj);
1548
1760
  }
@@ -1590,7 +1802,8 @@ function dependant(obj, prop = allProps) {
1590
1802
  if (!currentActiveEffect || (typeof prop === 'symbol' && prop !== allProps && prop !== keysOf))
1591
1803
  return;
1592
1804
  const node = getEffectNode(currentActiveEffect);
1593
- if ('dependencyHook' in node) {
1805
+ const hasDependencyHook = node.dependencyHook !== undefined;
1806
+ if (hasDependencyHook) {
1594
1807
  node.dependencyHook(obj, prop);
1595
1808
  }
1596
1809
  let objectWatchers = watchers.get(obj);
@@ -1613,25 +1826,25 @@ function dependant(obj, prop = allProps) {
1613
1826
  effectToReactiveObjects.set(currentActiveEffect, new Set([obj]));
1614
1827
  }
1615
1828
  // Store dependency stack if introspection is enabled
1616
- const gatherReasons = options.introspection?.gatherReasons;
1617
- if (gatherReasons) {
1618
- const lineageConfig = gatherReasons.lineages;
1619
- if (lineageConfig === 'dependency' || lineageConfig === 'both') {
1620
- let objStacks = dependencyStacks.get(obj);
1621
- if (!objStacks) {
1622
- objStacks = new Map();
1623
- dependencyStacks.set(obj, objStacks);
1624
- }
1625
- let propStacks = objStacks.get(prop);
1626
- if (!propStacks) {
1627
- propStacks = new Map();
1628
- objStacks.set(prop, propStacks);
1629
- }
1630
- propStacks.set(currentActiveEffect, debugHooks.captureLineage());
1829
+ const lineageMode = options.introspection?.gatherReasons?.lineages;
1830
+ const shouldGatherDependencyLineage = lineageMode === 'dependency' || lineageMode === 'both';
1831
+ if (shouldGatherDependencyLineage) {
1832
+ let objStacks = dependencyStacks.get(obj);
1833
+ if (!objStacks) {
1834
+ objStacks = new Map();
1835
+ dependencyStacks.set(obj, objStacks);
1836
+ }
1837
+ let propStacks = objStacks.get(prop);
1838
+ if (!propStacks) {
1839
+ propStacks = new Map();
1840
+ objStacks.set(prop, propStacks);
1631
1841
  }
1842
+ propStacks.set(currentActiveEffect, debugHooks.captureLineage());
1632
1843
  }
1633
1844
  }
1634
1845
 
1846
+ // Simple module to manage inert state without circular dependencies
1847
+ let inertDepth = 0;
1635
1848
  /**
1636
1849
  * Finds a cycle in a sequence of functions by looking for the first repetition
1637
1850
  */
@@ -1657,6 +1870,9 @@ function formatRoots(roots, limit = 20) {
1657
1870
  const end = names.slice(-10);
1658
1871
  return `${start.join(' → ')} ... (${names.length - 15} more) ... ${end.join(' → ')}`;
1659
1872
  }
1873
+ function externalReasonFrom(fn) {
1874
+ return fn.name ? { type: 'external', detail: fn.name } : undefined;
1875
+ }
1660
1876
  // Nested map structure for efficient counting and batch cleanup
1661
1877
  // batchId -> effect root -> obj -> prop -> count
1662
1878
  let activationRegistry;
@@ -1730,6 +1946,40 @@ let causesClosure = new WeakMap();
1730
1946
  let consequencesClosure = new WeakMap();
1731
1947
  // Batch re-entrance depth and broken state
1732
1948
  let broken = false;
1949
+ /** True after an unrecoverable reactive failure until `reset()`. */
1950
+ function isReactiveBroken() {
1951
+ return broken;
1952
+ }
1953
+ const reactiveBrokenHandlers = new Set();
1954
+ const reactiveResetHandlers = new Set();
1955
+ function onReactiveBroken(handler) {
1956
+ reactiveBrokenHandlers.add(handler);
1957
+ return () => reactiveBrokenHandlers.delete(handler);
1958
+ }
1959
+ function onReactiveReset(handler) {
1960
+ reactiveResetHandlers.add(handler);
1961
+ return () => reactiveResetHandlers.delete(handler);
1962
+ }
1963
+ function notifyReactiveBroken(error) {
1964
+ for (const handler of Array.from(reactiveBrokenHandlers)) {
1965
+ try {
1966
+ handler(error);
1967
+ }
1968
+ catch (handlerError) {
1969
+ options.warn('[reactive] onReactiveBroken handler threw', handlerError);
1970
+ }
1971
+ }
1972
+ }
1973
+ function notifyReactiveReset() {
1974
+ for (const handler of Array.from(reactiveResetHandlers)) {
1975
+ try {
1976
+ handler();
1977
+ }
1978
+ catch (handlerError) {
1979
+ options.warn('[reactive] onReactiveReset handler threw', handlerError);
1980
+ }
1981
+ }
1982
+ }
1733
1983
  /**
1734
1984
  * Gets or creates an IterableWeakSet for a closure map
1735
1985
  */
@@ -1748,7 +1998,7 @@ function getOrCreateClosure(closure, root) {
1748
1998
  * @param targetRoot - Root function of the effect being triggered
1749
1999
  */
1750
2000
  function addGraphEdge(callerRoot, targetRoot) {
1751
- if (options.cycleHandling === 'production')
2001
+ if (options.scheduler === 'raw')
1752
2002
  return;
1753
2003
  // Add to forward graph: callerRoot → targetRoot
1754
2004
  const triggers = effectTriggers.get(callerRoot);
@@ -1863,7 +2113,7 @@ function hasPathExcluding(start, end, exclude) {
1863
2113
  * @param effect - The effect being cleaned up
1864
2114
  */
1865
2115
  function cleanupEffectFromGraph(effect) {
1866
- if (options.cycleHandling === 'production')
2116
+ if (options.scheduler === 'raw')
1867
2117
  return;
1868
2118
  const root = getRoot(effect);
1869
2119
  // Get closures before removing direct edges (needed for propagation)
@@ -1972,7 +2222,7 @@ const executingStack = [];
1972
2222
  * Called once when batch starts or when new effects are added
1973
2223
  */
1974
2224
  function computeAllInDegrees(batch) {
1975
- if (options.cycleHandling === 'production')
2225
+ if (options.scheduler === 'raw')
1976
2226
  return;
1977
2227
  const activeEffect = getActiveEffect();
1978
2228
  const activeRoot = activeEffect ? getRoot(activeEffect) : null;
@@ -1980,6 +2230,16 @@ function computeAllInDegrees(batch) {
1980
2230
  batch.inDegrees.clear();
1981
2231
  for (const [root] of batch.all) {
1982
2232
  let inDegree = 0;
2233
+ // Make sure parents are executed before children: if parent is in batch, count it as a dependency
2234
+ const effect = batch.all.get(root);
2235
+ const parent = getEffectNode(effect).parent;
2236
+ const parentRoot = parent ? getRoot(parent) : undefined;
2237
+ if (parentRoot &&
2238
+ batch.all.has(parentRoot) &&
2239
+ parentRoot !== activeRoot &&
2240
+ parentRoot !== root) {
2241
+ inDegree++;
2242
+ }
1983
2243
  const causes = causesClosure.get(root);
1984
2244
  if (causes) {
1985
2245
  for (const causeRoot of causes) {
@@ -1999,17 +2259,26 @@ function computeAllInDegrees(batch) {
1999
2259
  function decrementInDegreesForExecuted(batch, executedRoot) {
2000
2260
  // Get all effects that this executed effect triggers
2001
2261
  const consequences = consequencesClosure.get(executedRoot);
2002
- if (!consequences)
2003
- return;
2004
- for (const consequenceRoot of consequences) {
2005
- // Only update if it's still in the batch
2006
- if (batch.all.has(consequenceRoot)) {
2007
- const currentDegree = batch.inDegrees.get(consequenceRoot) ?? 0;
2008
- if (currentDegree > 0) {
2009
- batch.inDegrees.set(consequenceRoot, currentDegree - 1);
2262
+ if (consequences) {
2263
+ for (const consequenceRoot of consequences) {
2264
+ // Only update if it's still in the batch
2265
+ if (batch.all.has(consequenceRoot)) {
2266
+ const currentDegree = batch.inDegrees.get(consequenceRoot) ?? 0;
2267
+ if (currentDegree > 0) {
2268
+ batch.inDegrees.set(consequenceRoot, currentDegree - 1);
2269
+ }
2010
2270
  }
2011
2271
  }
2012
2272
  }
2273
+ for (const [root, effect] of batch.all) {
2274
+ const parent = getEffectNode(effect).parent;
2275
+ if (!parent || getRoot(parent) !== executedRoot)
2276
+ continue;
2277
+ const currentDegree = batch.inDegrees.get(root) ?? 0;
2278
+ if (currentDegree > 0) {
2279
+ batch.inDegrees.set(root, currentDegree - 1);
2280
+ }
2281
+ }
2013
2282
  }
2014
2283
  /**
2015
2284
  * Finds a path from startRoot to endRoot in the dependency graph
@@ -2099,6 +2368,14 @@ function addToBatch(effect, caller, immediate, reason) {
2099
2368
  // Build reason from pending triggers if not provided
2100
2369
  if (!reason && node.pendingTriggers) {
2101
2370
  reason = { type: 'propChange', triggers: node.pendingTriggers };
2371
+ // Add chain: if this is being triggered from another effect, get its reason
2372
+ if (caller) {
2373
+ const callerNode = getEffectNode(caller);
2374
+ if (callerNode.currentReason) {
2375
+ reason.chain = callerNode.currentReason;
2376
+ }
2377
+ }
2378
+ reason = chainExternalReason(reason);
2102
2379
  }
2103
2380
  node.pendingTriggers = undefined;
2104
2381
  if (reason) {
@@ -2140,15 +2417,15 @@ function addToBatch(effect, caller, immediate, reason) {
2140
2417
  }
2141
2418
  }
2142
2419
  // 1. Add to batch first (needed for cycle detection)
2143
- // TODO: Check if it's the correct way to do (these different behavior in function of dev/production)
2144
- if (options.cycleHandling === 'production') {
2145
- // Production mode: FIFO (delete and re-add to move to end)
2420
+ // TODO: Check if this difference between raw and graph-backed scheduling is the right tradeoff.
2421
+ if (options.scheduler === 'raw') {
2422
+ // Raw mode: FIFO (delete and re-add to move to end)
2146
2423
  if (currentBatch.all.has(root)) {
2147
2424
  currentBatch.all.delete(root);
2148
2425
  }
2149
2426
  }
2150
2427
  else {
2151
- // Dev mode: skip if already queued — the existing entry will re-run
2428
+ // Graph-backed modes: skip if already queued — the existing entry will re-run
2152
2429
  if (currentBatch.all.has(root)) {
2153
2430
  return;
2154
2431
  }
@@ -2157,7 +2434,7 @@ function addToBatch(effect, caller, immediate, reason) {
2157
2434
  if (node.stopped)
2158
2435
  return;
2159
2436
  currentBatch.all.set(root, effect);
2160
- if (caller && true && options.cycleHandling !== 'production') {
2437
+ if (caller && true && options.scheduler !== 'raw') {
2161
2438
  const callerRoot = getRoot(caller);
2162
2439
  // const root = getRoot(effect) // Already have root
2163
2440
  // Check for cycle BEFORE adding edge
@@ -2179,6 +2456,9 @@ function addToBatch(effect, caller, immediate, reason) {
2179
2456
  }
2180
2457
  addGraphEdge(callerRoot, root);
2181
2458
  }
2459
+ if (options.scheduler !== 'raw') {
2460
+ computeAllInDegrees(currentBatch);
2461
+ }
2182
2462
  }
2183
2463
  /**
2184
2464
  * Adds a cleanup function to be called when the current batch of effects completes
@@ -2275,7 +2555,7 @@ function executeNext(effectuatedRoots) {
2275
2555
  // Find an effect with in-degree 0 using cached values
2276
2556
  let nextEffect = null;
2277
2557
  let nextRoot = null;
2278
- if (options.cycleHandling === 'production') {
2558
+ if (options.scheduler === 'raw') {
2279
2559
  // In flat mode, we just take the first effect in the queue (FIFO)
2280
2560
  const first = currentBatch.all.entries().next().value;
2281
2561
  if (first) {
@@ -2365,9 +2645,7 @@ function executeNext(effectuatedRoots) {
2365
2645
  }
2366
2646
  return result;
2367
2647
  }
2368
- // Track which sub-effects have been executed to prevent infinite loops
2369
- // These are all the effects triggered under `activeEffect` and all their sub-effects
2370
- function batch(effect, immediate) {
2648
+ function batch(effect, batchOptions) {
2371
2649
  if (broken) {
2372
2650
  throw new ReactiveError('[reactive] Reactive system is broken after an unrecoverable error. Call reset() to recover.', { code: ReactiveErrorCode.BrokenEffects });
2373
2651
  }
@@ -2382,15 +2660,38 @@ function batch(effect, immediate) {
2382
2660
  throw new Error('Activation registry already exists');
2383
2661
  optionCall('beginChain', roots);
2384
2662
  }
2385
- // 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();
2663
+ const immediate = batchOptions?.immediate === true;
2664
+ const contained = batchOptions?.contained === true;
2665
+ const callerToUse = batchOptions?.caller || getActiveEffect();
2387
2666
  // Optimization: If nested and NOT immediate, just join the existing batch
2388
- if (!isNewBatch && !immediate) {
2667
+ if (!isNewBatch && !contained && !immediate) {
2389
2668
  for (let i = 0; i < effect.length; i++) {
2390
- addToBatch(effect[i], caller);
2669
+ addToBatch(effect[i], callerToUse);
2391
2670
  }
2392
2671
  return;
2393
2672
  }
2673
+ if (!isNewBatch && !contained && immediate) {
2674
+ const firstReturn = {};
2675
+ for (let i = 0; i < effect.length; i++) {
2676
+ executingStack.push(effect[i]);
2677
+ try {
2678
+ const node = getEffectNode(effect[i]);
2679
+ const reason = node.nextReason;
2680
+ if (node.cleanup) {
2681
+ const cleanup = node.cleanup;
2682
+ node.cleanup = undefined;
2683
+ cleanup(reason);
2684
+ }
2685
+ const rv = effect[i]();
2686
+ if (rv !== undefined && !('value' in firstReturn))
2687
+ firstReturn.value = rv;
2688
+ }
2689
+ finally {
2690
+ executingStack.pop();
2691
+ }
2692
+ }
2693
+ return firstReturn.value;
2694
+ }
2394
2695
  const currentBatch = {
2395
2696
  all: new Map(),
2396
2697
  inDegrees: new Map(),
@@ -2398,9 +2699,11 @@ function batch(effect, immediate) {
2398
2699
  };
2399
2700
  batchStack.push(currentBatch);
2400
2701
  let success = false;
2702
+ let failure;
2401
2703
  try {
2402
2704
  const effectuatedRoots = [];
2403
2705
  const firstReturn = {};
2706
+ let initialError;
2404
2707
  if (immediate) {
2405
2708
  // Execute initial effects in providing order
2406
2709
  for (let i = 0; i < effect.length; i++) {
@@ -2417,16 +2720,22 @@ function batch(effect, immediate) {
2417
2720
  if (rv !== undefined && !('value' in firstReturn))
2418
2721
  firstReturn.value = rv;
2419
2722
  }
2723
+ catch (error) {
2724
+ initialError = error;
2725
+ break;
2726
+ }
2420
2727
  finally {
2421
2728
  executingStack.pop();
2422
2729
  currentBatch.all.delete(getRoot(effect[i]));
2423
2730
  }
2424
2731
  }
2732
+ if (initialError)
2733
+ throw initialError;
2425
2734
  }
2426
2735
  else {
2427
2736
  // Add initial effects to batch and compute dependencies
2428
2737
  for (let i = 0; i < effect.length; i++) {
2429
- addToBatch(effect[i], caller, false);
2738
+ addToBatch(effect[i], callerToUse, false);
2430
2739
  }
2431
2740
  computeAllInDegrees(currentBatch);
2432
2741
  }
@@ -2480,9 +2789,18 @@ function batch(effect, immediate) {
2480
2789
  success = true;
2481
2790
  return firstReturn.value;
2482
2791
  }
2792
+ catch (error) {
2793
+ failure = error;
2794
+ if (batchStack.length === 1)
2795
+ optionCall('error', '[reactive] Root batch failure before broken state:', error);
2796
+ throw error;
2797
+ }
2483
2798
  finally {
2484
2799
  if (!success && batchStack.length === 1) {
2800
+ const wasBroken = broken;
2485
2801
  broken = true;
2802
+ if (!wasBroken)
2803
+ notifyReactiveBroken(failure);
2486
2804
  }
2487
2805
  batchStack.pop();
2488
2806
  if (batchStack.length === 0) {
@@ -2498,6 +2816,7 @@ function batch(effect, immediate) {
2498
2816
  * All existing effects become orphaned and must be recreated.
2499
2817
  */
2500
2818
  function reset() {
2819
+ const wasBroken = broken;
2501
2820
  broken = false;
2502
2821
  activationRegistry = undefined;
2503
2822
  batchStack.length = 0;
@@ -2508,6 +2827,8 @@ function reset() {
2508
2827
  resetRegistry();
2509
2828
  resetTracking();
2510
2829
  effectHistory.present.active = undefined;
2830
+ if (wasBroken)
2831
+ notifyReactiveReset();
2511
2832
  }
2512
2833
  // Inject batch function to allow atomic game loops in requestAnimationFrame/setTimeout/...
2513
2834
  // Note: Automatic batching of async callbacks (setTimeout, Promise.then, etc.) is NOT implemented.
@@ -2524,7 +2845,7 @@ const atomic = decorator({
2524
2845
  const atomicEffect = () => original.apply(this, args);
2525
2846
  // Debug: helpful to have a name
2526
2847
  Object.defineProperty(atomicEffect, 'name', { value: `atomic(${original.name})` });
2527
- return batch(atomicEffect, 'immediate');
2848
+ return batch(atomicEffect, { immediate: true });
2528
2849
  };
2529
2850
  },
2530
2851
  default(original) {
@@ -2532,7 +2853,7 @@ const atomic = decorator({
2532
2853
  const atomicEffect = () => original.apply(this, args);
2533
2854
  // Debug: helpful to have a name
2534
2855
  Object.defineProperty(atomicEffect, 'name', { value: `atomic(${original.name})` });
2535
- return batch(atomicEffect, 'immediate');
2856
+ return batch(atomicEffect, { immediate: true });
2536
2857
  };
2537
2858
  },
2538
2859
  });
@@ -2571,7 +2892,7 @@ function captured(prev, fn) {
2571
2892
  * ```
2572
2893
  */
2573
2894
  function atom(fn) {
2574
- return batch(fn, 'immediate');
2895
+ return batch(fn, { immediate: true });
2575
2896
  }
2576
2897
  const fr = new FinalizationRegistry((f) => f());
2577
2898
  /**
@@ -2580,7 +2901,7 @@ const fr = new FinalizationRegistry((f) => f());
2580
2901
  * @param options - Options for effect execution
2581
2902
  * @returns A cleanup function to stop the effect
2582
2903
  */
2583
- const effect = named(effectMarker.leave, flavored(function effect(fn, effectOptions = {}) {
2904
+ const effect = captioned(named(effectMarker.leave, flavored(function effect(fn, effectOptions = {}) {
2584
2905
  if (effectOptions?.name)
2585
2906
  Object.defineProperty(fn, 'name', { value: effectOptions.name });
2586
2907
  // Use per-effect asyncMode or fall back to global option
@@ -2593,7 +2914,10 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2593
2914
  const prevCleanup = node.cleanup;
2594
2915
  node.cleanup = undefined;
2595
2916
  try {
2596
- untracked(() => prevCleanup(node.nextReason || { type: 'stopped' }));
2917
+ untracked `effect:cleanup`(() => prevCleanup(chainExternalReason(node.nextReason || {
2918
+ type: 'stopped',
2919
+ chain: node.currentReason,
2920
+ })));
2597
2921
  }
2598
2922
  catch (error) {
2599
2923
  // If we want to report them, we could use options.warn or similar
@@ -2626,6 +2950,9 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2626
2950
  }
2627
2951
  // Set reaction reason for the upcoming run
2628
2952
  access.reaction = node.nextReason || access.reaction;
2953
+ node.currentReason =
2954
+ node.nextReason ||
2955
+ (access.reaction && access.reaction !== true ? access.reaction : undefined);
2629
2956
  node.nextReason = undefined;
2630
2957
  optionCall('enter', getRoot(fn));
2631
2958
  optionCall('effectRun', getRoot(fn), access.reaction);
@@ -2688,7 +3015,8 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2688
3015
  // This ensures that when we cancel, the original promise's .catch() handlers are triggered
2689
3016
  // We do this by rejecting the race promise, which makes the original promise chain see the rejection
2690
3017
  // through the zone-wrapped .then()/.catch() handlers
2691
- runningPromise = runningPromise.catch((error) => {
3018
+ runningPromise = runningPromise
3019
+ .catch((error) => {
2692
3020
  // Propagate async errors to the effect's error handler
2693
3021
  // This ensures onEffectThrow handlers are triggered for async errors
2694
3022
  if (error !== cancelError) {
@@ -2696,6 +3024,10 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2696
3024
  }
2697
3025
  // If thrower didn't throw (handled), we absorb the error.
2698
3026
  // If thrower threw (unhandled), it propagates as a new unhandled rejection, which is correct.
3027
+ })
3028
+ .finally(() => {
3029
+ // Clear currentReason when async effect completes
3030
+ node.currentReason = undefined;
2699
3031
  });
2700
3032
  }
2701
3033
  else {
@@ -2706,7 +3038,13 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2706
3038
  catch (error) {
2707
3039
  debugHooks.decorateError(error, runEffect);
2708
3040
  // catcher:self`
2709
- errorToThrow = error;
3041
+ errorToThrow = error instanceof Error ? error : new Error(String(error));
3042
+ }
3043
+ finally {
3044
+ // Clear currentReason for synchronous effects
3045
+ if (!runningPromise) {
3046
+ node.currentReason = undefined;
3047
+ }
2710
3048
  }
2711
3049
  // Create cleanup function for next run
2712
3050
  node.cleanup = (reason) => {
@@ -2737,8 +3075,11 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2737
3075
  const childReason = reason
2738
3076
  ? reason.type === 'lineage'
2739
3077
  ? reason
2740
- : { type: 'lineage', parent: reason }
2741
- : { type: 'stopped' };
3078
+ : { type: 'lineage', parent: reason, chain: node.currentReason }
3079
+ : (chainExternalReason({ type: 'stopped', chain: node.currentReason }) ?? {
3080
+ type: 'stopped',
3081
+ chain: node.currentReason,
3082
+ });
2742
3083
  for (const childCleanup of children)
2743
3084
  childCleanup(childReason);
2744
3085
  delete node.children;
@@ -2751,7 +3092,7 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2751
3092
  const node = getEffectNode(runEffect);
2752
3093
  if (debugHooks.isDevtoolsEnabled()) {
2753
3094
  const stack = debugHooks.captureStack(); // Robustly skips internal mutts frames
2754
- if (Array.isArray(stack) && stack.length > 0) {
3095
+ if (stack) {
2755
3096
  node.creationStack = stack;
2756
3097
  }
2757
3098
  }
@@ -2795,7 +3136,7 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2795
3136
  abortController = undefined;
2796
3137
  }
2797
3138
  };
2798
- batch(runEffect, 'immediate');
3139
+ batch(runEffect, { immediate: true });
2799
3140
  // Only ROOT effects are registered for GC cleanup and zone tracking
2800
3141
  const isRootEffect = !parent;
2801
3142
  const stopEffect = (reason) => {
@@ -2811,7 +3152,7 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2811
3152
  runningPromise = null;
2812
3153
  }
2813
3154
  try {
2814
- node.cleanup?.(reason || { type: 'stopped' });
3155
+ node.cleanup?.(chainExternalReason(reason || { type: 'stopped', chain: node.currentReason }));
2815
3156
  }
2816
3157
  catch (error) {
2817
3158
  // Cleanup errors should basically be ignored or at least not stop the world
@@ -2854,30 +3195,72 @@ const effect = named(effectMarker.leave, flavored(function effect(fn, effectOpti
2854
3195
  named(name) {
2855
3196
  return flavorOptions(this, { name }, { name: 'named' });
2856
3197
  },
2857
- }));
3198
+ })), {
3199
+ name: 'effect',
3200
+ warn: (message) => options.warn(`[reactive] ${message}`),
3201
+ shouldWarnAnonymous: (_callback, args) => !(args[1] && typeof args[1] === 'object' && 'name' in args[1]),
3202
+ });
3203
+ const untracked = captioned(function untracked(fn) {
3204
+ const external = externalReasonFrom(fn);
3205
+ return external
3206
+ ? externalReason.with(external, () => effectHistory.present.root(fn))
3207
+ : effectHistory.present.root(fn);
3208
+ });
3209
+ function runInert(fn) {
3210
+ // Increment the counter
3211
+ const originalDepth = inertDepth;
3212
+ inertDepth = originalDepth + 1;
3213
+ try {
3214
+ return fn();
3215
+ }
3216
+ finally {
3217
+ inertDepth = originalDepth;
3218
+ }
3219
+ }
3220
+ function wrapInert(fn) {
3221
+ function inertEffect(...args) {
3222
+ return runInert(() => fn.apply(this, args));
3223
+ }
3224
+ Object.defineProperty(inertEffect, 'name', { value: `inert(${fn.name})` });
3225
+ return inertEffect;
3226
+ }
2858
3227
  /**
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
3228
+ * Executes a function with fast-path reads that bypass proxy overhead and dependency tracking.
3229
+ * Writes remain fully reactive. Uses a counter for safe nesting.
3230
+ * Can also decorate methods so the whole method body runs inertly.
2861
3231
  * @param fn - The function to execute
2862
3232
  */
2863
- function untracked(fn) {
2864
- return effectHistory.present.root(fn);
2865
- }
3233
+ const inert = decorator({
3234
+ method(original) {
3235
+ return wrapInert(original);
3236
+ },
3237
+ default(fn) {
3238
+ if (typeof fn !== 'function')
3239
+ throw new Error('inert() expects a function');
3240
+ return runInert(fn);
3241
+ },
3242
+ });
2866
3243
  /**
2867
3244
  * Executes a function from a virgin/root context - no parent effect, no tracking
2868
3245
  * Creates completely independent effects that won't be cleaned up by any parent
2869
3246
  * @param fn - The function to execute
2870
3247
  */
2871
- function root(fn) {
2872
- return effectHistory.root(fn);
2873
- }
3248
+ const root = captioned(function root(fn) {
3249
+ // When broken, `atomic`/`batch` throws immediately. DOM wrappers (e.g. Sursaut
3250
+ // `root\`event:…\``) still need to run listener code for inspection UI; skip batching.
3251
+ const runner = broken ? fn : atomic(fn);
3252
+ const external = externalReasonFrom(fn);
3253
+ return external
3254
+ ? externalReason.with(external, () => effectHistory.root(runner))
3255
+ : effectHistory.root(runner);
3256
+ });
2874
3257
  function biDi(received, get, set) {
2875
3258
  if (typeof get !== 'function') {
2876
3259
  set = get.set;
2877
3260
  get = get.get;
2878
3261
  }
2879
3262
  let programmaticallySetValue = Symbol();
2880
- effect.named('biDi')(markWithRoot(() => {
3263
+ effect `biDi`(markWithRoot(() => {
2881
3264
  const newValue = get();
2882
3265
  const pValue = programmaticallySetValue;
2883
3266
  programmaticallySetValue = Symbol();
@@ -2892,6 +3275,86 @@ function biDi(received, get, set) {
2892
3275
  : () => { };
2893
3276
  }
2894
3277
 
3278
+ /**
3279
+ * Creates a mixin that can be used both as a class (extends) and as a function (mixin)
3280
+ *
3281
+ * This function supports:
3282
+ * - Using mixins as base classes: `class MyClass extends MyMixin`
3283
+ * - Using mixins as functions: `class MyClass extends MyMixin(SomeBase)`
3284
+ * - Composing mixins: `const Composed = MixinA(MixinB)`
3285
+ * - Type-safe property inference for all patterns
3286
+ *
3287
+ * @param mixinFunction - The function that creates the mixin
3288
+ * @param unwrapFunction - Optional function to unwrap reactive objects for method calls
3289
+ * @returns A mixin that can be used both as a class and as a function
3290
+ */
3291
+ function mixin(mixinFunction, unwrapFunction) {
3292
+ /**
3293
+ * Cache for mixin results to ensure the same base class always returns the same mixed class
3294
+ */
3295
+ const mixinCache = new WeakMap();
3296
+ // Apply the mixin to Object as the base class
3297
+ const MixedBase = mixinFunction(Object);
3298
+ mixinCache.set(Object, MixedBase);
3299
+ // Create the proxy that handles both constructor and function calls
3300
+ return new Proxy(MixedBase, {
3301
+ // Handle `MixinClass(SomeBase)` - use as mixin function
3302
+ apply(_target, _thisArg, args) {
3303
+ if (args.length === 0) {
3304
+ throw new Error('Mixin requires a base class');
3305
+ }
3306
+ const baseClass = args[0];
3307
+ if (typeof baseClass !== 'function') {
3308
+ throw new Error('Mixin requires a constructor function');
3309
+ }
3310
+ // Check if it's a valid constructor or a mixin
3311
+ if (!isConstructor(baseClass) &&
3312
+ !(baseClass && typeof baseClass === 'function' && baseClass.prototype)) {
3313
+ throw new Error('Mixin requires a valid constructor');
3314
+ }
3315
+ // Check cache first
3316
+ const cached = mixinCache.get(baseClass);
3317
+ if (cached) {
3318
+ return cached;
3319
+ }
3320
+ let usedBase = baseClass;
3321
+ if (unwrapFunction) {
3322
+ // Create a proxied base class that handles method unwrapping
3323
+ const ProxiedBaseClass = class extends baseClass {
3324
+ };
3325
+ // Proxy the prototype methods to handle unwrapping
3326
+ const originalPrototype = baseClass.prototype;
3327
+ const proxiedPrototype = new Proxy(originalPrototype, {
3328
+ get(target, prop, receiver) {
3329
+ const value = FoolProof.get(target, prop, receiver);
3330
+ // Only wrap methods that are likely to access private fields
3331
+ // Skip symbols and special properties that the reactive system needs
3332
+ if (typeof value === 'function' &&
3333
+ typeof prop === 'string' &&
3334
+ !['constructor', 'toString', 'valueOf'].includes(prop)) {
3335
+ // Return a wrapped version that uses unwrapped context
3336
+ return function (...args) {
3337
+ // Use the unwrapping function if provided, otherwise use this
3338
+ const context = unwrapFunction(this);
3339
+ return value.apply(context, args);
3340
+ };
3341
+ }
3342
+ return value;
3343
+ },
3344
+ });
3345
+ // Set the proxied prototype
3346
+ Object.setPrototypeOf(ProxiedBaseClass.prototype, proxiedPrototype);
3347
+ usedBase = ProxiedBaseClass;
3348
+ }
3349
+ // Create the mixed class using the proxied base class
3350
+ const mixedClass = mixinFunction(usedBase);
3351
+ // Cache the result
3352
+ mixinCache.set(baseClass, mixedClass);
3353
+ return mixedClass;
3354
+ },
3355
+ });
3356
+ }
3357
+
2895
3358
  // Track which objects contain which other objects (back-references)
2896
3359
  const objectParents = new WeakMap();
2897
3360
  // Track which objects have deep watchers
@@ -3021,14 +3484,15 @@ function getState(obj) {
3021
3484
  }
3022
3485
  return state;
3023
3486
  }
3024
- function collectEffects(obj, evolution, effects, objectWatchers, ...keyChains) {
3487
+ function collectEffects(obj, evolution, effects, objectWatchers, collectDependencyStack, ...keyChains) {
3025
3488
  const sourceEffect = getActiveEffect();
3026
3489
  for (const keys of keyChains)
3027
3490
  for (const key of keys) {
3028
3491
  const deps = objectWatchers.get(key);
3029
3492
  if (deps) {
3030
3493
  // Make sure `some.prop++` does not keep a dependency to `some.props`
3031
- deps.delete(sourceEffect);
3494
+ if (sourceEffect)
3495
+ deps.delete(sourceEffect);
3032
3496
  for (const effect of deps) {
3033
3497
  const runningChain = isRunning(effect);
3034
3498
  if (runningChain) {
@@ -3036,7 +3500,7 @@ function collectEffects(obj, evolution, effects, objectWatchers, ...keyChains) {
3036
3500
  continue;
3037
3501
  }
3038
3502
  if (!effects.has(effect)) {
3039
- effects.set(effect, getDependencyStack(effect, obj, key));
3503
+ effects.set(effect, collectDependencyStack ? getDependencyStack(effect, obj, key) : undefined);
3040
3504
  if (!hasBatched(effect))
3041
3505
  recordActivation(effect, obj, evolution, key);
3042
3506
  }
@@ -3069,16 +3533,18 @@ function touched(obj, evolution, props) {
3069
3533
  const effects = new Map();
3070
3534
  const structural = !['set', 'invalidate'].includes(evolution.type);
3071
3535
  const broad = structural ? [allProps, keysOf] : [allProps];
3536
+ const gatherReasons = options.introspection?.gatherReasons;
3537
+ const lineageConfig = gatherReasons?.lineages;
3538
+ const collectDependencyStack = lineageConfig === 'dependency' || lineageConfig === 'both';
3072
3539
  if (props)
3073
- collectEffects(obj, evolution, effects, objectWatchers, broad, props);
3540
+ collectEffects(obj, evolution, effects, objectWatchers, collectDependencyStack, broad, props);
3074
3541
  else
3075
- collectEffects(obj, evolution, effects, objectWatchers, objectWatchers.keys());
3542
+ collectEffects(obj, evolution, effects, objectWatchers, collectDependencyStack, objectWatchers.keys());
3076
3543
  const triggers = Array.from(effects.keys());
3544
+ const sourceEffect = getActiveEffect();
3077
3545
  optionCall('touched', obj, evolution, props, triggers);
3078
3546
  // Store pending triggers for CleanupReason before batching
3079
- if (options.introspection?.gatherReasons) {
3080
- const gatherReasons = options.introspection.gatherReasons;
3081
- const lineageConfig = gatherReasons.lineages;
3547
+ if (gatherReasons && effects.size > 0) {
3082
3548
  let touchLineage;
3083
3549
  if (lineageConfig === 'touch' || lineageConfig === 'both') {
3084
3550
  touchLineage = debugHooks.captureLineage();
@@ -3095,7 +3561,7 @@ function touched(obj, evolution, props) {
3095
3561
  });
3096
3562
  }
3097
3563
  }
3098
- batch(triggers);
3564
+ batch(triggers, { caller: sourceEffect });
3099
3565
  }
3100
3566
  // Bubble up changes if this object has deep watchers
3101
3567
  if (objectsWithDeepWatchers.has(obj)) {
@@ -3119,6 +3585,7 @@ function touchedOpaque(obj, evolution, prop) {
3119
3585
  const gather = options.introspection?.gatherReasons;
3120
3586
  if (gather) {
3121
3587
  const lineageConfig = gather.lineages;
3588
+ let touchLineage;
3122
3589
  for (const effect of deps) {
3123
3590
  const node = getEffectNode(effect);
3124
3591
  if (!node.isOpaque)
@@ -3130,10 +3597,9 @@ function touchedOpaque(obj, evolution, prop) {
3130
3597
  }
3131
3598
  effects.add(effect);
3132
3599
  if (gather) {
3133
- let touchLineage;
3134
3600
  let dependencyStack;
3135
3601
  if (lineageConfig === 'touch' || lineageConfig === 'both') {
3136
- touchLineage = debugHooks.captureLineage();
3602
+ touchLineage ?? (touchLineage = debugHooks.captureLineage());
3137
3603
  }
3138
3604
  if (lineageConfig === 'dependency' || lineageConfig === 'both') {
3139
3605
  dependencyStack = getDependencyStack(effect, obj, prop);
@@ -3169,7 +3635,7 @@ function touchedOpaque(obj, evolution, prop) {
3169
3635
  }
3170
3636
  if (effects.size > 0) {
3171
3637
  optionCall('touched', obj, evolution, [prop], Array.from(effects));
3172
- batch(Array.from(effects));
3638
+ batch(Array.from(effects), { caller: sourceEffect });
3173
3639
  }
3174
3640
  }
3175
3641
 
@@ -3191,9 +3657,10 @@ function addUnreactiveProps(proto, set) {
3191
3657
  return proto;
3192
3658
  }
3193
3659
  // Merge sets
3194
- set = proto[unreactiveProperties] = new Set(proto[unreactiveProperties]);
3660
+ const merged = new Set(existing);
3661
+ proto[unreactiveProperties] = merged;
3195
3662
  for (const p of set)
3196
- existing.add(p);
3663
+ merged.add(p);
3197
3664
  }
3198
3665
  // If no set, mark as fully unreactive, otherwise create set
3199
3666
  else
@@ -3215,6 +3682,8 @@ function nonReactive(...obj) {
3215
3682
  }
3216
3683
  return obj[0];
3217
3684
  }
3685
+ const markRaw = nonReactive;
3686
+ const markRawProps = addUnreactiveProps;
3218
3687
  function nonReactiveClass(...cls) {
3219
3688
  for (const c of cls)
3220
3689
  if (c)
@@ -3292,7 +3761,7 @@ function notifyPropertyChange(targetObj, prop, oldValue, newValue, hadProperty)
3292
3761
  const origin = { obj: unwrappedObj, prop };
3293
3762
  // Deep touch: only notify nested property changes with origin filtering
3294
3763
  // 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));
3764
+ const changes = untracked `deepTouch:recursive`(() => recursiveTouch(oldValue, newValue, new WeakMap(), [], origin));
3296
3765
  // When deep touch found no child differences, the object identity still changed.
3297
3766
  // Migrate watchers from old → new so the dependency chain is preserved.
3298
3767
  if (changes.length === 0) {
@@ -3432,7 +3901,7 @@ function dispatchNotifications(notifications) {
3432
3901
  const originWatchers = watchers.get(origin.obj);
3433
3902
  if (originWatchers) {
3434
3903
  const originEffects = new Map();
3435
- collectEffects(origin.obj, { type: 'set', prop: origin.prop }, originEffects, originWatchers, [allProps], [origin.prop]);
3904
+ collectEffects(origin.obj, { type: 'set', prop: origin.prop }, originEffects, originWatchers, false, [allProps], [origin.prop]);
3436
3905
  allowedEffects = new Set(originEffects.keys());
3437
3906
  }
3438
3907
  // If no allowed effects, skip all notifications (no one should be notified)
@@ -3451,7 +3920,7 @@ function dispatchNotifications(notifications) {
3451
3920
  if (objectWatchers) {
3452
3921
  currentEffects = new Map();
3453
3922
  const broad = evolution.type !== 'set' ? [allProps, keysOf] : [allProps];
3454
- collectEffects(obj, evolution, currentEffects, objectWatchers, broad, propsArray);
3923
+ collectEffects(obj, evolution, currentEffects, objectWatchers, false, broad, propsArray);
3455
3924
  // Filter effects by ancestor chain if origin exists
3456
3925
  // Include effects that either directly depend on origin or have an ancestor that does
3457
3926
  if (origin && allowedEffects) {
@@ -3513,11 +3982,94 @@ const metaProtos = new WeakMap();
3513
3982
  const wrapProtos = new WeakMap();
3514
3983
  const arrayLengths = new WeakMap();
3515
3984
  const hasReentry = new Set();
3985
+ const accessAnalysisCache = new WeakMap();
3986
+ const readonlyObjectToProxy = new WeakMap();
3987
+ const shallowObjectToProxy = new WeakMap();
3988
+ const readonlyMutators = new Set([
3989
+ 'copyWithin',
3990
+ 'fill',
3991
+ 'pop',
3992
+ 'push',
3993
+ 'reverse',
3994
+ 'shift',
3995
+ 'sort',
3996
+ 'splice',
3997
+ 'unshift',
3998
+ 'add',
3999
+ 'clear',
4000
+ 'delete',
4001
+ 'set',
4002
+ ]);
3516
4003
  // Sub-proxy registration for custom reactive behaviors
3517
4004
  const subsRegister = new WeakMap();
3518
4005
  // Internal untracked flag for setter/getter operations - only used when testing oldValue while setting a value
3519
4006
  // TODO: `touched` trigger also compares to old value and should use the internalUntracked flag
3520
4007
  let internalUntracked = false;
4008
+ function wrapReactiveValue(obj, prop, value) {
4009
+ // Optional fast-path for inert reads - skips reactive wrapping
4010
+ // Disabled by default for safety, can be enabled for performance-critical read-only contexts
4011
+ if (inertDepth > 0)
4012
+ return value;
4013
+ if (!isReactive(value) && typeof value === 'object' && value !== null) {
4014
+ const reactiveValue = reactiveObject(value);
4015
+ // Only create back-references if this object needs them
4016
+ if (needsBackReferences(obj)) {
4017
+ addBackReference(reactiveValue, obj, prop);
4018
+ }
4019
+ return reactiveValue;
4020
+ }
4021
+ return value;
4022
+ }
4023
+ function computeAccessAnalysis(obj, prop, receiver) {
4024
+ const proto = Object.getPrototypeOf(obj);
4025
+ const isOwnProp = Object.hasOwn(obj, prop);
4026
+ const shouldIgnoreAccessor = options.ignoreAccessors &&
4027
+ isOwnProp &&
4028
+ proto !== null &&
4029
+ (isOwnAccessor(receiver, prop) || isOwnAccessor(obj, prop));
4030
+ let hasProp = isOwnProp;
4031
+ let owner = isOwnProp ? obj : undefined;
4032
+ if (!isOwnProp) {
4033
+ let raw = proto;
4034
+ while (raw && raw !== Object.prototype) {
4035
+ if (Object.hasOwn(raw, prop)) {
4036
+ hasProp = true;
4037
+ owner = raw;
4038
+ break;
4039
+ }
4040
+ raw = Object.getPrototypeOf(raw);
4041
+ }
4042
+ }
4043
+ return {
4044
+ hasProp,
4045
+ owner,
4046
+ isInheritedAccess: hasProp && !isOwnProp,
4047
+ shouldIgnoreAccessor,
4048
+ ignoreAccessors: options.ignoreAccessors,
4049
+ instanceMembers: options.instanceMembers,
4050
+ };
4051
+ }
4052
+ function analyzeAccess(obj, prop, receiver) {
4053
+ const proto = Object.getPrototypeOf(obj);
4054
+ if (Object.hasOwn(obj, prop))
4055
+ return computeAccessAnalysis(obj, prop, receiver);
4056
+ if (proto === null || Array.isArray(obj))
4057
+ return computeAccessAnalysis(obj, prop, receiver);
4058
+ let propCache = accessAnalysisCache.get(proto);
4059
+ if (!propCache) {
4060
+ propCache = new Map();
4061
+ accessAnalysisCache.set(proto, propCache);
4062
+ }
4063
+ const cached = propCache.get(prop);
4064
+ if (cached &&
4065
+ cached.ignoreAccessors === options.ignoreAccessors &&
4066
+ cached.instanceMembers === options.instanceMembers)
4067
+ return cached;
4068
+ const analysis = computeAccessAnalysis(obj, prop, receiver);
4069
+ if (analysis.hasProp)
4070
+ propCache.set(prop, analysis);
4071
+ return analysis;
4072
+ }
3521
4073
  const reactiveHandlers = {
3522
4074
  [Symbol.toStringTag]: 'MutTs Reactive',
3523
4075
  get(obj, prop, receiver) {
@@ -3545,32 +4097,29 @@ const reactiveHandlers = {
3545
4097
  // Symbols: fast-path — no reactivity tracking
3546
4098
  if (typeof prop === 'symbol' || prop === 'constructor' || isUnreactiveProp(obj, prop))
3547
4099
  return FoolProof.get(obj, prop, receiver);
3548
- // Check if property exists using a trap-free walk to avoid triggering
3549
- // the has-trap cascade on prototype chains of reactive proxies.
3550
- const isOwnProp = Object.hasOwn(obj, prop);
3551
- // For accessor properties, check the unwrapped object to see if it's an accessor
3552
- // This ensures ignoreAccessors works correctly even after operations like Object.setPrototypeOf
3553
- // Skip for null-proto objects (pounce scopes) — they never have accessors
3554
- const shouldIgnoreAccessor = options.ignoreAccessors &&
3555
- isOwnProp &&
3556
- Object.getPrototypeOf(obj) !== null &&
3557
- (isOwnAccessor(receiver, prop) || isOwnAccessor(obj, prop));
3558
- // Check if property exists using a trap-free walk to avoid triggering
3559
- // the has-trap cascade on prototype chains of reactive proxies.
3560
- let hasProp = isOwnProp;
3561
- let owner = isOwnProp ? obj : undefined;
3562
- if (!isOwnProp) {
3563
- let raw = Object.getPrototypeOf(obj);
3564
- while (raw && raw !== Object.prototype) {
3565
- if (Object.hasOwn(raw, prop)) {
3566
- hasProp = true;
3567
- owner = raw;
3568
- break;
4100
+ const subProxy = subsRegister.get(obj);
4101
+ if (inertDepth > 0) {
4102
+ const value = (subProxy?.get || FoolProof.get)(obj, prop, receiver);
4103
+ return wrapReactiveValue(obj, prop, value);
4104
+ }
4105
+ const activeEffect = getActiveEffect();
4106
+ if (!activeEffect) {
4107
+ const value = (subProxy?.get || FoolProof.get)(obj, prop, receiver);
4108
+ return wrapReactiveValue(obj, prop, value);
4109
+ }
4110
+ if (!subProxy && !Array.isArray(obj)) {
4111
+ const proto = Object.getPrototypeOf(obj);
4112
+ if (proto === Object.prototype || proto === null) {
4113
+ const ownDesc = Object.getOwnPropertyDescriptor(obj, prop);
4114
+ if (ownDesc && 'value' in ownDesc) {
4115
+ dependant(obj, prop);
4116
+ return wrapReactiveValue(obj, prop, ownDesc.value);
3569
4117
  }
3570
- raw = Object.getPrototypeOf(raw);
3571
4118
  }
3572
4119
  }
3573
- const isInheritedAccess = hasProp && !isOwnProp;
4120
+ // Check if property exists using a trap-free walk to avoid triggering
4121
+ // the has-trap cascade on prototype chains of reactive proxies.
4122
+ const { hasProp, owner, isInheritedAccess, shouldIgnoreAccessor } = analyzeAccess(obj, prop, receiver);
3574
4123
  // Depend if...
3575
4124
  if (!hasProp ||
3576
4125
  (!(options.instanceMembers && isInheritedAccess && obj instanceof Object) &&
@@ -3583,16 +4132,8 @@ const reactiveHandlers = {
3583
4132
  }
3584
4133
  // For arrays, use FoolProof.get (Indexer path) for numeric index reactivity.
3585
4134
  // For all other objects, inline Reflect.get directly (skips 3 function calls).
3586
- 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;
4135
+ const value = (subProxy?.get || FoolProof.get)(obj, prop, receiver);
4136
+ return wrapReactiveValue(obj, prop, value);
3596
4137
  },
3597
4138
  set(obj, prop, value, receiver) {
3598
4139
  const unwrapped = unwrap(receiver);
@@ -3665,6 +4206,11 @@ const reactiveHandlers = {
3665
4206
  cycle: [], // We don't have the full cycle here, but we know it involves obj
3666
4207
  });
3667
4208
  hasReentry.add(obj);
4209
+ if (inertDepth > 0) {
4210
+ const rv = (subsRegister.get(obj)?.has || Reflect.has)(obj, prop);
4211
+ hasReentry.delete(obj);
4212
+ return rv;
4213
+ }
3668
4214
  if (!internalUntracked && !isUnreactiveProp(obj, prop))
3669
4215
  dependant(obj, prop);
3670
4216
  const rv = (subsRegister.get(obj)?.has || Reflect.has)(obj, prop);
@@ -3673,7 +4219,7 @@ const reactiveHandlers = {
3673
4219
  },
3674
4220
  deleteProperty(obj, prop) {
3675
4221
  if (!Object.hasOwn(obj, prop))
3676
- return false;
4222
+ return true;
3677
4223
  const oldVal = obj[prop];
3678
4224
  // Remove back-references if this object has deep watchers
3679
4225
  if (objectsWithDeepWatchers.has(obj) && typeof oldVal === 'object' && oldVal !== null) {
@@ -3688,6 +4234,9 @@ const reactiveHandlers = {
3688
4234
  return true;
3689
4235
  },
3690
4236
  ownKeys(obj) {
4237
+ if (inertDepth > 0) {
4238
+ return subsRegister.get(obj)?.ownKeys?.(obj) || Reflect.ownKeys(obj);
4239
+ }
3691
4240
  dependant(obj, keysOf);
3692
4241
  return subsRegister.get(obj)?.ownKeys?.(obj) || Reflect.ownKeys(obj);
3693
4242
  },
@@ -3696,6 +4245,98 @@ const reactiveHandlers = {
3696
4245
  Reflect.getOwnPropertyDescriptor(obj, prop));
3697
4246
  },
3698
4247
  };
4248
+ function readonlyError(prop) {
4249
+ return new ReactiveError(`[reactive] Cannot mutate readonly reactive property '${String(prop)}'`, {
4250
+ code: ReactiveErrorCode.WriteInComputed,
4251
+ });
4252
+ }
4253
+ function readonlyValue(value) {
4254
+ if (!value || typeof value !== 'object')
4255
+ return value;
4256
+ return readonlyReactive(value);
4257
+ }
4258
+ const shallowReactiveHandlers = {
4259
+ get(obj, prop, receiver) {
4260
+ if (typeof prop === 'symbol' || prop === 'constructor' || isUnreactiveProp(obj, prop))
4261
+ return Reflect.get(obj, prop, receiver);
4262
+ if (getActiveEffect())
4263
+ dependant(obj, prop);
4264
+ return Reflect.get(obj, prop, receiver);
4265
+ },
4266
+ set(obj, prop, value, receiver) {
4267
+ const unwrapped = unwrap(receiver);
4268
+ if (obj !== unwrapped)
4269
+ return Object.defineProperty(unwrapped, prop, {
4270
+ value,
4271
+ configurable: true,
4272
+ writable: true,
4273
+ enumerable: true,
4274
+ });
4275
+ if (isUnreactiveProp(obj, prop))
4276
+ return FoolProof.set(obj, prop, value, receiver);
4277
+ const hadProperty = Reflect.has(obj, prop);
4278
+ const oldVal = hadProperty ? Reflect.get(obj, prop, receiver) : absent;
4279
+ const newValue = unwrap(value);
4280
+ if (oldVal !== newValue && FoolProof.set(obj, prop, newValue, receiver)) {
4281
+ touched1(obj, { type: hadProperty ? 'set' : 'add', prop }, prop);
4282
+ }
4283
+ return true;
4284
+ },
4285
+ has(obj, prop) {
4286
+ return reactiveHandlers.has(obj, prop);
4287
+ },
4288
+ deleteProperty(obj, prop) {
4289
+ if (!Object.hasOwn(obj, prop))
4290
+ return true;
4291
+ delete obj[prop];
4292
+ touched1(obj, { type: 'del', prop }, prop);
4293
+ return true;
4294
+ },
4295
+ ownKeys(obj) {
4296
+ return reactiveHandlers.ownKeys(obj);
4297
+ },
4298
+ getOwnPropertyDescriptor(obj, prop) {
4299
+ return Reflect.getOwnPropertyDescriptor(obj, prop);
4300
+ },
4301
+ };
4302
+ const readonlyReactiveHandlers = {
4303
+ get(obj, prop, receiver) {
4304
+ if (readonlyMutators.has(prop)) {
4305
+ return () => {
4306
+ throw readonlyError(prop);
4307
+ };
4308
+ }
4309
+ const reactiveTarget = reactiveObject(obj);
4310
+ const value = FoolProof.get(reactiveTarget, prop, receiver);
4311
+ if (typeof value === 'function') {
4312
+ return (...args) => readonlyValue(value.apply(reactiveTarget, args));
4313
+ }
4314
+ return readonlyValue(value);
4315
+ },
4316
+ set(_obj, prop) {
4317
+ throw readonlyError(prop);
4318
+ },
4319
+ deleteProperty(_obj, prop) {
4320
+ throw readonlyError(prop);
4321
+ },
4322
+ defineProperty(_obj, prop) {
4323
+ throw readonlyError(prop);
4324
+ },
4325
+ setPrototypeOf() {
4326
+ throw readonlyError('[[Prototype]]');
4327
+ },
4328
+ has(obj, prop) {
4329
+ const reactiveTarget = reactiveObject(obj);
4330
+ return Reflect.has(reactiveTarget, prop);
4331
+ },
4332
+ ownKeys(obj) {
4333
+ const reactiveTarget = reactiveObject(obj);
4334
+ return Reflect.ownKeys(reactiveTarget);
4335
+ },
4336
+ getOwnPropertyDescriptor(obj, prop) {
4337
+ return Reflect.getOwnPropertyDescriptor(obj, prop);
4338
+ },
4339
+ };
3699
4340
  const reactiveClasses = new WeakSet();
3700
4341
  // Create the ReactiveBase mixin
3701
4342
  /**
@@ -3737,6 +4378,34 @@ function reactiveObject(anyTarget, subProxy) {
3737
4378
  storeProxyRelationship(target, proxy);
3738
4379
  return proxy;
3739
4380
  }
4381
+ function shallowReactiveObject(anyTarget) {
4382
+ if (!anyTarget || typeof anyTarget !== 'object')
4383
+ return anyTarget;
4384
+ const target = unwrap(anyTarget);
4385
+ if (isNonReactive(target))
4386
+ return target;
4387
+ const existing = shallowObjectToProxy.get(target);
4388
+ if (existing)
4389
+ return existing;
4390
+ const proxy = new Proxy(target, shallowReactiveHandlers);
4391
+ shallowObjectToProxy.set(target, proxy);
4392
+ proxyToObject.set(proxy, target);
4393
+ return proxy;
4394
+ }
4395
+ function readonlyReactiveObject(anyTarget) {
4396
+ if (!anyTarget || typeof anyTarget !== 'object')
4397
+ return anyTarget;
4398
+ const target = unwrap(anyTarget);
4399
+ if (isNonReactive(target))
4400
+ return target;
4401
+ const existing = readonlyObjectToProxy.get(target);
4402
+ if (existing)
4403
+ return existing;
4404
+ const proxy = new Proxy(target, readonlyReactiveHandlers);
4405
+ readonlyObjectToProxy.set(target, proxy);
4406
+ proxyToObject.set(proxy, target);
4407
+ return proxy;
4408
+ }
3740
4409
  /**
3741
4410
  * Main decorator for making classes reactive
3742
4411
  * Automatically makes class instances reactive when created
@@ -3767,6 +4436,8 @@ const reactive = decorator({
3767
4436
  },
3768
4437
  default: reactiveObject,
3769
4438
  });
4439
+ const shallowReactive = shallowReactiveObject;
4440
+ const readonlyReactive = readonlyReactiveObject;
3770
4441
 
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
4442
+ export { markRawProps 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, inert as M, inheritCaption as N, isConstructor as O, isDev as P, isNonReactive as Q, ReactiveBase as R, isObject as S, isProd as T, isReactive as U, isReactiveBroken as V, isTest as W, legacyDecorator as X, link as Y, Zone as Z, markRaw as _, IterableWeakSet as a, mixin as a0, modernDecorator as a1, named as a2, objectToProxy as a3, onEffectThrow as a4, onReactiveBroken as a5, onReactiveReset as a6, prodPreset as a7, proxyToObject as a8, reactive as a9, inertDepth as aA, optionCall as aB, FoolProof as aC, effectHistory as aD, unreactiveProperties as aE, __runInitializers as aF, __esDecorate as aG, contentRef as aH, notifyPropertyChange as aI, metaProtos as aJ, wrapProtos as aK, objectParents as aL, watchers as aM, effectToReactiveObjects as aN, effectMarker as aO, setDebugHooks as aP, allProps as aQ, options as aa, readonlyReactive as ab, reset as ac, root as ad, shallowReactive as ae, tag as af, toRaw as ag, touched as ah, touched1 as ai, unlink as aj, untracked as ak, unwrap as al, withEffectContext as am, wrapInert as an, zip as ao, markWithRoot as ap, dependant as aq, getEffectNode as ar, chainExternalReason as as, keysOf as at, objectsWithDeepWatchers as au, effectToDeepWatchedObjects as av, deepWatchers as aw, registerDeepWatcher as ax, rootFunctionSymbol as ay, getRoot 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 };
4443
+ //# sourceMappingURL=proxy-C2lnvvbx.esm.js.map