Skip to content

Commit b3fcf82

Browse files
fix(signals): do not listen to observable changes on instance injector destroy
1 parent 2deed39 commit b3fcf82

File tree

2 files changed

+133
-73
lines changed

2 files changed

+133
-73
lines changed

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

Lines changed: 110 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ import {
99
signal,
1010
} from '@angular/core';
1111
import { TestBed } from '@angular/core/testing';
12-
import { BehaviorSubject, finalize, pipe, Subject, tap } from 'rxjs';
13-
import { rxMethod } from '../src';
14-
import { createLocalService } from '../../spec/helpers';
15-
import { provideRouter } from '@angular/router';
1612
import { provideLocationMocks } from '@angular/common/testing';
13+
import { provideRouter } from '@angular/router';
1714
import { RouterTestingHarness } from '@angular/router/testing';
15+
import { BehaviorSubject, pipe, Subject, tap } from 'rxjs';
16+
import { rxMethod } from '../src';
17+
import { createLocalService } from '../../spec/helpers';
1818

1919
describe('rxMethod', () => {
2020
it('runs with a value', () => {
@@ -239,43 +239,44 @@ describe('rxMethod', () => {
239239
expect(counter()).toBe(4);
240240
});
241241

242-
it('completes on manual destroy with Signals', () => {
243-
TestBed.runInInjectionContext(() => {
244-
let completed = false;
245-
const counter = signal(1);
246-
const fn = rxMethod<number>(finalize(() => (completed = true)));
247-
TestBed.flushEffects();
248-
fn(counter);
249-
fn.unsubscribe();
250-
expect(completed).toBe(true);
251-
});
252-
});
253-
254242
/**
255-
* This test suite verifies that the internal effect of
256-
* an RxMethod instance is executed with the correct injector
257-
* and is destroyed at the specified time.
258-
*
259-
* Since we cannot directly observe the destruction of the effect from the outside,
260-
* we test it indirectly.
243+
* This test suite verifies that a signal or observable passed to a reactive
244+
* method that is initialized at the ancestor injector level is tracked within
245+
* the correct injection context and untracked at the specified time.
261246
*
262-
* Components use the globalSignal from GlobalService and pass it
263-
* to the `log` method. If the component is destroyed but a subsequent
264-
* Signal change still increases the `globalSignalChangerCounter`,
265-
* it indicates that the internal effect is still active.
247+
* Components use `globalSignal` or `globalObservable` from `GlobalService`
248+
* and pass it to the reactive method. If the component is destroyed but
249+
* signal or observable change still increases the corresponding counter,
250+
* the internal effect or subscription is still active.
266251
*/
267-
describe('Internal effect for Signal tracking', () => {
252+
describe('with instance injector', () => {
268253
@Injectable({ providedIn: 'root' })
269254
class GlobalService {
270-
globalSignal = signal(1);
255+
readonly globalSignal = signal(1);
256+
readonly globalObservable = new BehaviorSubject(1);
257+
271258
globalSignalChangeCounter = 0;
259+
globalObservableChangeCounter = 0;
260+
261+
readonly signalMethod = rxMethod<number>(
262+
tap(() => this.globalSignalChangeCounter++)
263+
);
264+
readonly observableMethod = rxMethod<number>(
265+
tap(() => this.globalObservableChangeCounter++)
266+
);
272267

273-
log = rxMethod<number>(pipe(tap(() => this.globalSignalChangeCounter++)));
268+
incrementSignal(): void {
269+
this.globalSignal.update((value) => value + 1);
270+
}
271+
272+
incrementObservable(): void {
273+
this.globalObservable.next(this.globalObservable.value + 1);
274+
}
274275
}
275276

276277
@Component({
277-
selector: `app-storeless`,
278-
template: ``,
278+
selector: 'app-without-store',
279+
template: '',
279280
standalone: true,
280281
})
281282
class WithoutStoreComponent {}
@@ -297,111 +298,161 @@ describe('rxMethod', () => {
297298
return TestBed.inject(GlobalService);
298299
}
299300

300-
it('it tracks the Signal when component is active', async () => {
301+
it('tracks a signal until the component is destroyed', async () => {
301302
@Component({
302303
selector: 'app-with-store',
303-
template: ``,
304+
template: '',
304305
standalone: true,
305306
})
306307
class WithStoreComponent {
307308
store = inject(GlobalService);
308309

309310
constructor() {
310-
this.store.log(this.store.globalSignal);
311+
this.store.signalMethod(this.store.globalSignal);
311312
}
312313
}
313314

314315
const globalService = setup(WithStoreComponent);
316+
const harness = await RouterTestingHarness.create('/with-store');
315317

316-
await RouterTestingHarness.create('/with-store');
317318
expect(globalService.globalSignalChangeCounter).toBe(1);
318319

319-
globalService.globalSignal.update((value) => value + 1);
320+
globalService.incrementSignal();
320321
TestBed.flushEffects();
321322
expect(globalService.globalSignalChangeCounter).toBe(2);
322323

323-
globalService.globalSignal.update((value) => value + 1);
324+
globalService.incrementSignal();
324325
TestBed.flushEffects();
325326
expect(globalService.globalSignalChangeCounter).toBe(3);
327+
328+
await harness.navigateByUrl('/without-store');
329+
globalService.incrementSignal();
330+
TestBed.flushEffects();
331+
332+
expect(globalService.globalSignalChangeCounter).toBe(3);
326333
});
327334

328-
it('destroys with component injector when rxMethod is in root and RxMethod in component', async () => {
335+
it('tracks an observable until the component is destroyed', async () => {
329336
@Component({
330337
selector: 'app-with-store',
331-
template: ``,
338+
template: '',
332339
standalone: true,
333340
})
334341
class WithStoreComponent {
335342
store = inject(GlobalService);
336343

337344
constructor() {
338-
this.store.log(this.store.globalSignal);
345+
this.store.observableMethod(this.store.globalObservable);
339346
}
340347
}
341348

342349
const globalService = setup(WithStoreComponent);
343-
344350
const harness = await RouterTestingHarness.create('/with-store');
345351

346-
// effect is destroyed → Signal is not tracked anymore
352+
expect(globalService.globalObservableChangeCounter).toBe(1);
353+
354+
globalService.incrementObservable();
355+
expect(globalService.globalObservableChangeCounter).toBe(2);
356+
357+
globalService.incrementObservable();
358+
expect(globalService.globalObservableChangeCounter).toBe(3);
359+
347360
await harness.navigateByUrl('/without-store');
348-
globalService.globalSignal.update((value) => value + 1);
349-
TestBed.flushEffects();
361+
globalService.incrementObservable();
350362

351-
expect(globalService.globalSignalChangeCounter).toBe(1);
363+
expect(globalService.globalObservableChangeCounter).toBe(3);
352364
});
353365

354-
it("falls back to rxMethod's injector when RxMethod's call is outside of injection context", async () => {
366+
it('tracks a signal until the provided injector is destroyed', async () => {
355367
@Component({
356-
selector: `app-store`,
357-
template: ``,
368+
selector: 'app-with-store',
369+
template: '',
358370
standalone: true,
359371
})
360372
class WithStoreComponent implements OnInit {
361373
store = inject(GlobalService);
374+
injector = inject(Injector);
362375

363376
ngOnInit() {
364-
this.store.log(this.store.globalSignal);
377+
this.store.signalMethod(this.store.globalSignal, {
378+
injector: this.injector,
379+
});
365380
}
366381
}
367382

368383
const globalService = setup(WithStoreComponent);
369-
370384
const harness = await RouterTestingHarness.create('/with-store');
371385

372-
// Signal is still tracked because RxMethod injector is used
386+
globalService.incrementSignal();
387+
TestBed.flushEffects();
388+
389+
expect(globalService.globalSignalChangeCounter).toBe(2);
390+
373391
await harness.navigateByUrl('/without-store');
374-
globalService.globalSignal.update((value) => value + 1);
392+
globalService.incrementSignal();
375393
TestBed.flushEffects();
376394

377395
expect(globalService.globalSignalChangeCounter).toBe(2);
378396
});
379397

380-
it('provides the injector for RxMethod on call', async () => {
398+
it('tracks an observable until the provided injector is destroyed', async () => {
381399
@Component({
382-
selector: `app-store`,
383-
template: ``,
400+
selector: 'app-with-store',
401+
template: '',
384402
standalone: true,
385403
})
386404
class WithStoreComponent implements OnInit {
387405
store = inject(GlobalService);
388406
injector = inject(Injector);
389407

390408
ngOnInit() {
391-
this.store.log(this.store.globalSignal, { injector: this.injector });
409+
this.store.observableMethod(this.store.globalObservable, {
410+
injector: this.injector,
411+
});
392412
}
393413
}
394414

395415
const globalService = setup(WithStoreComponent);
416+
const harness = await RouterTestingHarness.create('/with-store');
417+
418+
globalService.incrementObservable();
419+
420+
expect(globalService.globalObservableChangeCounter).toBe(2);
421+
422+
await harness.navigateByUrl('/without-store');
423+
globalService.incrementObservable();
424+
425+
expect(globalService.globalObservableChangeCounter).toBe(2);
426+
});
396427

428+
it('falls back to source injector when reactive method is called is outside of injection context', async () => {
429+
@Component({
430+
selector: 'app-with-store',
431+
template: '',
432+
standalone: true,
433+
})
434+
class WithStoreComponent implements OnInit {
435+
store = inject(GlobalService);
436+
437+
ngOnInit() {
438+
this.store.signalMethod(this.store.globalSignal);
439+
this.store.observableMethod(this.store.globalObservable);
440+
}
441+
}
442+
443+
const globalService = setup(WithStoreComponent);
397444
const harness = await RouterTestingHarness.create('/with-store');
398445

399-
// effect is destroyed → Signal is not tracked anymore
446+
expect(globalService.globalSignalChangeCounter).toBe(1);
447+
expect(globalService.globalObservableChangeCounter).toBe(1);
448+
400449
await harness.navigateByUrl('/without-store');
401-
globalService.globalSignal.update((value) => value + 1);
450+
globalService.incrementSignal();
402451
TestBed.flushEffects();
452+
globalService.incrementObservable();
403453

404-
expect(globalService.globalSignalChangeCounter).toBe(1);
454+
expect(globalService.globalSignalChangeCounter).toBe(2);
455+
expect(globalService.globalObservableChangeCounter).toBe(2);
405456
});
406457
});
407458
});

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

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,24 @@ export function rxMethod<Input>(
2424
assertInInjectionContext(rxMethod);
2525
}
2626

27-
const injector = config?.injector ?? inject(Injector);
28-
const destroyRef = injector.get(DestroyRef);
27+
const sourceInjector = config?.injector ?? inject(Injector);
2928
const source$ = new Subject<Input>();
30-
3129
const sourceSub = generator(source$).subscribe();
32-
destroyRef.onDestroy(() => sourceSub.unsubscribe());
30+
sourceInjector.get(DestroyRef).onDestroy(() => sourceSub.unsubscribe());
3331

3432
const rxMethodFn = (
3533
input: Input | Signal<Input> | Observable<Input>,
3634
config?: { injector?: Injector }
3735
) => {
38-
if (isSignal(input)) {
39-
const instanceInjector = config?.injector ?? getCallerInjectorIfAvailable() ?? injector;
36+
if (isStatic(input)) {
37+
source$.next(input);
38+
return { unsubscribe: noop };
39+
}
4040

41+
const instanceInjector =
42+
config?.injector ?? getCallerInjector() ?? sourceInjector;
43+
44+
if (isSignal(input)) {
4145
const watcher = effect(
4246
() => {
4347
const value = input();
@@ -51,25 +55,30 @@ export function rxMethod<Input>(
5155
return instanceSub;
5256
}
5357

54-
if (isObservable(input)) {
55-
const instanceSub = input.subscribe((value) => source$.next(value));
56-
sourceSub.add(instanceSub);
58+
const instanceSub = input.subscribe((value) => source$.next(value));
59+
sourceSub.add(instanceSub);
5760

58-
return instanceSub;
61+
if (instanceInjector !== sourceInjector) {
62+
instanceInjector
63+
.get(DestroyRef)
64+
.onDestroy(() => instanceSub.unsubscribe());
5965
}
6066

61-
source$.next(input);
62-
return { unsubscribe: noop };
67+
return instanceSub;
6368
};
6469
rxMethodFn.unsubscribe = sourceSub.unsubscribe.bind(sourceSub);
6570

6671
return rxMethodFn;
6772
}
6873

69-
function getCallerInjectorIfAvailable(): Injector | null {
74+
function isStatic<T>(value: T | Signal<T> | Observable<T>): value is T {
75+
return !isSignal(value) && !isObservable(value);
76+
}
77+
78+
function getCallerInjector(): Injector | null {
7079
try {
7180
return inject(Injector);
72-
} catch (e) {
81+
} catch {
7382
return null;
7483
}
7584
}

0 commit comments

Comments
 (0)