Skip to content

Commit 7573168

Browse files
committed
feat: add reactive helper on
An alternative to `explicitEffect` for explicit dependencies in an effect
1 parent 099eea2 commit 7573168

File tree

7 files changed

+346
-0
lines changed

7 files changed

+346
-0
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# ngxtension/reactive-on
2+
3+
Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/reactive-on`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"lib": {
3+
"entryFile": "src/index.ts"
4+
}
5+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "ngxtension/reactive-on",
3+
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
4+
"projectType": "library",
5+
"sourceRoot": "libs/ngxtension/reactive-on/src",
6+
"targets": {
7+
"test": {
8+
"executor": "@nx/jest:jest",
9+
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
10+
"options": {
11+
"jestConfig": "libs/ngxtension/jest.config.ts",
12+
"testPathPattern": ["reactive-on"]
13+
}
14+
},
15+
"lint": {
16+
"executor": "@nx/eslint:lint",
17+
"outputs": ["{options.outputFile}"]
18+
}
19+
}
20+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './on';
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import {
2+
ApplicationRef,
3+
Injector,
4+
computed,
5+
effect,
6+
signal,
7+
} from '@angular/core';
8+
import { TestBed } from '@angular/core/testing';
9+
import { on } from './on';
10+
11+
describe(on.name, () => {
12+
let injector: Injector;
13+
let appRef: ApplicationRef;
14+
15+
beforeEach(() => {
16+
injector = TestBed.inject(Injector);
17+
appRef = TestBed.inject(ApplicationRef);
18+
});
19+
20+
it('should run effect when dependency changes (single)', () => {
21+
const count = signal(0);
22+
const log: number[] = [];
23+
24+
effect(
25+
on(count, (c) => {
26+
log.push(c);
27+
}),
28+
{ injector },
29+
);
30+
31+
appRef.tick();
32+
expect(log).toEqual([0]);
33+
34+
count.set(1);
35+
appRef.tick();
36+
expect(log).toEqual([0, 1]);
37+
});
38+
39+
it('should not run effect when untracked dependency changes', () => {
40+
const count = signal(0);
41+
const other = signal(10);
42+
const log: number[] = [];
43+
44+
effect(
45+
on(count, (c) => {
46+
// accessing 'other' which is not in deps
47+
log.push(c + other());
48+
}),
49+
{ injector },
50+
);
51+
52+
appRef.tick();
53+
expect(log).toEqual([10]);
54+
55+
other.set(20);
56+
appRef.tick();
57+
// Should NOT run again because 'other' is not in deps list passed to on()
58+
expect(log).toEqual([10]);
59+
60+
count.set(1);
61+
appRef.tick();
62+
// Should run now, seeing the new value of 'other' ONLY because 'count' changed
63+
expect(log).toEqual([10, 21]);
64+
});
65+
66+
it('should run effect with multiple dependencies (array)', () => {
67+
const a = signal(1);
68+
const b = signal(2);
69+
const log: number[] = [];
70+
71+
effect(
72+
on([a, b], ([valA, valB]) => {
73+
log.push(valA + valB);
74+
}),
75+
{ injector },
76+
);
77+
78+
appRef.tick();
79+
expect(log).toEqual([3]);
80+
81+
a.set(2);
82+
appRef.tick();
83+
expect(log).toEqual([3, 4]);
84+
85+
b.set(3);
86+
appRef.tick();
87+
expect(log).toEqual([3, 4, 5]);
88+
});
89+
90+
it('should pass object with signals as input', () => {
91+
const a = signal(1);
92+
const b = signal(2);
93+
const log: number[] = [];
94+
95+
effect(
96+
on({ a, b }, ({ a: valA, b: valB }) => {
97+
log.push(valA * valB);
98+
}),
99+
{ injector },
100+
);
101+
102+
appRef.tick();
103+
expect(log).toEqual([2]);
104+
105+
a.set(3);
106+
appRef.tick();
107+
expect(log).toEqual([2, 6]);
108+
109+
b.set(4);
110+
appRef.tick();
111+
expect(log).toEqual([2, 6, 12]);
112+
});
113+
114+
it('should pass previous input correctly', () => {
115+
const count = signal(0);
116+
const log: string[] = [];
117+
118+
effect(
119+
on(count, (input, prevInput) => {
120+
const result = `cur: ${input}, prevIn: ${prevInput}`;
121+
log.push(result);
122+
return undefined;
123+
}),
124+
{ injector },
125+
);
126+
127+
appRef.tick();
128+
expect(log).toEqual(['cur: 0, prevIn: undefined']);
129+
130+
count.set(1);
131+
appRef.tick();
132+
expect(log).toEqual(['cur: 0, prevIn: undefined', 'cur: 1, prevIn: 0']);
133+
});
134+
135+
it('should support cleanup function', () => {
136+
const count = signal(0);
137+
const log: string[] = [];
138+
139+
const effectRef = effect(
140+
on(count, (c, _, __, onCleanup) => {
141+
log.push(`run: ${c}`);
142+
onCleanup(() => {
143+
log.push(`cleanup: ${c}`);
144+
});
145+
}),
146+
{ injector },
147+
);
148+
149+
appRef.tick();
150+
expect(log).toEqual(['run: 0']);
151+
152+
count.set(1);
153+
appRef.tick();
154+
expect(log).toEqual(['run: 0', 'cleanup: 0', 'run: 1']);
155+
156+
effectRef.destroy();
157+
expect(log).toEqual(['run: 0', 'cleanup: 0', 'run: 1', 'cleanup: 1']);
158+
});
159+
160+
it('should pass previous value correctly', () => {
161+
const count = signal(1);
162+
const log: number[] = [];
163+
164+
effect(
165+
on(count, (c, _, prevValue) => {
166+
const result = c + ((prevValue as number) || 0);
167+
log.push(result);
168+
return result;
169+
}),
170+
{ injector },
171+
);
172+
173+
appRef.tick();
174+
expect(log).toEqual([1]); // 1 + 0 (undefined prevValue treated as 0)
175+
176+
count.set(2);
177+
appRef.tick();
178+
expect(log).toEqual([1, 3]); // 2 + 1 (prevValue was 1)
179+
180+
count.set(3);
181+
appRef.tick();
182+
expect(log).toEqual([1, 3, 6]); // 3 + 3 (prevValue was 3)
183+
});
184+
185+
it('should work with computed signals', () => {
186+
const count = signal(1);
187+
const doubleCount = computed(() => count() * 2);
188+
const log: number[] = [];
189+
190+
effect(
191+
on(doubleCount, (val) => {
192+
log.push(val);
193+
}),
194+
{ injector },
195+
);
196+
197+
appRef.tick();
198+
expect(log).toEqual([2]);
199+
200+
count.set(2);
201+
appRef.tick();
202+
expect(log).toEqual([2, 4]);
203+
});
204+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { EffectCleanupRegisterFn, Signal, untracked } from '@angular/core';
2+
3+
export type Accessor<T> = Signal<T> | (() => T);
4+
5+
/**
6+
* Makes dependencies of a computation explicit
7+
8+
* @param deps list of reactive dependencies or a single reactive dependency
9+
* @param fn computation on input; the current previous content(s) of input and the previous value are given as arguments and it returns a new value
10+
* @returns an effect function that is passed into `effect`. For example:
11+
*
12+
* ```typescript
13+
* effect(on(a, (v) => console.log(v, b())));
14+
*
15+
* // is equivalent to:
16+
* effect(() => {
17+
* const v = a();
18+
* untracked(() => console.log(v, b()));
19+
* });
20+
* ```
21+
*/
22+
export function on<
23+
const Deps extends readonly Accessor<any>[],
24+
U,
25+
V = U | undefined,
26+
>(
27+
deps: readonly [...Deps],
28+
fn: (
29+
input: {
30+
-readonly [K in keyof Deps]: Deps[K] extends Accessor<infer T>
31+
? T
32+
: never;
33+
},
34+
prevInput:
35+
| {
36+
-readonly [K in keyof Deps]: Deps[K] extends Accessor<infer T>
37+
? T
38+
: never;
39+
}
40+
| undefined,
41+
prevValue: V | undefined,
42+
cleanupFn: EffectCleanupRegisterFn,
43+
) => U,
44+
): (onCleanup: EffectCleanupRegisterFn) => void;
45+
46+
export function on<
47+
const Deps extends Record<string, Accessor<any>>,
48+
U,
49+
V = U | undefined,
50+
>(
51+
deps: Deps,
52+
fn: (
53+
input: { [K in keyof Deps]: Deps[K] extends Accessor<infer T> ? T : never },
54+
prevInput:
55+
| { [K in keyof Deps]: Deps[K] extends Accessor<infer T> ? T : never }
56+
| undefined,
57+
prevValue: V | undefined,
58+
cleanupFn: EffectCleanupRegisterFn,
59+
) => U,
60+
): (onCleanup: EffectCleanupRegisterFn) => void;
61+
62+
export function on<S, U, V = U | undefined>(
63+
deps: Accessor<S>,
64+
fn: (
65+
input: S,
66+
prevInput: S | undefined,
67+
prevValue: V | undefined,
68+
cleanupFn: EffectCleanupRegisterFn,
69+
) => U,
70+
): (onCleanup: EffectCleanupRegisterFn) => void;
71+
72+
export function on(
73+
deps:
74+
| Accessor<any>
75+
| readonly Accessor<any>[]
76+
| Record<string, Accessor<any>>,
77+
fn: (
78+
input: any,
79+
prevInput: any,
80+
prevValue: any,
81+
cleanupFn: EffectCleanupRegisterFn,
82+
) => any,
83+
): (onCleanup: EffectCleanupRegisterFn) => void {
84+
const isArray = Array.isArray(deps);
85+
const isAccessor = typeof deps === 'function';
86+
let prevInput: any;
87+
let prevValue: any;
88+
89+
return (onCleanup: EffectCleanupRegisterFn) => {
90+
let input: any;
91+
92+
if (isArray) {
93+
input = (deps as readonly Accessor<any>[]).map((d) => d());
94+
} else if (isAccessor) {
95+
input = (deps as Accessor<any>)();
96+
} else {
97+
// Object
98+
input = Object.keys(deps).reduce(
99+
(acc, key) => {
100+
acc[key] = (deps as Record<string, Accessor<any>>)[key]();
101+
return acc;
102+
},
103+
{} as Record<string, any>,
104+
);
105+
}
106+
107+
untracked(() => {
108+
prevValue = fn(input, prevInput, prevValue, onCleanup);
109+
prevInput = input;
110+
});
111+
};
112+
}

tsconfig.base.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@
135135
"ngxtension/not-pattern": ["libs/ngxtension/not-pattern/src/index.ts"],
136136
"ngxtension/on-event": ["libs/ngxtension/on-event/src/index.ts"],
137137
"ngxtension/poll": ["libs/ngxtension/poll/src/index.ts"],
138+
"ngxtension/reactive-on": ["libs/ngxtension/reactive-on/src/index.ts"],
138139
"ngxtension/repeat": ["libs/ngxtension/repeat/src/index.ts"],
139140
"ngxtension/repeat-pipe": ["libs/ngxtension/repeat-pipe/src/index.ts"],
140141
"ngxtension/resize": ["libs/ngxtension/resize/src/index.ts"],

0 commit comments

Comments
 (0)