Skip to content

Commit

Permalink
fix(upgrade): avoid memory leak when removing downgraded components
Browse files Browse the repository at this point in the history
Previously, due to the way the AngularJS and Angular clean-up processes
interfere with each other when removing an AngularJS element that
contains a downgraded Angular component, the data associated with the
host element of the downgraded component was not removed. This data was
kept in an internal AngularJS cache, which prevented the element and
component instance from being garbage-collected, leading to memory
leaks.

This commit fixes this by ensuring the element data is explicitly
removed when cleaning up a downgraded component.

NOTE:
This is essentially the equivalent of angular#26209 but for downgraded (instead
of upgraded) components.

Fixes angular#39911
Closes angular#39921
  • Loading branch information
gkalpak committed Dec 3, 2020
1 parent 343fdc2 commit 830b3b0
Show file tree
Hide file tree
Showing 5 changed files with 138 additions and 5 deletions.
1 change: 1 addition & 0 deletions packages/upgrade/src/common/src/angular1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export type IAugmentedJQuery = Node[]&{
data?: (name: string, value?: any) => any;
text?: () => string;
inheritedData?: (name: string, value?: any) => any;
children?: () => IAugmentedJQuery;
contents?: () => IAugmentedJQuery;
parent?: () => IAugmentedJQuery;
empty?: () => void;
Expand Down
31 changes: 29 additions & 2 deletions packages/upgrade/src/common/src/downgrade_component_adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import {ApplicationRef, ChangeDetectorRef, ComponentFactory, ComponentRef, EventEmitter, Injector, OnChanges, SimpleChange, SimpleChanges, StaticProvider, Testability, TestabilityRegistry, Type} from '@angular/core';

import {IAttributes, IAugmentedJQuery, ICompileService, INgModelController, IParseService, IScope} from './angular1';
import {element as angularElement, IAttributes, IAugmentedJQuery, ICompileService, INgModelController, IParseService, IScope} from './angular1';
import {PropertyBinding} from './component_info';
import {$SCOPE} from './constants';
import {getTypeName, hookupNgModel, strictEquals} from './util';
Expand Down Expand Up @@ -216,11 +216,38 @@ export class DowngradeComponentAdapter {
const destroyComponentRef = this.wrapCallback(() => this.componentRef.destroy());
let destroyed = false;

this.element.on!('$destroy', () => this.componentScope.$destroy());
this.element.on!('$destroy', () => {
// The `$destroy` event may have been triggered by the `cleanData()` call in the
// `componentScope` `$destroy` handler below. In that case, we don't want to call
// `componentScope.$destroy()` again.
if (!destroyed) this.componentScope.$destroy();
});
this.componentScope.$on('$destroy', () => {
if (!destroyed) {
destroyed = true;
testabilityRegistry.unregisterApplication(this.componentRef.location.nativeElement);

// The `componentScope` might be getting destroyed, because an ancestor element is being
// removed/destroyed. If that is the case, jqLite/jQuery would normally invoke `cleanData()`
// on the removed element and all descendants.
// - https://github.com/angular/angular.js/blob/2e72ea13fa98bebf6ed4b5e3c45eaf5f990ed16f/src/jqLite.js#L349-L355
// - https://github.com/jquery/jquery/blob/6984d1747623dbc5e87fd6c261a5b6b1628c107c/src/manipulation.js#L182
//
// Here, however, `destroyComponentRef()` may under some circumstances remove the element
// from the DOM and therefore it will no longer be a descendant of the removed element when
// `cleanData()` is called. This would result in a memory leak, because the element's data
// and event handlers (and all objects directly or indirectly referenced by them) would be
// retained.
//
// To ensure the element is always properly cleaned up, we manually call `cleanData()` on
// this element and its descendants before destroying the `ComponentRef`.
//
// NOTE:
// `cleanData()` also will invoke the AngularJS `$destroy` event on the element:
// - https://github.com/angular/angular.js/blob/2e72ea13fa98bebf6ed4b5e3c45eaf5f990ed16f/src/Angular.js#L1932-L1945
angularElement.cleanData(this.element);
angularElement.cleanData(this.element.children!());

destroyComponentRef();
}
});
Expand Down
28 changes: 26 additions & 2 deletions packages/upgrade/src/dynamic/test/upgrade_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,7 @@ withEachNg1Version(() => {
};
});

@Component({selector: 'ng2', template: 'test'})
@Component({selector: 'ng2', template: '<span>test</span>'})
class Ng2 {
ngOnDestroy() {
onDestroyed.emit('destroyed');
Expand All @@ -695,8 +695,32 @@ withEachNg1Version(() => {
ng1Module.directive('ng2', adapter.downgradeNg2Component(Ng2));
const element = html('<ng1></ng1>');
adapter.bootstrap(element, ['ng1']).ready((ref) => {
const ng2Element = angular.element(element.querySelector('ng2') as Element);
const ng2Children = (ng2Element as any).children!();
let ng2ElementDestroyed = false;
let ng2ChildrenDestroyed = false;

ng2Element.data!('foo', 1);
ng2Children.data!('bar', 2);
ng2Element.on!('$destroy', () => ng2ElementDestroyed = true);
ng2Children.on!('$destroy', () => ng2ChildrenDestroyed = true);

expect(element.textContent).toContain('test');
expect(ng2Element.data!('foo')).toBe(1);
expect(ng2Children.data!('bar')).toBe(2);
expect(ng2ElementDestroyed).toBe(false);
expect(ng2ChildrenDestroyed).toBe(false);

onDestroyed.subscribe(() => {
ref.dispose();
setTimeout(() => {
expect(element.textContent).not.toContain('test');
expect(ng2Element.data!('foo')).toBeUndefined();
expect(ng2Children.data!('baz')).toBeUndefined();
expect(ng2ElementDestroyed).toBe(true);
expect(ng2ChildrenDestroyed).toBe(true);

ref.dispose();
});
});
});
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,7 @@ withEachNg1Version(() => {

it('should properly run cleanup when ng1 directive is destroyed', waitForAsync(() => {
let destroyed = false;
@Component({selector: 'ng2', template: 'test'})
@Component({selector: 'ng2', template: '<span>test</span>'})
class Ng2Component implements OnDestroy {
ngOnDestroy() {
destroyed = true;
Expand All @@ -563,14 +563,33 @@ withEachNg1Version(() => {
platformBrowserDynamic().bootstrapModule(Ng2Module).then((ref) => {
const adapter = ref.injector.get(UpgradeModule) as UpgradeModule;
adapter.bootstrap(element, [ng1Module.name]);

const ng2Element = angular.element(element.querySelector('ng2') as Element);
const ng2Children = (ng2Element as any).children!();
let ng2ElementDestroyed = false;
let ng2ChildrenDestroyed = false;

ng2Element.data!('foo', 1);
ng2Children.data!('bar', 2);
ng2Element.on!('$destroy', () => ng2ElementDestroyed = true);
ng2Children.on!('$destroy', () => ng2ChildrenDestroyed = true);

expect(element.textContent).toContain('test');
expect(destroyed).toBe(false);
expect(ng2Element.data!('foo')).toBe(1);
expect(ng2Children.data!('bar')).toBe(2);
expect(ng2ElementDestroyed).toBe(false);
expect(ng2ChildrenDestroyed).toBe(false);

const $rootScope = adapter.$injector.get('$rootScope');
$rootScope.$apply('destroyIt = true');

expect(element.textContent).not.toContain('test');
expect(destroyed).toBe(true);
expect(ng2Element.data!('foo')).toBeUndefined();
expect(ng2Children.data!('baz')).toBeUndefined();
expect(ng2ElementDestroyed).toBe(true);
expect(ng2ChildrenDestroyed).toBe(true);
});
}));

Expand Down
62 changes: 62 additions & 0 deletions packages/upgrade/static/test/integration/downgrade_module_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1187,6 +1187,68 @@ withEachNg1Version(() => {
});
}));

it('should properly run cleanup when a downgraded component is destroyed', waitForAsync(() => {
let destroyed = false;

@Component({selector: 'ng2', template: '<span>test</span>'})
class Ng2Component implements OnDestroy {
ngOnDestroy() {
destroyed = true;
}
}

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

const bootstrapFn = (extraProviders: StaticProvider[]) =>
platformBrowserDynamic(extraProviders).bootstrapModule(Ng2Module);
const lazyModuleName = downgradeModule<Ng2Module>(bootstrapFn);
const ng1Module =
angular.module_('ng1', [lazyModuleName])
.directive(
'ng2', downgradeComponent({component: Ng2Component, propagateDigest}));

const element = html('<div><div ng-if="!hideNg2"><ng2></ng2></div></div>');
const $injector = angular.bootstrap(element, [ng1Module.name]);
const $rootScope = $injector.get($ROOT_SCOPE) as angular.IRootScopeService;

setTimeout(() => { // Wait for the module to be bootstrapped.
setTimeout(() => { // Wait for the hostView to be attached (during the `$digest`).
const ng2Element = angular.element(element.querySelector('ng2') as Element);
const ng2Children = (ng2Element as any).children!();
let ng2ElementDestroyed = false;
let ng2ChildrenDestroyed = false;

ng2Element.data!('foo', 1);
ng2Children.data!('bar', 2);
ng2Element.on!('$destroy', () => ng2ElementDestroyed = true);
ng2Children.on!('$destroy', () => ng2ChildrenDestroyed = true);

expect(element.textContent).toContain('test');
expect(destroyed).toBe(false);
expect(ng2Element.data!('foo')).toBe(1);
expect(ng2Children.data!('bar')).toBe(2);
expect(ng2ElementDestroyed).toBe(false);
expect(ng2ChildrenDestroyed).toBe(false);

$rootScope.$apply('hideNg2 = true');

expect(element.textContent).not.toContain('test');
expect(destroyed).toBe(true);
expect(ng2Element.data!('foo')).toBeUndefined();
expect(ng2Children.data!('baz')).toBeUndefined();
expect(ng2ElementDestroyed).toBe(true);
expect(ng2ChildrenDestroyed).toBe(true);
});
});
}));

it('should only retrieve the Angular zone once (and cache it for later use)',
fakeAsync(() => {
let count = 0;
Expand Down

0 comments on commit 830b3b0

Please sign in to comment.