Skip to content

Commit e1aaa7e

Browse files
crisbetoIgorMinar
authored andcommitted
fix(ivy): component destroy hook called twice when configured as provider (angular#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 angular#28470
1 parent 0ea216b commit e1aaa7e

File tree

12 files changed

+283
-73
lines changed

12 files changed

+283
-73
lines changed

integration/_payload-limits.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"master": {
2222
"uncompressed": {
2323
"runtime": 1440,
24-
"main": 207765,
24+
"main": 209904,
2525
"polyfills": 38390
2626
}
2727
}

packages/core/src/di/r3_injector.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,10 @@ export function isTypeProvider(value: SingleProvider): value is TypeProvider {
467467
return typeof value === 'function';
468468
}
469469

470+
export function isClassProvider(value: SingleProvider): value is ClassProvider {
471+
return !!(value as StaticClassProvider | ClassProvider).useClass;
472+
}
473+
470474
function hasDeps(value: ClassProvider | ConstructorProvider | StaticClassProvider):
471475
value is ClassProvider&{deps: any[]} {
472476
return !!(value as any).deps;

packages/core/src/render3/di.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -520,10 +520,6 @@ export function getNodeInjectable(
520520
setTNodeAndViewData(tNode, lData);
521521
try {
522522
value = lData[index] = factory.factory(null, tData, lData, tNode);
523-
const tView = lData[TVIEW];
524-
if (value && factory.isProvider && value.ngOnDestroy) {
525-
(tView.destroyHooks || (tView.destroyHooks = [])).push(index, value.ngOnDestroy);
526-
}
527523
} finally {
528524
if (factory.injectImpl) setInjectImplementation(previousInjectImplementation);
529525
setIncludeViewProviders(previousIncludeViewProviders);

packages/core/src/render3/di_setup.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88

99

1010
import {resolveForwardRef} from '../di/forward_ref';
11-
import {Provider} from '../di/interface/provider';
12-
import {isTypeProvider, providerToFactory} from '../di/r3_injector';
11+
import {ClassProvider, Provider} from '../di/interface/provider';
12+
import {isClassProvider, isTypeProvider, providerToFactory} from '../di/r3_injector';
1313

1414
import {DirectiveDef} from '.';
1515
import {diPublicInInjector, getNodeInjectable, getOrCreateNodeInjectorForNode} from './di';
@@ -81,10 +81,19 @@ function resolveProvider(
8181
const cptViewProvidersCount =
8282
tNode.providerIndexes >> TNodeProviderIndexes.CptViewProvidersCountShift;
8383

84+
if (isClassProvider(provider) || isTypeProvider(provider)) {
85+
const prototype = ((provider as ClassProvider).useClass || provider).prototype;
86+
const ngOnDestroy = prototype.ngOnDestroy;
87+
88+
if (ngOnDestroy) {
89+
const tView = lView[TVIEW];
90+
(tView.destroyHooks || (tView.destroyHooks = [])).push(tInjectables.length, ngOnDestroy);
91+
}
92+
}
93+
8494
if (isTypeProvider(provider) || !provider.multi) {
8595
// Single provider case: the factory is created and pushed immediately
86-
const factory =
87-
new NodeInjectorFactory(providerFactory, isViewProvider, true, directiveInject);
96+
const factory = new NodeInjectorFactory(providerFactory, isViewProvider, directiveInject);
8897
const existingFactoryIndex = indexOf(
8998
token, tInjectables, isViewProvider ? beginIndex : beginIndex + cptViewProvidersCount,
9099
endIndex);
@@ -246,7 +255,7 @@ function multiFactory(
246255
this: NodeInjectorFactory, _: null, tData: TData, lData: LView, tNode: TElementNode) => any,
247256
index: number, isViewProvider: boolean, isComponent: boolean,
248257
f: () => any): NodeInjectorFactory {
249-
const factory = new NodeInjectorFactory(factoryFn, isViewProvider, true, directiveInject);
258+
const factory = new NodeInjectorFactory(factoryFn, isViewProvider, directiveInject);
250259
factory.multi = [];
251260
factory.index = index;
252261
factory.componentProviders = 0;

packages/core/src/render3/instructions.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2067,8 +2067,7 @@ function baseResolveDirective<T>(
20672067
tView: TView, viewData: LView, def: DirectiveDef<T>,
20682068
directiveFactory: (t: Type<T>| null) => any) {
20692069
tView.data.push(def);
2070-
const nodeInjectorFactory =
2071-
new NodeInjectorFactory(directiveFactory, isComponentDef(def), false, null);
2070+
const nodeInjectorFactory = new NodeInjectorFactory(directiveFactory, isComponentDef(def), null);
20722071
tView.blueprint.push(nodeInjectorFactory);
20732072
viewData.push(nodeInjectorFactory);
20742073
}

packages/core/src/render3/interfaces/injector.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,10 +235,6 @@ export class NodeInjectorFactory {
235235
* Set to `true` if the token is declared in `viewProviders` (or if it is component).
236236
*/
237237
isViewProvider: boolean,
238-
/**
239-
* Set to `true` if the token is a provider, and not a directive.
240-
*/
241-
public isProvider: boolean,
242238
injectImplementation: null|(<T>(token: Type<T>|InjectionToken<T>, flags: InjectFlags) => T)) {
243239
this.canSeeViewProviders = isViewProvider;
244240
this.injectImpl = injectImplementation;

packages/core/src/render3/node_manipulation.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
import {ViewEncapsulation} from '../metadata/view';
1010

1111
import {attachPatchData} from './context_discovery';
12-
import {callHooks} from './hooks';
1312
import {LContainer, NATIVE, VIEWS, unusedValueExportToPlacateAjd as unused1} from './interfaces/container';
1413
import {ComponentDef} from './interfaces/definition';
14+
import {NodeInjectorFactory} from './interfaces/injector';
1515
import {TContainerNode, TElementContainerNode, TElementNode, TNode, TNodeFlags, TNodeType, TViewNode, unusedValueExportToPlacateAjd as unused2} from './interfaces/node';
1616
import {unusedValueExportToPlacateAjd as unused3} from './interfaces/projection';
1717
import {ProceduralRenderer3, RComment, RElement, RNode, RText, Renderer3, isProceduralRenderer, unusedValueExportToPlacateAjd as unused4} from './interfaces/renderer';
@@ -495,8 +495,16 @@ function removeListeners(lView: LView): void {
495495
function executeOnDestroys(view: LView): void {
496496
const tView = view[TVIEW];
497497
let destroyHooks: HookData|null;
498+
498499
if (tView != null && (destroyHooks = tView.destroyHooks) != null) {
499-
callHooks(view, destroyHooks);
500+
for (let i = 0; i < destroyHooks.length; i += 2) {
501+
const context = view[destroyHooks[i] as number];
502+
503+
// Only call the destroy hook if the context has been requested.
504+
if (!(context instanceof NodeInjectorFactory)) {
505+
(destroyHooks[i + 1] as() => void).call(context);
506+
}
507+
}
500508
}
501509
}
502510

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Component, InjectionToken} from '@angular/core';
10+
import {TestBed} from '@angular/core/testing';
11+
12+
13+
describe('component', () => {
14+
describe('view destruction', () => {
15+
it('should invoke onDestroy only once when a component is registered as a provider', () => {
16+
const testToken = new InjectionToken<ParentWithOnDestroy>('testToken');
17+
let destroyCalls = 0;
18+
19+
@Component({
20+
selector: 'comp-with-on-destroy',
21+
template: '',
22+
providers: [{provide: testToken, useExisting: ParentWithOnDestroy}]
23+
})
24+
class ParentWithOnDestroy {
25+
ngOnDestroy() { destroyCalls++; }
26+
}
27+
28+
@Component({selector: 'child', template: ''})
29+
class ChildComponent {
30+
// We need to inject the parent so the provider is instantiated.
31+
constructor(_parent: ParentWithOnDestroy) {}
32+
}
33+
34+
@Component({
35+
template: `
36+
<comp-with-on-destroy>
37+
<child></child>
38+
</comp-with-on-destroy>
39+
`
40+
})
41+
class App {
42+
}
43+
44+
TestBed.configureTestingModule({declarations: [App, ParentWithOnDestroy, ChildComponent]});
45+
const fixture = TestBed.createComponent(App);
46+
fixture.detectChanges();
47+
fixture.destroy();
48+
49+
expect(destroyCalls).toBe(1, 'Expected `ngOnDestroy` to only be called once.');
50+
});
51+
});
52+
});
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Component, Injectable} from '@angular/core';
10+
import {TestBed} from '@angular/core/testing';
11+
import {onlyInIvy} from '@angular/private/testing';
12+
13+
14+
describe('providers', () => {
15+
describe('lifecycles', () => {
16+
it('should inherit ngOnDestroy hooks on providers', () => {
17+
const logs: string[] = [];
18+
19+
@Injectable()
20+
class SuperInjectableWithDestroyHook {
21+
ngOnDestroy() { logs.push('OnDestroy'); }
22+
}
23+
24+
@Injectable()
25+
class SubInjectableWithDestroyHook extends SuperInjectableWithDestroyHook {
26+
}
27+
28+
@Component({template: '', providers: [SubInjectableWithDestroyHook]})
29+
class App {
30+
constructor(foo: SubInjectableWithDestroyHook) {}
31+
}
32+
33+
TestBed.configureTestingModule({declarations: [App]});
34+
const fixture = TestBed.createComponent(App);
35+
fixture.detectChanges();
36+
fixture.destroy();
37+
38+
expect(logs).toEqual(['OnDestroy']);
39+
});
40+
41+
it('should not call ngOnDestroy for providers that have not been requested', () => {
42+
const logs: string[] = [];
43+
44+
@Injectable()
45+
class InjectableWithDestroyHook {
46+
ngOnDestroy() { logs.push('OnDestroy'); }
47+
}
48+
49+
@Component({template: '', providers: [InjectableWithDestroyHook]})
50+
class App {
51+
}
52+
53+
TestBed.configureTestingModule({declarations: [App]});
54+
const fixture = TestBed.createComponent(App);
55+
fixture.detectChanges();
56+
fixture.destroy();
57+
58+
expect(logs).toEqual([]);
59+
});
60+
61+
it('should only call ngOnDestroy once for multiple instances', () => {
62+
const logs: string[] = [];
63+
64+
@Injectable()
65+
class InjectableWithDestroyHook {
66+
ngOnDestroy() { logs.push('OnDestroy'); }
67+
}
68+
69+
@Component({selector: 'my-cmp', template: ''})
70+
class MyComponent {
71+
constructor(foo: InjectableWithDestroyHook) {}
72+
}
73+
74+
@Component({
75+
template: `
76+
<my-cmp></my-cmp>
77+
<my-cmp></my-cmp>
78+
`,
79+
providers: [InjectableWithDestroyHook]
80+
})
81+
class App {
82+
}
83+
84+
TestBed.configureTestingModule({declarations: [App, MyComponent]});
85+
const fixture = TestBed.createComponent(App);
86+
fixture.detectChanges();
87+
fixture.destroy();
88+
89+
expect(logs).toEqual(['OnDestroy']);
90+
});
91+
92+
it('should call ngOnDestroy when providing same token via useClass', () => {
93+
const logs: string[] = [];
94+
95+
@Injectable()
96+
class InjectableWithDestroyHook {
97+
ngOnDestroy() { logs.push('OnDestroy'); }
98+
}
99+
100+
@Component({
101+
template: '',
102+
providers: [{provide: InjectableWithDestroyHook, useClass: InjectableWithDestroyHook}]
103+
})
104+
class App {
105+
constructor(foo: InjectableWithDestroyHook) {}
106+
}
107+
108+
TestBed.configureTestingModule({declarations: [App]});
109+
const fixture = TestBed.createComponent(App);
110+
fixture.detectChanges();
111+
fixture.destroy();
112+
113+
expect(logs).toEqual(['OnDestroy']);
114+
});
115+
116+
onlyInIvy('Destroy hook of useClass provider is invoked correctly')
117+
.it('should only call ngOnDestroy of value when providing via useClass', () => {
118+
const logs: string[] = [];
119+
120+
@Injectable()
121+
class InjectableWithDestroyHookToken {
122+
ngOnDestroy() { logs.push('OnDestroy Token'); }
123+
}
124+
125+
@Injectable()
126+
class InjectableWithDestroyHookValue {
127+
ngOnDestroy() { logs.push('OnDestroy Value'); }
128+
}
129+
130+
@Component({
131+
template: '',
132+
providers: [
133+
{provide: InjectableWithDestroyHookToken, useClass: InjectableWithDestroyHookValue}
134+
]
135+
})
136+
class App {
137+
constructor(foo: InjectableWithDestroyHookToken) {}
138+
}
139+
140+
TestBed.configureTestingModule({declarations: [App]});
141+
const fixture = TestBed.createComponent(App);
142+
fixture.detectChanges();
143+
fixture.destroy();
144+
145+
expect(logs).toEqual(['OnDestroy Value']);
146+
});
147+
148+
it('should only call ngOnDestroy of value when providing via useExisting', () => {
149+
const logs: string[] = [];
150+
151+
@Injectable()
152+
class InjectableWithDestroyHookToken {
153+
ngOnDestroy() { logs.push('OnDestroy Token'); }
154+
}
155+
156+
@Injectable()
157+
class InjectableWithDestroyHookExisting {
158+
ngOnDestroy() { logs.push('OnDestroy Existing'); }
159+
}
160+
161+
@Component({
162+
template: '',
163+
providers: [
164+
InjectableWithDestroyHookExisting, {
165+
provide: InjectableWithDestroyHookToken,
166+
useExisting: InjectableWithDestroyHookExisting
167+
}
168+
]
169+
})
170+
class App {
171+
constructor(foo1: InjectableWithDestroyHookExisting, foo2: InjectableWithDestroyHookToken) {
172+
}
173+
}
174+
175+
TestBed.configureTestingModule({declarations: [App]});
176+
const fixture = TestBed.createComponent(App);
177+
fixture.detectChanges();
178+
fixture.destroy();
179+
180+
expect(logs).toEqual(['OnDestroy Existing']);
181+
});
182+
183+
});
184+
});

0 commit comments

Comments
 (0)