From f8a220194673394316e2cbbde11fcb863015c39a Mon Sep 17 00:00:00 2001 From: Matt Turco Date: Tue, 11 Mar 2025 11:54:02 -0600 Subject: [PATCH] feat(core): add hook for producer creation side effects Adds a hook in the same style as `postSignalSetFn` for running side effects when a producer has been created. This hook will be passed the reactive node being created. --- .../core/primitives/signals/index.api.md | 13 ++++++-- packages/core/primitives/signals/index.ts | 3 ++ .../core/primitives/signals/src/computed.ts | 2 ++ packages/core/primitives/signals/src/graph.ts | 17 +++++++++++ .../primitives/signals/src/linked_signal.ts | 3 +- .../core/primitives/signals/src/signal.ts | 13 ++++---- packages/core/test/signals/computed_spec.ts | 18 ++++++++++- .../core/test/signals/linked_signal_spec.ts | 11 +++++++ packages/core/test/signals/signal_spec.ts | 30 +++++++++++++++++-- 9 files changed, 99 insertions(+), 11 deletions(-) diff --git a/goldens/public-api/core/primitives/signals/index.api.md b/goldens/public-api/core/primitives/signals/index.api.md index cd4ce9e07042..314be283e9a2 100644 --- a/goldens/public-api/core/primitives/signals/index.api.md +++ b/goldens/public-api/core/primitives/signals/index.api.md @@ -107,6 +107,9 @@ export interface Reactive { // @public (undocumented) export const REACTIVE_NODE: ReactiveNode; +// @public (undocumented) +export type ReactiveHookFn = (node: ReactiveNode) => void; + // @public export interface ReactiveNode { consumerAllowSignalWrites: boolean; @@ -132,7 +135,10 @@ export interface ReactiveNode { } // @public (undocumented) -export function runPostSignalSetFn(): void; +export function runPostProducerCreatedFn(node: ReactiveNode): void; + +// @public (undocumented) +export function runPostSignalSetFn(node: SignalNode): void; // @public (undocumented) export function setActiveConsumer(consumer: ReactiveNode | null): ReactiveNode | null; @@ -141,7 +147,10 @@ export function setActiveConsumer(consumer: ReactiveNode | null): ReactiveNode | export function setAlternateWeakRefImpl(impl: unknown): void; // @public (undocumented) -export function setPostSignalSetFn(fn: (() => void) | null): (() => void) | null; +export function setPostProducerCreatedFn(fn: ReactiveHookFn | null): ReactiveHookFn | null; + +// @public (undocumented) +export function setPostSignalSetFn(fn: ReactiveHookFn | null): ReactiveHookFn | null; // @public (undocumented) export function setThrowInvalidWriteToSignalError(fn: (node: SignalNode) => never): void; diff --git a/packages/core/primitives/signals/index.ts b/packages/core/primitives/signals/index.ts index 39798235b6d7..d733d01fe9ee 100644 --- a/packages/core/primitives/signals/index.ts +++ b/packages/core/primitives/signals/index.ts @@ -20,6 +20,7 @@ export {setThrowInvalidWriteToSignalError} from './src/errors'; export { REACTIVE_NODE, Reactive, + ReactiveHookFn, ReactiveNode, SIGNAL, consumerAfterComputation, @@ -36,7 +37,9 @@ export { producerNotifyConsumers, producerUpdateValueVersion, producerUpdatesAllowed, + runPostProducerCreatedFn, setActiveConsumer, + setPostProducerCreatedFn, } from './src/graph'; export { SIGNAL_NODE, diff --git a/packages/core/primitives/signals/src/computed.ts b/packages/core/primitives/signals/src/computed.ts index d42cf2a77bc4..42ee5322ec66 100644 --- a/packages/core/primitives/signals/src/computed.ts +++ b/packages/core/primitives/signals/src/computed.ts @@ -16,6 +16,7 @@ import { ReactiveNode, setActiveConsumer, SIGNAL, + runPostProducerCreatedFn, } from './graph'; /** @@ -76,6 +77,7 @@ export function createComputed( return node.value; }; (computed as ComputedGetter)[SIGNAL] = node; + runPostProducerCreatedFn(node); return computed as unknown as ComputedGetter; } diff --git a/packages/core/primitives/signals/src/graph.ts b/packages/core/primitives/signals/src/graph.ts index 79369bbf72b2..52667b34116f 100644 --- a/packages/core/primitives/signals/src/graph.ts +++ b/packages/core/primitives/signals/src/graph.ts @@ -25,6 +25,13 @@ type Version = number & {__brand: 'Version'}; */ let epoch: Version = 1 as Version; +export type ReactiveHookFn = (node: ReactiveNode) => void; + +/** + * If set, called after a producer `ReactiveNode` is created. + */ +let postProducerCreatedFn: ReactiveHookFn | null = null; + /** * Symbol used to tell `Signal`s apart from other functions. * @@ -527,3 +534,13 @@ function assertProducerNode(node: ReactiveNode): asserts node is ProducerNode { function isConsumerNode(node: ReactiveNode): node is ConsumerNode { return node.producerNode !== undefined; } + +export function runPostProducerCreatedFn(node: ReactiveNode): void { + postProducerCreatedFn?.(node); +} + +export function setPostProducerCreatedFn(fn: ReactiveHookFn | null): ReactiveHookFn | null { + const prev = postProducerCreatedFn; + postProducerCreatedFn = fn; + return prev; +} diff --git a/packages/core/primitives/signals/src/linked_signal.ts b/packages/core/primitives/signals/src/linked_signal.ts index 4ff9737fe4b0..818b3e32e0c0 100644 --- a/packages/core/primitives/signals/src/linked_signal.ts +++ b/packages/core/primitives/signals/src/linked_signal.ts @@ -16,6 +16,7 @@ import { producerUpdateValueVersion, REACTIVE_NODE, ReactiveNode, + runPostProducerCreatedFn, SIGNAL, } from './graph'; import {signalSetFn, signalUpdateFn} from './signal'; @@ -86,7 +87,7 @@ export function createLinkedSignal( const getter = linkedSignalGetter as LinkedSignalGetter; getter[SIGNAL] = node; - + runPostProducerCreatedFn(node); return getter; } diff --git a/packages/core/primitives/signals/src/signal.ts b/packages/core/primitives/signals/src/signal.ts index 28c562384fd2..13e187eb7af0 100644 --- a/packages/core/primitives/signals/src/signal.ts +++ b/packages/core/primitives/signals/src/signal.ts @@ -15,6 +15,8 @@ import { producerUpdatesAllowed, REACTIVE_NODE, ReactiveNode, + ReactiveHookFn, + runPostProducerCreatedFn, SIGNAL, } from './graph'; @@ -28,7 +30,7 @@ declare const ngDevMode: boolean | undefined; * This hook can be used to achieve various effects, such as running effects synchronously as part * of setting a signal. */ -let postSignalSetFn: (() => void) | null = null; +let postSignalSetFn: ReactiveHookFn | null = null; export interface SignalNode extends ReactiveNode { value: T; @@ -57,10 +59,11 @@ export function createSignal(initialValue: T, equal?: ValueEqualityFn): Si return node.value; }) as SignalGetter; (getter as any)[SIGNAL] = node; + runPostProducerCreatedFn(node); return getter; } -export function setPostSignalSetFn(fn: (() => void) | null): (() => void) | null { +export function setPostSignalSetFn(fn: ReactiveHookFn | null): ReactiveHookFn | null { const prev = postSignalSetFn; postSignalSetFn = fn; return prev; @@ -90,8 +93,8 @@ export function signalUpdateFn(node: SignalNode, updater: (value: T) => T) signalSetFn(node, updater(node.value)); } -export function runPostSignalSetFn(): void { - postSignalSetFn?.(); +export function runPostSignalSetFn(node: SignalNode): void { + postSignalSetFn?.(node); } // Note: Using an IIFE here to ensure that the spread assignment is not considered @@ -110,5 +113,5 @@ function signalValueChanged(node: SignalNode): void { node.version++; producerIncrementEpoch(); producerNotifyConsumers(node); - postSignalSetFn?.(); + postSignalSetFn?.(node); } diff --git a/packages/core/test/signals/computed_spec.ts b/packages/core/test/signals/computed_spec.ts index 24243bb9d154..3c75cf361239 100644 --- a/packages/core/test/signals/computed_spec.ts +++ b/packages/core/test/signals/computed_spec.ts @@ -7,7 +7,13 @@ */ import {computed, signal} from '@angular/core'; -import {createWatch, ReactiveNode, SIGNAL, defaultEquals} from '@angular/core/primitives/signals'; +import { + createWatch, + ReactiveNode, + SIGNAL, + defaultEquals, + setPostProducerCreatedFn, +} from '@angular/core/primitives/signals'; describe('computed', () => { it('should create computed', () => { @@ -317,4 +323,14 @@ describe('computed', () => { expect(derived()).toBe(2); }); }); + + it('should call the post-producer-created fn when signal is called', () => { + const producerKindsCreated: string[] = []; + const prev = setPostProducerCreatedFn((node) => producerKindsCreated.push(node.kind)); + const count = signal(0); + computed(() => count() % 2 === 0); + + expect(producerKindsCreated).toEqual(['signal', 'computed']); + setPostProducerCreatedFn(prev); + }); }); diff --git a/packages/core/test/signals/linked_signal_spec.ts b/packages/core/test/signals/linked_signal_spec.ts index 0fe145ca05f5..de4d505be2d4 100644 --- a/packages/core/test/signals/linked_signal_spec.ts +++ b/packages/core/test/signals/linked_signal_spec.ts @@ -7,6 +7,7 @@ */ import {isSignal, linkedSignal, signal, computed} from '@angular/core'; +import {setPostProducerCreatedFn} from '@angular/core/primitives/signals'; import {testingEffect} from './effect_util'; describe('linkedSignal', () => { @@ -275,4 +276,14 @@ describe('linkedSignal', () => { choice.set('explicit'); expect(choice()).toBe('explicit'); }); + + it('should call the post-producer-created fn when signal is called', () => { + let producers = 0; + const prev = setPostProducerCreatedFn(() => producers++); + const options = signal(['apple', 'banana', 'fig']); + linkedSignal(() => options()[0]); + + expect(producers).toBe(2); + setPostProducerCreatedFn(prev); + }); }); diff --git a/packages/core/test/signals/signal_spec.ts b/packages/core/test/signals/signal_spec.ts index 3facfb3cc423..6057bb30f9b9 100644 --- a/packages/core/test/signals/signal_spec.ts +++ b/packages/core/test/signals/signal_spec.ts @@ -7,7 +7,13 @@ */ import {computed, signal} from '@angular/core'; -import {ReactiveNode, setPostSignalSetFn, SIGNAL} from '@angular/core/primitives/signals'; +import { + ReactiveHookFn, + ReactiveNode, + setPostProducerCreatedFn, + setPostSignalSetFn, + SIGNAL, +} from '@angular/core/primitives/signals'; describe('signals', () => { it('should be a getter which reflects the set value', () => { @@ -166,7 +172,7 @@ describe('signals', () => { }); describe('post-signal-set functions', () => { - let prevPostSignalSetFn: (() => void) | null = null; + let prevPostSignalSetFn: ReactiveHookFn | null = null; let log: number; beforeEach(() => { log = 0; @@ -197,5 +203,25 @@ describe('signals', () => { counter.set(0); expect(log).toBe(0); }); + + it('should pass post-signal-set fn the node that was updated', () => { + const counter = signal(0, {debugName: 'test-signal'}); + let node: ReactiveNode | null = null; + setPostSignalSetFn((n: ReactiveNode) => { + node = n; + }); + + counter.set(1); + expect(node!.debugName).toBe('test-signal'); + }); + }); + + it('should call the post-producer-created fn when signal is called', () => { + const producerKindsCreated: string[] = []; + const prev = setPostProducerCreatedFn((node) => producerKindsCreated.push(node.kind)); + signal(0); + + expect(producerKindsCreated).toEqual(['signal']); + setPostProducerCreatedFn(prev); }); });