diff --git a/packages/core/rxjs-interop/src/take_until_destroyed.ts b/packages/core/rxjs-interop/src/take_until_destroyed.ts index db0e45d8fea..a72f2593cc4 100644 --- a/packages/core/rxjs-interop/src/take_until_destroyed.ts +++ b/packages/core/rxjs-interop/src/take_until_destroyed.ts @@ -26,8 +26,12 @@ export function takeUntilDestroyed(destroyRef?: DestroyRef): MonoTypeOperator destroyRef = inject(DestroyRef); } - const destroyed$ = new Observable((observer) => { - const unregisterFn = destroyRef!.onDestroy(observer.next.bind(observer)); + const destroyed$ = new Observable((subscriber) => { + if ((destroyRef as unknown as {destroyed: boolean}).destroyed) { + subscriber.next(); + return; + } + const unregisterFn = destroyRef!.onDestroy(subscriber.next.bind(subscriber)); return unregisterFn; }); diff --git a/packages/core/rxjs-interop/test/take_until_destroyed_spec.ts b/packages/core/rxjs-interop/test/take_until_destroyed_spec.ts index 79c3c937fb7..59b85d6dbca 100644 --- a/packages/core/rxjs-interop/test/take_until_destroyed_spec.ts +++ b/packages/core/rxjs-interop/test/take_until_destroyed_spec.ts @@ -6,10 +6,18 @@ * found in the LICENSE file at https://angular.dev/license */ -import {DestroyRef, EnvironmentInjector, Injector, runInInjectionContext} from '../../src/core'; -import {BehaviorSubject} from 'rxjs'; +import { + Component, + DestroyRef, + EnvironmentInjector, + inject, + Injector, + runInInjectionContext, +} from '../../src/core'; +import {BehaviorSubject, finalize} from 'rxjs'; import {takeUntilDestroyed} from '../src/take_until_destroyed'; +import {TestBed} from '@angular/core/testing'; describe('takeUntilDestroyed', () => { it('should complete an observable when the current context is destroyed', () => { @@ -76,4 +84,39 @@ describe('takeUntilDestroyed', () => { expect(unregisterFn).toHaveBeenCalled(); }); + + // https://github.com/angular/angular/issues/54527 + it('should unsubscribe after the current context has already been destroyed', async () => { + const recorder: any[] = []; + + // Note that we need a "real" view for this test because, in other cases, + // `DestroyRef` would resolve to the root injector rather than to the + // `NodeInjectorDestroyRef`, where `lView` is used. + @Component({template: ''}) + class TestComponent { + destroyRef = inject(DestroyRef); + + source$ = new BehaviorSubject(0); + + ngOnDestroy(): void { + Promise.resolve().then(() => { + this.source$ + .pipe( + takeUntilDestroyed(this.destroyRef), + finalize(() => recorder.push('finalize()')), + ) + .subscribe((value) => recorder.push(value)); + }); + } + } + + const fixture = TestBed.createComponent(TestComponent); + fixture.destroy(); + + // Wait until the `ngOnDestroy` microtask is run. + await Promise.resolve(); + + // Ensure the `value` is not recorded, but unsubscribed immediately. + expect(recorder).toEqual(['finalize()']); + }); }); diff --git a/packages/core/src/di/r3_injector.ts b/packages/core/src/di/r3_injector.ts index 97e2fcf2a8e..96a6d227922 100644 --- a/packages/core/src/di/r3_injector.ts +++ b/packages/core/src/di/r3_injector.ts @@ -175,6 +175,9 @@ export abstract class EnvironmentInjector implements Injector { abstract destroy(): void; + /** @internal */ + abstract get destroyed(): boolean; + /** * @internal */ @@ -199,7 +202,7 @@ export class R3Injector extends EnvironmentInjector implements PrimitivesInjecto /** * Flag indicating that this injector was previously destroyed. */ - get destroyed(): boolean { + override get destroyed(): boolean { return this._destroyed; } private _destroyed = false; diff --git a/packages/core/src/linker/destroy_ref.ts b/packages/core/src/linker/destroy_ref.ts index 08da24f0d59..86812ff4a3d 100644 --- a/packages/core/src/linker/destroy_ref.ts +++ b/packages/core/src/linker/destroy_ref.ts @@ -46,6 +46,9 @@ export abstract class DestroyRef { */ abstract onDestroy(callback: () => void): () => void; + /** @internal */ + abstract get destroyed(): boolean; + /** * @internal * @nocollapse @@ -64,6 +67,10 @@ export class NodeInjectorDestroyRef extends DestroyRef { super(); } + override get destroyed() { + return isDestroyed(this._lView); + } + override onDestroy(callback: () => void): () => void { const lView = this._lView;