Skip to content

Commit fd565ed

Browse files
feat(signals): add rxjs-interop subpackage (#4061)
1 parent 71a9d7f commit fd565ed

File tree

12 files changed

+259
-20
lines changed

12 files changed

+259
-20
lines changed

modules/signals/index.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1 @@
1-
/**
2-
* DO NOT EDIT
3-
*
4-
* This file is automatically generated at build
5-
*/
6-
7-
export * from './public_api';
1+
export * from './src/index';

modules/signals/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@
2121
},
2222
"homepage": "https://github.com/ngrx/platform#readme",
2323
"peerDependencies": {
24-
"@angular/core": "^16.0.0"
24+
"@angular/core": "^16.0.0",
25+
"rxjs": "^6.5.3 || ^7.4.0"
26+
},
27+
"peerDependenciesMeta": {
28+
"rxjs": {
29+
"optional": true
30+
}
2531
},
2632
"schematics": "./schematics/collection.json",
2733
"sideEffects": false,

modules/signals/project.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@
3535
"options": {
3636
"lintFilePatterns": [
3737
"modules/signals/*/**/*.ts",
38-
"modules/signals/*/**/*.html"
38+
"modules/signals/*/**/*.html",
39+
"modules/signals/rxjs-interop/**/*.ts",
40+
"modules/signals/rxjs-interop/**/*.html"
3941
]
4042
},
4143
"outputs": ["{options.outputFile}"]
File renamed without changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"lib": {
3+
"entryFile": "index.ts"
4+
}
5+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { Injectable, signal } from '@angular/core';
2+
import { TestBed } from '@angular/core/testing';
3+
import { BehaviorSubject, pipe, Subject, tap } from 'rxjs';
4+
import { rxMethod } from '../src';
5+
import { createLocalService, testEffects } from '../../spec/helpers';
6+
7+
describe('rxMethod', () => {
8+
it('runs with a value', () => {
9+
const results: number[] = [];
10+
const method = TestBed.runInInjectionContext(() =>
11+
rxMethod<number>(pipe(tap((value) => results.push(value))))
12+
);
13+
14+
method(1);
15+
expect(results.length).toBe(1);
16+
expect(results[0]).toBe(1);
17+
18+
method(2);
19+
expect(results.length).toBe(2);
20+
expect(results[1]).toBe(2);
21+
});
22+
23+
it('runs with an observable', () => {
24+
const results: string[] = [];
25+
const method = TestBed.runInInjectionContext(() =>
26+
rxMethod<string>(pipe(tap((value) => results.push(value))))
27+
);
28+
const subject$ = new Subject<string>();
29+
30+
method(subject$);
31+
expect(results.length).toBe(0);
32+
33+
subject$.next('ngrx');
34+
expect(results[0]).toBe('ngrx');
35+
36+
subject$.next('rocks');
37+
expect(results[1]).toBe('rocks');
38+
});
39+
40+
it(
41+
'runs with a signal',
42+
testEffects((tick) => {
43+
const results: number[] = [];
44+
const method = rxMethod<number>(
45+
pipe(tap((value) => results.push(value)))
46+
);
47+
const sig = signal(1);
48+
49+
method(sig);
50+
expect(results.length).toBe(0);
51+
52+
tick();
53+
expect(results[0]).toBe(1);
54+
55+
sig.set(10);
56+
expect(results.length).toBe(1);
57+
58+
tick();
59+
expect(results[1]).toBe(10);
60+
})
61+
);
62+
63+
it('runs with void input', () => {
64+
const results: number[] = [];
65+
const subject$ = new Subject<void>();
66+
const method = TestBed.runInInjectionContext(() =>
67+
rxMethod<void>(pipe(tap(() => results.push(1))))
68+
);
69+
70+
method();
71+
expect(results.length).toBe(1);
72+
73+
method(subject$);
74+
expect(results.length).toBe(1);
75+
76+
subject$.next();
77+
expect(results.length).toBe(2);
78+
});
79+
80+
it(
81+
'manually unsubscribes from method instance',
82+
testEffects((tick) => {
83+
const results: number[] = [];
84+
const method = rxMethod<number>(
85+
pipe(tap((value) => results.push(value)))
86+
);
87+
const subject$ = new Subject<number>();
88+
const sig = signal(0);
89+
90+
const sub1 = method(subject$);
91+
const sub2 = method(sig);
92+
expect(results).toEqual([]);
93+
94+
subject$.next(1);
95+
sig.set(1);
96+
tick();
97+
expect(results).toEqual([1, 1]);
98+
99+
sub1.unsubscribe();
100+
subject$.next(2);
101+
sig.set(2);
102+
tick();
103+
expect(results).toEqual([1, 1, 2]);
104+
105+
sub2.unsubscribe();
106+
sig.set(3);
107+
tick();
108+
expect(results).toEqual([1, 1, 2]);
109+
})
110+
);
111+
112+
it('manually unsubscribes from method and all instances', () => {
113+
const results: number[] = [];
114+
let destroyed = false;
115+
const method = TestBed.runInInjectionContext(() =>
116+
rxMethod<number>(
117+
pipe(
118+
tap({
119+
next: (value) => results.push(value),
120+
finalize: () => (destroyed = true),
121+
})
122+
)
123+
)
124+
);
125+
const subject1$ = new BehaviorSubject(1);
126+
const subject2$ = new BehaviorSubject(1);
127+
128+
method(subject1$);
129+
method(subject2$);
130+
method(1);
131+
expect(results).toEqual([1, 1, 1]);
132+
133+
method.unsubscribe();
134+
expect(destroyed).toBe(true);
135+
136+
subject1$.next(2);
137+
subject2$.next(2);
138+
method(2);
139+
expect(results).toEqual([1, 1, 1]);
140+
});
141+
142+
it('unsubscribes from method and all instances on destroy', () => {
143+
const results: number[] = [];
144+
let destroyed = false;
145+
const subject$ = new BehaviorSubject(1);
146+
const sig = signal(1);
147+
148+
@Injectable()
149+
class TestService {
150+
method = rxMethod<number>(
151+
pipe(
152+
tap({
153+
next: (value) => results.push(value),
154+
finalize: () => (destroyed = true),
155+
})
156+
)
157+
);
158+
}
159+
160+
const { service, tick, destroy } = createLocalService(TestService);
161+
162+
service.method(subject$);
163+
service.method(sig);
164+
service.method(1);
165+
tick();
166+
expect(results).toEqual([1, 1, 1]);
167+
168+
destroy();
169+
expect(destroyed).toBe(true);
170+
171+
subject$.next(2);
172+
sig.set(2);
173+
service.method(2);
174+
tick();
175+
expect(results).toEqual([1, 1, 1]);
176+
});
177+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { rxMethod } from './rx-method';
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {
2+
assertInInjectionContext,
3+
DestroyRef,
4+
inject,
5+
Injector,
6+
isSignal,
7+
Signal,
8+
} from '@angular/core';
9+
import { toObservable } from '@angular/core/rxjs-interop';
10+
import { isObservable, Observable, of, Subject, Unsubscribable } from 'rxjs';
11+
12+
type RxMethodInput<Input> = Input | Observable<Input> | Signal<Input>;
13+
14+
type RxMethod<Input> = ((input: RxMethodInput<Input>) => Unsubscribable) &
15+
Unsubscribable;
16+
17+
export function rxMethod<Input>(
18+
generator: (source$: Observable<Input>) => Observable<unknown>,
19+
config?: { injector?: Injector }
20+
): RxMethod<Input> {
21+
if (!config?.injector) {
22+
assertInInjectionContext(rxMethod);
23+
}
24+
25+
const injector = config?.injector ?? inject(Injector);
26+
const destroyRef = injector.get(DestroyRef);
27+
const source$ = new Subject<Input>();
28+
29+
const sourceSub = generator(source$).subscribe();
30+
destroyRef.onDestroy(() => sourceSub.unsubscribe());
31+
32+
const rxMethodFn = (input: RxMethodInput<Input>) => {
33+
let input$: Observable<Input>;
34+
35+
if (isSignal(input)) {
36+
input$ = toObservable(input, { injector });
37+
} else if (isObservable(input)) {
38+
input$ = input;
39+
} else {
40+
input$ = of(input);
41+
}
42+
43+
const instanceSub = input$.subscribe((value) => source$.next(value));
44+
sourceSub.add(instanceSub);
45+
46+
return instanceSub;
47+
};
48+
rxMethodFn.unsubscribe = sourceSub.unsubscribe.bind(sourceSub);
49+
50+
return rxMethodFn;
51+
}

modules/signals/spec/helpers.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,29 @@ export function testEffects(testFn: (tick: () => void) => void): () => void {
1414
};
1515
}
1616

17-
export function createLocalStore<Store extends Type<unknown>>(
18-
storeToken: Store
17+
export function createLocalService<Service extends Type<unknown>>(
18+
serviceToken: Service
1919
): {
20-
store: InstanceType<Store>;
20+
service: InstanceType<Service>;
21+
tick: () => void;
2122
destroy: () => void;
2223
} {
2324
@Component({
2425
standalone: true,
2526
template: '',
26-
providers: [storeToken],
27+
providers: [serviceToken],
2728
})
2829
class TestComponent {
29-
store = inject(storeToken);
30+
service = inject(serviceToken);
3031
}
3132

3233
const fixture = TestBed.configureTestingModule({
3334
imports: [TestComponent],
3435
}).createComponent(TestComponent);
3536

3637
return {
37-
store: fixture.componentInstance.store,
38+
service: fixture.componentInstance.service,
39+
tick: () => fixture.detectChanges(),
3840
destroy: () => fixture.destroy(),
3941
};
4042
}

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
withState,
99
} from '../src';
1010
import { STATE_SIGNAL } from '../src/signal-state';
11-
import { createLocalStore } from './helpers';
11+
import { createLocalService } from './helpers';
1212

1313
describe('signalStore', () => {
1414
describe('creation', () => {
@@ -190,7 +190,7 @@ describe('signalStore', () => {
190190
})
191191
);
192192

193-
createLocalStore(Store).destroy();
193+
createLocalService(Store).destroy();
194194

195195
expect(message).toBe('onDestroy');
196196
});
@@ -233,7 +233,7 @@ describe('signalStore', () => {
233233
})
234234
);
235235

236-
createLocalStore(Store).destroy();
236+
createLocalService(Store).destroy();
237237

238238
expect(message).toBe('onDestroy');
239239
});
@@ -256,7 +256,7 @@ describe('signalStore', () => {
256256
},
257257
})
258258
);
259-
const { destroy } = createLocalStore(Store);
259+
const { destroy } = createLocalService(Store);
260260

261261
expect(messages).toEqual(['onInit']);
262262

0 commit comments

Comments
 (0)