Skip to content

Commit bf948be

Browse files
JoostKmattrbeck
authored andcommitted
fix(core): run linked signal equality check without reactive consumer
This commit ports the changes in #55818 from `computed` to `linkedSignal`, which duplicates the core logic to recompute the downstream value for an upstream change. (cherry picked from commit 523d69a)
1 parent 23ea431 commit bf948be

File tree

2 files changed

+136
-9
lines changed

2 files changed

+136
-9
lines changed

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

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
REACTIVE_NODE,
1818
ReactiveNode,
1919
runPostProducerCreatedFn,
20+
setActiveConsumer,
2021
SIGNAL,
2122
} from './graph';
2223
import {signalSetFn, signalUpdateFn} from './signal';
@@ -151,25 +152,30 @@ export const LINKED_SIGNAL_NODE: object = /* @__PURE__ */ (() => {
151152

152153
const prevConsumer = consumerBeforeComputation(node);
153154
let newValue: unknown;
155+
let wasEqual = false;
154156
try {
155157
const newSourceValue = node.source();
156-
const prev =
157-
oldValue === UNSET || oldValue === ERRORED
158-
? undefined
159-
: {
160-
source: node.sourceValue,
161-
value: oldValue,
162-
};
158+
const oldValueValid = oldValue !== UNSET && oldValue !== ERRORED;
159+
const prev = oldValueValid
160+
? {
161+
source: node.sourceValue,
162+
value: oldValue,
163+
}
164+
: undefined;
163165
newValue = node.computation(newSourceValue, prev);
164166
node.sourceValue = newSourceValue;
167+
// We want to mark this node as errored if calling `equal` throws; however, we don't want
168+
// to track any reactive reads inside `equal`.
169+
setActiveConsumer(null);
170+
wasEqual = oldValueValid && newValue !== ERRORED && node.equal(oldValue, newValue);
165171
} catch (err) {
166172
newValue = ERRORED;
167173
node.error = err;
168174
} finally {
169175
consumerAfterComputation(node, prevConsumer);
170176
}
171177

172-
if (oldValue !== UNSET && newValue !== ERRORED && node.equal(oldValue, newValue)) {
178+
if (wasEqual) {
173179
// No change to `valueVersion` - old and new values are
174180
// semantically equivalent.
175181
node.value = oldValue;

packages/core/test/signals/linked_signal_spec.ts

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

99
import {isSignal, linkedSignal, signal, computed} from '../../src/core';
10-
import {setPostProducerCreatedFn} from '../../primitives/signals';
10+
import {defaultEquals, setPostProducerCreatedFn} from '../../primitives/signals';
1111
import {testingEffect} from './effect_util';
1212

1313
describe('linkedSignal', () => {
@@ -319,4 +319,125 @@ describe('linkedSignal', () => {
319319
expect(producers).toBe(2);
320320
setPostProducerCreatedFn(prev);
321321
});
322+
323+
describe('with custom equal', () => {
324+
it('should cache exceptions thrown by equal', () => {
325+
const s = signal(0);
326+
327+
let computedRunCount = 0;
328+
let equalRunCount = 0;
329+
const c = linkedSignal(
330+
() => {
331+
computedRunCount++;
332+
return s();
333+
},
334+
{
335+
equal: () => {
336+
equalRunCount++;
337+
throw new Error('equal');
338+
},
339+
},
340+
);
341+
342+
// equal() isn't run for the initial computation.
343+
expect(c()).toBe(0);
344+
expect(computedRunCount).toBe(1);
345+
expect(equalRunCount).toBe(0);
346+
347+
s.set(1);
348+
349+
// Error is thrown by equal().
350+
expect(() => c()).toThrowError('equal');
351+
expect(computedRunCount).toBe(2);
352+
expect(equalRunCount).toBe(1);
353+
354+
// Error is cached; c throws again without needing to rerun computation or equal().
355+
expect(() => c()).toThrowError('equal');
356+
expect(computedRunCount).toBe(2);
357+
expect(equalRunCount).toBe(1);
358+
});
359+
360+
it('should not track signal reads inside equal', () => {
361+
const value = signal(1);
362+
const epsilon = signal(0.5);
363+
364+
let innerRunCount = 0;
365+
let equalRunCount = 0;
366+
const inner = linkedSignal(
367+
() => {
368+
innerRunCount++;
369+
return value();
370+
},
371+
{
372+
equal: (a, b) => {
373+
equalRunCount++;
374+
return Math.abs(a - b) < epsilon();
375+
},
376+
},
377+
);
378+
379+
let outerRunCount = 0;
380+
const outer = linkedSignal(() => {
381+
outerRunCount++;
382+
return inner();
383+
});
384+
385+
// Everything runs the first time.
386+
expect(outer()).toBe(1);
387+
expect(innerRunCount).toBe(1);
388+
expect(outerRunCount).toBe(1);
389+
390+
// Difference is less than epsilon().
391+
value.set(1.2);
392+
393+
// `inner` reruns because `value` was changed, and `equal` is called for the first time.
394+
expect(outer()).toBe(1);
395+
expect(innerRunCount).toBe(2);
396+
expect(equalRunCount).toBe(1);
397+
// `outer does not rerun because `equal` determined that `inner` had not changed.
398+
expect(outerRunCount).toBe(1);
399+
400+
// Previous difference is now greater than epsilon().
401+
epsilon.set(0.1);
402+
403+
// While changing `epsilon` would change the outcome of the `inner`, we don't rerun it
404+
// because we intentionally don't track reactive reads in `equal`.
405+
expect(outer()).toBe(1);
406+
expect(innerRunCount).toBe(2);
407+
expect(equalRunCount).toBe(1);
408+
// Equally important is that the signal read in `equal` doesn't leak into the outer reactive
409+
// context either.
410+
expect(outerRunCount).toBe(1);
411+
});
412+
413+
it('should recover from exception', () => {
414+
let shouldThrow = true;
415+
const source = signal(0);
416+
const derived = linkedSignal({
417+
source,
418+
computation: (value, previous) => {
419+
return `${value}, hasPrevious: ${previous !== undefined}`;
420+
},
421+
equal: (a, b) => {
422+
if (shouldThrow) {
423+
throw new Error('equal');
424+
}
425+
return defaultEquals(a, b);
426+
},
427+
});
428+
429+
// Initial read doesn't throw because it doesn't call `equal`.
430+
expect(derived()).toBe('0, hasPrevious: false');
431+
432+
// Update `source` to begin throwing.
433+
source.set(1);
434+
expect(() => derived()).toThrowError('equal');
435+
436+
// Stop throwing and update `source` to cause `derived` to recompute. No previous value
437+
// should be made available as the linked signal transitions from an error state.
438+
shouldThrow = false;
439+
source.set(2);
440+
expect(derived()).toBe('2, hasPrevious: false');
441+
});
442+
});
322443
});

0 commit comments

Comments
 (0)