fix(ivy): component destroy hook called twice when configured as provider (#28470)

Fixes the `ngOnDestroy` hook on a component or directive being called twice, if the type is also registered as a provider.

This PR resolves FW-1010.

PR Close #28470
This commit is contained in:
Kristiyan Kostadinov
2019-01-31 10:38:43 +01:00
committed by Igor Minar
parent 0ea216b993
commit e1aaa7ec48
12 changed files with 285 additions and 75 deletions
@@ -0,0 +1,52 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Component, InjectionToken} from '@angular/core';
import {TestBed} from '@angular/core/testing';
describe('component', () => {
describe('view destruction', () => {
it('should invoke onDestroy only once when a component is registered as a provider', () => {
const testToken = new InjectionToken<ParentWithOnDestroy>('testToken');
let destroyCalls = 0;
@Component({
selector: 'comp-with-on-destroy',
template: '',
providers: [{provide: testToken, useExisting: ParentWithOnDestroy}]
})
class ParentWithOnDestroy {
ngOnDestroy() { destroyCalls++; }
}
@Component({selector: 'child', template: ''})
class ChildComponent {
// We need to inject the parent so the provider is instantiated.
constructor(_parent: ParentWithOnDestroy) {}
}
@Component({
template: `
<comp-with-on-destroy>
<child></child>
</comp-with-on-destroy>
`
})
class App {
}
TestBed.configureTestingModule({declarations: [App, ParentWithOnDestroy, ChildComponent]});
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
fixture.destroy();
expect(destroyCalls).toBe(1, 'Expected `ngOnDestroy` to only be called once.');
});
});
});
@@ -0,0 +1,184 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Component, Injectable} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {onlyInIvy} from '@angular/private/testing';
describe('providers', () => {
describe('lifecycles', () => {
it('should inherit ngOnDestroy hooks on providers', () => {
const logs: string[] = [];
@Injectable()
class SuperInjectableWithDestroyHook {
ngOnDestroy() { logs.push('OnDestroy'); }
}
@Injectable()
class SubInjectableWithDestroyHook extends SuperInjectableWithDestroyHook {
}
@Component({template: '', providers: [SubInjectableWithDestroyHook]})
class App {
constructor(foo: SubInjectableWithDestroyHook) {}
}
TestBed.configureTestingModule({declarations: [App]});
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
fixture.destroy();
expect(logs).toEqual(['OnDestroy']);
});
it('should not call ngOnDestroy for providers that have not been requested', () => {
const logs: string[] = [];
@Injectable()
class InjectableWithDestroyHook {
ngOnDestroy() { logs.push('OnDestroy'); }
}
@Component({template: '', providers: [InjectableWithDestroyHook]})
class App {
}
TestBed.configureTestingModule({declarations: [App]});
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
fixture.destroy();
expect(logs).toEqual([]);
});
it('should only call ngOnDestroy once for multiple instances', () => {
const logs: string[] = [];
@Injectable()
class InjectableWithDestroyHook {
ngOnDestroy() { logs.push('OnDestroy'); }
}
@Component({selector: 'my-cmp', template: ''})
class MyComponent {
constructor(foo: InjectableWithDestroyHook) {}
}
@Component({
template: `
<my-cmp></my-cmp>
<my-cmp></my-cmp>
`,
providers: [InjectableWithDestroyHook]
})
class App {
}
TestBed.configureTestingModule({declarations: [App, MyComponent]});
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
fixture.destroy();
expect(logs).toEqual(['OnDestroy']);
});
it('should call ngOnDestroy when providing same token via useClass', () => {
const logs: string[] = [];
@Injectable()
class InjectableWithDestroyHook {
ngOnDestroy() { logs.push('OnDestroy'); }
}
@Component({
template: '',
providers: [{provide: InjectableWithDestroyHook, useClass: InjectableWithDestroyHook}]
})
class App {
constructor(foo: InjectableWithDestroyHook) {}
}
TestBed.configureTestingModule({declarations: [App]});
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
fixture.destroy();
expect(logs).toEqual(['OnDestroy']);
});
onlyInIvy('Destroy hook of useClass provider is invoked correctly')
.it('should only call ngOnDestroy of value when providing via useClass', () => {
const logs: string[] = [];
@Injectable()
class InjectableWithDestroyHookToken {
ngOnDestroy() { logs.push('OnDestroy Token'); }
}
@Injectable()
class InjectableWithDestroyHookValue {
ngOnDestroy() { logs.push('OnDestroy Value'); }
}
@Component({
template: '',
providers: [
{provide: InjectableWithDestroyHookToken, useClass: InjectableWithDestroyHookValue}
]
})
class App {
constructor(foo: InjectableWithDestroyHookToken) {}
}
TestBed.configureTestingModule({declarations: [App]});
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
fixture.destroy();
expect(logs).toEqual(['OnDestroy Value']);
});
it('should only call ngOnDestroy of value when providing via useExisting', () => {
const logs: string[] = [];
@Injectable()
class InjectableWithDestroyHookToken {
ngOnDestroy() { logs.push('OnDestroy Token'); }
}
@Injectable()
class InjectableWithDestroyHookExisting {
ngOnDestroy() { logs.push('OnDestroy Existing'); }
}
@Component({
template: '',
providers: [
InjectableWithDestroyHookExisting, {
provide: InjectableWithDestroyHookToken,
useExisting: InjectableWithDestroyHookExisting
}
]
})
class App {
constructor(foo1: InjectableWithDestroyHookExisting, foo2: InjectableWithDestroyHookToken) {
}
}
TestBed.configureTestingModule({declarations: [App]});
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
fixture.destroy();
expect(logs).toEqual(['OnDestroy Existing']);
});
});
});
+18 -17
View File
@@ -6,9 +6,9 @@
* found in the LICENSE file at https://angular.io/license
*/
import {ViewEncapsulation, defineInjectable, defineInjector} from '../../src/core';
import {InjectionToken, ViewEncapsulation, defineInjectable, defineInjector} from '../../src/core';
import {AttributeMarker, ComponentFactory, LifecycleHooksFeature, defineComponent, directiveInject, markDirty, template, getRenderedText} from '../../src/render3/index';
import {AttributeMarker, ComponentFactory, LifecycleHooksFeature, defineComponent, directiveInject, markDirty, template, getRenderedText, ProvidersFeature} from '../../src/render3/index';
import {bind, container, containerRefreshEnd, containerRefreshStart, element, elementEnd, elementProperty, elementStart, embeddedViewEnd, embeddedViewStart, nextContext, text, textBinding, tick} from '../../src/render3/instructions';
import {ComponentDef, RenderFlags} from '../../src/render3/interfaces/definition';
@@ -669,22 +669,22 @@ describe('recursive components', () => {
});
describe('view destruction', () => {
let wasOnDestroyCalled = false;
class ComponentWithOnDestroy {
static ngComponentDef = defineComponent({
selectors: [['comp-with-destroy']],
type: ComponentWithOnDestroy,
consts: 0,
vars: 0,
factory: () => new ComponentWithOnDestroy(),
template: (rf: any, ctx: any) => {},
});
ngOnDestroy() { wasOnDestroyCalled = true; }
}
it('should invoke onDestroy when directly destroying a root view', () => {
let wasOnDestroyCalled = false;
class ComponentWithOnDestroy {
static ngComponentDef = defineComponent({
selectors: [['comp-with-destroy']],
type: ComponentWithOnDestroy,
consts: 0,
vars: 0,
factory: () => new ComponentWithOnDestroy(),
template: (rf: any, ctx: any) => {},
});
ngOnDestroy() { wasOnDestroyCalled = true; }
}
// This test asserts that the view tree is set up correctly based on the knowledge that this
// tree is used during view destruction. If the child view is not correctly attached as a
// child of the root view, then the onDestroy hook on the child view will never be called
@@ -704,4 +704,5 @@ describe('view destruction', () => {
true,
'Expected component onDestroy method to be called when its parent view is destroyed');
});
});
@@ -1355,6 +1355,7 @@ describe('providers', () => {
expect(fixture.html).toEqual('<div></div>');
expect(logs).toEqual(['Injectable OnDestroy']);
});
});
});
interface ComponentTest {