diff --git a/modules/signals/spec/signal-method.spec.ts b/modules/signals/spec/signal-method.spec.ts new file mode 100644 index 0000000000..ab554ff78a --- /dev/null +++ b/modules/signals/spec/signal-method.spec.ts @@ -0,0 +1,201 @@ +import { signalMethod } from '../src'; +import { TestBed } from '@angular/core/testing'; +import { + createEnvironmentInjector, + EnvironmentInjector, + runInInjectionContext, + signal, +} from '@angular/core'; + +describe('signalMethod', () => { + const createAdder = (processingFn: (value: number) => void) => + TestBed.runInInjectionContext(() => signalMethod(processingFn)); + + it('processes a non-signal input', () => { + let a = 1; + const adder = createAdder((value) => (a += value)); + adder(2); + expect(a).toBe(3); + }); + + it('processes a signal input', () => { + let a = 1; + const summand = signal(1); + const adder = createAdder((value) => (a += value)); + + adder(summand); + expect(a).toBe(1); + + TestBed.flushEffects(); + expect(a).toBe(2); + + summand.set(2); + summand.set(2); + TestBed.flushEffects(); + expect(a).toBe(4); + + TestBed.flushEffects(); + expect(a).toBe(4); + }); + + it('throws if is a not created in an injection context', () => { + expect(() => signalMethod(() => void true)).toThrowError(); + }); + + describe('destroying signalMethod', () => { + it('stops signal tracking, when signalMethod gets destroyed', () => { + let a = 1; + const summand = signal(1); + const adder = createAdder((value) => (a += value)); + adder(summand); + + summand.set(2); + TestBed.flushEffects(); + expect(a).toBe(3); + + adder.destroy(); + + summand.set(2); + TestBed.flushEffects(); + expect(a).toBe(3); + }); + + it('can also destroy a signalMethod that processes non-signal inputs', () => { + const adder = createAdder(() => void true); + expect(() => adder(1).destroy()).not.toThrowError(); + }); + + it('stops tracking all signals on signalMethod destroy', () => { + let a = 1; + const summand1 = signal(1); + const summand2 = signal(2); + const adder = createAdder((value) => (a += value)); + adder(summand1); + adder(summand2); + + summand1.set(2); + summand2.set(3); + TestBed.flushEffects(); + expect(a).toBe(6); + + adder.destroy(); + + summand1.set(2); + summand2.set(3); + TestBed.flushEffects(); + expect(a).toBe(6); + }); + + it('does not cause issues if destroyed signalMethodFn contains destroyed effectRefs', () => { + let a = 1; + const summand1 = signal(1); + const summand2 = signal(2); + const adder = createAdder((value) => (a += value)); + + const childInjector = createEnvironmentInjector( + [], + TestBed.inject(EnvironmentInjector) + ); + + adder(summand1, { injector: childInjector }); + adder(summand2); + + TestBed.flushEffects(); + expect(a).toBe(4); + childInjector.destroy(); + + summand1.set(2); + summand2.set(2); + TestBed.flushEffects(); + + adder.destroy(); + expect(a).toBe(4); + }); + + it('uses the provided injector (source injector) on creation', () => { + let a = 1; + const sourceInjector = createEnvironmentInjector( + [], + TestBed.inject(EnvironmentInjector) + ); + const adder = signalMethod((value: number) => (a += value), { + injector: sourceInjector, + }); + const value = signal(1); + + adder(value); + TestBed.flushEffects(); + + sourceInjector.destroy(); + value.set(2); + TestBed.flushEffects(); + + expect(a).toBe(2); + }); + + it('prioritizes the provided caller injector over source injector', () => { + let a = 1; + const callerInjector = createEnvironmentInjector( + [], + TestBed.inject(EnvironmentInjector) + ); + const sourceInjector = createEnvironmentInjector( + [], + TestBed.inject(EnvironmentInjector) + ); + const adder = signalMethod((value: number) => (a += value), { + injector: sourceInjector, + }); + const value = signal(1); + + TestBed.runInInjectionContext(() => { + adder(value, { injector: callerInjector }); + }); + TestBed.flushEffects(); + expect(a).toBe(2); + + sourceInjector.destroy(); + value.set(2); + TestBed.flushEffects(); + + expect(a).toBe(4); + }); + + it('prioritizes the provided injector over source and caller injector', () => { + let a = 1; + const callerInjector = createEnvironmentInjector( + [], + TestBed.inject(EnvironmentInjector) + ); + const sourceInjector = createEnvironmentInjector( + [], + TestBed.inject(EnvironmentInjector) + ); + const providedInjector = createEnvironmentInjector( + [], + TestBed.inject(EnvironmentInjector) + ); + + const adder = signalMethod((value: number) => (a += value), { + injector: sourceInjector, + }); + const value = signal(1); + + runInInjectionContext(callerInjector, () => + adder(value, { injector: providedInjector }) + ); + TestBed.flushEffects(); + expect(a).toBe(2); + + sourceInjector.destroy(); + value.set(2); + TestBed.flushEffects(); + expect(a).toBe(4); + + callerInjector.destroy(); + value.set(1); + TestBed.flushEffects(); + expect(a).toBe(5); + }); + }); +}); diff --git a/modules/signals/src/index.ts b/modules/signals/src/index.ts index b7506ba5a8..6ff26b95bf 100644 --- a/modules/signals/src/index.ts +++ b/modules/signals/src/index.ts @@ -1,5 +1,6 @@ export { deepComputed } from './deep-computed'; export { DeepSignal } from './deep-signal'; +export { signalMethod } from './signal-method'; export { signalState, SignalState } from './signal-state'; export { signalStore } from './signal-store'; export { signalStoreFeature, type } from './signal-store-feature'; diff --git a/modules/signals/src/signal-method.ts b/modules/signals/src/signal-method.ts new file mode 100644 index 0000000000..f9322e34c2 --- /dev/null +++ b/modules/signals/src/signal-method.ts @@ -0,0 +1,66 @@ +import { + assertInInjectionContext, + effect, + EffectRef, + inject, + Injector, + isSignal, + Signal, + untracked, +} from '@angular/core'; + +type SignalMethod = (( + input: Input | Signal, + config?: { injector?: Injector } +) => EffectRef) & + EffectRef; + +export function signalMethod( + processingFn: (value: Input) => void, + config?: { injector?: Injector } +): SignalMethod { + if (!config?.injector) { + assertInInjectionContext(signalMethod); + } + + const watchers: EffectRef[] = []; + const sourceInjector = config?.injector ?? inject(Injector); + + const signalMethodFn = ( + input: Input | Signal, + config?: { injector?: Injector } + ): EffectRef => { + if (isSignal(input)) { + const instanceInjector = + config?.injector ?? getCallerInjector() ?? sourceInjector; + + const watcher = effect( + (onCleanup) => { + const value = input(); + untracked(() => processingFn(value)); + onCleanup(() => watchers.splice(watchers.indexOf(watcher), 1)); + }, + { injector: instanceInjector } + ); + watchers.push(watcher); + + return watcher; + } else { + processingFn(input); + return { destroy: () => void true }; + } + }; + + signalMethodFn.destroy = () => + watchers.forEach((watcher) => watcher.destroy()); + + return signalMethodFn; +} + +function getCallerInjector(): Injector | null { + try { + return inject(Injector); + } catch { + return null; + } +} diff --git a/projects/ngrx.io/content/guide/signals/signal-method.md b/projects/ngrx.io/content/guide/signals/signal-method.md new file mode 100644 index 0000000000..f9bd86ad05 --- /dev/null +++ b/projects/ngrx.io/content/guide/signals/signal-method.md @@ -0,0 +1,167 @@ +# SignalMethod + +`signalMethod` is a standalone factory function used for managing side effects with Angular signals. It accepts a callback and returns a processor function that can handle either a static value or a signal. The input type can be specified using a generic type argument: + +```ts +import { Component } from '@angular/core'; +import { signalMethod } from '@ngrx/signals'; + +@Component({ /* ... */ }) +export class NumbersComponent { + // 👇 This method will have an input argument + // of type `number | Signal`. + readonly logDoubledNumber = signalMethod((num) => { + const double = num * 2; + console.log(double); + }); +} +``` + +`logDoubledNumber` can be called with a static value of type `number`, or a Signal of type `number`: + +```ts +@Component({ /* ... */ }) +export class NumbersComponent { + readonly logDoubledNumber = signalMethod((num) => { + const double = num * 2; + console.log(double); + }); + + constructor() { + this.logDoubledNumber(1); + // console output: 2 + + const num = signal(2); + this.logDoubledNumber(num); + // console output: 4 + + setTimeout(() => num.set(3), 3_000); + // console output after 3 seconds: 6 + } +} +``` + +## Automatic Cleanup + +`signalMethod` uses an `effect` internally to track the Signal changes. +By default, the `effect` runs in the injection context of the caller. In the example above, that is `NumbersComponent`. That means, that the `effect` is automatically cleaned up when the component is destroyed. + +If the call happens outside an injection context, then the injector of the `signalMethod` is used. This would be the case, if `logDoubledNumber` runs in `ngOnInit`: + +```ts +@Component({ /* ... */ }) +export class NumbersComponent implements OnInit { + readonly logDoubledNumber = signalMethod((num) => { + const double = num * 2; + console.log(double); + }); + + ngOnInit(): void { + const value = signal(2); + // 👇 Uses the injection context of the `NumbersComponent`. + this.logDoubledNumber(value); + } +} +``` + +Even though `logDoubledNumber` is called outside an injection context, automatic cleanup occurs when `NumbersComponent` is destroyed, since `logDoubledNumber` was created within the component's injection context. + +However, when creating a `signalMethod` in an ancestor injection context, the cleanup behavior is different: + +```ts +@Injectable({ providedIn: 'root' }) +export class NumbersService { + readonly logDoubledNumber = signalMethod((num) => { + const double = num * 2; + console.log(double); + }); +} + +@Component({ /* ... */ }) +export class NumbersComponent implements OnInit { + readonly numbersService = inject(NumbersService); + + ngOnInit(): void { + const value = signal(2); + // 👇 Uses the injection context of the `NumbersService`, which is root. + this.numbersService.logDoubledNumber(value); + } +} +``` + +Here, the `effect` outlives the component, which would produce a memory leak. + +## Manual Cleanup + +When a `signalMethod` is created in an ancestor injection context, it's necessary to explicitly provide the caller injector to ensure proper cleanup: + +```ts +@Component({ /* ... */ }) +export class NumbersComponent implements OnInit { + readonly numbersService = inject(NumbersService); + readonly injector = inject(Injector); + + ngOnInit(): void { + const value = signal(1); + // 👇 Providing the `NumbersComponent` injector + // to ensure cleanup on component destroy. + this.numbersService.logDoubledNumber(value, { + injector: this.injector, + }); + + // 👇 No need to provide an injector for static values. + this.numbersService.logDoubledNumber(2); + } +} +``` + +## Initialization Outside of Injection Context + +The `signalMethod` must be initialized within an injection context. To initialize it outside an injection context, it's necessary to provide an injector as the second argument: + +```ts +@Component({ /* ... */ }) +export class NumbersComponent implements OnInit { + readonly injector = inject(Injector); + + ngOnInit() { + const logDoubledNumber = signalMethod( + (num) => console.log(num * 2), + { injector: this.injector }, + ); + } +} +``` + +## Advantages over Effect + +At first sight, `signalMethod`, might be the same as `effect`: + +```ts +@Component({ /* ... */ }) +export class NumbersComponent { + readonly num = signal(2); + readonly logDoubledNumberEffect = effect(() => { + console.log(this.num() * 2); + }); + readonly logDoubledNumber = signalMethod((num) => { + console.log(num * 2); + }); + + constructor() { + this.logDoubledNumber(this.num); + } +} +``` + +However, `signalMethod` offers three distinctive advantages over `effect`: + +- **Flexible Input**: The input argument can be a static value, not just a signal. Additionally, the processor function can be called multiple times with different inputs. +- **No Injection Context Required**: Unlike an `effect`, which requires an injection context or an Injector, `signalMethod`'s "processor function" can be called without an injection context. +- **Explicit Tracking**: Only the Signal of the parameter is tracked, while Signals within the "processor function" stay untracked. + +## `signalMethod` compared to `rxMethod` + +`signalMethod` is `rxMethod` without RxJS, and is therefore much smaller in terms of bundle size. + +Be aware that RxJS is superior to Signals in managing race conditions. Signals have a glitch-free effect, meaning that for multiple synchronous changes, only the last change is propagated. Additionally, they lack powerful operators like `switchMap` or `concatMap`. diff --git a/projects/ngrx.io/content/navigation.json b/projects/ngrx.io/content/navigation.json index 46ab962f18..2959b3f05c 100644 --- a/projects/ngrx.io/content/navigation.json +++ b/projects/ngrx.io/content/navigation.json @@ -327,6 +327,10 @@ "title": "DeepComputed", "url": "guide/signals/deep-computed" }, + { + "title": "SignalMethod", + "url": "guide/signals/signal-method" + }, { "title": "RxJS Integration", "url": "guide/signals/rxjs-integration"