diff --git a/modules/signals/index.ts b/modules/signals/index.ts index 637e1cf2bf..cba1843545 100644 --- a/modules/signals/index.ts +++ b/modules/signals/index.ts @@ -1,7 +1 @@ -/** - * DO NOT EDIT - * - * This file is automatically generated at build - */ - -export * from './public_api'; +export * from './src/index'; diff --git a/modules/signals/package.json b/modules/signals/package.json index 070c2a4cd3..55dbcb4443 100644 --- a/modules/signals/package.json +++ b/modules/signals/package.json @@ -21,7 +21,13 @@ }, "homepage": "https://github.com/ngrx/platform#readme", "peerDependencies": { - "@angular/core": "^16.0.0" + "@angular/core": "^16.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } }, "schematics": "./schematics/collection.json", "sideEffects": false, diff --git a/modules/signals/project.json b/modules/signals/project.json index fc096253e6..2a0f679458 100644 --- a/modules/signals/project.json +++ b/modules/signals/project.json @@ -35,7 +35,9 @@ "options": { "lintFilePatterns": [ "modules/signals/*/**/*.ts", - "modules/signals/*/**/*.html" + "modules/signals/*/**/*.html", + "modules/signals/rxjs-interop/**/*.ts", + "modules/signals/rxjs-interop/**/*.html" ] }, "outputs": ["{options.outputFile}"] diff --git a/modules/signals/public_api.ts b/modules/signals/rxjs-interop/index.ts similarity index 100% rename from modules/signals/public_api.ts rename to modules/signals/rxjs-interop/index.ts diff --git a/modules/signals/rxjs-interop/ng-package.json b/modules/signals/rxjs-interop/ng-package.json new file mode 100644 index 0000000000..1dc0b0bd36 --- /dev/null +++ b/modules/signals/rxjs-interop/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "index.ts" + } +} diff --git a/modules/signals/rxjs-interop/spec/rx-method.spec.ts b/modules/signals/rxjs-interop/spec/rx-method.spec.ts new file mode 100644 index 0000000000..a3b6720f10 --- /dev/null +++ b/modules/signals/rxjs-interop/spec/rx-method.spec.ts @@ -0,0 +1,177 @@ +import { Injectable, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { BehaviorSubject, pipe, Subject, tap } from 'rxjs'; +import { rxMethod } from '../src'; +import { createLocalService, testEffects } from '../../spec/helpers'; + +describe('rxMethod', () => { + it('runs with a value', () => { + const results: number[] = []; + const method = TestBed.runInInjectionContext(() => + rxMethod(pipe(tap((value) => results.push(value)))) + ); + + method(1); + expect(results.length).toBe(1); + expect(results[0]).toBe(1); + + method(2); + expect(results.length).toBe(2); + expect(results[1]).toBe(2); + }); + + it('runs with an observable', () => { + const results: string[] = []; + const method = TestBed.runInInjectionContext(() => + rxMethod(pipe(tap((value) => results.push(value)))) + ); + const subject$ = new Subject(); + + method(subject$); + expect(results.length).toBe(0); + + subject$.next('ngrx'); + expect(results[0]).toBe('ngrx'); + + subject$.next('rocks'); + expect(results[1]).toBe('rocks'); + }); + + it( + 'runs with a signal', + testEffects((tick) => { + const results: number[] = []; + const method = rxMethod( + pipe(tap((value) => results.push(value))) + ); + const sig = signal(1); + + method(sig); + expect(results.length).toBe(0); + + tick(); + expect(results[0]).toBe(1); + + sig.set(10); + expect(results.length).toBe(1); + + tick(); + expect(results[1]).toBe(10); + }) + ); + + it('runs with void input', () => { + const results: number[] = []; + const subject$ = new Subject(); + const method = TestBed.runInInjectionContext(() => + rxMethod(pipe(tap(() => results.push(1)))) + ); + + method(); + expect(results.length).toBe(1); + + method(subject$); + expect(results.length).toBe(1); + + subject$.next(); + expect(results.length).toBe(2); + }); + + it( + 'manually unsubscribes from method instance', + testEffects((tick) => { + const results: number[] = []; + const method = rxMethod( + pipe(tap((value) => results.push(value))) + ); + const subject$ = new Subject(); + const sig = signal(0); + + const sub1 = method(subject$); + const sub2 = method(sig); + expect(results).toEqual([]); + + subject$.next(1); + sig.set(1); + tick(); + expect(results).toEqual([1, 1]); + + sub1.unsubscribe(); + subject$.next(2); + sig.set(2); + tick(); + expect(results).toEqual([1, 1, 2]); + + sub2.unsubscribe(); + sig.set(3); + tick(); + expect(results).toEqual([1, 1, 2]); + }) + ); + + it('manually unsubscribes from method and all instances', () => { + const results: number[] = []; + let destroyed = false; + const method = TestBed.runInInjectionContext(() => + rxMethod( + pipe( + tap({ + next: (value) => results.push(value), + finalize: () => (destroyed = true), + }) + ) + ) + ); + const subject1$ = new BehaviorSubject(1); + const subject2$ = new BehaviorSubject(1); + + method(subject1$); + method(subject2$); + method(1); + expect(results).toEqual([1, 1, 1]); + + method.unsubscribe(); + expect(destroyed).toBe(true); + + subject1$.next(2); + subject2$.next(2); + method(2); + expect(results).toEqual([1, 1, 1]); + }); + + it('unsubscribes from method and all instances on destroy', () => { + const results: number[] = []; + let destroyed = false; + const subject$ = new BehaviorSubject(1); + const sig = signal(1); + + @Injectable() + class TestService { + method = rxMethod( + pipe( + tap({ + next: (value) => results.push(value), + finalize: () => (destroyed = true), + }) + ) + ); + } + + const { service, tick, destroy } = createLocalService(TestService); + + service.method(subject$); + service.method(sig); + service.method(1); + tick(); + expect(results).toEqual([1, 1, 1]); + + destroy(); + expect(destroyed).toBe(true); + + subject$.next(2); + sig.set(2); + service.method(2); + tick(); + expect(results).toEqual([1, 1, 1]); + }); +}); diff --git a/modules/signals/rxjs-interop/src/index.ts b/modules/signals/rxjs-interop/src/index.ts new file mode 100644 index 0000000000..4feadb416a --- /dev/null +++ b/modules/signals/rxjs-interop/src/index.ts @@ -0,0 +1 @@ +export { rxMethod } from './rx-method'; diff --git a/modules/signals/rxjs-interop/src/rx-method.ts b/modules/signals/rxjs-interop/src/rx-method.ts new file mode 100644 index 0000000000..54858e99f8 --- /dev/null +++ b/modules/signals/rxjs-interop/src/rx-method.ts @@ -0,0 +1,51 @@ +import { + assertInInjectionContext, + DestroyRef, + inject, + Injector, + isSignal, + Signal, +} from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { isObservable, Observable, of, Subject, Unsubscribable } from 'rxjs'; + +type RxMethodInput = Input | Observable | Signal; + +type RxMethod = ((input: RxMethodInput) => Unsubscribable) & + Unsubscribable; + +export function rxMethod( + generator: (source$: Observable) => Observable, + config?: { injector?: Injector } +): RxMethod { + if (!config?.injector) { + assertInInjectionContext(rxMethod); + } + + const injector = config?.injector ?? inject(Injector); + const destroyRef = injector.get(DestroyRef); + const source$ = new Subject(); + + const sourceSub = generator(source$).subscribe(); + destroyRef.onDestroy(() => sourceSub.unsubscribe()); + + const rxMethodFn = (input: RxMethodInput) => { + let input$: Observable; + + if (isSignal(input)) { + input$ = toObservable(input, { injector }); + } else if (isObservable(input)) { + input$ = input; + } else { + input$ = of(input); + } + + const instanceSub = input$.subscribe((value) => source$.next(value)); + sourceSub.add(instanceSub); + + return instanceSub; + }; + rxMethodFn.unsubscribe = sourceSub.unsubscribe.bind(sourceSub); + + return rxMethodFn; +} diff --git a/modules/signals/spec/helpers.ts b/modules/signals/spec/helpers.ts index 5498ff710b..f5dddc2edf 100644 --- a/modules/signals/spec/helpers.ts +++ b/modules/signals/spec/helpers.ts @@ -14,19 +14,20 @@ export function testEffects(testFn: (tick: () => void) => void): () => void { }; } -export function createLocalStore>( - storeToken: Store +export function createLocalService>( + serviceToken: Service ): { - store: InstanceType; + service: InstanceType; + tick: () => void; destroy: () => void; } { @Component({ standalone: true, template: '', - providers: [storeToken], + providers: [serviceToken], }) class TestComponent { - store = inject(storeToken); + service = inject(serviceToken); } const fixture = TestBed.configureTestingModule({ @@ -34,7 +35,8 @@ export function createLocalStore>( }).createComponent(TestComponent); return { - store: fixture.componentInstance.store, + service: fixture.componentInstance.service, + tick: () => fixture.detectChanges(), destroy: () => fixture.destroy(), }; } diff --git a/modules/signals/spec/signal-store.spec.ts b/modules/signals/spec/signal-store.spec.ts index 39ced94ed7..5d6ecf08bf 100644 --- a/modules/signals/spec/signal-store.spec.ts +++ b/modules/signals/spec/signal-store.spec.ts @@ -8,7 +8,7 @@ import { withState, } from '../src'; import { STATE_SIGNAL } from '../src/signal-state'; -import { createLocalStore } from './helpers'; +import { createLocalService } from './helpers'; describe('signalStore', () => { describe('creation', () => { @@ -190,7 +190,7 @@ describe('signalStore', () => { }) ); - createLocalStore(Store).destroy(); + createLocalService(Store).destroy(); expect(message).toBe('onDestroy'); }); @@ -233,7 +233,7 @@ describe('signalStore', () => { }) ); - createLocalStore(Store).destroy(); + createLocalService(Store).destroy(); expect(message).toBe('onDestroy'); }); @@ -256,7 +256,7 @@ describe('signalStore', () => { }, }) ); - const { destroy } = createLocalStore(Store); + const { destroy } = createLocalService(Store); expect(messages).toEqual(['onInit']); diff --git a/modules/signals/tsconfig.build.json b/modules/signals/tsconfig.build.json index 1cc454eaf9..2bd15d09c9 100644 --- a/modules/signals/tsconfig.build.json +++ b/modules/signals/tsconfig.build.json @@ -22,7 +22,7 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true }, - "files": ["public_api.ts"], + "files": ["index.ts"], "include": ["**/*.ts"], "exclude": ["**/*.spec.ts"], "angularCompilerOptions": { diff --git a/tsconfig.json b/tsconfig.json index 32d0b6f1e3..70a06565bd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -43,6 +43,7 @@ "./modules/schematics/schematics-core" ], "@ngrx/signals": ["./modules/signals"], + "@ngrx/signals/rxjs-interop": ["./modules/signals/rxjs-interop"], "@ngrx/signals/schematics-core": ["./modules/signals/schematics-core"], "@ngrx/store": ["./modules/store"], "@ngrx/store-devtools": ["./modules/store-devtools"],