Skip to content

Commit 641339f

Browse files
committed
feat(core): add custom set option to linkedSignal
Introduce a custom `set` option in `linkedSignal` options to allow overriding and customizing the default write-back behavior of writable signals. This lets developers route updates back to the source of truth (e.g., converting Fahrenheit back to Celsius) or perform other side effects like updating properties inside a parent signal. Additionally, the custom callback receives the standard signal setter as its second parameter (`rawSet`) to allow direct internal mutation if desired. TAG=agy CONV=addbb5c4-4233-49e8-b844-6f732d7d5c72
1 parent 8b3973a commit 641339f

4 files changed

Lines changed: 186 additions & 7 deletions

File tree

adev/src/content/guide/signals/linked-signal.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,63 @@ const activeUserEditCopy = linkedSignal({
133133
equal: (a, b) => a.id === b.id,
134134
});
135135
```
136+
137+
## Customizing the set operation
138+
139+
Sometimes you may want the `set` and `update` operations of a `linkedSignal` to write back to the source of truth instead of updating the `linkedSignal`'s value directly. You can customize this behavior by passing a `set` function in the options.
140+
141+
The custom `set` function receives two arguments:
142+
143+
1. The new value being set.
144+
2. A `rawSet` function, which you can invoke to update the `linkedSignal`'s internal state directly (matching the default behavior).
145+
146+
### Writing back to a source signal
147+
148+
Consider a component that displays and allows editing temperature in Fahrenheit, but uses a Celsius signal as its source of truth:
149+
150+
```typescript
151+
const tempC = signal(0);
152+
const tempF = linkedSignal(() => (tempC() * 9) / 5 + 32, {
153+
set: (valF) => tempC.set(((valF - 32) * 5) / 9),
154+
});
155+
156+
console.log(tempF()); // 32
157+
158+
// Setting Fahrenheit updates Celsius, which reactively updates Fahrenheit
159+
tempF.set(212);
160+
console.log(tempC()); // 100
161+
console.log(tempF()); // 212
162+
```
163+
164+
### Updating a property inside a parent object
165+
166+
Another common scenario is updating a specific property inside a parent object. The parent is held in a signal, and you link to a nested property:
167+
168+
```typescript
169+
interface Order {
170+
id: number;
171+
shippingMethod: string;
172+
}
173+
174+
const order = signal<Order>({
175+
id: 42,
176+
shippingMethod: 'Ground',
177+
});
178+
179+
const shippingMethod = linkedSignal(() => order().shippingMethod, {
180+
set: (newMethod) => {
181+
// Perform an immutable update to write the change back to the order
182+
order.update((currentOrder) => ({
183+
...currentOrder,
184+
shippingMethod: newMethod,
185+
}));
186+
},
187+
});
188+
189+
console.log(shippingMethod()); // 'Ground'
190+
191+
// Updating the shippingMethod updates the parent order object
192+
shippingMethod.set('Air');
193+
console.log(order()); // { id: 42, shippingMethod: 'Air' }
194+
console.log(shippingMethod()); // 'Air'
195+
```

packages/core/src/render3/reactivity/linked_signal.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from '../../../primitives/signals';
1818
import {Signal, ValueEqualityFn} from './api';
1919
import {signalAsReadonlyFn, WritableSignal} from './signal';
20+
import {untracked} from './untracked';
2021

2122
const identityFn = <T>(v: T) => v;
2223

@@ -27,7 +28,11 @@ const identityFn = <T>(v: T) => v;
2728
*/
2829
export function linkedSignal<D>(
2930
computation: () => D,
30-
options?: {equal?: ValueEqualityFn<NoInfer<D>>; debugName?: string},
31+
options?: {
32+
equal?: ValueEqualityFn<NoInfer<D>>;
33+
debugName?: string;
34+
set?: (value: NoInfer<D>, rawSet: (value: NoInfer<D>) => void) => void;
35+
},
3136
): WritableSignal<D>;
3237

3338
/**
@@ -44,6 +49,7 @@ export function linkedSignal<S, D>(options: {
4449
computation: (source: NoInfer<S>, previous?: {source: NoInfer<S>; value: NoInfer<D>}) => D;
4550
equal?: ValueEqualityFn<NoInfer<D>>;
4651
debugName?: string;
52+
set?: (value: NoInfer<D>, rawSet: (value: NoInfer<D>) => void) => void;
4753
}): WritableSignal<D>;
4854

4955
export function linkedSignal<S, D>(
@@ -53,30 +59,40 @@ export function linkedSignal<S, D>(
5359
computation: ComputationFn<S, D>;
5460
equal?: ValueEqualityFn<D>;
5561
debugName?: string;
62+
set?: (value: D, rawSet: (value: D) => void) => void;
5663
}
5764
| (() => D),
58-
options?: {equal?: ValueEqualityFn<D>; debugName?: string},
65+
options?: {
66+
equal?: ValueEqualityFn<D>;
67+
debugName?: string;
68+
set?: (value: D, rawSet: (value: D) => void) => void;
69+
},
5970
): WritableSignal<D> {
6071
if (typeof optionsOrComputation === 'function') {
6172
const getter = createLinkedSignal<D, D>(
6273
optionsOrComputation,
6374
identityFn<D>,
6475
options?.equal,
6576
) as LinkedSignalGetter<D, D> & WritableSignal<D>;
66-
return upgradeLinkedSignalGetter(getter, options?.debugName);
77+
return upgradeLinkedSignalGetter(getter, options?.debugName, options?.set);
6778
} else {
6879
const getter = createLinkedSignal<S, D>(
6980
optionsOrComputation.source,
7081
optionsOrComputation.computation,
7182
optionsOrComputation.equal,
7283
);
73-
return upgradeLinkedSignalGetter(getter, optionsOrComputation.debugName);
84+
return upgradeLinkedSignalGetter(
85+
getter,
86+
optionsOrComputation.debugName,
87+
optionsOrComputation.set,
88+
);
7489
}
7590
}
7691

7792
function upgradeLinkedSignalGetter<S, D>(
7893
getter: LinkedSignalGetter<S, D>,
7994
debugName?: string,
95+
customSet?: (value: D, rawSet: (value: D) => void) => void,
8096
): WritableSignal<D> {
8197
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
8298
getter[SIGNAL].debugName = debugName;
@@ -86,8 +102,15 @@ function upgradeLinkedSignalGetter<S, D>(
86102
const node = getter[SIGNAL] as LinkedSignalNode<S, D>;
87103
const upgradedGetter = getter as LinkedSignalGetter<S, D> & WritableSignal<D>;
88104

89-
upgradedGetter.set = (newValue: D) => linkedSignalSetFn(node, newValue);
90-
upgradedGetter.update = (updateFn: (value: D) => D) => linkedSignalUpdateFn(node, updateFn);
105+
if (customSet !== undefined) {
106+
const rawSet = (newValue: D) => linkedSignalSetFn(node, newValue);
107+
upgradedGetter.set = (newValue: D) => customSet(newValue, rawSet);
108+
upgradedGetter.update = (updateFn: (value: D) => D) =>
109+
customSet(updateFn(untracked(getter)), rawSet);
110+
} else {
111+
upgradedGetter.set = (newValue: D) => linkedSignalSetFn(node, newValue);
112+
upgradedGetter.update = (updateFn: (value: D) => D) => linkedSignalUpdateFn(node, updateFn);
113+
}
91114
upgradedGetter.asReadonly = signalAsReadonlyFn.bind(getter as any) as () => Signal<D>;
92115

93116
return upgradedGetter;

packages/core/test/authoring/linked_signal_signature_test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,20 @@ export class LinkedSignalSignatureTest {
8080
source,
8181
computation: (s, previous) => String(s),
8282
});
83+
84+
/** number */
85+
shorthandWithCustomSetter = linkedSignal(() => 0, {
86+
set: (value: number, rawSet) => {
87+
rawSet(value);
88+
},
89+
});
90+
91+
/** string */
92+
advancedWithCustomSetter = linkedSignal({
93+
source,
94+
computation: (s) => String(s),
95+
set: (value: string, rawSet) => {
96+
rawSet(value);
97+
},
98+
});
8399
}

packages/core/test/signals/linked_signal_spec.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

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

1313
describe('linkedSignal', () => {
1414
it('should conform to the writable signals contract', () => {
@@ -440,4 +440,84 @@ describe('linkedSignal', () => {
440440
expect(derived()).toBe('2, hasPrevious: false');
441441
});
442442
});
443+
444+
describe('with custom setter', () => {
445+
it('should run custom setter on set()', () => {
446+
const customCalls: any[] = [];
447+
const source = signal(10);
448+
const derived = linkedSignal(() => source() * 2, {
449+
set: (value, rawSet) => {
450+
customCalls.push({value});
451+
rawSet(value);
452+
},
453+
});
454+
455+
expect(derived()).toBe(20);
456+
457+
derived.set(30);
458+
expect(customCalls).toEqual([{value: 30}]);
459+
expect(derived()).toBe(30);
460+
});
461+
462+
it('should support routing set() to the source signal', () => {
463+
const tempC = signal(0);
464+
const tempF = linkedSignal(() => (tempC() * 9) / 5 + 32, {
465+
set: (valF) => tempC.set(((valF - 32) * 5) / 9),
466+
});
467+
468+
expect(tempF()).toBe(32);
469+
470+
// Setting Fahrenheit should update Celsius, which reactively updates Fahrenheit
471+
tempF.set(212);
472+
expect(tempC()).toBe(100);
473+
expect(tempF()).toBe(212);
474+
});
475+
476+
it('should run custom setter on update() and pass updated value', () => {
477+
const customCalls: any[] = [];
478+
const source = signal(10);
479+
const derived = linkedSignal(() => source() * 2, {
480+
set: (value, rawSet) => {
481+
customCalls.push({value});
482+
rawSet(value);
483+
},
484+
});
485+
486+
expect(derived()).toBe(20);
487+
488+
derived.update((v) => v + 5);
489+
expect(customCalls).toEqual([{value: 25}]);
490+
expect(derived()).toBe(25);
491+
});
492+
493+
it('should untrack current value read during update()', () => {
494+
const source = signal(10);
495+
const derived = linkedSignal(() => source() * 2, {
496+
set: (value, rawSet) => {
497+
rawSet(value);
498+
},
499+
});
500+
501+
const effectRuns: number[] = [];
502+
const trigger = signal(0);
503+
const watchDestroy = testingEffect(() => {
504+
trigger();
505+
derived.update((v) => v + 1);
506+
effectRuns.push(derived());
507+
});
508+
509+
try {
510+
flushEffects();
511+
expect(derived()).toBe(21);
512+
expect(effectRuns).toEqual([21]);
513+
514+
trigger.set(1);
515+
flushEffects();
516+
expect(derived()).toBe(22);
517+
expect(effectRuns).toEqual([21, 22]);
518+
} finally {
519+
watchDestroy();
520+
}
521+
});
522+
});
443523
});

0 commit comments

Comments
 (0)