Skip to content

Commit c34d7e0

Browse files
fix(core): onDestroy should be registered only on valid DestroyRef (#49804)
It might happen that the lifecycle scope represented by DestroyRef becomes invalid before an onDestroy hook is registered (ex. injector or component instance got destroyed). In such cases registration of the onDestroy hooks should no longer be possible. Fixes #49658 PR Close #49804
1 parent 6ade3ec commit c34d7e0

File tree

6 files changed

+42
-2
lines changed

6 files changed

+42
-2
lines changed

goldens/public-api/core/errors.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,9 @@ export const enum RuntimeErrorCode {
123123
// (undocumented)
124124
UNSUPPORTED_PROJECTION_DOM_NODES = -503,
125125
// (undocumented)
126-
VIEW_ALREADY_ATTACHED = 902
126+
VIEW_ALREADY_ATTACHED = 902,
127+
// (undocumented)
128+
VIEW_ALREADY_DESTROYED = 911
127129
}
128130

129131
// (No @packageDocumentation comment for this package)

packages/core/src/di/r3_injector.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ export class R3Injector extends EnvironmentInjector {
212212
}
213213

214214
override onDestroy(callback: () => void): () => void {
215+
this.assertNotDestroyed();
215216
this._onDestroyHooks.push(callback);
216217
return () => this.removeOnDestroy(callback);
217218
}

packages/core/src/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export const enum RuntimeErrorCode {
9595
MISSING_ZONEJS = 908,
9696
UNEXPECTED_ZONE_STATE = 909,
9797
UNSAFE_IFRAME_ATTRS = -910,
98+
VIEW_ALREADY_DESTROYED = 911,
9899
}
99100

100101

packages/core/src/render3/assert.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {TIcu} from './interfaces/i18n';
1515
import {NodeInjectorOffset} from './interfaces/injector';
1616
import {TNode} from './interfaces/node';
1717
import {isLContainer, isLView} from './interfaces/type_checks';
18-
import {DECLARATION_COMPONENT_VIEW, HEADER_OFFSET, LView, T_HOST, TVIEW, TView} from './interfaces/view';
18+
import {DECLARATION_COMPONENT_VIEW, FLAGS, HEADER_OFFSET, LView, LViewFlags, T_HOST, TVIEW, TView} from './interfaces/view';
1919

2020
// [Assert functions do not constraint type when they are guarded by a truthy
2121
// expression.](https://github.com/microsoft/TypeScript/issues/37295)

packages/core/src/render3/util/view_utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {RuntimeError, RuntimeErrorCode} from '../../errors';
910
import {assertGreaterThan, assertGreaterThanOrEqual, assertIndexInRange, assertLessThan} from '../../util/assert';
1011
import {assertTNode, assertTNodeForLView} from '../assert';
1112
import {LContainer, TYPE} from '../interfaces/container';
@@ -187,6 +188,10 @@ export function updateTransplantedViewCount(lContainer: LContainer, amount: 1|-
187188
* Stores a LView-specific destroy callback.
188189
*/
189190
export function storeLViewOnDestroy(lView: LView, onDestroyCallback: () => void) {
191+
if ((lView[FLAGS] & LViewFlags.Destroyed) === LViewFlags.Destroyed) {
192+
throw new RuntimeError(
193+
RuntimeErrorCode.VIEW_ALREADY_DESTROYED, ngDevMode && 'View has already been destroyed.');
194+
}
190195
if (lView[ON_DESTROY_HOOKS] === null) {
191196
lView[ON_DESTROY_HOOKS] = [];
192197
}

packages/core/test/acceptance/destroy_ref_spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ describe('DestroyRef', () => {
5656
// (since we've unregistered one of the callbacks)
5757
expect(onDestroyCalls).toBe(2);
5858
});
59+
60+
it('should throw when trying to register destroy callback on destroyed injector', () => {
61+
const envInjector = createEnvironmentInjector([], TestBed.inject(EnvironmentInjector));
62+
const destroyRef = envInjector.get(DestroyRef);
63+
64+
envInjector.destroy();
65+
66+
expect(() => {
67+
destroyRef.onDestroy(() => {});
68+
}).toThrowError('NG0205: Injector has already been destroyed.');
69+
});
5970
});
6071

6172
describe('for node injector', () => {
@@ -229,4 +240,24 @@ describe('DestroyRef', () => {
229240
// (since we've unregistered one of the callbacks)
230241
expect(onDestroyCalls).toBe(2);
231242
});
243+
244+
it('should throw when trying to register destroy callback on destroyed LView', () => {
245+
@Component({
246+
selector: 'test',
247+
standalone: true,
248+
template: ``,
249+
})
250+
class TestCmp {
251+
constructor(public destroyRef: DestroyRef) {}
252+
}
253+
254+
255+
const fixture = TestBed.createComponent(TestCmp);
256+
const destroyRef = fixture.componentRef.instance.destroyRef;
257+
fixture.componentRef.destroy();
258+
259+
expect(() => {
260+
destroyRef.onDestroy(() => {});
261+
}).toThrowError('NG0911: View has already been destroyed.');
262+
});
232263
});

0 commit comments

Comments
 (0)