@actuallab/fusion 13.0.25 → 13.0.167

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -23,6 +23,7 @@ __export(index_exports, {
23
23
  ComputeContext: () => ComputeContext,
24
24
  ComputeFunction: () => ComputeFunction,
25
25
  Computed: () => Computed,
26
+ ComputedOptions: () => ComputedOptions,
26
27
  ComputedRegistry: () => ComputedRegistry,
27
28
  ComputedState: () => ComputedState,
28
29
  ConsistencyState: () => ConsistencyState,
@@ -93,6 +94,11 @@ var ComputedRegistry = class _ComputedRegistry {
93
94
  }
94
95
  static register(computed) {
95
96
  const key = computed.input;
97
+ const displaced = _ComputedRegistry._entries.get(key)?.deref();
98
+ if (displaced === computed)
99
+ return;
100
+ if (displaced !== void 0 && displaced.state !== 2 /* Invalidated */)
101
+ displaced.invalidate();
96
102
  _ComputedRegistry._entries.set(key, new WeakRef(computed));
97
103
  _ComputedRegistry._finalization.register(computed, key, computed);
98
104
  }
@@ -108,19 +114,36 @@ var import_core2 = require("@actuallab/core");
108
114
  var computeContextKey = new import_core2.AsyncContextKey("ComputeContext", void 0);
109
115
  var ComputeContext = class {
110
116
  computed;
117
+ parent;
111
118
  _isCapturing = true;
112
- constructor(computed) {
119
+ _captured;
120
+ constructor(computed, parent) {
113
121
  this.computed = computed;
122
+ this.parent = parent;
114
123
  }
115
124
  get isCapturing() {
116
125
  return this._isCapturing;
117
126
  }
127
+ get captured() {
128
+ return this._captured;
129
+ }
118
130
  stopCapturing() {
119
131
  this._isCapturing = false;
120
132
  }
121
133
  captureDependency(dependency) {
122
134
  if (!this._isCapturing) return;
123
- this.computed.addDependency(dependency);
135
+ this._captured = dependency;
136
+ this.computed?.addDependency(dependency);
137
+ }
138
+ // Walks the caller chain for a computation still in progress under the same key —
139
+ // C# AsyncLockSet(LockReentryMode.CheckedFail) analog, used to fail fast on reentry.
140
+ // Only Computing ancestors count: a context leaked past its finished computation
141
+ // (e.g. via AsyncLocalStorage into a later callback) must not fail later calls.
142
+ hasKeyInChain(key) {
143
+ const computed = this.computed;
144
+ if (computed?.isComputing === true && computed.input === key)
145
+ return true;
146
+ return this.parent?.hasKeyInChain(key) ?? false;
124
147
  }
125
148
  /** Resolve ComputeContext from an AsyncContext (or current). */
126
149
  static from(ctx) {
@@ -128,7 +151,20 @@ var ComputeContext = class {
128
151
  }
129
152
  };
130
153
 
154
+ // src/computed-options.ts
155
+ var ComputedOptions = class _ComputedOptions {
156
+ static default = new _ComputedOptions();
157
+ static mutableStateDefault = new _ComputedOptions({
158
+ errorAutoInvalidateDelay: Infinity
159
+ });
160
+ errorAutoInvalidateDelay;
161
+ constructor(overrides) {
162
+ this.errorAutoInvalidateDelay = overrides?.errorAutoInvalidateDelay ?? 1e3;
163
+ }
164
+ };
165
+
131
166
  // src/computed.ts
167
+ var { errorLog } = getLogs("Computed");
132
168
  var _nextVersion = 0;
133
169
  var ConsistencyState = /* @__PURE__ */ ((ConsistencyState2) => {
134
170
  ConsistencyState2[ConsistencyState2["Computing"] = 0] = "Computing";
@@ -136,36 +172,44 @@ var ConsistencyState = /* @__PURE__ */ ((ConsistencyState2) => {
136
172
  ConsistencyState2[ConsistencyState2["Invalidated"] = 2] = "Invalidated";
137
173
  return ConsistencyState2;
138
174
  })(ConsistencyState || {});
139
- var Computed = class _Computed {
140
- /** Delay before auto-invalidating a Computed that holds an error result. 0 = disabled. */
141
- static errorAutoInvalidateDelay = 1e3;
175
+ var Computed = class {
142
176
  /** Capture the Computed backing a computation — works with both local and RPC compute methods. */
143
177
  static async capture(fn) {
144
- const captureComputed = new _Computed("__capture__");
145
- const captureCtx = new ComputeContext(captureComputed);
146
- const asyncCtx = import_core3.AsyncContext.getOrCreate().with(
147
- computeContextKey,
148
- captureCtx
178
+ const currentCtx = import_core3.AsyncContext.getOrCreate();
179
+ const captureCtx = new ComputeContext(
180
+ void 0,
181
+ currentCtx.get(computeContextKey)
149
182
  );
150
- await asyncCtx.run(fn);
151
- const deps = captureComputed.dependencies;
152
- if (deps.size === 0)
183
+ const asyncCtx = currentCtx.with(computeContextKey, captureCtx);
184
+ try {
185
+ await asyncCtx.run(fn);
186
+ } catch (e) {
187
+ if ((0, import_core3.isCancellation)(e)) throw e;
188
+ const captured2 = captureCtx.captured;
189
+ if (captured2?.hasError) return captured2;
190
+ throw e;
191
+ }
192
+ const captured = captureCtx.captured;
193
+ if (captured === void 0)
153
194
  throw new Error(
154
195
  "No Computed was captured \u2014 fn must call a compute function."
155
196
  );
156
- return deps.values().next().value;
197
+ return captured;
157
198
  }
158
199
  input;
200
+ options;
159
201
  _version;
160
202
  _state;
161
203
  _output;
162
204
  _invalidatePending = false;
205
+ _errorTimer;
163
206
  _dependencies = /* @__PURE__ */ new Set();
164
207
  _dependants = /* @__PURE__ */ new Map();
165
- onInvalidated = new import_core3.EventHandlerSet();
208
+ _onInvalidated = new import_core3.EventHandlerSet();
166
209
  _renewer;
167
- constructor(input, renewer) {
210
+ constructor(input, renewer, options = ComputedOptions.default) {
168
211
  this.input = input;
212
+ this.options = options;
169
213
  this._renewer = renewer;
170
214
  this._version = ++_nextVersion;
171
215
  this._state = 0 /* Computing */;
@@ -199,6 +243,9 @@ var Computed = class _Computed {
199
243
  get isConsistent() {
200
244
  return this._state === 1 /* Consistent */;
201
245
  }
246
+ get isComputing() {
247
+ return this._state === 0 /* Computing */;
248
+ }
202
249
  get dependencies() {
203
250
  return this._dependencies;
204
251
  }
@@ -206,7 +253,14 @@ var Computed = class _Computed {
206
253
  if (this._state === 1 /* Consistent */) return this;
207
254
  const latest = this._latest();
208
255
  if (latest?.isConsistent) return latest;
209
- if (this._renewer !== void 0) return this._renewer();
256
+ if (this._renewer !== void 0) {
257
+ const renewer = this._renewer;
258
+ const isolated = (import_core3.AsyncContext.current ?? import_core3.AsyncContext.empty).with(
259
+ computeContextKey,
260
+ void 0
261
+ );
262
+ return isolated.run(renewer);
263
+ }
210
264
  throw new Error(
211
265
  "Cannot recompute: Computed is invalidated and has no renewer."
212
266
  );
@@ -243,11 +297,11 @@ var Computed = class _Computed {
243
297
  this.invalidate();
244
298
  return;
245
299
  }
246
- if (this._output.hasError && _Computed.errorAutoInvalidateDelay > 0)
247
- setTimeout(
248
- () => this.invalidate(),
249
- _Computed.errorAutoInvalidateDelay
250
- );
300
+ const delay = this.options.errorAutoInvalidateDelay;
301
+ if (this._output.hasError && delay !== Infinity && delay > 0) {
302
+ this._errorTimer = setTimeout(() => this.invalidate(), delay);
303
+ this._errorTimer.unref?.();
304
+ }
251
305
  }
252
306
  _register() {
253
307
  ComputedRegistry.register(this);
@@ -255,6 +309,8 @@ var Computed = class _Computed {
255
309
  _unregister() {
256
310
  ComputedRegistry.unregister(this);
257
311
  }
312
+ // Never throws — mirrors .NET Computed.Invalidate: own handlers fire first
313
+ // (each isolated), then dependants propagate unconditionally, then unregister.
258
314
  invalidate() {
259
315
  if (this._state === 2 /* Invalidated */)
260
316
  return;
@@ -263,35 +319,74 @@ var Computed = class _Computed {
263
319
  return;
264
320
  }
265
321
  this._state = 2 /* Invalidated */;
266
- this._dependencies.clear();
267
- for (const [, ref] of this._dependants) {
268
- const dependant = ref.deref();
269
- if (dependant != null) dependant.invalidate();
322
+ if (this._errorTimer !== void 0) {
323
+ clearTimeout(this._errorTimer);
324
+ this._errorTimer = void 0;
325
+ }
326
+ try {
327
+ this._onInvalidated.triggerSafe(
328
+ void 0,
329
+ (e) => errorLog?.log("onInvalidated handler failed", e)
330
+ );
331
+ this._onInvalidated.clear();
332
+ } finally {
333
+ for (const dependency of this._dependencies)
334
+ dependency._dependants.delete(this._version);
335
+ this._dependencies.clear();
336
+ for (const [, ref] of this._dependants) {
337
+ const dependant = ref.deref();
338
+ if (dependant != null) {
339
+ try {
340
+ dependant.invalidate();
341
+ } catch (e) {
342
+ errorLog?.log("Error while invalidating dependant", e);
343
+ }
344
+ }
345
+ }
346
+ this._dependants.clear();
347
+ this._unregister();
270
348
  }
271
- this._dependants.clear();
272
- this.onInvalidated.trigger();
273
- this.onInvalidated.clear();
274
- this._unregister();
349
+ }
350
+ onInvalidated(handler) {
351
+ if (this._state === 2 /* Invalidated */) {
352
+ handler();
353
+ return;
354
+ }
355
+ this._onInvalidated.add(handler);
275
356
  }
276
357
  whenInvalidated(abortSignal) {
277
358
  if (this._state === 2 /* Invalidated */)
278
359
  return Promise.resolve();
279
- const ps = new import_core3.PromiseSource();
280
- const handler = () => ps.resolve(void 0);
281
- this.onInvalidated.add(handler);
282
- if (abortSignal !== void 0 && !abortSignal.aborted) {
283
- abortSignal.addEventListener(
284
- "abort",
285
- () => {
286
- this.onInvalidated.remove(handler);
287
- ps.reject(abortSignal.reason);
288
- },
289
- { once: true }
360
+ if (abortSignal?.aborted)
361
+ return Promise.reject(
362
+ abortSignal.reason ?? new Error("Operation cancelled.")
290
363
  );
364
+ const ps = new import_core3.PromiseSource();
365
+ let onAbort;
366
+ const onInvalidated = () => {
367
+ if (onAbort !== void 0)
368
+ abortSignal.removeEventListener("abort", onAbort);
369
+ ps.resolve(void 0);
370
+ };
371
+ this._onInvalidated.add(onInvalidated);
372
+ if (abortSignal !== void 0) {
373
+ onAbort = () => {
374
+ this._onInvalidated.remove(onInvalidated);
375
+ ps.reject(
376
+ abortSignal.reason ?? new Error("Operation cancelled.")
377
+ );
378
+ };
379
+ abortSignal.addEventListener("abort", onAbort, { once: true });
291
380
  }
292
381
  return ps;
293
382
  }
294
383
  addDependency(dependency) {
384
+ if (this._state !== 0 /* Computing */)
385
+ return;
386
+ if (dependency._state === 2 /* Invalidated */) {
387
+ this.invalidate();
388
+ return;
389
+ }
295
390
  this._dependencies.add(dependency);
296
391
  dependency._dependants.set(
297
392
  this._version,
@@ -300,6 +395,9 @@ var Computed = class _Computed {
300
395
  }
301
396
  };
302
397
  var StateBoundComputed = class extends Computed {
398
+ constructor(input, renewer, options = ComputedOptions.mutableStateDefault) {
399
+ super(input, renewer, options);
400
+ }
303
401
  _latest() {
304
402
  return this.input.computed;
305
403
  }
@@ -313,19 +411,21 @@ var StateBoundComputed = class extends Computed {
313
411
  var import_core4 = require("@actuallab/core");
314
412
  var RS = "";
315
413
  function defaultArgToString(arg) {
316
- return JSON.stringify(arg) ?? "undefined";
414
+ return JSON.stringify(arg) ?? "";
317
415
  }
318
416
  var _nextFunctionId = 0;
319
417
  var ComputeFunction = class {
320
418
  methodName;
321
419
  id;
420
+ options;
322
421
  argToString = defaultArgToString;
323
422
  _impl;
324
423
  _locks = /* @__PURE__ */ new Map();
325
- constructor(methodName, impl) {
424
+ constructor(methodName, impl, options = ComputedOptions.default) {
326
425
  this.methodName = methodName;
327
426
  this.id = `${methodName}[${++_nextFunctionId}]`;
328
427
  this._impl = impl;
428
+ this.options = options;
329
429
  }
330
430
  /** Build a string key for the given instance and arguments. */
331
431
  buildKey(instance, args) {
@@ -335,7 +435,7 @@ var ComputeFunction = class {
335
435
  }
336
436
  return key;
337
437
  }
338
- async invoke(instance, args, prevComputed) {
438
+ async invoke(instance, args) {
339
439
  const asyncCtx = import_core4.AsyncContext.fromArgs(args);
340
440
  const callerComputeCtx = asyncCtx?.get(computeContextKey);
341
441
  const argsWithoutCtx = asyncCtx?.stripFromArgs(args) ?? args;
@@ -345,6 +445,8 @@ var ComputeFunction = class {
345
445
  callerComputeCtx?.captureDependency(existing);
346
446
  return existing;
347
447
  }
448
+ if (callerComputeCtx?.hasKeyInChain(key))
449
+ throw new Error(`Reentrant compute call detected for key "${key}".`);
348
450
  let lock = this._locks.get(key);
349
451
  if (lock === void 0) {
350
452
  lock = new import_core4.AsyncLock();
@@ -353,10 +455,17 @@ var ComputeFunction = class {
353
455
  const computed = await lock.run(async () => {
354
456
  const cached = ComputedRegistry.get(key);
355
457
  if (cached?.isConsistent) return cached;
356
- let newComputed;
357
- const renewer = prevComputed?._renewer ?? (() => this.invoke(instance, argsWithoutCtx, newComputed));
358
- newComputed = new Computed(key, renewer);
359
- const childComputeCtx = new ComputeContext(newComputed);
458
+ const renewer = () => this.invoke(instance, argsWithoutCtx);
459
+ const newComputed = new Computed(
460
+ key,
461
+ renewer,
462
+ this.options
463
+ );
464
+ ComputedRegistry.register(newComputed);
465
+ const childComputeCtx = new ComputeContext(
466
+ newComputed,
467
+ callerComputeCtx
468
+ );
360
469
  const childAsyncCtx = (asyncCtx ?? import_core4.AsyncContext.empty).with(
361
470
  computeContextKey,
362
471
  childComputeCtx
@@ -364,7 +473,7 @@ var ComputeFunction = class {
364
473
  let fnResult;
365
474
  try {
366
475
  const value = childAsyncCtx.run(
367
- () => this._impl.call(instance, ...argsWithoutCtx)
476
+ () => this._impl.call(instance, ...argsWithoutCtx, childAsyncCtx)
368
477
  );
369
478
  const resolved = value instanceof Promise ? await value : value;
370
479
  fnResult = (0, import_core4.result)(resolved);
@@ -372,27 +481,38 @@ var ComputeFunction = class {
372
481
  fnResult = (0, import_core4.errorResult)(e);
373
482
  }
374
483
  newComputed.setOutput(fnResult);
484
+ if (fnResult.hasError && (0, import_core4.isCancellation)(fnResult.error))
485
+ newComputed.invalidate();
375
486
  return newComputed;
376
487
  });
488
+ if (!lock.isLocked && this._locks.get(key) === lock)
489
+ this._locks.delete(key);
377
490
  callerComputeCtx?.captureDependency(computed);
378
491
  return computed;
379
492
  }
380
493
  };
381
494
 
382
495
  // src/compute-method.ts
496
+ var import_core5 = require("@actuallab/core");
383
497
  var METHODS_META = /* @__PURE__ */ Symbol.for("actuallab.methods");
384
498
  function getMethodsMeta(cls) {
385
499
  return cls[Symbol.metadata]?.[METHODS_META];
386
500
  }
387
- function computeMethod(target, context) {
501
+ function computeMethod(targetOrOptions, context) {
502
+ if (typeof targetOrOptions === "function")
503
+ return computeMethodImpl(targetOrOptions, context, {});
504
+ const options = targetOrOptions ?? {};
505
+ return (target, ctx) => computeMethodImpl(target, ctx, options);
506
+ }
507
+ function computeMethodImpl(target, context, options) {
388
508
  const methodName = String(context.name);
389
- const methods = context.metadata[METHODS_META] ??= {};
509
+ const methods = (0, import_core5.ownMetadata)(context.metadata, METHODS_META);
390
510
  methods[methodName] = {
391
511
  ...methods[methodName],
392
512
  compute: true,
393
- argCount: target.length
513
+ argCount: (0, import_core5.resolveArgCount)(target, options.argCount, methodName)
394
514
  };
395
- const cf = new ComputeFunction(methodName, target);
515
+ const cf = new ComputeFunction(methodName, target, new ComputedOptions(options));
396
516
  const replacement = function(...allArgs) {
397
517
  return cf.invoke(this, allArgs).then((c) => c.value);
398
518
  };
@@ -424,11 +544,20 @@ function wrapComputeMethod(fn) {
424
544
  }
425
545
 
426
546
  // src/update-delayer.ts
547
+ var import_core6 = require("@actuallab/core");
548
+ var defaultRetryDelays = import_core6.RetryDelaySeq.exp(1e3, 6e4);
549
+ var defaultMinDelayMs = 32;
550
+ function computeUpdateDelay(retryCount, updateDelayMs, retryDelays, minDelayMs) {
551
+ const min = retryCount === 0 ? minDelayMs : retryDelays.min;
552
+ const raw = retryCount > 0 ? retryDelays.getDelay(retryCount) : updateDelayMs;
553
+ return Math.max(min, raw);
554
+ }
427
555
  var FixedDelayer = class _FixedDelayer {
428
556
  static zero = () => Promise.resolve();
429
557
  static _cache = /* @__PURE__ */ new Map();
558
+ // Floors ms at minDelay like C# FixedDelayer.Get — a zero delayer requires the explicit .zero.
430
559
  static get(ms) {
431
- if (ms <= 0) return _FixedDelayer.zero;
560
+ ms = Math.max(ms, defaultMinDelayMs);
432
561
  let delayer = _FixedDelayer._cache.get(ms);
433
562
  if (delayer === void 0) {
434
563
  delayer = new _FixedDelayer(ms).delay;
@@ -437,42 +566,57 @@ var FixedDelayer = class _FixedDelayer {
437
566
  return delayer;
438
567
  }
439
568
  ms;
569
+ retryDelays;
570
+ minDelay;
440
571
  delay;
441
- constructor(ms) {
572
+ constructor(ms, retryDelays = defaultRetryDelays, minDelay = defaultMinDelayMs) {
442
573
  this.ms = ms;
443
- this.delay = ms > 1e4 ? (abortSignal) => new Promise((resolve, reject) => {
444
- const timer = setTimeout(resolve, this.ms);
445
- if (abortSignal === void 0) return;
446
- if (abortSignal.aborted) {
447
- clearTimeout(timer);
448
- reject(abortSignal.reason instanceof Error ? abortSignal.reason : new Error(String(abortSignal.reason)));
449
- return;
450
- }
451
- abortSignal.addEventListener(
452
- "abort",
453
- () => {
454
- clearTimeout(timer);
455
- reject(abortSignal.reason instanceof Error ? abortSignal.reason : new Error(String(abortSignal.reason)));
456
- },
457
- { once: true }
574
+ this.retryDelays = retryDelays;
575
+ this.minDelay = minDelay;
576
+ this.delay = (retryCount, abortSignal) => {
577
+ const delayMs = computeUpdateDelay(
578
+ retryCount,
579
+ this.ms,
580
+ this.retryDelays,
581
+ this.minDelay
458
582
  );
459
- }) : () => new Promise(
460
- (resolve) => setTimeout(resolve, this.ms)
461
- );
583
+ if (delayMs <= 0) return Promise.resolve();
584
+ return (0, import_core6.awaitWithCleanup)(abortSignal, "resolve", (complete, addCleanup) => {
585
+ const timer = setTimeout(complete, delayMs);
586
+ addCleanup(() => clearTimeout(timer));
587
+ });
588
+ };
462
589
  }
463
590
  };
464
591
  var defaultUpdateDelayer = FixedDelayer.get(1e3 / 60);
465
592
 
593
+ // src/ui-update-delayer.ts
594
+ var import_core8 = require("@actuallab/core");
595
+
466
596
  // src/ui-action-tracker.ts
467
- var import_core5 = require("@actuallab/core");
468
- var { errorLog } = getLogs("UIActionTracker");
597
+ var import_core7 = require("@actuallab/core");
598
+ var { errorLog: errorLog2 } = getLogs("UIActionTracker");
469
599
  var UIActionTracker = class {
470
600
  _activeCount = 0;
471
- changed = new import_core5.EventHandlerSet();
601
+ _lastResultAt = 0;
602
+ _errorTimes = [];
603
+ changed = new import_core7.EventHandlerSet();
472
604
  errors = [];
605
+ // C# UIActionTracker.Options.InstantUpdatePeriod / UIActionFailureTracker MaxDuplicateRecency.
606
+ instantUpdatePeriod = 300;
607
+ maxDuplicateRecency = 1e3;
608
+ maxErrors = 100;
473
609
  get isActive() {
474
610
  return this._activeCount > 0;
475
611
  }
612
+ // C# UIActionTracker.AreInstantUpdatesEnabled — active, or within instantUpdatePeriod of the last result.
613
+ areInstantUpdatesEnabled() {
614
+ if (this._activeCount > 0)
615
+ return true;
616
+ if (this._lastResultAt === 0)
617
+ return false;
618
+ return this._lastResultAt + this.instantUpdatePeriod >= Date.now();
619
+ }
476
620
  /** Run a command silently — errors are added to the error list but not thrown. */
477
621
  async run(fn) {
478
622
  this._activeCount++;
@@ -480,12 +624,10 @@ var UIActionTracker = class {
480
624
  try {
481
625
  await fn();
482
626
  } catch (e) {
483
- errorLog?.log("UI action failed", e);
484
- this.errors.push(e);
627
+ errorLog2?.log("UI action failed", e);
628
+ this._addError(e);
485
629
  } finally {
486
- this._activeCount--;
487
- await new Promise((r) => setTimeout(r, 50));
488
- this.changed.trigger();
630
+ this._onCompleted();
489
631
  }
490
632
  }
491
633
  /** Call a command and return the result — errors are added to the error list AND thrown. */
@@ -495,80 +637,127 @@ var UIActionTracker = class {
495
637
  try {
496
638
  return await fn();
497
639
  } catch (e) {
498
- errorLog?.log("UI action failed", e);
499
- this.errors.push(e);
640
+ errorLog2?.log("UI action failed", e);
641
+ this._addError(e);
500
642
  throw e;
501
643
  } finally {
502
- this._activeCount--;
503
- await new Promise((r) => setTimeout(r, 50));
504
- this.changed.trigger();
644
+ this._onCompleted();
505
645
  }
506
646
  }
507
647
  dismissError(index) {
508
648
  if (index >= 0 && index < this.errors.length) {
509
649
  this.errors.splice(index, 1);
650
+ this._errorTimes.splice(index, 1);
510
651
  this.changed.trigger();
511
652
  }
512
653
  }
654
+ // Private methods
655
+ _onCompleted() {
656
+ this._activeCount--;
657
+ this._lastResultAt = Date.now();
658
+ this.changed.trigger();
659
+ }
660
+ // C# UIActionFailureTracker.TryAddFailure — drop a same-name/same-message error seen within
661
+ // maxDuplicateRecency; a size cap is the backstop against unbounded growth.
662
+ _addError(e) {
663
+ const now = Date.now();
664
+ const { name, message } = errorInfo(e);
665
+ const minAt = now - this.maxDuplicateRecency;
666
+ for (let i = 0; i < this.errors.length; i++) {
667
+ if (this._errorTimes[i] < minAt)
668
+ continue;
669
+ const prev = errorInfo(this.errors[i]);
670
+ if (prev.name === name && prev.message === message)
671
+ return;
672
+ }
673
+ this.errors.push(e);
674
+ this._errorTimes.push(now);
675
+ while (this.errors.length > this.maxErrors) {
676
+ this.errors.shift();
677
+ this._errorTimes.shift();
678
+ }
679
+ this.changed.trigger();
680
+ }
513
681
  };
682
+ function errorInfo(e) {
683
+ if (e instanceof Error)
684
+ return { name: e.name, message: e.message };
685
+ return { name: "", message: String(e) };
686
+ }
514
687
  var uiActions = new UIActionTracker();
515
688
 
516
689
  // src/ui-update-delayer.ts
517
690
  var UIUpdateDelayer = class _UIUpdateDelayer {
518
691
  static _cache = /* @__PURE__ */ new Map();
692
+ // Floors ms at minDelay like C# FixedDelayer.Get — a zero delayer requires FixedDelayer.zero.
519
693
  static get(ms) {
520
- if (ms <= 0) return FixedDelayer.zero;
694
+ ms = Math.max(ms, defaultMinDelayMs);
521
695
  let delayer = _UIUpdateDelayer._cache.get(ms);
522
696
  if (delayer !== void 0) return delayer;
697
+ delayer = new _UIUpdateDelayer(ms).delay;
698
+ _UIUpdateDelayer._cache.set(ms, delayer);
699
+ return delayer;
700
+ }
701
+ ms;
702
+ retryDelays;
703
+ minDelay;
704
+ delay;
705
+ constructor(ms, retryDelays = defaultRetryDelays, minDelay = defaultMinDelayMs) {
706
+ this.ms = ms;
707
+ this.retryDelays = retryDelays;
708
+ this.minDelay = minDelay;
709
+ this.delay = this._delay.bind(this);
710
+ }
711
+ // Private methods
712
+ // Mirrors C# UpdateDelayer.Delay: race the instant-updates window against the full delay,
713
+ // then enforce minDelay measured from delay start so instant updates never hot-loop the CPU.
714
+ async _delay(retryCount, abortSignal) {
523
715
  const t = uiActions;
524
- delayer = (abortSignal) => {
525
- if (t.isActive) return Promise.resolve();
526
- return new Promise((resolve, reject) => {
527
- const timer = setTimeout(resolve, ms);
716
+ const minDelay = retryCount === 0 ? this.minDelay : this.retryDelays.min;
717
+ const delayMs = computeUpdateDelay(retryCount, this.ms, this.retryDelays, this.minDelay);
718
+ if (delayMs <= 0)
719
+ return;
720
+ const startedAt = Date.now();
721
+ if (!t.areInstantUpdatesEnabled())
722
+ await (0, import_core8.awaitWithCleanup)(abortSignal, "resolve", (complete, addCleanup) => {
723
+ const timer = setTimeout(complete, delayMs);
724
+ addCleanup(() => clearTimeout(timer));
528
725
  const onChanged = () => {
529
- if (t.isActive) {
530
- clearTimeout(timer);
531
- t.changed.remove(onChanged);
532
- resolve();
533
- }
726
+ if (t.areInstantUpdatesEnabled())
727
+ complete();
534
728
  };
535
729
  t.changed.add(onChanged);
536
- if (abortSignal !== void 0) {
537
- if (abortSignal.aborted) {
538
- clearTimeout(timer);
539
- t.changed.remove(onChanged);
540
- reject(abortSignal.reason instanceof Error ? abortSignal.reason : new Error(String(abortSignal.reason)));
541
- return;
542
- }
543
- abortSignal.addEventListener(
544
- "abort",
545
- () => {
546
- clearTimeout(timer);
547
- t.changed.remove(onChanged);
548
- reject(abortSignal.reason instanceof Error ? abortSignal.reason : new Error(String(abortSignal.reason)));
549
- },
550
- { once: true }
551
- );
552
- }
730
+ addCleanup(() => t.changed.remove(onChanged));
731
+ });
732
+ if (abortSignal?.aborted)
733
+ return;
734
+ const remaining = minDelay - (Date.now() - startedAt);
735
+ if (remaining > 0)
736
+ await (0, import_core8.awaitWithCleanup)(abortSignal, "resolve", (complete, addCleanup) => {
737
+ const timer = setTimeout(complete, remaining);
738
+ addCleanup(() => clearTimeout(timer));
553
739
  });
554
- };
555
- _UIUpdateDelayer._cache.set(ms, delayer);
556
- return delayer;
557
740
  }
558
741
  };
559
742
 
560
743
  // src/state.ts
561
- var import_core6 = require("@actuallab/core");
562
- var State = class {
744
+ var import_core9 = require("@actuallab/core");
745
+ var State = class _State {
563
746
  _computed;
564
747
  _updateIndex = 0;
565
- _lastNonErrorValue;
748
+ // Reference to the last non-error computed, not its raw value, so an actual
749
+ // `undefined` value is distinguishable from "no non-error value yet" (S12).
750
+ _lastNonErrorComputed;
566
751
  _whenUpdatedSource = null;
752
+ _isDisposed = false;
567
753
  get updateIndex() {
568
754
  return this._updateIndex;
569
755
  }
756
+ get isDisposed() {
757
+ return this._isDisposed;
758
+ }
570
759
  get lastNonErrorValue() {
571
- return this._lastNonErrorValue;
760
+ return this._lastNonErrorComputed?.value;
572
761
  }
573
762
  get hasValue() {
574
763
  return this._computed.hasValue;
@@ -607,152 +796,153 @@ var State = class {
607
796
  whenInvalidated() {
608
797
  return this._computed.whenInvalidated();
609
798
  }
610
- whenUpdated() {
611
- return this._whenUpdatedSource ??= new import_core6.PromiseSource();
799
+ // Versioned wait: resolves at once when a newer generation than sinceIndex
800
+ // already landed (no lost wakeup), rejects once disposed (C# StateSnapshot.WhenUpdated).
801
+ whenUpdated(sinceIndex = this._updateIndex) {
802
+ if (this._isDisposed)
803
+ return Promise.reject(_State._disposedError());
804
+ if (this._updateIndex > sinceIndex)
805
+ return import_core9.resolvedVoidPromise;
806
+ return this._whenUpdatedSource ??= new import_core9.PromiseSource();
612
807
  }
613
808
  whenFirstTimeUpdated() {
614
- return this._updateIndex > 0 ? import_core6.resolvedVoidPromise : this.whenUpdated();
809
+ return this.whenUpdated(0);
615
810
  }
616
811
  _initialize(output, renewer) {
617
- this._computed = new StateBoundComputed(this, renewer);
812
+ this._computed = this._createComputed(renewer);
618
813
  this._computed.setOutput(output);
619
- this._lastNonErrorValue = this._computed.valueOrUndefined;
814
+ if (this._computed.hasValue) this._lastNonErrorComputed = this._computed;
815
+ }
816
+ _createComputed(renewer) {
817
+ return new StateBoundComputed(this, renewer);
620
818
  }
621
819
  _update(computed, output) {
820
+ const replaced = this._computed;
622
821
  computed.setOutput(output);
623
822
  this._computed = computed;
624
- if (computed.hasValue) this._lastNonErrorValue = computed.value;
823
+ if (replaced?.isConsistent) replaced.invalidate();
824
+ if (computed.hasValue) this._lastNonErrorComputed = computed;
625
825
  this._updateIndex++;
626
826
  this._whenUpdatedSource?.resolve(void 0);
627
827
  this._whenUpdatedSource = null;
628
828
  }
829
+ _onDisposed() {
830
+ if (this._isDisposed)
831
+ return;
832
+ this._isDisposed = true;
833
+ this._whenUpdatedSource?.reject(_State._disposedError());
834
+ this._whenUpdatedSource = null;
835
+ }
836
+ static _disposedError() {
837
+ return new Error("State is disposed.");
838
+ }
629
839
  };
630
840
 
631
841
  // src/computed-state.ts
632
- var import_core7 = require("@actuallab/core");
633
- var { errorLog: errorLog2 } = getLogs("ComputedState");
842
+ var import_core10 = require("@actuallab/core");
843
+ var { errorLog: errorLog3 } = getLogs("ComputedState");
634
844
  var ComputedState = class extends State {
635
845
  _computer;
636
846
  _updateDelayer;
637
847
  _disposeController;
638
- _cancelDelaySource = new import_core7.PromiseSource();
848
+ _cancelDelaySource = new import_core10.PromiseSource();
639
849
  _renewer;
640
- _hasOutput = false;
641
850
  constructor(computer, options) {
642
851
  super();
643
852
  this._computer = computer;
644
853
  this._updateDelayer = options?.updateDelayer ?? defaultUpdateDelayer;
645
854
  this._disposeController = new AbortController();
646
855
  this._renewer = async () => {
647
- const whenUpdated = this.whenUpdated();
856
+ const sinceIndex = this._updateIndex;
648
857
  this._cancelDelaySource.resolve(void 0);
649
- await whenUpdated;
858
+ await this.whenUpdated(sinceIndex);
650
859
  return this._computed;
651
860
  };
652
- if (options?.initialOutput !== void 0 || options?.initialValue !== void 0) {
653
- this._initialize(
654
- options.initialOutput ?? options.initialValue,
655
- this._renewer
656
- );
657
- this._hasOutput = true;
658
- }
861
+ this._initialize(
862
+ options?.initialOutput ?? options?.initialValue,
863
+ this._renewer
864
+ );
865
+ this._computed.invalidate();
659
866
  void this._updateCycle();
660
867
  }
661
- get hasValue() {
662
- if (!this._hasOutput && this._updateIndex === 0) return false;
663
- return this._computed.hasValue;
664
- }
665
- get hasError() {
666
- if (!this._hasOutput && this._updateIndex === 0) return false;
667
- return this._computed.hasError;
668
- }
669
- get value() {
670
- if (!this._hasOutput && this._updateIndex === 0)
671
- throw new Error("ComputedState has no value yet.");
672
- if (this._computed.hasValue) return this._computed.value;
673
- if (this._lastNonErrorValue !== void 0)
674
- return this._lastNonErrorValue;
675
- throw new Error("ComputedState has no value yet.");
676
- }
677
- get error() {
678
- if (!this._hasOutput && this._updateIndex === 0) return void 0;
679
- return this._computed.error;
680
- }
681
- get valueOrUndefined() {
682
- if (!this._hasOutput && this._updateIndex === 0) return void 0;
683
- return this._computed.valueOrUndefined ?? this._lastNonErrorValue;
684
- }
685
- get output() {
686
- if (!this._hasOutput && this._updateIndex === 0)
687
- throw new Error("ComputedState has no output yet.");
688
- return this._computed.output;
689
- }
690
- get computed() {
691
- if (!this._hasOutput && this._updateIndex === 0)
692
- throw new Error("ComputedState has not been computed yet.");
693
- return this._computed;
694
- }
695
- get isDisposed() {
696
- return this._disposeController.signal.aborted;
697
- }
698
868
  dispose() {
699
- if (!this.isDisposed) this._disposeController.abort();
869
+ if (this.isDisposed)
870
+ return;
871
+ this._onDisposed();
872
+ this._disposeController.abort();
700
873
  }
701
874
  async _updateCycle() {
702
875
  const disposeSignal = this._disposeController.signal;
876
+ let retryCount = 0;
703
877
  try {
704
878
  while (!disposeSignal.aborted) {
705
879
  const computed = new StateBoundComputed(this, this._renewer);
706
880
  const computeCtx = new ComputeContext(
707
881
  computed
708
882
  );
709
- const asyncCtx = (import_core7.AsyncContext.current ?? import_core7.AsyncContext.empty).with(computeContextKey, computeCtx);
883
+ const asyncCtx = (import_core10.AsyncContext.current ?? import_core10.AsyncContext.empty).with(computeContextKey, computeCtx);
710
884
  let output;
711
885
  try {
712
886
  const value = asyncCtx.run(() => this._computer());
713
887
  const resolved = value instanceof Promise ? await value : value;
714
- output = (0, import_core7.result)(resolved);
888
+ output = (0, import_core10.result)(resolved);
715
889
  } catch (e) {
716
- output = (0, import_core7.errorResult)(e);
890
+ output = (0, import_core10.errorResult)(e);
717
891
  }
892
+ if (this.isDisposed)
893
+ return;
718
894
  this._update(computed, output);
895
+ retryCount = output.hasError ? retryCount + 1 : 0;
719
896
  if (this._cancelDelaySource.isCompleted)
720
- this._cancelDelaySource = new import_core7.PromiseSource();
897
+ this._cancelDelaySource = new import_core10.PromiseSource();
721
898
  try {
722
899
  await computed.whenInvalidated(disposeSignal);
723
900
  } catch {
724
901
  return;
725
902
  }
726
903
  await Promise.race([
727
- this._updateDelayer(disposeSignal),
904
+ this._updateDelayer(retryCount, disposeSignal),
728
905
  this._cancelDelaySource
729
906
  ]);
730
907
  }
731
908
  } catch (e) {
732
909
  if (!disposeSignal.aborted)
733
- errorLog2?.log("UpdateCycle() failed and stopped", e);
910
+ errorLog3?.log("UpdateCycle() failed and stopped", e);
734
911
  }
735
912
  }
736
913
  };
737
914
 
738
915
  // src/mutable-state.ts
739
- var import_core8 = require("@actuallab/core");
916
+ var import_core11 = require("@actuallab/core");
740
917
  var MutableState = class extends State {
918
+ _nextOutput;
741
919
  _renewer = () => {
742
- const computed = new StateBoundComputed(this, this._renewer);
743
- this._update(computed, this._computed.output);
920
+ const computed = this._createComputed(this._renewer);
921
+ this._update(computed, this._nextOutput);
922
+ this._wireRenewal(computed);
744
923
  return computed;
745
924
  };
746
925
  constructor(initialOutput) {
747
926
  super();
748
- this._initialize(initialOutput, this._renewer);
927
+ this._nextOutput = initialOutput instanceof import_core11.Result ? initialOutput : (0, import_core11.result)(initialOutput);
928
+ this._initialize(this._nextOutput, this._renewer);
929
+ this._wireRenewal(this._computed);
749
930
  }
750
931
  use(asyncContext) {
751
932
  return this._computed.use(asyncContext);
752
933
  }
753
934
  set(output) {
935
+ const next = output instanceof import_core11.Result ? output : (0, import_core11.result)(output);
936
+ if (this._nextOutput.equals(next))
937
+ return;
938
+ this._nextOutput = next;
754
939
  this._computed.invalidate();
755
- this._update(new StateBoundComputed(this, this._renewer), output);
940
+ }
941
+ // Private methods
942
+ // Renew synchronously on invalidation so a MutableState is never observed invalidated.
943
+ // update() de-dupes: if the computed was already renewed, it returns the latest instead.
944
+ _wireRenewal(computed) {
945
+ computed.onInvalidated(() => void computed.update());
756
946
  }
757
947
  };
758
948
  // Annotate the CommonJS export names for ESM import in node:
@@ -760,6 +950,7 @@ var MutableState = class extends State {
760
950
  ComputeContext,
761
951
  ComputeFunction,
762
952
  Computed,
953
+ ComputedOptions,
763
954
  ComputedRegistry,
764
955
  ComputedState,
765
956
  ConsistencyState,