Skip to content

Commit d12a186

Browse files
leonsenftAndrewKushnir
authored andcommitted
fix(core): treat exceptions in equal as part of computation (#55818)
Prevent leaking signal reads and exceptions from a custom `equal` function of a producer `computed()` to a consumer. Upstream tc39/proposal-signals#90 with a notable change: Angular does **not** track reactive reads in custom `equal` implementations. PR Close #55818
1 parent 797800c commit d12a186

File tree

2 files changed

+128
-7
lines changed

2 files changed

+128
-7
lines changed

packages/core/primitives/signals/src/computed.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
producerUpdateValueVersion,
1515
REACTIVE_NODE,
1616
ReactiveNode,
17+
setActiveConsumer,
1718
SIGNAL,
1819
} from './graph';
1920

@@ -120,21 +121,25 @@ const COMPUTED_NODE = /* @__PURE__ */ (() => {
120121

121122
const prevConsumer = consumerBeforeComputation(node);
122123
let newValue: unknown;
124+
let wasEqual = false;
123125
try {
124126
newValue = node.computation();
127+
// We want to mark this node as errored if calling `equal` throws; however, we don't want
128+
// to track any reactive reads inside `equal`.
129+
setActiveConsumer(null);
130+
wasEqual =
131+
oldValue !== UNSET &&
132+
oldValue !== ERRORED &&
133+
newValue !== ERRORED &&
134+
node.equal(oldValue, newValue);
125135
} catch (err) {
126136
newValue = ERRORED;
127137
node.error = err;
128138
} finally {
129139
consumerAfterComputation(node, prevConsumer);
130140
}
131141

132-
if (
133-
oldValue !== UNSET &&
134-
oldValue !== ERRORED &&
135-
newValue !== ERRORED &&
136-
node.equal(oldValue, newValue)
137-
) {
142+
if (wasEqual) {
138143
// No change to `valueVersion` - old and new values are
139144
// semantically equivalent.
140145
node.value = oldValue;

packages/core/test/signals/computed_spec.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {computed, signal} from '@angular/core';
10-
import {createWatch, ReactiveNode, SIGNAL} from '@angular/core/primitives/signals';
10+
import {createWatch, ReactiveNode, SIGNAL, defaultEquals} from '@angular/core/primitives/signals';
1111

1212
describe('computed', () => {
1313
it('should create computed', () => {
@@ -201,4 +201,120 @@ describe('computed', () => {
201201
] as ReactiveNode;
202202
expect(node.debugName).toBe('computedSignal');
203203
});
204+
205+
describe('with custom equal', () => {
206+
it('should cache exceptions thrown by equal', () => {
207+
const s = signal(0);
208+
209+
let computedRunCount = 0;
210+
let equalRunCount = 0;
211+
const c = computed(
212+
() => {
213+
computedRunCount++;
214+
return s();
215+
},
216+
{
217+
equal: () => {
218+
equalRunCount++;
219+
throw new Error('equal');
220+
},
221+
},
222+
);
223+
224+
// equal() isn't run for the initial computation.
225+
expect(c()).toBe(0);
226+
expect(computedRunCount).toBe(1);
227+
expect(equalRunCount).toBe(0);
228+
229+
s.set(1);
230+
231+
// Error is thrown by equal().
232+
expect(() => c()).toThrowError('equal');
233+
expect(computedRunCount).toBe(2);
234+
expect(equalRunCount).toBe(1);
235+
236+
// Error is cached; c throws again without needing to rerun computation or equal().
237+
expect(() => c()).toThrowError('equal');
238+
expect(computedRunCount).toBe(2);
239+
expect(equalRunCount).toBe(1);
240+
});
241+
242+
it('should not track signal reads inside equal', () => {
243+
const value = signal(1);
244+
const epsilon = signal(0.5);
245+
246+
let innerRunCount = 0;
247+
let equalRunCount = 0;
248+
const inner = computed(
249+
() => {
250+
innerRunCount++;
251+
return value();
252+
},
253+
{
254+
equal: (a, b) => {
255+
equalRunCount++;
256+
return Math.abs(a - b) < epsilon();
257+
},
258+
},
259+
);
260+
261+
let outerRunCount = 0;
262+
const outer = computed(() => {
263+
outerRunCount++;
264+
return inner();
265+
});
266+
267+
// Everything runs the first time.
268+
expect(outer()).toBe(1);
269+
expect(innerRunCount).toBe(1);
270+
expect(outerRunCount).toBe(1);
271+
272+
// Difference is less than epsilon().
273+
value.set(1.2);
274+
275+
// `inner` reruns because `value` was changed, and `equal` is called for the first time.
276+
expect(outer()).toBe(1);
277+
expect(innerRunCount).toBe(2);
278+
expect(equalRunCount).toBe(1);
279+
// `outer does not rerun because `equal` determined that `inner` had not changed.
280+
expect(outerRunCount).toBe(1);
281+
282+
// Previous difference is now greater than epsilon().
283+
epsilon.set(0.1);
284+
285+
// While changing `epsilon` would change the outcome of the `inner`, we don't rerun it
286+
// because we intentionally don't track reactive reads in `equal`.
287+
expect(outer()).toBe(1);
288+
expect(innerRunCount).toBe(2);
289+
expect(equalRunCount).toBe(1);
290+
// Equally important is that the signal read in `equal` doesn't leak into the outer reactive
291+
// context either.
292+
expect(outerRunCount).toBe(1);
293+
});
294+
295+
it('should recover from exception', () => {
296+
let shouldThrow = true;
297+
const source = signal(0);
298+
const derived = computed(source, {
299+
equal: (a, b) => {
300+
if (shouldThrow) {
301+
throw new Error('equal');
302+
}
303+
return defaultEquals(a, b);
304+
},
305+
});
306+
307+
// Initial read doesn't throw because it doesn't call `equal`.
308+
expect(derived()).toBe(0);
309+
310+
// Update `source` to begin throwing.
311+
source.set(1);
312+
expect(() => derived()).toThrowError('equal');
313+
314+
// Stop throwing and update `source` to cause `derived` to recompute.
315+
shouldThrow = false;
316+
source.set(2);
317+
expect(derived()).toBe(2);
318+
});
319+
});
204320
});

0 commit comments

Comments
 (0)