Skip to content

Commit dbb8702

Browse files
arturovtmmalerba
authored andcommitted
fix(core): call DestroyRef on destroy callback if view is destroyed [patch] (#61061)
Patch version of 5f7f046 PR Close #61061
1 parent 5905090 commit dbb8702

File tree

3 files changed

+69
-23
lines changed

3 files changed

+69
-23
lines changed

packages/core/rxjs-interop/test/take_until_destroyed_spec.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,17 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {DestroyRef, EnvironmentInjector, Injector, runInInjectionContext} from '@angular/core';
10-
import {BehaviorSubject} from 'rxjs';
9+
import {
10+
Component,
11+
DestroyRef,
12+
EnvironmentInjector,
13+
inject,
14+
Injector,
15+
OnDestroy,
16+
runInInjectionContext,
17+
} from '@angular/core';
18+
import {TestBed} from '@angular/core/testing';
19+
import {BehaviorSubject, finalize} from 'rxjs';
1120

1221
import {takeUntilDestroyed} from '../src/take_until_destroyed';
1322

@@ -76,4 +85,39 @@ describe('takeUntilDestroyed', () => {
7685

7786
expect(unregisterFn).toHaveBeenCalled();
7887
});
88+
89+
// https://github.com/angular/angular/issues/54527
90+
it('should unsubscribe after the current context has already been destroyed', async () => {
91+
const recorder: any[] = [];
92+
93+
// Note that we need a "real" view for this test because, in other cases,
94+
// `DestroyRef` would resolve to the root injector rather than to the
95+
// `NodeInjectorDestroyRef`, where `lView` is used.
96+
@Component({template: ''})
97+
class TestComponent implements OnDestroy {
98+
destroyRef = inject(DestroyRef);
99+
100+
source$ = new BehaviorSubject(0);
101+
102+
ngOnDestroy(): void {
103+
Promise.resolve().then(() => {
104+
this.source$
105+
.pipe(
106+
takeUntilDestroyed(this.destroyRef),
107+
finalize(() => recorder.push('finalize()')),
108+
)
109+
.subscribe((value) => recorder.push(value));
110+
});
111+
}
112+
}
113+
114+
const fixture = TestBed.createComponent(TestComponent);
115+
fixture.destroy();
116+
117+
// Wait until the `ngOnDestroy` microtask is run.
118+
await Promise.resolve();
119+
120+
// Ensure the `value` is not recorded, but unsubscribed immediately.
121+
expect(recorder).toEqual(['finalize()']);
122+
});
79123
});

packages/core/src/linker/destroy_ref.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {EnvironmentInjector} from '../di';
10+
import {isDestroyed} from '../render3/interfaces/type_checks';
1011
import {LView} from '../render3/interfaces/view';
1112
import {getLView} from '../render3/state';
1213
import {removeLViewOnDestroy, storeLViewOnDestroy} from '../render3/util/view_utils';
@@ -62,8 +63,28 @@ export class NodeInjectorDestroyRef extends DestroyRef {
6263
}
6364

6465
override onDestroy(callback: () => void): () => void {
65-
storeLViewOnDestroy(this._lView, callback);
66-
return () => removeLViewOnDestroy(this._lView, callback);
66+
const lView = this._lView;
67+
68+
// Checking if `lView` is already destroyed before storing the `callback` enhances
69+
// safety and integrity for applications.
70+
// If `lView` is destroyed, we call the `callback` immediately to ensure that
71+
// any necessary cleanup is handled gracefully.
72+
// With this approach, we're providing better reliability in managing resources.
73+
// One of the use cases is `takeUntilDestroyed`, which aims to replace `takeUntil`
74+
// in existing applications. While `takeUntil` can be safely called once the view
75+
// is destroyed — resulting in no errors and finalizing the subscription depending
76+
// on whether a subject or replay subject is used, replacing it with
77+
// `takeUntilDestroyed` introduces a breaking change, as it throws an error if
78+
// the `lView` is destroyed (https://github.com/angular/angular/issues/54527).
79+
if (isDestroyed(lView)) {
80+
callback();
81+
// We return a "noop" callback, which, when executed, does nothing because
82+
// we haven't stored anything on the `lView`, and thus there's nothing to remove.
83+
return () => {};
84+
}
85+
86+
storeLViewOnDestroy(lView, callback);
87+
return () => removeLViewOnDestroy(lView, callback);
6788
}
6889
}
6990

packages/core/test/acceptance/destroy_ref_spec.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -248,25 +248,6 @@ describe('DestroyRef', () => {
248248
expect(onDestroyCalls).toBe(2);
249249
});
250250

251-
it('should throw when trying to register destroy callback on destroyed LView', () => {
252-
@Component({
253-
selector: 'test',
254-
standalone: true,
255-
template: ``,
256-
})
257-
class TestCmp {
258-
constructor(public destroyRef: DestroyRef) {}
259-
}
260-
261-
const fixture = TestBed.createComponent(TestCmp);
262-
const destroyRef = fixture.componentRef.instance.destroyRef;
263-
fixture.componentRef.destroy();
264-
265-
expect(() => {
266-
destroyRef.onDestroy(() => {});
267-
}).toThrowError('NG0911: View has already been destroyed.');
268-
});
269-
270251
it('should allow unregistration while destroying', () => {
271252
const destroyedLog: string[] = [];
272253

0 commit comments

Comments
 (0)