Skip to content

Commit bdd1d3e

Browse files
rainerhahnekamptimdeschryvermarkostanimirovic
authored
feat(signals): add signalMethod (#4597)
Closes #4581 Co-authored-by: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Co-authored-by: Marko Stanimirović <markostanimirovic95@gmail.com>
1 parent e626082 commit bdd1d3e

File tree

5 files changed

+439
-0
lines changed

5 files changed

+439
-0
lines changed
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { signalMethod } from '../src';
2+
import { TestBed } from '@angular/core/testing';
3+
import {
4+
createEnvironmentInjector,
5+
EnvironmentInjector,
6+
runInInjectionContext,
7+
signal,
8+
} from '@angular/core';
9+
10+
describe('signalMethod', () => {
11+
const createAdder = (processingFn: (value: number) => void) =>
12+
TestBed.runInInjectionContext(() => signalMethod<number>(processingFn));
13+
14+
it('processes a non-signal input', () => {
15+
let a = 1;
16+
const adder = createAdder((value) => (a += value));
17+
adder(2);
18+
expect(a).toBe(3);
19+
});
20+
21+
it('processes a signal input', () => {
22+
let a = 1;
23+
const summand = signal(1);
24+
const adder = createAdder((value) => (a += value));
25+
26+
adder(summand);
27+
expect(a).toBe(1);
28+
29+
TestBed.flushEffects();
30+
expect(a).toBe(2);
31+
32+
summand.set(2);
33+
summand.set(2);
34+
TestBed.flushEffects();
35+
expect(a).toBe(4);
36+
37+
TestBed.flushEffects();
38+
expect(a).toBe(4);
39+
});
40+
41+
it('throws if is a not created in an injection context', () => {
42+
expect(() => signalMethod<void>(() => void true)).toThrowError();
43+
});
44+
45+
describe('destroying signalMethod', () => {
46+
it('stops signal tracking, when signalMethod gets destroyed', () => {
47+
let a = 1;
48+
const summand = signal(1);
49+
const adder = createAdder((value) => (a += value));
50+
adder(summand);
51+
52+
summand.set(2);
53+
TestBed.flushEffects();
54+
expect(a).toBe(3);
55+
56+
adder.destroy();
57+
58+
summand.set(2);
59+
TestBed.flushEffects();
60+
expect(a).toBe(3);
61+
});
62+
63+
it('can also destroy a signalMethod that processes non-signal inputs', () => {
64+
const adder = createAdder(() => void true);
65+
expect(() => adder(1).destroy()).not.toThrowError();
66+
});
67+
68+
it('stops tracking all signals on signalMethod destroy', () => {
69+
let a = 1;
70+
const summand1 = signal(1);
71+
const summand2 = signal(2);
72+
const adder = createAdder((value) => (a += value));
73+
adder(summand1);
74+
adder(summand2);
75+
76+
summand1.set(2);
77+
summand2.set(3);
78+
TestBed.flushEffects();
79+
expect(a).toBe(6);
80+
81+
adder.destroy();
82+
83+
summand1.set(2);
84+
summand2.set(3);
85+
TestBed.flushEffects();
86+
expect(a).toBe(6);
87+
});
88+
89+
it('does not cause issues if destroyed signalMethodFn contains destroyed effectRefs', () => {
90+
let a = 1;
91+
const summand1 = signal(1);
92+
const summand2 = signal(2);
93+
const adder = createAdder((value) => (a += value));
94+
95+
const childInjector = createEnvironmentInjector(
96+
[],
97+
TestBed.inject(EnvironmentInjector)
98+
);
99+
100+
adder(summand1, { injector: childInjector });
101+
adder(summand2);
102+
103+
TestBed.flushEffects();
104+
expect(a).toBe(4);
105+
childInjector.destroy();
106+
107+
summand1.set(2);
108+
summand2.set(2);
109+
TestBed.flushEffects();
110+
111+
adder.destroy();
112+
expect(a).toBe(4);
113+
});
114+
115+
it('uses the provided injector (source injector) on creation', () => {
116+
let a = 1;
117+
const sourceInjector = createEnvironmentInjector(
118+
[],
119+
TestBed.inject(EnvironmentInjector)
120+
);
121+
const adder = signalMethod((value: number) => (a += value), {
122+
injector: sourceInjector,
123+
});
124+
const value = signal(1);
125+
126+
adder(value);
127+
TestBed.flushEffects();
128+
129+
sourceInjector.destroy();
130+
value.set(2);
131+
TestBed.flushEffects();
132+
133+
expect(a).toBe(2);
134+
});
135+
136+
it('prioritizes the provided caller injector over source injector', () => {
137+
let a = 1;
138+
const callerInjector = createEnvironmentInjector(
139+
[],
140+
TestBed.inject(EnvironmentInjector)
141+
);
142+
const sourceInjector = createEnvironmentInjector(
143+
[],
144+
TestBed.inject(EnvironmentInjector)
145+
);
146+
const adder = signalMethod((value: number) => (a += value), {
147+
injector: sourceInjector,
148+
});
149+
const value = signal(1);
150+
151+
TestBed.runInInjectionContext(() => {
152+
adder(value, { injector: callerInjector });
153+
});
154+
TestBed.flushEffects();
155+
expect(a).toBe(2);
156+
157+
sourceInjector.destroy();
158+
value.set(2);
159+
TestBed.flushEffects();
160+
161+
expect(a).toBe(4);
162+
});
163+
164+
it('prioritizes the provided injector over source and caller injector', () => {
165+
let a = 1;
166+
const callerInjector = createEnvironmentInjector(
167+
[],
168+
TestBed.inject(EnvironmentInjector)
169+
);
170+
const sourceInjector = createEnvironmentInjector(
171+
[],
172+
TestBed.inject(EnvironmentInjector)
173+
);
174+
const providedInjector = createEnvironmentInjector(
175+
[],
176+
TestBed.inject(EnvironmentInjector)
177+
);
178+
179+
const adder = signalMethod((value: number) => (a += value), {
180+
injector: sourceInjector,
181+
});
182+
const value = signal(1);
183+
184+
runInInjectionContext(callerInjector, () =>
185+
adder(value, { injector: providedInjector })
186+
);
187+
TestBed.flushEffects();
188+
expect(a).toBe(2);
189+
190+
sourceInjector.destroy();
191+
value.set(2);
192+
TestBed.flushEffects();
193+
expect(a).toBe(4);
194+
195+
callerInjector.destroy();
196+
value.set(1);
197+
TestBed.flushEffects();
198+
expect(a).toBe(5);
199+
});
200+
});
201+
});

modules/signals/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { deepComputed } from './deep-computed';
22
export { DeepSignal } from './deep-signal';
3+
export { signalMethod } from './signal-method';
34
export { signalState, SignalState } from './signal-state';
45
export { signalStore } from './signal-store';
56
export { signalStoreFeature, type } from './signal-store-feature';
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {
2+
assertInInjectionContext,
3+
effect,
4+
EffectRef,
5+
inject,
6+
Injector,
7+
isSignal,
8+
Signal,
9+
untracked,
10+
} from '@angular/core';
11+
12+
type SignalMethod<Input> = ((
13+
input: Input | Signal<Input>,
14+
config?: { injector?: Injector }
15+
) => EffectRef) &
16+
EffectRef;
17+
18+
export function signalMethod<Input>(
19+
processingFn: (value: Input) => void,
20+
config?: { injector?: Injector }
21+
): SignalMethod<Input> {
22+
if (!config?.injector) {
23+
assertInInjectionContext(signalMethod);
24+
}
25+
26+
const watchers: EffectRef[] = [];
27+
const sourceInjector = config?.injector ?? inject(Injector);
28+
29+
const signalMethodFn = (
30+
input: Input | Signal<Input>,
31+
config?: { injector?: Injector }
32+
): EffectRef => {
33+
if (isSignal(input)) {
34+
const instanceInjector =
35+
config?.injector ?? getCallerInjector() ?? sourceInjector;
36+
37+
const watcher = effect(
38+
(onCleanup) => {
39+
const value = input();
40+
untracked(() => processingFn(value));
41+
onCleanup(() => watchers.splice(watchers.indexOf(watcher), 1));
42+
},
43+
{ injector: instanceInjector }
44+
);
45+
watchers.push(watcher);
46+
47+
return watcher;
48+
} else {
49+
processingFn(input);
50+
return { destroy: () => void true };
51+
}
52+
};
53+
54+
signalMethodFn.destroy = () =>
55+
watchers.forEach((watcher) => watcher.destroy());
56+
57+
return signalMethodFn;
58+
}
59+
60+
function getCallerInjector(): Injector | null {
61+
try {
62+
return inject(Injector);
63+
} catch {
64+
return null;
65+
}
66+
}

0 commit comments

Comments
 (0)