diff --git a/adev/src/content/guide/signals/linked-signal.md b/adev/src/content/guide/signals/linked-signal.md index e8169d3f9d2c..cfa963c6683a 100644 --- a/adev/src/content/guide/signals/linked-signal.md +++ b/adev/src/content/guide/signals/linked-signal.md @@ -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). + +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({ + 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' +``` diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index 1cc47bdb4250..704fe3e55401 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -1168,6 +1168,7 @@ export class KeyValueDiffers { export function linkedSignal(computation: () => D, options?: { equal?: ValueEqualityFn>; debugName?: string; + set?: (value: NoInfer, rawSet: (value: NoInfer) => void) => void; }): WritableSignal; // @public @@ -1179,6 +1180,7 @@ export function linkedSignal(options: { }) => D; equal?: ValueEqualityFn>; debugName?: string; + set?: (value: NoInfer, rawSet: (value: NoInfer) => void) => void; }): WritableSignal; // @public diff --git a/packages/core/src/render3/reactivity/linked_signal.ts b/packages/core/src/render3/reactivity/linked_signal.ts index a2adb1d07283..619fc5993c23 100644 --- a/packages/core/src/render3/reactivity/linked_signal.ts +++ b/packages/core/src/render3/reactivity/linked_signal.ts @@ -17,6 +17,7 @@ import { } from '../../../primitives/signals'; import {Signal, ValueEqualityFn} from './api'; import {signalAsReadonlyFn, WritableSignal} from './signal'; +import {untracked} from './untracked'; const identityFn = (v: T) => v; @@ -27,7 +28,11 @@ const identityFn = (v: T) => v; */ export function linkedSignal( computation: () => D, - options?: {equal?: ValueEqualityFn>; debugName?: string}, + options?: { + equal?: ValueEqualityFn>; + debugName?: string; + set?: (value: NoInfer, rawSet: (value: NoInfer) => void) => void; + }, ): WritableSignal; /** @@ -44,6 +49,7 @@ export function linkedSignal(options: { computation: (source: NoInfer, previous?: {source: NoInfer; value: NoInfer}) => D; equal?: ValueEqualityFn>; debugName?: string; + set?: (value: NoInfer, rawSet: (value: NoInfer) => void) => void; }): WritableSignal; export function linkedSignal( @@ -53,9 +59,14 @@ export function linkedSignal( computation: ComputationFn; equal?: ValueEqualityFn; debugName?: string; + set?: (value: D, rawSet: (value: D) => void) => void; } | (() => D), - options?: {equal?: ValueEqualityFn; debugName?: string}, + options?: { + equal?: ValueEqualityFn; + debugName?: string; + set?: (value: D, rawSet: (value: D) => void) => void; + }, ): WritableSignal { if (typeof optionsOrComputation === 'function') { const getter = createLinkedSignal( @@ -63,20 +74,25 @@ export function linkedSignal( identityFn, options?.equal, ) as LinkedSignalGetter & WritableSignal; - return upgradeLinkedSignalGetter(getter, options?.debugName); + return upgradeLinkedSignalGetter(getter, options?.debugName, options?.set); } else { const getter = createLinkedSignal( optionsOrComputation.source, optionsOrComputation.computation, optionsOrComputation.equal, ); - return upgradeLinkedSignalGetter(getter, optionsOrComputation.debugName); + return upgradeLinkedSignalGetter( + getter, + optionsOrComputation.debugName, + optionsOrComputation.set, + ); } } function upgradeLinkedSignalGetter( getter: LinkedSignalGetter, debugName?: string, + customSet?: (value: D, rawSet: (value: D) => void) => void, ): WritableSignal { if (typeof ngDevMode !== 'undefined' && ngDevMode) { getter[SIGNAL].debugName = debugName; @@ -86,8 +102,15 @@ function upgradeLinkedSignalGetter( const node = getter[SIGNAL] as LinkedSignalNode; const upgradedGetter = getter as LinkedSignalGetter & WritableSignal; - 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; return upgradedGetter; diff --git a/packages/core/test/authoring/linked_signal_signature_test.ts b/packages/core/test/authoring/linked_signal_signature_test.ts index 77f757130e71..02982042da66 100644 --- a/packages/core/test/authoring/linked_signal_signature_test.ts +++ b/packages/core/test/authoring/linked_signal_signature_test.ts @@ -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); + }, + }); } diff --git a/packages/core/test/signals/linked_signal_spec.ts b/packages/core/test/signals/linked_signal_spec.ts index 61b5392d0b9c..03f6f72447d7 100644 --- a/packages/core/test/signals/linked_signal_spec.ts +++ b/packages/core/test/signals/linked_signal_spec.ts @@ -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', () => { @@ -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(); + } + }); + }); });