diff --git a/modules/signals/spec/signal-method.spec.ts b/modules/signals/spec/signal-method.spec.ts new file mode 100644 index 0000000000..df253645ab --- /dev/null +++ b/modules/signals/spec/signal-method.spec.ts @@ -0,0 +1,201 @@ +import { signalMethod } from '../src/signal-method'; +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(); + }); + + 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(); + }); + + describe('destroying signalMethod', () => { + 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/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..cb2d24d14b --- /dev/null +++ b/projects/ngrx.io/content/guide/signals/signal-method.md @@ -0,0 +1,201 @@ +# signalMethod + +`signalMethod` is a factory function to execute side effects based on Signal changes. It creates one function (processing function) with one typed parameter that can be a static value or a Signal. Upon invocation, the "processing function" has to be provided. + +`signalMethod` is `rxMethod` without RxJS. `signalMethod` can also be used outside of `signalStore` or `signalState`: + +```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 { + // 👇 This method will have an input argument + // of type `number | Signal`. + readonly logDoubledNumber = signalMethod(num => { + const double = num * 2; + console.log(double); + }) + + constructor(): void { + // 👇 prints 2 synchronously + this.logDoubledNumber(1); + + const value = signal(2); + // 👇 prints 4 asynchronously (triggered by an internal effect()) + this.logDoubledNumber(value); + } +} +``` + +## Automatic Signal Tracking + +`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 of 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 { + readonly logDoubledNumber = signalMethod(num => { + const double = num * 2; + console.log(double); + }) + + ngOnInit(): void { + this.logDoubledNumber(1); + + const value = signal(2); + // 👇 uses the injection context of the `signalMethod` + this.logDoubledNumber(value); + } +} +``` + +For the `NumbersComponent`, it doesn't make a difference. Again, the `effect` is automatically cleaned up when the component is destroyed. + +Careful, when `signalMethod` is used in a service which is provided in `root`: + +```ts + +@Injectable({providedIn: 'root'}) +export class NumbersService { + readonly logDoubledNumber = signalMethod(num => { + const double = num * 2; + console.log(double); + }) +} + +@Component({ /* ... */}) +export class NumbersComponent { + readonly logDoubledNumber = inject(NumbersService).logDoubledNumber; + + ngOnInit(): void { + this.logDoubledNumber(1); + + const value = signal(2); + // 👇 uses the injection context of the `NumbersService`, which is root. + this.logDoubledNumber(value); + } +} +``` + +Here, the `effect` outlives the component, which would produce a memory leak. + +As a consequence, try to call the "processor function" always in an injection context: + +```ts + +@Component({ /* ... */}) +export class NumbersComponent { + readonly logDoubledNumber = inject(NumbersService).logDoubledNumber; + + ngOnInit(): void { + this.logDoubledNumber(1); + + const value = signal(2); + // 👇 uses the injection context of the `NumbersService`, which is root. + this.logDoubledNumber(value); + } +} +``` + +## Providing an Injector + +If you cannot run the "processor function" in an injection context, you can also provide an injector manually: + +```ts + +@Component({ /* ... */}) +export class NumbersComponent { + readonly logDoubledNumber = inject(NumbersService).logDoubledNumber; + readonly injector = inject(Injector); + + ngOnInit(): void { + this.logDoubledNumber(1); + + const value = signal(2); + // 👇 uses the injection context of the `NumbersService`, which is root. + this.logDoubledNumber(value, {injector: this.injector}); + } +} +``` + +Whereas the "processor function" doesn't require an active injection context, the call of `signalMethod` does: + +```ts + +@Component({ /* ... */}) +export class NumbersComponent { +// 👇 Would cause a runtime error + ngOnInit() { + const logDoubledNumber = signalMethod(num => console.log(num * 2)); + } +} +``` + +In these cases, you also have to provide the injector manually: + +```ts + +@Component({ /* ... */}) +export class NumbersComponent { + readonly injector = inject(Injector); + + ngOnInit() { + // 👇 Works now + const logDoubledNumber = signalMethod(num => console.log(num * 2), {injector: this.injector}); + } +} +``` + +## Advantages over simple effect + +On first sight, `signalMethod`, might be the same as `effect`: + +```ts + +@Component({ /* ... */}) +export class NumbersComponent { + readonly value = signal(2); + readonly logDoubledNumberEffect = effect(() => { + const double = num * 2; + console.log(double); + }) + + constructor(): void { + this.logDoubledNumber(value); + } +} +``` + +However, `signalMethod` offers three distinctive advantages over `effect`: + +- **Flexible Parameter**: The parameter can also be a simple number, not just a Signal. +- **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. + + +
+ +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 8e51c01507..02fc3635c0 100644 --- a/projects/ngrx.io/content/navigation.json +++ b/projects/ngrx.io/content/navigation.json @@ -323,6 +323,10 @@ "title": "DeepComputed", "url": "guide/signals/deep-computed" }, + { + "title": "SignalMethod", + "url": "guide/signals/signal-method" + }, { "title": "RxJS Integration", "url": "guide/signals/rxjs-integration"