Skip to content

Commit 35e45dc

Browse files
jelbournmatsko
authored andcommitted
fix(ivy): prevent errors from views being destroyed twice (angular#28413)
Previously, attempting to destroy a view with listeners more than once throws an error during event listener cleanup. This happens because `cleanup` field on the `TView` has already been cleared out by the time the second destruction runs. The `destroyed` flag on LView was previously being set in the `destroyLView` function, but this flag was never _checked_ anywhere in the codebase. This commit moves _setting_ this flag to the `cleanupView` function, just before destroy hooks are called. This is necessary because the destroy hooks can contain arbitrary user code, such as (surprise!) attempting to destroy the view (again). We also add a check to `destroyLView` to skip already-destroyed views. This prevents the cleanup code path from running twice. PR Close angular#28413
1 parent b35ef18 commit 35e45dc

File tree

3 files changed

+79
-12
lines changed

3 files changed

+79
-12
lines changed

packages/core/src/render3/node_manipulation.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -374,13 +374,14 @@ export function getLViewChild(lView: LView): LView|LContainer|null {
374374
* @param view The view to be destroyed.
375375
*/
376376
export function destroyLView(view: LView) {
377-
const renderer = view[RENDERER];
378-
if (isProceduralRenderer(renderer) && renderer.destroyNode) {
379-
walkTNodeTree(view, WalkTNodeTreeAction.Destroy, renderer, null);
377+
if (!(view[FLAGS] & LViewFlags.Destroyed)) {
378+
const renderer = view[RENDERER];
379+
if (isProceduralRenderer(renderer) && renderer.destroyNode) {
380+
walkTNodeTree(view, WalkTNodeTreeAction.Destroy, renderer, null);
381+
}
382+
383+
destroyViewTree(view);
380384
}
381-
destroyViewTree(view);
382-
// Sets the destroyed flag
383-
view[FLAGS] |= LViewFlags.Destroyed;
384385
}
385386

386387
/**
@@ -418,6 +419,14 @@ export function getParentState(state: LView | LContainer, rootView: LView): LVie
418419
function cleanUpView(viewOrContainer: LView | LContainer): void {
419420
if ((viewOrContainer as LView).length >= HEADER_OFFSET) {
420421
const view = viewOrContainer as LView;
422+
423+
// Mark the LView as destroyed *before* executing the onDestroy hooks. An onDestroy hook
424+
// runs arbitrary user code, which could include its own `viewRef.destroy()` (or similar). If
425+
// We don't flag the view as destroyed before the hooks, this could lead to an infinite loop.
426+
// This also aligns with the ViewEngine behavior. It also means that the onDestroy hook is
427+
// really more of an "afterDestroy" hook if you think about it.
428+
view[FLAGS] |= LViewFlags.Destroyed;
429+
421430
executeOnDestroys(view);
422431
removeListeners(view);
423432
const hostTNode = view[HOST_NODE];

packages/core/test/render3/render_util.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,12 @@ export class ComponentFixture<T> extends BaseFixture {
180180
}
181181

182182
destroy(): void {
183-
this.containerElement.removeChild(this.hostElement);
183+
// Skip removing the DOM element if it has already been removed (the view has already
184+
// been destroyed).
185+
if (this.hostElement.parentNode === this.containerElement) {
186+
this.containerElement.removeChild(this.hostElement);
187+
}
188+
184189
destroyLView(getRootView(this.component));
185190
}
186191
}

packages/core/test/render3/view_container_ref_spec.ts

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

9-
import {ChangeDetectorRef, Component as _Component, ComponentFactoryResolver, ElementRef, EmbeddedViewRef, NgModuleRef, Pipe, PipeTransform, QueryList, RendererFactory2, TemplateRef, ViewContainerRef, createInjector, defineInjector, ɵAPP_ROOT as APP_ROOT, ɵNgModuleDef as NgModuleDef} from '../../src/core';
9+
import {ChangeDetectorRef, Component as _Component, ComponentFactoryResolver, ElementRef, EmbeddedViewRef, NgModuleRef, Pipe, PipeTransform, QueryList, RendererFactory2, TemplateRef, ViewContainerRef, ViewRef, createInjector, defineInjector, ɵAPP_ROOT as APP_ROOT, ɵNgModuleDef as NgModuleDef} from '../../src/core';
1010
import {ViewEncapsulation} from '../../src/metadata';
11-
import {AttributeMarker, NO_CHANGE, NgOnChangesFeature, defineComponent, defineDirective, definePipe, injectComponentFactoryResolver, loadViewQuery, queryRefresh, viewQuery} from '../../src/render3/index';
1211

13-
import {allocHostVars, bind, container, containerRefreshEnd, containerRefreshStart, directiveInject, element, elementEnd, elementProperty, elementStart, embeddedViewEnd, embeddedViewStart, interpolation1, interpolation3, nextContext, projection, projectionDef, reference, template, text, textBinding, elementHostAttrs} from '../../src/render3/instructions';
12+
import {AttributeMarker, defineComponent, defineDirective, definePipe, injectComponentFactoryResolver, listener, loadViewQuery, NgOnChangesFeature, queryRefresh, viewQuery,} from '../../src/render3/index';
13+
14+
import {allocHostVars, bind, container, containerRefreshEnd, containerRefreshStart, directiveInject, element, elementEnd, elementHostAttrs, elementProperty, elementStart, embeddedViewEnd, embeddedViewStart, interpolation1, interpolation3, nextContext, projection, projectionDef, reference, template, text, textBinding,} from '../../src/render3/instructions';
1415
import {RenderFlags} from '../../src/render3/interfaces/definition';
1516
import {RElement} from '../../src/render3/interfaces/renderer';
16-
import {templateRefExtractor} from '../../src/render3/view_engine_compatibility_prebound';
1717
import {NgModuleFactory} from '../../src/render3/ng_module_ref';
1818
import {pipe, pipeBind1} from '../../src/render3/pipe';
1919
import {getLView} from '../../src/render3/state';
2020
import {getNativeByIndex} from '../../src/render3/util';
21+
import {templateRefExtractor} from '../../src/render3/view_engine_compatibility_prebound';
2122
import {NgForOf} from '../../test/render3/common_with_def';
2223

2324
import {getRendererFactory2} from './imported_renderer2';
24-
import {ComponentFixture, TemplateFixture, createComponent, getDirectiveOnNode} from './render_util';
25+
import {ComponentFixture, createComponent, getDirectiveOnNode, TemplateFixture,} from './render_util';
2526

2627
const Component: typeof _Component = function(...args: any[]): any {
2728
// In test we use @Component for documentation only so it's safe to mock out the implementation.
@@ -2087,4 +2088,56 @@ describe('ViewContainerRef', () => {
20872088
});
20882089

20892090
});
2091+
2092+
describe('view destruction', () => {
2093+
class CompWithListenerThatDestroysItself {
2094+
constructor(private viewRef: ViewRef) {}
2095+
2096+
onClick() {}
2097+
2098+
ngOnDestroy() { this.viewRef.destroy(); }
2099+
2100+
static ngComponentDef = defineComponent({
2101+
type: CompWithListenerThatDestroysItself,
2102+
selectors: [['comp-with-listener-and-on-destroy']],
2103+
consts: 2,
2104+
vars: 0,
2105+
/** <button (click)="onClick()"> Click me </button> */
2106+
template: function CompTemplate(rf: RenderFlags, ctx: any) {
2107+
if (rf & RenderFlags.Create) {
2108+
elementStart(0, 'button');
2109+
{
2110+
listener('click', function() { return ctx.onClick(); });
2111+
text(1, 'Click me');
2112+
}
2113+
elementEnd();
2114+
}
2115+
},
2116+
// We want the ViewRef, so we rely on the knowledge that `ViewRef` is actually given
2117+
// when injecting `ChangeDetectorRef`.
2118+
factory:
2119+
() => new CompWithListenerThatDestroysItself(directiveInject(ChangeDetectorRef as any)),
2120+
});
2121+
}
2122+
2123+
2124+
it('should not error when destroying a view with listeners twice', () => {
2125+
const CompWithChildListener = createComponent('test-app', (rf: RenderFlags, ctx: any) => {
2126+
if (rf & RenderFlags.Create) {
2127+
element(0, 'comp-with-listener-and-on-destroy');
2128+
}
2129+
}, 1, 0, [CompWithListenerThatDestroysItself]);
2130+
2131+
const fixture = new ComponentFixture(CompWithChildListener);
2132+
fixture.update();
2133+
2134+
// Destroying the parent view will also destroy all of its children views and call their
2135+
// onDestroy hooks. Here, our child view attempts to destroy itself *again* in its onDestroy.
2136+
// This test exists to verify that no errors are thrown when doing this. We want the test
2137+
// component to destroy its own view in onDestroy because the destroy hooks happen as a
2138+
// *part of* view destruction. We also ensure that the test component has at least one
2139+
// listener so that it runs the event listener cleanup code path.
2140+
fixture.destroy();
2141+
});
2142+
});
20902143
});

0 commit comments

Comments
 (0)