Skip to content

Commit 6074704

Browse files
feat(signals): allow auto-tracking function for signalMethod and rxMethod (#4986)
Align signalMethod and rxMethod with Angular’s resource/linkedSignal pattern so callers can pass a lazy () => T instead of creating an intermediate computed signal. Closes #4986.
1 parent 50fa551 commit 6074704

File tree

6 files changed

+127
-21
lines changed

6 files changed

+127
-21
lines changed

modules/signals/rxjs-interop/spec/rx-method.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,35 @@ describe('rxMethod', () => {
6565
expect(results[1]).toBe(10);
6666
}));
6767

68+
it('runs and tracks a function', () => {
69+
TestBed.runInInjectionContext(() => {
70+
const results: number[] = [];
71+
const method = rxMethod<number>(
72+
pipe(tap((value) => results.push(value)))
73+
);
74+
75+
const a = signal(1);
76+
const b = signal(1);
77+
const multplier = () => a() * b();
78+
79+
method(multplier);
80+
expect(results.length).toBe(0);
81+
82+
TestBed.tick();
83+
expect(results[0]).toBe(1);
84+
85+
a.set(5);
86+
expect(results.length).toBe(1);
87+
88+
TestBed.tick();
89+
expect(results[1]).toBe(5);
90+
91+
b.set(2);
92+
TestBed.tick();
93+
expect(results[2]).toBe(10);
94+
});
95+
});
96+
6897
it('runs with void input', () => {
6998
const results: number[] = [];
7099
const subject$ = new Subject<void>();

modules/signals/rxjs-interop/src/rx-method.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import {
44
effect,
55
inject,
66
Injector,
7-
isSignal,
8-
Signal,
97
untracked,
108
} from '@angular/core';
119
import { isObservable, noop, Observable, Subject } from 'rxjs';
@@ -17,7 +15,7 @@ type RxMethodRef = {
1715
};
1816

1917
export type RxMethod<Input> = ((
20-
input: Input | Signal<Input> | Observable<Input>,
18+
input: Input | (() => Input) | Observable<Input>,
2119
config?: { injector?: Injector }
2220
) => RxMethodRef) &
2321
RxMethodRef;
@@ -36,7 +34,7 @@ export function rxMethod<Input>(
3634
sourceInjector.get(DestroyRef).onDestroy(() => sourceSub.unsubscribe());
3735

3836
const rxMethodFn = (
39-
input: Input | Signal<Input> | Observable<Input>,
37+
input: Input | (() => Input) | Observable<Input>,
4038
config?: { injector?: Injector }
4139
): RxMethodRef => {
4240
if (isStatic(input)) {
@@ -64,7 +62,7 @@ export function rxMethod<Input>(
6462
const instanceInjector =
6563
config?.injector ?? callerInjector ?? sourceInjector;
6664

67-
if (isSignal(input)) {
65+
if (typeof input === 'function') {
6866
const watcher = effect(
6967
() => {
7068
const value = input();
@@ -93,8 +91,8 @@ export function rxMethod<Input>(
9391
return rxMethodFn;
9492
}
9593

96-
function isStatic<T>(value: T | Signal<T> | Observable<T>): value is T {
97-
return !isSignal(value) && !isObservable(value);
94+
function isStatic<T>(value: T | (() => T) | Observable<T>): value is T {
95+
return typeof value !== 'function' && !isObservable(value);
9896
}
9997

10098
function getCallerInjector(): Injector | undefined {

modules/signals/spec/signal-method.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,28 @@ describe('signalMethod', () => {
3939
expect(a).toBe(4);
4040
});
4141

42+
it('tracks signals within a function input automatically', () => {
43+
const a = signal(1);
44+
const b = signal(1);
45+
const add = () => a() + b();
46+
let sum = 0;
47+
const adder = createAdder((value) => (sum += value));
48+
49+
adder(add);
50+
expect(sum).toBe(0);
51+
52+
TestBed.tick();
53+
expect(sum).toBe(2);
54+
55+
a.set(2);
56+
b.set(2);
57+
TestBed.tick();
58+
expect(sum).toBe(6);
59+
60+
TestBed.tick();
61+
expect(sum).toBe(6);
62+
});
63+
4264
it('throws if is a not created in an injection context', () => {
4365
expect(() => signalMethod<void>(() => void true)).toThrowError();
4466
});

modules/signals/src/signal-method.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,13 @@ import {
55
EffectRef,
66
inject,
77
Injector,
8-
isSignal,
9-
Signal,
108
untracked,
119
} from '@angular/core';
1210

1311
declare const ngDevMode: unknown;
1412

1513
export type SignalMethod<Input> = ((
16-
input: Input | Signal<Input>,
14+
input: Input | (() => Input),
1715
config?: { injector?: Injector }
1816
) => EffectRef) &
1917
EffectRef;
@@ -30,10 +28,10 @@ export function signalMethod<Input>(
3028
const sourceInjector = config?.injector ?? inject(Injector);
3129

3230
const signalMethodFn = (
33-
input: Input | Signal<Input>,
31+
input: Input | (() => Input),
3432
config?: { injector?: Injector }
3533
): EffectRef => {
36-
if (isSignal(input)) {
34+
if (isReactiveComputation(input)) {
3735
const callerInjector = getCallerInjector();
3836
if (
3937
typeof ngDevMode !== 'undefined' &&
@@ -90,3 +88,7 @@ function getCallerInjector(): Injector | undefined {
9088
return undefined;
9189
}
9290
}
91+
92+
function isReactiveComputation<T>(value: T | (() => T)): value is () => T {
93+
return typeof value === 'function';
94+
}

projects/www/src/app/pages/guide/signals/rxjs-integration.md

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ RxJS is still a major part of NgRx and the Angular ecosystem, and the `@ngrx/sig
66

77
The `rxMethod` is a standalone factory function designed for managing side effects by utilizing RxJS APIs.
88
It takes a chain of RxJS operators as input and returns a reactive method.
9-
The reactive method can accept a static value, signal, or observable as an input argument.
9+
The reactive method can accept a static value, a reactive computation (like a Signal), or observable as an input argument.
1010
Input can be typed by providing a generic argument to the `rxMethod` function.
1111

1212
<ngrx-code-example>
@@ -21,7 +21,7 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop';
2121
})
2222
export class Numbers {
2323
// 👇 This reactive method will have an input argument
24-
// of type `number | Signal<number> | Observable<number>`.
24+
// of type `number | (() => number) | Observable<number>`.
2525
readonly logDoubledNumber = rxMethod<number>(
2626
// 👇 RxJS operators are chained together using the `pipe` function.
2727
pipe(
@@ -63,7 +63,7 @@ export class Numbers {
6363
}
6464
```
6565

66-
When a reactive method is called with a signal, the reactive chain is executed every time the signal value changes.
66+
When a reactive method is called with a reactive computation - like a Signal - the reactive chain is executed every time the signal value changes.
6767

6868
```ts
6969
import { Component, signal } from '@angular/core';
@@ -92,6 +92,37 @@ export class Numbers {
9292
}
9393
```
9494

95+
And here the same example with a more generic reactive computation.
96+
97+
```ts
98+
import { Component, signal } from '@angular/core';
99+
import { map, pipe, tap } from 'rxjs';
100+
import { rxMethod } from '@ngrx/signals/rxjs-interop';
101+
102+
@Component({
103+
/* ... */
104+
})
105+
export class Numbers {
106+
readonly logDoubledNumber = rxMethod<number>(
107+
pipe(
108+
map((num) => num * 2),
109+
tap(console.log)
110+
)
111+
);
112+
113+
constructor() {
114+
const a = signal(5);
115+
const b = signal(2);
116+
117+
this.logDoubledNumber(() => a() + b());
118+
// console output: 20
119+
120+
setTimeout(() => b.set(10), 3_000);
121+
// console output after 3 seconds: 40
122+
}
123+
}
124+
```
125+
95126
When a reactive method is called with an observable, the reactive chain is executed every time the observable emits a new value.
96127

97128
```ts
@@ -268,7 +299,7 @@ export class Numbers implements OnInit {
268299

269300
<ngrx-docs-alert type="inform">
270301

271-
If the injector is not provided when calling the reactive method with a signal or observable outside the injection context, a warning message about a potential memory leak is displayed in development mode.
302+
If the injector is not provided when calling the reactive method with a reactive computation or observable outside the injection context, a warning message about a potential memory leak is displayed in development mode.
272303

273304
</ngrx-docs-alert>
274305

projects/www/src/app/pages/guide/signals/signal-method.md

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# SignalMethod
22

3-
`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:
3+
`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 reactive computation, i.e. either a Signal or a function, which will be tracked automatically. The input type can be specified using a generic type argument:
44

55
<ngrx-code-example>
66

@@ -13,7 +13,7 @@ import { signalMethod } from '@ngrx/signals';
1313
})
1414
export class Numbers {
1515
// 👇 This method will have an input argument
16-
// of type `number | Signal<number>`.
16+
// of type `number | (() => number)`.
1717
readonly logDoubledNumber = signalMethod<number>((num) => {
1818
const double = num * 2;
1919
console.log(double);
@@ -23,7 +23,7 @@ export class Numbers {
2323

2424
</ngrx-code-example>
2525

26-
`logDoubledNumber` can be called with a static value of type `number`, or a Signal of type `number`:
26+
`logDoubledNumber` can be called with a static value of type `number`, or a reactive computation of type `number`. Since a Signal is a function returning a value, it is also a reactive computation.
2727

2828
```ts
2929
@Component({
@@ -49,6 +49,30 @@ export class Numbers {
4949
}
5050
```
5151

52+
Finally, a reactive computation example shows an automaticaly tracked computation, built from multiple Signals.
53+
54+
```ts
55+
@Component({
56+
/* ... */
57+
})
58+
export class Numbers {
59+
readonly logDoubledNumber = signalMethod<number>((num) => {
60+
const double = num * 2;
61+
console.log(double);
62+
});
63+
64+
constructor() {
65+
const num1 = signal(1);
66+
const num2 = signal(1);
67+
this.logDoubledNumber(() => num1() + num2());
68+
// console output: 4
69+
70+
setTimeout(() => num1.set(2), 3_000);
71+
// console output after 3 seconds: 6
72+
}
73+
}
74+
```
75+
5276
## Automatic Cleanup
5377

5478
`signalMethod` uses an `effect` internally to track the Signal changes.
@@ -184,9 +208,9 @@ export class Numbers {
184208

185209
However, `signalMethod` offers three distinctive advantages over `effect`:
186210

187-
- **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.
211+
- **Flexible Input**: The input argument can be a static value, not just a reactive computation. Additionally, the processor function can be called multiple times with different inputs.
188212
- **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.
189-
- **Explicit Tracking**: Only the Signal of the parameter is tracked, while Signals within the "processor function" stay untracked.
213+
- **Explicit Tracking**: Only the reactive computation of the parameter is tracked, while Signals within the "processor function" stay untracked.
190214

191215
## `signalMethod` compared to `rxMethod`
192216

0 commit comments

Comments
 (0)