fix(core): remove application from the testability registry when the root view is removed (#39876)

In the new behavior Angular removes applications from the testability registry when the
root view gets destroyed. This eliminates a memory leak, because before that the
TestabilityRegistry holds references to HTML elements, thus they cannot be GCed.

PR Close #22106

PR Close #39876
This commit is contained in:
arturovt 2020-11-29 02:01:03 +02:00 committed by Misko Hevery
parent 75fc89384d
commit df27027ecb
9 changed files with 67 additions and 37 deletions

View File

@ -39,7 +39,7 @@
"master": { "master": {
"uncompressed": { "uncompressed": {
"runtime-es2015": 2285, "runtime-es2015": 2285,
"main-es2015": 241879, "main-es2015": 242455,
"polyfills-es2015": 36709, "polyfills-es2015": 36709,
"5-es2015": 745 "5-es2015": 745
} }

View File

@ -594,6 +594,7 @@ export class ApplicationRef {
private _runningTick: boolean = false; private _runningTick: boolean = false;
private _enforceNoNewChanges: boolean = false; private _enforceNoNewChanges: boolean = false;
private _stable = true; private _stable = true;
private _onMicrotaskEmptySubscription: Subscription;
/** /**
* Get a list of component types registered to this application. * Get a list of component types registered to this application.
@ -622,7 +623,7 @@ export class ApplicationRef {
private _initStatus: ApplicationInitStatus) { private _initStatus: ApplicationInitStatus) {
this._enforceNoNewChanges = isDevMode(); this._enforceNoNewChanges = isDevMode();
this._zone.onMicrotaskEmpty.subscribe({ this._onMicrotaskEmptySubscription = this._zone.onMicrotaskEmpty.subscribe({
next: () => { next: () => {
this._zone.run(() => { this._zone.run(() => {
this.tick(); this.tick();
@ -715,15 +716,20 @@ export class ApplicationRef {
isBoundToModule(componentFactory) ? undefined : this._injector.get(NgModuleRef); isBoundToModule(componentFactory) ? undefined : this._injector.get(NgModuleRef);
const selectorOrNode = rootSelectorOrNode || componentFactory.selector; const selectorOrNode = rootSelectorOrNode || componentFactory.selector;
const compRef = componentFactory.create(Injector.NULL, [], selectorOrNode, ngModule); const compRef = componentFactory.create(Injector.NULL, [], selectorOrNode, ngModule);
const nativeElement = compRef.location.nativeElement;
const testability = compRef.injector.get(Testability, null);
const testabilityRegistry = testability && compRef.injector.get(TestabilityRegistry);
if (testability && testabilityRegistry) {
testabilityRegistry.registerApplication(nativeElement, testability);
}
compRef.onDestroy(() => { compRef.onDestroy(() => {
this._unloadComponent(compRef); this.detachView(compRef.hostView);
remove(this.components, compRef);
if (testabilityRegistry) {
testabilityRegistry.unregisterApplication(nativeElement);
}
}); });
const testability = compRef.injector.get(Testability, null);
if (testability) {
compRef.injector.get(TestabilityRegistry)
.registerApplication(compRef.location.nativeElement, testability);
}
this._loadComponent(compRef); this._loadComponent(compRef);
if (isDevMode()) { if (isDevMode()) {
@ -796,15 +802,11 @@ export class ApplicationRef {
listeners.forEach((listener) => listener(componentRef)); listeners.forEach((listener) => listener(componentRef));
} }
private _unloadComponent(componentRef: ComponentRef<any>): void {
this.detachView(componentRef.hostView);
remove(this.components, componentRef);
}
/** @internal */ /** @internal */
ngOnDestroy() { ngOnDestroy() {
// TODO(alxhub): Dispose of the NgZone. // TODO(alxhub): Dispose of the NgZone.
this._views.slice().forEach((view) => view.destroy()); this._views.slice().forEach((view) => view.destroy());
this._onMicrotaskEmptySubscription.unsubscribe();
} }
/** /**

View File

@ -248,7 +248,6 @@ export function injectComponentFactoryResolver(): viewEngine_ComponentFactoryRes
* *
*/ */
export class ComponentRef<T> extends viewEngine_ComponentRef<T> { export class ComponentRef<T> extends viewEngine_ComponentRef<T> {
destroyCbs: (() => void)[]|null = [];
instance: T; instance: T;
hostView: ViewRef<T>; hostView: ViewRef<T>;
changeDetectorRef: ViewEngine_ChangeDetectorRef; changeDetectorRef: ViewEngine_ChangeDetectorRef;
@ -269,16 +268,10 @@ export class ComponentRef<T> extends viewEngine_ComponentRef<T> {
} }
destroy(): void { destroy(): void {
if (this.destroyCbs) { this.hostView.destroy();
this.destroyCbs.forEach(fn => fn());
this.destroyCbs = null;
!this.hostView.destroyed && this.hostView.destroy();
}
} }
onDestroy(callback: () => void): void { onDestroy(callback: () => void): void {
if (this.destroyCbs) { this.hostView.onDestroy(callback);
this.destroyCbs.push(callback);
}
} }
} }

View File

@ -19,7 +19,7 @@ import {assertTNodeType} from '../node_assert';
import {getCurrentDirectiveDef, getCurrentTNode, getLView, getTView} from '../state'; import {getCurrentDirectiveDef, getCurrentTNode, getLView, getTView} from '../state';
import {getComponentLViewByIndex, getNativeByTNode, unwrapRNode} from '../util/view_utils'; import {getComponentLViewByIndex, getNativeByTNode, unwrapRNode} from '../util/view_utils';
import {getLCleanup, handleError, loadComponentRenderer, markViewDirty} from './shared'; import {getLCleanup, getTViewCleanup, handleError, loadComponentRenderer, markViewDirty} from './shared';
@ -120,7 +120,7 @@ function listenerInternal(
eventTargetResolver?: GlobalTargetResolver): void { eventTargetResolver?: GlobalTargetResolver): void {
const isTNodeDirectiveHost = isDirectiveHost(tNode); const isTNodeDirectiveHost = isDirectiveHost(tNode);
const firstCreatePass = tView.firstCreatePass; const firstCreatePass = tView.firstCreatePass;
const tCleanup: false|any[] = firstCreatePass && (tView.cleanup || (tView.cleanup = [])); const tCleanup: false|any[] = firstCreatePass && getTViewCleanup(tView);
// When the ɵɵlistener instruction was generated and is executed we know that there is either a // When the ɵɵlistener instruction was generated and is executed we know that there is either a
// native listener or a directive output on this element. As such we we know that we will have to // native listener or a directive output on this element. As such we we know that we will have to

View File

@ -752,14 +752,26 @@ export function locateHostElement(
* On the first template pass, saves in TView: * On the first template pass, saves in TView:
* - Cleanup function * - Cleanup function
* - Index of context we just saved in LView.cleanupInstances * - Index of context we just saved in LView.cleanupInstances
*
* This function can also be used to store instance specific cleanup fns. In that case the `context`
* is `null` and the function is store in `LView` (rather than it `TView`).
*/ */
export function storeCleanupWithContext( export function storeCleanupWithContext(
tView: TView, lView: LView, context: any, cleanupFn: Function): void { tView: TView, lView: LView, context: any, cleanupFn: Function): void {
const lCleanup = getLCleanup(lView); const lCleanup = getLCleanup(lView);
lCleanup.push(context); if (context === null) {
// If context is null that this is instance specific callback. These callbacks can only be
// inserted after template shared instances. For this reason in ngDevMode we freeze the TView.
if (ngDevMode) {
Object.freeze(getTViewCleanup(tView));
}
lCleanup.push(cleanupFn);
} else {
lCleanup.push(context);
if (tView.firstCreatePass) { if (tView.firstCreatePass) {
getTViewCleanup(tView).push(cleanupFn, lCleanup.length - 1); getTViewCleanup(tView).push(cleanupFn, lCleanup.length - 1);
}
} }
} }
@ -1997,7 +2009,7 @@ export function getLCleanup(view: LView): any[] {
return view[CLEANUP] || (view[CLEANUP] = ngDevMode ? new LCleanup() : []); return view[CLEANUP] || (view[CLEANUP] = ngDevMode ? new LCleanup() : []);
} }
function getTViewCleanup(tView: TView): any[] { export function getTViewCleanup(tView: TView): any[] {
return tView.cleanup || (tView.cleanup = ngDevMode ? new TCleanup() : []); return tView.cleanup || (tView.cleanup = ngDevMode ? new TCleanup() : []);
} }

View File

@ -163,8 +163,11 @@ export interface LView extends Array<any> {
* *
* These change per LView instance, so they cannot be stored on TView. Instead, * These change per LView instance, so they cannot be stored on TView. Instead,
* TView.cleanup saves an index to the necessary context in this array. * TView.cleanup saves an index to the necessary context in this array.
*
* After `LView` is created it is possible to attach additional instance specific functions at the
* end of the `lView[CLENUP]` because we know that no more `T` level cleanup functions will be
* addeded here.
*/ */
// TODO: flatten into LView[]
[CLEANUP]: any[]|null; [CLEANUP]: any[]|null;
/** /**

View File

@ -10,7 +10,7 @@ import {ViewEncapsulation} from '../metadata/view';
import {Renderer2} from '../render/api'; import {Renderer2} from '../render/api';
import {RendererStyleFlags2} from '../render/api_flags'; import {RendererStyleFlags2} from '../render/api_flags';
import {addToArray, removeFromArray} from '../util/array_utils'; import {addToArray, removeFromArray} from '../util/array_utils';
import {assertDefined, assertDomNode, assertEqual, assertString} from '../util/assert'; import {assertDefined, assertDomNode, assertEqual, assertFunction, assertString} from '../util/assert';
import {assertLContainer, assertLView, assertTNodeForLView} from './assert'; import {assertLContainer, assertLView, assertTNodeForLView} from './assert';
import {attachPatchData} from './context_discovery'; import {attachPatchData} from './context_discovery';
import {icuContainerIterate} from './i18n/i18n_tree_shaking'; import {icuContainerIterate} from './i18n/i18n_tree_shaking';
@ -418,7 +418,7 @@ function cleanUpView(tView: TView, lView: LView): void {
lView[FLAGS] |= LViewFlags.Destroyed; lView[FLAGS] |= LViewFlags.Destroyed;
executeOnDestroys(tView, lView); executeOnDestroys(tView, lView);
removeListeners(tView, lView); processCleanups(tView, lView);
// For component views only, the local renderer is destroyed at clean up time. // For component views only, the local renderer is destroyed at clean up time.
if (lView[TVIEW].type === TViewType.Component && isProceduralRenderer(lView[RENDERER])) { if (lView[TVIEW].type === TViewType.Component && isProceduralRenderer(lView[RENDERER])) {
ngDevMode && ngDevMode.rendererDestroy++; ngDevMode && ngDevMode.rendererDestroy++;
@ -443,10 +443,14 @@ function cleanUpView(tView: TView, lView: LView): void {
} }
/** Removes listeners and unsubscribes from output subscriptions */ /** Removes listeners and unsubscribes from output subscriptions */
function removeListeners(tView: TView, lView: LView): void { function processCleanups(tView: TView, lView: LView): void {
const tCleanup = tView.cleanup; const tCleanup = tView.cleanup;
const lCleanup = lView[CLEANUP]!;
// `LCleanup` contains both share information with `TCleanup` as well as instance specific
// information appended at the end. We need to know where the end of the `TCleanup` information
// is, and we track this with `lastLCleanupIndex`.
let lastLCleanupIndex = -1;
if (tCleanup !== null) { if (tCleanup !== null) {
const lCleanup = lView[CLEANUP]!;
for (let i = 0; i < tCleanup.length - 1; i += 2) { for (let i = 0; i < tCleanup.length - 1; i += 2) {
if (typeof tCleanup[i] === 'string') { if (typeof tCleanup[i] === 'string') {
// This is a native DOM listener // This is a native DOM listener
@ -454,7 +458,7 @@ function removeListeners(tView: TView, lView: LView): void {
const target = typeof idxOrTargetGetter === 'function' ? const target = typeof idxOrTargetGetter === 'function' ?
idxOrTargetGetter(lView) : idxOrTargetGetter(lView) :
unwrapRNode(lView[idxOrTargetGetter]); unwrapRNode(lView[idxOrTargetGetter]);
const listener = lCleanup[tCleanup[i + 2]]; const listener = lCleanup[lastLCleanupIndex = tCleanup[i + 2]];
const useCaptureOrSubIdx = tCleanup[i + 3]; const useCaptureOrSubIdx = tCleanup[i + 3];
if (typeof useCaptureOrSubIdx === 'boolean') { if (typeof useCaptureOrSubIdx === 'boolean') {
// native DOM listener registered with Renderer3 // native DOM listener registered with Renderer3
@ -462,19 +466,26 @@ function removeListeners(tView: TView, lView: LView): void {
} else { } else {
if (useCaptureOrSubIdx >= 0) { if (useCaptureOrSubIdx >= 0) {
// unregister // unregister
lCleanup[useCaptureOrSubIdx](); lCleanup[lastLCleanupIndex = useCaptureOrSubIdx]();
} else { } else {
// Subscription // Subscription
lCleanup[-useCaptureOrSubIdx].unsubscribe(); lCleanup[lastLCleanupIndex = -useCaptureOrSubIdx].unsubscribe();
} }
} }
i += 2; i += 2;
} else { } else {
// This is a cleanup function that is grouped with the index of its context // This is a cleanup function that is grouped with the index of its context
const context = lCleanup[tCleanup[i + 1]]; const context = lCleanup[lastLCleanupIndex = tCleanup[i + 1]];
tCleanup[i].call(context); tCleanup[i].call(context);
} }
} }
if (lCleanup !== null) {
for (let i = lastLCleanupIndex + 1; i < lCleanup.length; i++) {
const instanceCleanupFn = lCleanup[i];
ngDevMode && assertFunction(instanceCleanupFn, 'Expecting instance cleanup function.');
instanceCleanupFn();
}
}
lView[CLEANUP] = null; lView[CLEANUP] = null;
} }
} }

View File

@ -31,6 +31,12 @@ export function assertString(actual: any, msg: string): asserts actual is string
} }
} }
export function assertFunction(actual: any, msg: string): asserts actual is Function {
if (!(typeof actual === 'function')) {
throwError(msg, actual === null ? 'null' : typeof actual, 'function', '===');
}
}
export function assertEqual<T>(actual: T, expected: T, msg: string) { export function assertEqual<T>(actual: T, expected: T, msg: string) {
if (!(actual == expected)) { if (!(actual == expected)) {
throwError(msg, actual, expected, '=='); throwError(msg, actual, expected, '==');

View File

@ -1436,6 +1436,9 @@
{ {
"name": "getTView" "name": "getTView"
}, },
{
"name": "getTViewCleanup"
},
{ {
"name": "getToken" "name": "getToken"
}, },