Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions adev/src/content/guide/signals/linked-signal.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,65 @@ const activeUserEditCopy = linkedSignal({
equal: (a, b) => a.id === b.id,
});
```

## Customizing the set operation

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.

The custom `set` function receives two arguments:

1. The new value being set.
2. A `rawSet` function, which you can invoke to update the `linkedSignal`'s internal state directly (matching the default behavior).
Comment thread
alxhub marked this conversation as resolved.

NOTE: Using `rawSet` allows you to update the `linkedSignal`'s value directly. This can be useful to prevent the computation from running, for example if it is an expensive derivation and you already know the result.

### Writing back to a source signal

Consider a component that displays and allows editing temperature in Fahrenheit, but uses a Celsius signal as its source of truth:

```typescript
const tempC = signal(0);
const tempF = linkedSignal(() => (tempC() * 9) / 5 + 32, {
set: (valF) => tempC.set(((valF - 32) * 5) / 9),
});

console.log(tempF()); // 32

// Setting Fahrenheit updates Celsius, which reactively updates Fahrenheit
tempF.set(212);
console.log(tempC()); // 100
console.log(tempF()); // 212
```

### Updating a property inside a parent object

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:

```typescript
interface Order {
id: number;
shippingMethod: string;
}

const order = signal<Order>({
id: 42,
shippingMethod: 'Ground',
});

const shippingMethod = linkedSignal(() => order().shippingMethod, {
set: (newMethod) => {
// Perform an immutable update to write the change back to the order
order.update((currentOrder) => ({
...currentOrder,
shippingMethod: newMethod,
}));
},
});

console.log(shippingMethod()); // 'Ground'

// Updating the shippingMethod updates the parent order object
shippingMethod.set('Air');
console.log(order()); // { id: 42, shippingMethod: 'Air' }
console.log(shippingMethod()); // 'Air'
```
2 changes: 2 additions & 0 deletions goldens/public-api/core/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1168,6 +1168,7 @@ export class KeyValueDiffers {
export function linkedSignal<D>(computation: () => D, options?: {
equal?: ValueEqualityFn<NoInfer<D>>;
debugName?: string;
set?: (value: NoInfer<D>, rawSet: (value: NoInfer<D>) => void) => void;
}): WritableSignal<D>;

// @public
Expand All @@ -1179,6 +1180,7 @@ export function linkedSignal<S, D>(options: {
}) => D;
equal?: ValueEqualityFn<NoInfer<D>>;
debugName?: string;
set?: (value: NoInfer<D>, rawSet: (value: NoInfer<D>) => void) => void;
}): WritableSignal<D>;

// @public
Expand Down
35 changes: 29 additions & 6 deletions packages/core/src/render3/reactivity/linked_signal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '../../../primitives/signals';
import {Signal, ValueEqualityFn} from './api';
import {signalAsReadonlyFn, WritableSignal} from './signal';
import {untracked} from './untracked';

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

Expand All @@ -27,7 +28,11 @@ const identityFn = <T>(v: T) => v;
*/
export function linkedSignal<D>(
computation: () => D,
options?: {equal?: ValueEqualityFn<NoInfer<D>>; debugName?: string},
options?: {
equal?: ValueEqualityFn<NoInfer<D>>;
debugName?: string;
set?: (value: NoInfer<D>, rawSet: (value: NoInfer<D>) => void) => void;
},
): WritableSignal<D>;

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

export function linkedSignal<S, D>(
Expand All @@ -53,30 +59,40 @@ export function linkedSignal<S, D>(
computation: ComputationFn<S, D>;
equal?: ValueEqualityFn<D>;
debugName?: string;
set?: (value: D, rawSet: (value: D) => void) => void;
}
| (() => D),
options?: {equal?: ValueEqualityFn<D>; debugName?: string},
options?: {
equal?: ValueEqualityFn<D>;
debugName?: string;
set?: (value: D, rawSet: (value: D) => void) => void;
},
): WritableSignal<D> {
if (typeof optionsOrComputation === 'function') {
const getter = createLinkedSignal<D, D>(
optionsOrComputation,
identityFn<D>,
options?.equal,
) as LinkedSignalGetter<D, D> & WritableSignal<D>;
return upgradeLinkedSignalGetter(getter, options?.debugName);
return upgradeLinkedSignalGetter(getter, options?.debugName, options?.set);
} else {
const getter = createLinkedSignal<S, D>(
optionsOrComputation.source,
optionsOrComputation.computation,
optionsOrComputation.equal,
);
return upgradeLinkedSignalGetter(getter, optionsOrComputation.debugName);
return upgradeLinkedSignalGetter(
getter,
optionsOrComputation.debugName,
optionsOrComputation.set,
);
}
}

function upgradeLinkedSignalGetter<S, D>(
getter: LinkedSignalGetter<S, D>,
debugName?: string,
customSet?: (value: D, rawSet: (value: D) => void) => void,
): WritableSignal<D> {
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
getter[SIGNAL].debugName = debugName;
Expand All @@ -86,8 +102,15 @@ function upgradeLinkedSignalGetter<S, D>(
const node = getter[SIGNAL] as LinkedSignalNode<S, D>;
const upgradedGetter = getter as LinkedSignalGetter<S, D> & WritableSignal<D>;

upgradedGetter.set = (newValue: D) => linkedSignalSetFn(node, newValue);
upgradedGetter.update = (updateFn: (value: D) => D) => linkedSignalUpdateFn(node, updateFn);
if (customSet !== undefined) {
const rawSet = (newValue: D) => linkedSignalSetFn(node, newValue);
upgradedGetter.set = (newValue: D) => customSet(newValue, rawSet);
upgradedGetter.update = (updateFn: (value: D) => D) =>
customSet(updateFn(untracked(getter)), rawSet);
} else {
upgradedGetter.set = (newValue: D) => linkedSignalSetFn(node, newValue);
upgradedGetter.update = (updateFn: (value: D) => D) => linkedSignalUpdateFn(node, updateFn);
}
upgradedGetter.asReadonly = signalAsReadonlyFn.bind(getter as any) as () => Signal<D>;

return upgradedGetter;
Expand Down
16 changes: 16 additions & 0 deletions packages/core/test/authoring/linked_signal_signature_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,20 @@ export class LinkedSignalSignatureTest {
source,
computation: (s, previous) => String(s),
});

/** number */
shorthandWithCustomSetter = linkedSignal(() => 0, {
set: (value: number, rawSet) => {
rawSet(value);
},
});

/** string */
advancedWithCustomSetter = linkedSignal({
source,
computation: (s) => String(s),
set: (value: string, rawSet) => {
rawSet(value);
},
});
}
82 changes: 81 additions & 1 deletion packages/core/test/signals/linked_signal_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import {isSignal, linkedSignal, signal, computed} from '../../src/core';
import {defaultEquals, setPostProducerCreatedFn} from '../../primitives/signals';
import {testingEffect} from './effect_util';
import {flushEffects, testingEffect} from './effect_util';

describe('linkedSignal', () => {
it('should conform to the writable signals contract', () => {
Expand Down Expand Up @@ -440,4 +440,84 @@ describe('linkedSignal', () => {
expect(derived()).toBe('2, hasPrevious: false');
});
});

describe('with custom setter', () => {
it('should run custom setter on set()', () => {
const customCalls: any[] = [];
const source = signal(10);
const derived = linkedSignal(() => source() * 2, {
set: (value, rawSet) => {
customCalls.push({value});
rawSet(value);
},
});

expect(derived()).toBe(20);

derived.set(30);
expect(customCalls).toEqual([{value: 30}]);
expect(derived()).toBe(30);
});

it('should support routing set() to the source signal', () => {
const tempC = signal(0);
const tempF = linkedSignal(() => (tempC() * 9) / 5 + 32, {
set: (valF) => tempC.set(((valF - 32) * 5) / 9),
});

expect(tempF()).toBe(32);

// Setting Fahrenheit should update Celsius, which reactively updates Fahrenheit
tempF.set(212);
expect(tempC()).toBe(100);
expect(tempF()).toBe(212);
});

it('should run custom setter on update() and pass updated value', () => {
const customCalls: any[] = [];
const source = signal(10);
const derived = linkedSignal(() => source() * 2, {
set: (value, rawSet) => {
customCalls.push({value});
rawSet(value);
},
});

expect(derived()).toBe(20);

derived.update((v) => v + 5);
expect(customCalls).toEqual([{value: 25}]);
expect(derived()).toBe(25);
});

it('should untrack current value read during update()', () => {
const source = signal(10);
const derived = linkedSignal(() => source() * 2, {
set: (value, rawSet) => {
rawSet(value);
},
});

const effectRuns: number[] = [];
const trigger = signal(0);
const watchDestroy = testingEffect(() => {
trigger();
derived.update((v) => v + 1);
effectRuns.push(derived());
});

try {
flushEffects();
expect(derived()).toBe(21);
expect(effectRuns).toEqual([21]);

trigger.set(1);
flushEffects();
expect(derived()).toBe(22);
expect(effectRuns).toEqual([21, 22]);
} finally {
watchDestroy();
}
});
});
});
Loading