@actuallab/fusion 13.0.20 → 13.0.163

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