From f82284b450c1005f7664b4f6b216494925436bdb Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Tue, 12 May 2026 17:10:55 -0700 Subject: [PATCH] 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. Fixes #59665 TAG=agy CONV=addbb5c4-4233-49e8-b844-6f732d7d5c72 --- .../content/guide/signals/linked-signal.md | 62 ++++++++++++++ goldens/public-api/core/index.api.md | 2 + .../src/render3/reactivity/linked_signal.ts | 35 ++++++-- .../authoring/linked_signal_signature_test.ts | 16 ++++ .../core/test/signals/linked_signal_spec.ts | 82 ++++++++++++++++++- 5 files changed, 190 insertions(+), 7 deletions(-) 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(); + } + }); + }); });