Skip to content

Commit af9be82

Browse files
alexyakuninclaude
andcommitted
fix(ts/fusion): state+UI robustness parity (S8, S9, S10, S17, S18)
S8: UpdateDelayer signature is now (retryCount, abortSignal); ComputedState ._updateCycle tracks a consecutive-error counter (reset on success) and the default delayers apply RetryDelaySeq (1 s -> 1 min) backoff for retryCount > 0 plus a 32 ms minDelay floor -- C# UpdateDelayer/FixedDelayer parity. FixedDelayer.get / UIUpdateDelayer.get floor the requested delay at minDelay like C# FixedDelayer.Get -- get(0) no longer silently returns the zero delayer (a truly zero delayer requires the explicit FixedDelayer.zero escape hatch). S9: UIActionTracker tracks lastResultAt and exposes areInstantUpdatesEnabled() (active OR within a 300 ms instant-update window after the last completion); run/call decrement + timestamp + trigger `changed` immediately, dropping the artificial 50 ms sleep. UIUpdateDelayer consults the new predicate. S10: UIUpdateDelayer enforces minDelay (~32 ms), measured from delay start, on every short-circuit path including the S9 instant-updates path -- mirrors C# UpdateDelayer.Delay (race the instant predicate against the delay, never return before minDelay). S17: useMutableState returns { value, error, set, state } (valueOrUndefined + error) so an error result renders instead of throwing. S18: UIActionTracker.errors dedups same-name/same-message errors seen within ~1 s (C# MaxDuplicateRecency) with a size cap as a backstop. Tests: new ts/packages/fusion/tests/stateui.test.ts covers S8 backoff growth + reset, the get(0) minDelay floor, S9 in/out-of-window behavior, S10 minDelay floor, S17 error shape, S18 dedup+cap. delayer-cleanup.test.ts updated to the (retryCount, abortSignal) delayer signature. Full suite: 695/695 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SDiBQM5rnhyBG1asuP1kDi
1 parent 4b3072f commit af9be82

9 files changed

Lines changed: 406 additions & 44 deletions

File tree

docs/plans/ts-port-audit.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,8 @@ Confidence: confirmed.
336336

337337
### S8. No retry backoff: `UpdateDelayer` has no `retryCount`, no `RetryDelays`, no transient-error tracking
338338

339+
Status: **closed** — fixed 2026-07-15 (batch stateui; delayer signature `(retryCount, abortSignal?)`, `RetryDelaySeq.exp(1 s, 60 s)` backoff, 32 ms min-delay floor incl. `FixedDelayer.get(0)`; all errors treated as transient — TS has no transiency classification).
340+
339341
Confidence: confirmed.
340342

341343
- TS: `UpdateDelayer` is `(abortSignal?) => Promise<void>` (`update-delayer.ts:2`) — the delay cannot depend on failure history. No `StateSnapshot`, so no `RetryCount`/`ErrorCount`. Error recovery relies solely on the global 1 s auto-invalidation (K5) — a constant ~1 s retry loop forever.
@@ -346,6 +348,8 @@ Confidence: confirmed.
346348

347349
### S9. `UIActionTracker` has no post-action instant-update window; the 50 ms buffer is ineffective and adds latency
348350

351+
Status: **closed** — fixed 2026-07-15 (batch stateui; 300 ms `instantUpdatePeriod`, the 50 ms sleep removed).
352+
349353
Confidence: confirmed.
350354

351355
- TS: `ui-action-tracker.ts:12-14``isActive` is true only while `_activeCount > 0`. In `run`/`call` (`ui-action-tracker.ts:27-31, 45-49`) the `finally` decrements first, then waits 50 ms, then triggers `changed`: (a) during the 50 ms "buffer", `isActive` is already false, so a `UIUpdateDelayer` consulted when the post-command invalidation arrives waits the full delay — the buffer buys nothing; (b) `await uiActions.call(fn)` returns 50 ms late.
@@ -356,6 +360,8 @@ Confidence: confirmed.
356360

357361
### S10. `UIUpdateDelayer` skips the delay entirely (no `MinDelay` floor) while a UI action is active — hot-loop risk
358362

363+
Status: **closed** — fixed 2026-07-15 (batch stateui; minDelay measured from delay start on every path).
364+
359365
Confidence: confirmed.
360366

361367
- TS: `ui-update-delayer.ts:15``if (t.isActive) return Promise.resolve();` and the `onChanged` cancel path also resolves with zero residual delay.
@@ -430,13 +436,17 @@ Confidence: confirmed. TS leaves `_computed` invalidated until the next `update(
430436

431437
### S17. `useMutableState` render throws if the state holds an error result
432438

439+
Status: **closed** — fixed 2026-07-15 (batch stateui; returns `{ value, error, set, state }`).
440+
433441
Confidence: confirmed. `use-mutable-state.ts:41` returns `state.value`, whose getter throws on error output — and the setter accepts `Result<T>`, so `setter(errorResult(e))` is a supported call. Next render of every component using the state throws, unmounting the tree absent an error boundary.
434442

435443
- **Recommended:** return `{ value, error, set }` (the shape `useComputedState` already uses) so error results render instead of throwing.
436444
- **Alternative:** narrow the setter to plain values (no `Result`) and document that errors can't be stored via this hook. Smaller, but loses parity with `MutableState.set`'s contract.
437445

438446
### S18. `UIActionTracker.errors` grows unbounded with no dedup
439447

448+
Status: **closed** — fixed 2026-07-15 (batch stateui; 1 s name+message recency dedup + 100-entry cap).
449+
440450
Confidence: confirmed. Every failure is pushed to a plain array; only manual `dismissError` removes entries (`ui-action-tracker.ts:10, 26, 43`). C# dedups same-type/same-message failures within `MaxDuplicateRecency` (1 s) (`UIActionFailureTracker.cs:64-98`). Combined with S8's fixed 1 s retry, an error-toast UI floods and memory grows for the session's lifetime.
441451

442452
- **Recommended:** port the recency-based dedup (same error name + message within ~1 s is dropped) plus a size cap as a backstop.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { useComputedState } from './use-computed-state.js';
22
export type { UseComputedStateResult } from './use-computed-state.js';
33
export { useMutableState } from './use-mutable-state.js';
4+
export type { UseMutableStateResult } from './use-mutable-state.js';
45
// Re-export from @actuallab/fusion for convenience
56
export { UIActionTracker, uiActions, UIUpdateDelayer } from '@actuallab/fusion';

ts/packages/fusion-react/src/use-mutable-state.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@ import { useEffect, useReducer, useRef } from 'react';
22
import { MutableState } from '@actuallab/fusion';
33
import type { Result } from '@actuallab/core';
44

5+
export interface UseMutableStateResult<T> {
6+
value: T | undefined;
7+
error: unknown;
8+
set: (value: Result<T> | T) => void;
9+
state: MutableState<T>;
10+
}
11+
512
/**
613
* React hook wrapping Fusion's MutableState.
7-
* Returns [value, setter, state] — re-renders on updates.
14+
* Returns { value, error, set, state } so an error result renders instead of throwing.
815
*/
9-
export function useMutableState<T>(
10-
initial: T
11-
): [T, (value: Result<T> | T) => void, MutableState<T>] {
16+
export function useMutableState<T>(initial: T): UseMutableStateResult<T> {
1217
const [, forceRender] = useReducer(c => c + 1, 0);
1318
const stateRef = useRef<MutableState<T> | null>(null);
1419

@@ -39,5 +44,10 @@ export function useMutableState<T>(
3944
};
4045
}, [state]);
4146

42-
return [state.value, v => state.set(v), state];
47+
return {
48+
value: state.valueOrUndefined,
49+
error: state.error,
50+
set: v => state.set(v),
51+
state,
52+
};
4353
}

ts/packages/fusion/src/computed-state.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export class ComputedState<T> extends State<T> {
7575

7676
private async _updateCycle(): Promise<void> {
7777
const disposeSignal = this._disposeController.signal;
78+
let retryCount = 0;
7879
try {
7980
while (!disposeSignal.aborted) {
8081
// Compute
@@ -99,6 +100,7 @@ export class ComputedState<T> extends State<T> {
99100
return; // Disposed mid-computation — publish nothing, terminate the cycle.
100101

101102
this._update(computed, output);
103+
retryCount = output.hasError ? retryCount + 1 : 0;
102104
if (this._cancelDelaySource.isCompleted)
103105
this._cancelDelaySource = new PromiseSource<void>();
104106

@@ -109,9 +111,9 @@ export class ComputedState<T> extends State<T> {
109111
return; // Cancelled via dispose
110112
}
111113

112-
// Wait for delay (cancellable by renewer)
114+
// Wait for delay (cancellable by renewer); retryCount grows the backoff (S8).
113115
await Promise.race([
114-
this._updateDelayer(disposeSignal),
116+
this._updateDelayer(retryCount, disposeSignal),
115117
this._cancelDelaySource,
116118
]);
117119
}

ts/packages/fusion/src/ui-action-tracker.ts

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,30 @@ const { errorLog } = getLogs('UIActionTracker');
66
/** Singleton tracking active UI commands — mirrors .NET's UIActionTracker. */
77
export class UIActionTracker {
88
private _activeCount = 0;
9+
private _lastResultAt = 0;
10+
private _errorTimes: number[] = [];
911
readonly changed = new EventHandlerSet<void>();
1012
readonly errors: unknown[] = [];
1113

14+
// C# UIActionTracker.Options.InstantUpdatePeriod / UIActionFailureTracker MaxDuplicateRecency.
15+
instantUpdatePeriod = 300;
16+
maxDuplicateRecency = 1000;
17+
maxErrors = 100;
18+
1219
get isActive(): boolean {
1320
return this._activeCount > 0;
1421
}
1522

23+
// C# UIActionTracker.AreInstantUpdatesEnabled — active, or within instantUpdatePeriod of the last result.
24+
areInstantUpdatesEnabled(): boolean {
25+
if (this._activeCount > 0)
26+
return true;
27+
if (this._lastResultAt === 0)
28+
return false;
29+
30+
return this._lastResultAt + this.instantUpdatePeriod >= Date.now();
31+
}
32+
1633
/** Run a command silently — errors are added to the error list but not thrown. */
1734
async run(fn: () => Promise<unknown>): Promise<void> {
1835
this._activeCount++;
@@ -22,12 +39,9 @@ export class UIActionTracker {
2239
} catch (e) {
2340
// Mirrors UIActionTracker.cs:58 — UI action failure.
2441
errorLog?.log('UI action failed', e);
25-
this.errors.push(e);
42+
this._addError(e);
2643
} finally {
27-
this._activeCount--;
28-
// Buffer 50ms for invalidations to arrive before signaling completion
29-
await new Promise<void>(r => setTimeout(r, 50));
30-
this.changed.trigger();
44+
this._onCompleted();
3145
}
3246
}
3347

@@ -40,21 +54,59 @@ export class UIActionTracker {
4054
} catch (e) {
4155
// Mirrors UIActionTracker.cs:58 — UI action failure.
4256
errorLog?.log('UI action failed', e);
43-
this.errors.push(e);
57+
this._addError(e);
4458
throw e;
4559
} finally {
46-
this._activeCount--;
47-
await new Promise<void>(r => setTimeout(r, 50));
48-
this.changed.trigger();
60+
this._onCompleted();
4961
}
5062
}
5163

5264
dismissError(index: number): void {
5365
if (index >= 0 && index < this.errors.length) {
5466
this.errors.splice(index, 1);
67+
this._errorTimes.splice(index, 1);
5568
this.changed.trigger();
5669
}
5770
}
71+
72+
// Private methods
73+
74+
private _onCompleted(): void {
75+
this._activeCount--;
76+
this._lastResultAt = Date.now();
77+
this.changed.trigger();
78+
}
79+
80+
// C# UIActionFailureTracker.TryAddFailure — drop a same-name/same-message error seen within
81+
// maxDuplicateRecency; a size cap is the backstop against unbounded growth.
82+
private _addError(e: unknown): void {
83+
const now = Date.now();
84+
const { name, message } = errorInfo(e);
85+
const minAt = now - this.maxDuplicateRecency;
86+
for (let i = 0; i < this.errors.length; i++) {
87+
if (this._errorTimes[i] < minAt)
88+
continue;
89+
90+
const prev = errorInfo(this.errors[i]);
91+
if (prev.name === name && prev.message === message)
92+
return;
93+
}
94+
95+
this.errors.push(e);
96+
this._errorTimes.push(now);
97+
while (this.errors.length > this.maxErrors) {
98+
this.errors.shift();
99+
this._errorTimes.shift();
100+
}
101+
this.changed.trigger();
102+
}
103+
}
104+
105+
function errorInfo(e: unknown): { name: string; message: string } {
106+
if (e instanceof Error)
107+
return { name: e.name, message: e.message };
108+
109+
return { name: '', message: String(e) };
58110
}
59111

60112
export const uiActions = new UIActionTracker();
Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,75 @@
1-
import { awaitWithCleanup } from '@actuallab/core';
1+
import { awaitWithCleanup, type RetryDelaySeq } from '@actuallab/core';
22
import { uiActions } from './ui-action-tracker.js';
3-
import { FixedDelayer, type UpdateDelayer } from './update-delayer.js';
3+
import {
4+
computeUpdateDelay,
5+
defaultMinDelayMs,
6+
defaultRetryDelays,
7+
type UpdateDelayer,
8+
} from './update-delayer.js';
49

5-
/** Re-computes after a fixed delay, but skips the delay when UIActionTracker is active. */
10+
/** Re-computes after a delay, but short-circuits while UIActionTracker enables instant updates. */
611
export class UIUpdateDelayer {
712
private static _cache = new Map<number, UpdateDelayer>();
813

14+
// Floors ms at minDelay like C# FixedDelayer.Get — a zero delayer requires FixedDelayer.zero.
915
static get(ms: number): UpdateDelayer {
10-
if (ms <= 0) return FixedDelayer.zero;
16+
ms = Math.max(ms, defaultMinDelayMs);
1117
let delayer = UIUpdateDelayer._cache.get(ms);
1218
if (delayer !== undefined) return delayer;
1319

20+
delayer = new UIUpdateDelayer(ms).delay;
21+
UIUpdateDelayer._cache.set(ms, delayer);
22+
return delayer;
23+
}
24+
25+
readonly ms: number;
26+
readonly retryDelays: RetryDelaySeq;
27+
readonly minDelay: number;
28+
readonly delay: UpdateDelayer;
29+
30+
constructor(
31+
ms: number,
32+
retryDelays: RetryDelaySeq = defaultRetryDelays,
33+
minDelay: number = defaultMinDelayMs
34+
) {
35+
this.ms = ms;
36+
this.retryDelays = retryDelays;
37+
this.minDelay = minDelay;
38+
this.delay = this._delay.bind(this);
39+
}
40+
41+
// Private methods
42+
43+
// Mirrors C# UpdateDelayer.Delay: race the instant-updates window against the full delay,
44+
// then enforce minDelay measured from delay start so instant updates never hot-loop the CPU.
45+
private async _delay(retryCount: number, abortSignal?: AbortSignal): Promise<void> {
1446
const t = uiActions;
15-
delayer = (abortSignal?: AbortSignal) => {
16-
if (t.isActive) return Promise.resolve();
47+
const minDelay = retryCount === 0 ? this.minDelay : this.retryDelays.min;
48+
const delayMs = computeUpdateDelay(retryCount, this.ms, this.retryDelays, this.minDelay);
49+
if (delayMs <= 0)
50+
return;
1751

18-
return awaitWithCleanup(abortSignal, 'resolve', (complete, addCleanup) => {
19-
const timer = setTimeout(complete, ms);
52+
const startedAt = Date.now();
53+
if (!t.areInstantUpdatesEnabled())
54+
await awaitWithCleanup(abortSignal, 'resolve', (complete, addCleanup) => {
55+
const timer = setTimeout(complete, delayMs);
2056
addCleanup(() => clearTimeout(timer));
2157

22-
// Cancel the delay when the tracker becomes active
2358
const onChanged = () => {
24-
if (t.isActive) complete();
59+
if (t.areInstantUpdatesEnabled())
60+
complete();
2561
};
2662
t.changed.add(onChanged);
2763
addCleanup(() => t.changed.remove(onChanged));
2864
});
29-
};
30-
UIUpdateDelayer._cache.set(ms, delayer);
31-
return delayer;
65+
if (abortSignal?.aborted)
66+
return;
67+
68+
const remaining = minDelay - (Date.now() - startedAt);
69+
if (remaining > 0)
70+
await awaitWithCleanup(abortSignal, 'resolve', (complete, addCleanup) => {
71+
const timer = setTimeout(complete, remaining);
72+
addCleanup(() => clearTimeout(timer));
73+
});
3274
}
3375
}

ts/packages/fusion/src/update-delayer.ts

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,39 @@
1-
import { awaitWithCleanup } from '@actuallab/core';
1+
import { awaitWithCleanup, RetryDelaySeq } from '@actuallab/core';
22

3-
/** Controls when state re-computation happens after invalidation. */
4-
export type UpdateDelayer = (abortSignal?: AbortSignal) => Promise<void>;
3+
/** Controls when state re-computation happens after invalidation, per consecutive-error retryCount. */
4+
export type UpdateDelayer = (
5+
retryCount: number,
6+
abortSignal?: AbortSignal
7+
) => Promise<void>;
58

6-
/** Re-computes after a fixed delay in milliseconds. */
9+
/** 1 s → 1 min exponential backoff — C# FixedDelayer.Defaults.RetryDelays parity. */
10+
export const defaultRetryDelays = RetryDelaySeq.exp(1000, 60_000);
11+
12+
/** Windows timer period is 15.6 ms, so 32 ms = 2..3 timer ticks — C# FixedDelayer.Defaults.MinDelay. */
13+
export const defaultMinDelayMs = 32;
14+
15+
// C# UpdateDelayer.GetDelay + minDelay floor: retryCount 0 uses the update delay
16+
// floored by minDelay; retryCount > 0 uses RetryDelays[retryCount] floored by RetryDelays.Min.
17+
export function computeUpdateDelay(
18+
retryCount: number,
19+
updateDelayMs: number,
20+
retryDelays: RetryDelaySeq,
21+
minDelayMs: number
22+
): number {
23+
const min = retryCount === 0 ? minDelayMs : retryDelays.min;
24+
const raw = retryCount > 0 ? retryDelays.getDelay(retryCount) : updateDelayMs;
25+
return Math.max(min, raw);
26+
}
27+
28+
/** Re-computes after a fixed update delay, with RetryDelaySeq backoff for consecutive errors. */
729
export class FixedDelayer {
830
static readonly zero: UpdateDelayer = () => Promise.resolve();
931

1032
private static _cache = new Map<number, UpdateDelayer>();
1133

34+
// Floors ms at minDelay like C# FixedDelayer.Get — a zero delayer requires the explicit .zero.
1235
static get(ms: number): UpdateDelayer {
13-
if (ms <= 0) return FixedDelayer.zero;
36+
ms = Math.max(ms, defaultMinDelayMs);
1437
let delayer = FixedDelayer._cache.get(ms);
1538
if (delayer === undefined) {
1639
delayer = new FixedDelayer(ms).delay;
@@ -20,18 +43,35 @@ export class FixedDelayer {
2043
}
2144

2245
readonly ms: number;
46+
readonly retryDelays: RetryDelaySeq;
47+
readonly minDelay: number;
2348
readonly delay: UpdateDelayer;
2449

25-
constructor(ms: number) {
50+
constructor(
51+
ms: number,
52+
retryDelays: RetryDelaySeq = defaultRetryDelays,
53+
minDelay: number = defaultMinDelayMs
54+
) {
2655
this.ms = ms;
56+
this.retryDelays = retryDelays;
57+
this.minDelay = minDelay;
2758
// Abort stops the wait early (resolves, never rejects) so a disposed
2859
// ComputedState's update loop terminates promptly and the race loser
2960
// carries no unhandled rejection (C# SuppressCancellationAwait spirit).
30-
this.delay = (abortSignal?: AbortSignal) =>
31-
awaitWithCleanup(abortSignal, 'resolve', (complete, addCleanup) => {
32-
const timer = setTimeout(complete, this.ms);
61+
this.delay = (retryCount, abortSignal) => {
62+
const delayMs = computeUpdateDelay(
63+
retryCount,
64+
this.ms,
65+
this.retryDelays,
66+
this.minDelay
67+
);
68+
if (delayMs <= 0) return Promise.resolve();
69+
70+
return awaitWithCleanup(abortSignal, 'resolve', (complete, addCleanup) => {
71+
const timer = setTimeout(complete, delayMs);
3372
addCleanup(() => clearTimeout(timer));
3473
});
74+
};
3575
}
3676
}
3777

0 commit comments

Comments
 (0)