Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(upgrade): fix memory leaks when the component is destroyed #39921

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/upgrade/src/common/src/angular1.ts
Expand Up @@ -123,6 +123,7 @@ export interface ICloneAttachFunction {
}
export type IAugmentedJQuery = Node[]&{
on?: (name: string, fn: () => void) => void;
off?: (name: string) => void;
data?: (name: string, value?: any) => any;
text?: () => string;
inheritedData?: (name: string, value?: any) => any;
Expand All @@ -135,7 +136,8 @@ export type IAugmentedJQuery = Node[]&{
injector?: () => IInjectorService;
triggerHandler?: (eventTypeOrObject: string|Event, extraParameters?: any[]) => IAugmentedJQuery;
remove?: () => void;
removeData?: () => void;
removeData?: (name: string) => void;
scope?: () => IScope;
};
export interface IProvider {
$get: IInjectable;
Expand Down
59 changes: 41 additions & 18 deletions packages/upgrade/src/common/src/downgrade_component_adapter.ts
Expand Up @@ -10,8 +10,8 @@ import {ApplicationRef, ChangeDetectorRef, ComponentFactory, ComponentRef, Event

import {IAttributes, IAugmentedJQuery, ICompileService, IInjectorService, INgModelController, IParseService, IScope} from './angular1';
import {PropertyBinding} from './component_info';
import {$SCOPE} from './constants';
import {getTypeName, hookupNgModel, strictEquals} from './util';
import {$SCOPE, INJECTOR_KEY} from './constants';
import {controllerKey, getTypeName, hookupNgModel, strictEquals} from './util';

const INITIAL_VALUE = {
__UNINITIALIZED__: true
Expand All @@ -29,6 +29,7 @@ export class DowngradeComponentAdapter {
private changeDetector!: ChangeDetectorRef;
// TODO(issue/24571): remove '!'.
private viewChangeDetector!: ChangeDetectorRef;
private unwatchFns: Function[] = [];

constructor(
private element: IAugmentedJQuery, private attrs: IAttributes, private scope: IScope,
Expand Down Expand Up @@ -126,7 +127,8 @@ export class DowngradeComponentAdapter {
const watchFn =
(prop => (currValue: any, prevValue: any) =>
this.updateInput(prop, prevValue, currValue))(input.prop);
this.componentScope.$watch(expr, watchFn);
const unwatch = this.componentScope.$watch(expr, watchFn);
this.unwatchFns.push(unwatch);
}
}

Expand All @@ -135,25 +137,28 @@ export class DowngradeComponentAdapter {
const prototype = this.componentFactory.componentType.prototype;
this.implementsOnChanges = !!(prototype && (<OnChanges>prototype).ngOnChanges);

this.componentScope.$watch(() => this.inputChangeCount, this.wrapCallback(() => {
// Invoke `ngOnChanges()`
if (this.implementsOnChanges) {
const inputChanges = this.inputChanges;
this.inputChanges = {};
(<OnChanges>this.component).ngOnChanges(inputChanges!);
}
const unwatch =
this.componentScope.$watch(() => this.inputChangeCount, this.wrapCallback(() => {
// Invoke `ngOnChanges()`
if (this.implementsOnChanges) {
const inputChanges = this.inputChanges;
this.inputChanges = {};
(<OnChanges>this.component).ngOnChanges(inputChanges!);
}

this.viewChangeDetector.markForCheck();
this.viewChangeDetector.markForCheck();

// If opted out of propagating digests, invoke change detection when inputs change.
if (!propagateDigest) {
detectChanges();
}
}));
// If opted out of propagating digests, invoke change detection when inputs change.
if (!propagateDigest) {
detectChanges();
}
}));
this.unwatchFns.push(unwatch);

// If not opted out of propagating digests, invoke change detection on every digest
if (propagateDigest) {
this.componentScope.$watch(this.wrapCallback(detectChanges));
const unwatch = this.componentScope.$watch(this.wrapCallback(detectChanges));
this.unwatchFns.push(unwatch);
}

// If necessary, attach the view so that it will be dirty-checked.
Expand Down Expand Up @@ -217,11 +222,29 @@ export class DowngradeComponentAdapter {
let destroyed = false;

this.element.on!('$destroy', () => this.componentScope.$destroy());
this.componentScope.$on('$destroy', () => {
const removeOnDestroyListenerFn = this.componentScope.$on('$destroy', () => {
if (!destroyed) {
destroyed = true;
testabilityRegistry.unregisterApplication(this.componentRef.location.nativeElement);
destroyComponentRef();
// We're storing the real injector on the element inside the `ParentInjectorPromise`
// and its `resolve()` method. This causes a memory leak if we don't
// remove the reference when the component is destroyed.
this.element.removeData!(controllerKey(INJECTOR_KEY));
// We can't call `element.remove()` during the digest cycle since it will throw
// a runtime error, but we have to de-register the `$destroy` listener.
// The `$destroy` listener that we added previously on the element captures `this`,
// as long as there is a reference to `() => this.componentScope.$destroy()`,
// `this` will not be GC'd.
this.element.off!('$destroy');
// We also have captured `this` in that callback and it's not removed automatically
// by the `componentScope`.
removeOnDestroyListenerFn();
// This will clear all watchers inside the `$$watchers` property since all those watchers
// capture `this` inside their callbacks.
while (this.unwatchFns.length) {
this.unwatchFns.pop()!();
}
}
});
}
Expand Down
Expand Up @@ -8,6 +8,7 @@

import {ChangeDetectionStrategy, Compiler, Component, destroyPlatform, Directive, ElementRef, EventEmitter, Injector, Input, NgModule, NgModuleRef, OnChanges, OnDestroy, Output, SimpleChanges} from '@angular/core';
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';
import {expect} from '@angular/core/testing/src/testing_internal';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {downgradeComponent, UpgradeComponent, UpgradeModule} from '@angular/upgrade/static';
Expand Down Expand Up @@ -627,6 +628,72 @@ withEachNg1Version(() => {
});
}));

it('should remove the data from the element', waitForAsync(() => {
@Component({selector: 'ng2', template: ''})
class Ng2Component {
}

@NgModule({
declarations: [Ng2Component],
entryComponents: [Ng2Component],
imports: [BrowserModule, UpgradeModule],
})
class Ng2Module {
ngDoBootstrap() {}
}

const ng1Module = angular.module_('ng1', []).directive(
'ng2', downgradeComponent({component: Ng2Component}));

const element = html('<ng2></n2>');

bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(upgrade => {
const ng2Element = angular.element(element);
expect(ng2Element.data!('$$$angularInjectorController')).toBeTruthy();

const $rootScope = upgrade.$injector.get('$rootScope') as angular.IRootScopeService;
$rootScope.$destroy();

expect(ng2Element.data!('$$$angularInjectorController')).toBeUndefined();
});
}));

it('should cleanup $$watchers and $$listeners on the scope', waitForAsync(() => {
@Component({selector: 'ng2', template: ''})
class Ng2Component {
}

@NgModule({
declarations: [Ng2Component],
entryComponents: [Ng2Component],
imports: [BrowserModule, UpgradeModule],
})
class Ng2Module {
ngDoBootstrap() {}
}

const ng1Module = angular.module_('ng1', []).directive(
'ng2', downgradeComponent({component: Ng2Component}));

const element = html('<ng2></n2>');

bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(upgrade => {
const scope = angular.element(element).scope!();

// We don't know how many watchers there can be, but let's ensure
// that there is at least 1 watcher.
expect(scope.$$watchersCount).toBeGreaterThan(0);
// The same thing with listeners.
expect(Object.keys(scope.$$listenerCount).length).toBeGreaterThan(0);

const $rootScope = upgrade.$injector.get('$rootScope') as angular.IRootScopeService;
$rootScope.$destroy();

expect(scope.$$watchersCount).toEqual(0);
expect(Object.keys(scope.$$listenerCount).length).toEqual(0);
});
}));

it('should work when compiled outside the dom (by fallback to the root ng2.injector)',
waitForAsync(() => {
@Component({selector: 'ng2', template: 'test'})
Expand Down