Skip to content

Commit fb66579

Browse files
alexyakuninclaude
andcommitted
fix(fusion-ts): ComputedState.value rethrows stored error instead of masking it (S2)
ComputedState overrode `value` and `valueOrUndefined` to fall back to the last non-error computed on an error output, diverging from the base State class (and C# State.Value) and producing a contradictory state where `hasValue` is false while `value` returns a value. Callers of a state whose computer starts failing kept receiving the last good value with no signal anything was wrong. Remove both overrides so `value` / `valueOrUndefined` / `output` inherit the base-class (C# State.Value / ValueOrDefault) semantics: `value` rethrows the stored error, `valueOrUndefined` returns undefined on error, `output` reflects the error result. Stale-value access stays available only through the explicit `lastNonErrorValue` getter (C# State.Value vs LastNonErrorValue split). The useComputedState hook already reads `valueOrUndefined` and exposes `error` separately, so its documented `{ value, error }` shape is unchanged and now matches useMutableState: `value` is undefined on error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SDiBQM5rnhyBG1asuP1kDi
1 parent fcff8c3 commit fb66579

4 files changed

Lines changed: 67 additions & 15 deletions

File tree

docs/plans/ts-port-audit.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,8 @@ Confidence: confirmed.
270270

271271
### S2. `ComputedState.value` silently masks errors by falling back to `lastNonErrorValue`
272272

273+
Status: **closed** — fixed 2026-07-15 (batch s2fix — the one item the campaign's batching initially missed): `value`/`valueOrUndefined`/`output` inherit the base-class behavior (throw / undefined / error result); stale access only via the explicit `lastNonErrorValue` (C# `Value` vs `ValueOrDefault` vs `LastNonErrorValue` parity). The React hook was already reading `valueOrUndefined`, so its `value` is now `undefined` on error, matching its documented shape.
274+
273275
Confidence: confirmed.
274276

275277
- TS: `computed-state.ts:72-79` — when `_computed.hasError`, `value` returns `_lastNonErrorValue` instead of throwing; `valueOrUndefined` (`computed-state.ts:86-89`) likewise. Base `State.value` and `MutableState` *do* throw on error (`state.ts:33-35`) — the port is internally inconsistent too.

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

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,6 @@ export class ComputedState<T> extends State<T> {
5454
void this._updateCycle();
5555
}
5656

57-
override get value(): T {
58-
if (this._computed.hasValue) return this._computed.value;
59-
if (this._lastNonErrorComputed !== undefined)
60-
return this._lastNonErrorComputed.value;
61-
throw new Error('ComputedState has no value yet.');
62-
}
63-
64-
override get valueOrUndefined(): T | undefined {
65-
if (this._computed.hasValue) return this._computed.value;
66-
return this._lastNonErrorComputed?.value;
67-
}
68-
6957
dispose(): void {
7058
if (this.isDisposed)
7159
return;

ts/packages/fusion/tests/computed-state.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,65 @@ describe('ComputedState audit fixes', () => {
232232
const computed = await updated;
233233
expect(computed.value).toBe(5);
234234
});
235+
236+
it('S2: an error output rethrows through value / reflects in output, no masking', async () => {
237+
const boom = new Error('transient');
238+
let counter = 0;
239+
const state = new ComputedState<number>(
240+
() => {
241+
counter++;
242+
if (counter === 2) throw boom;
243+
return counter;
244+
},
245+
{ updateDelayer: FixedDelayer.zero }
246+
);
247+
248+
await state.whenFirstTimeUpdated();
249+
expect(state.value).toBe(1);
250+
251+
state.computed.invalidate();
252+
await delay(20);
253+
254+
expect(state.hasValue).toBe(false);
255+
expect(state.hasError).toBe(true);
256+
// value rethrows the stored error instead of masking it with the stale value.
257+
expect(() => state.value).toThrow(boom);
258+
// valueOrUndefined stays base-consistent (error -> undefined, C# ValueOrDefault).
259+
expect(state.valueOrUndefined).toBeUndefined();
260+
expect(state.output.hasError).toBe(true);
261+
expect(state.output.error).toBe(boom);
262+
// The stale value is reachable only through the explicit getter.
263+
expect(state.lastNonErrorValue).toBe(1);
264+
});
265+
266+
it('S2: a first computation that errors has no masking source', async () => {
267+
const boom = new Error('boom');
268+
const state = new ComputedState<number>(
269+
() => {
270+
throw boom;
271+
},
272+
{ updateDelayer: FixedDelayer.zero }
273+
);
274+
275+
await state.whenFirstTimeUpdated();
276+
expect(state.hasValue).toBe(false);
277+
expect(state.hasError).toBe(true);
278+
expect(() => state.value).toThrow(boom);
279+
expect(state.valueOrUndefined).toBeUndefined();
280+
// The pre-invalidated initial computed is the last non-error computed,
281+
// so lastNonErrorValue is the placeholder — C# default(T) / initialValue parity.
282+
expect(state.lastNonErrorValue).toBeUndefined();
283+
284+
const withInitial = new ComputedState<number>(
285+
() => {
286+
throw boom;
287+
},
288+
{ initialValue: 7, updateDelayer: FixedDelayer.zero }
289+
);
290+
291+
await withInitial.whenFirstTimeUpdated();
292+
expect(() => withInitial.value).toThrow(boom);
293+
expect(withInitial.valueOrUndefined).toBeUndefined();
294+
expect(withInitial.lastNonErrorValue).toBe(7);
295+
});
235296
});

ts/packages/fusion/tests/last-non-error-value.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@ describe('lastNonErrorValue with undefined values (S12)', () => {
3636
await delay(20);
3737
expect(state.hasError).toBe(true);
3838

39-
// Raw-value storage read this as "no value yet" and threw; the computed
40-
// reference distinguishes an actual `undefined` from an absent value.
41-
expect(state.value).toBeUndefined();
39+
// value rethrows the stored error (base-class parity, S2); the last non-error
40+
// value stays reachable only through the explicit lastNonErrorValue getter, and
41+
// here it is a legitimate `undefined` distinguished from "no non-error value yet".
42+
expect(() => state.value).toThrow('boom');
4243
expect(state.valueOrUndefined).toBeUndefined();
4344
expect(state.lastNonErrorValue).toBeUndefined();
4445
});

0 commit comments

Comments
 (0)