Skip to content

Commit e55326f

Browse files
committed
fix(elements): run strategy methods in correct zone
Default change detection fails in some cases for @angular/elements where component events are called from the wrong zone. This fixes the issue by running all ComponentNgElementStrategy methods in the same zone it was created in. Fixes angular#24181
1 parent 196bfa8 commit e55326f

File tree

3 files changed

+107
-44
lines changed

3 files changed

+107
-44
lines changed

goldens/size-tracking/aio-payloads.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"master": {
1313
"uncompressed": {
1414
"runtime-es2015": 2987,
15-
"main-es2015": 450883,
15+
"main-es2015": 451395,
1616
"polyfills-es2015": 52630
1717
}
1818
}

packages/elements/src/component-factory-strategy.ts

+61-40
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {ApplicationRef, ComponentFactory, ComponentFactoryResolver, ComponentRef, EventEmitter, Injector, OnChanges, SimpleChange, SimpleChanges, Type} from '@angular/core';
9+
import {ApplicationRef, ComponentFactory, ComponentFactoryResolver, ComponentRef, EventEmitter, Injector, NgZone, OnChanges, SimpleChange, SimpleChanges, Type} from '@angular/core';
1010
import {merge, Observable, ReplaySubject} from 'rxjs';
1111
import {map, switchMap} from 'rxjs/operators';
1212

@@ -73,78 +73,94 @@ export class ComponentNgElementStrategy implements NgElementStrategy {
7373
*/
7474
private readonly unchangedInputs = new Set<string>();
7575

76+
/** Service for setting zone context. */
77+
private readonly ngZone = this.injector.get<NgZone>(NgZone);
78+
79+
/** The zone the element was created in or `null` if Zone.js is not loaded. */
80+
private readonly elementZone =
81+
(typeof Zone === 'undefined') ? null : this.ngZone.run(() => Zone.current);
82+
7683
constructor(private componentFactory: ComponentFactory<any>, private injector: Injector) {}
7784

7885
/**
7986
* Initializes a new component if one has not yet been created and cancels any scheduled
8087
* destruction.
8188
*/
8289
connect(element: HTMLElement) {
83-
// If the element is marked to be destroyed, cancel the task since the component was reconnected
84-
if (this.scheduledDestroyFn !== null) {
85-
this.scheduledDestroyFn();
86-
this.scheduledDestroyFn = null;
87-
return;
88-
}
90+
this.runInZone(() => {
91+
// If the element is marked to be destroyed, cancel the task since the component was
92+
// reconnected
93+
if (this.scheduledDestroyFn !== null) {
94+
this.scheduledDestroyFn();
95+
this.scheduledDestroyFn = null;
96+
return;
97+
}
8998

90-
if (this.componentRef === null) {
91-
this.initializeComponent(element);
92-
}
99+
if (this.componentRef === null) {
100+
this.initializeComponent(element);
101+
}
102+
});
93103
}
94104

95105
/**
96106
* Schedules the component to be destroyed after some small delay in case the element is just
97107
* being moved across the DOM.
98108
*/
99109
disconnect() {
100-
// Return if there is no componentRef or the component is already scheduled for destruction
101-
if (this.componentRef === null || this.scheduledDestroyFn !== null) {
102-
return;
103-
}
104-
105-
// Schedule the component to be destroyed after a small timeout in case it is being
106-
// moved elsewhere in the DOM
107-
this.scheduledDestroyFn = scheduler.schedule(() => {
108-
if (this.componentRef !== null) {
109-
this.componentRef.destroy();
110-
this.componentRef = null;
110+
this.runInZone(() => {
111+
// Return if there is no componentRef or the component is already scheduled for destruction
112+
if (this.componentRef === null || this.scheduledDestroyFn !== null) {
113+
return;
111114
}
112-
}, DESTROY_DELAY);
115+
116+
// Schedule the component to be destroyed after a small timeout in case it is being
117+
// moved elsewhere in the DOM
118+
this.scheduledDestroyFn = scheduler.schedule(() => {
119+
if (this.componentRef !== null) {
120+
this.componentRef.destroy();
121+
this.componentRef = null;
122+
}
123+
}, DESTROY_DELAY);
124+
});
113125
}
114126

115127
/**
116128
* Returns the component property value. If the component has not yet been created, the value is
117129
* retrieved from the cached initialization values.
118130
*/
119131
getInputValue(property: string): any {
120-
if (this.componentRef === null) {
121-
return this.initialInputValues.get(property);
122-
}
132+
return this.runInZone(() => {
133+
if (this.componentRef === null) {
134+
return this.initialInputValues.get(property);
135+
}
123136

124-
return this.componentRef.instance[property];
137+
return this.componentRef.instance[property];
138+
});
125139
}
126140

127141
/**
128142
* Sets the input value for the property. If the component has not yet been created, the value is
129143
* cached and set when the component is created.
130144
*/
131145
setInputValue(property: string, value: any): void {
132-
if (this.componentRef === null) {
133-
this.initialInputValues.set(property, value);
134-
return;
135-
}
146+
this.runInZone(() => {
147+
if (this.componentRef === null) {
148+
this.initialInputValues.set(property, value);
149+
return;
150+
}
136151

137-
// Ignore the value if it is strictly equal to the current value, except if it is `undefined`
138-
// and this is the first change to the value (because an explicit `undefined` _is_ strictly
139-
// equal to not having a value set at all, but we still need to record this as a change).
140-
if (strictEquals(value, this.getInputValue(property)) &&
141-
!((value === undefined) && this.unchangedInputs.has(property))) {
142-
return;
143-
}
152+
// Ignore the value if it is strictly equal to the current value, except if it is `undefined`
153+
// and this is the first change to the value (because an explicit `undefined` _is_ strictly
154+
// equal to not having a value set at all, but we still need to record this as a change).
155+
if (strictEquals(value, this.getInputValue(property)) &&
156+
!((value === undefined) && this.unchangedInputs.has(property))) {
157+
return;
158+
}
144159

145-
this.recordInputChange(property, value);
146-
this.componentRef.instance[property] = value;
147-
this.scheduleDetectChanges();
160+
this.recordInputChange(property, value);
161+
this.componentRef.instance[property] = value;
162+
this.scheduleDetectChanges();
163+
});
148164
}
149165

150166
/**
@@ -264,4 +280,9 @@ export class ComponentNgElementStrategy implements NgElementStrategy {
264280
this.callNgOnChanges(this.componentRef);
265281
this.componentRef.changeDetectorRef.detectChanges();
266282
}
283+
284+
/** Runs in the angular zone, if present. */
285+
private runInZone(fn: () => unknown) {
286+
return (this.elementZone && Zone.current !== this.elementZone) ? this.ngZone.run(fn) : fn();
287+
}
267288
}

packages/elements/test/component-factory-strategy_spec.ts

+45-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {ComponentFactory, ComponentRef, Injector, NgModuleRef, SimpleChange, SimpleChanges, Type} from '@angular/core';
9+
import {ApplicationRef, ComponentFactory, ComponentFactoryResolver, ComponentRef, Injector, NgModuleRef, NgZone, SimpleChange, SimpleChanges, Type} from '@angular/core';
1010
import {fakeAsync, tick} from '@angular/core/testing';
1111
import {Subject} from 'rxjs';
1212

@@ -20,22 +20,40 @@ describe('ComponentFactoryNgElementStrategy', () => {
2020
let injector: any;
2121
let componentRef: any;
2222
let applicationRef: any;
23+
let ngZone: any;
24+
25+
let injectables: Map<unknown, unknown>;
2326

2427
beforeEach(() => {
2528
factory = new FakeComponentFactory();
2629
componentRef = factory.componentRef;
2730

2831
applicationRef = jasmine.createSpyObj('applicationRef', ['attachView']);
32+
33+
ngZone = jasmine.createSpyObj('ngZone', ['run']);
34+
ngZone.run.and.callFake((fn: () => unknown) => fn());
35+
2936
injector = jasmine.createSpyObj('injector', ['get']);
30-
injector.get.and.returnValue(applicationRef);
37+
injector.get.and.callFake((token: unknown) => {
38+
if (!injectables.has(token)) {
39+
throw new Error(`Failed to get injectable from mock injector: ${token}`);
40+
}
41+
return injectables.get(token);
42+
});
43+
44+
injectables = new Map<unknown, unknown>([
45+
[ApplicationRef, applicationRef],
46+
[NgZone, ngZone],
47+
]);
3148

3249
strategy = new ComponentNgElementStrategy(factory, injector);
50+
ngZone.run.calls.reset();
3351
});
3452

3553
it('should create a new strategy from the factory', () => {
3654
const factoryResolver = jasmine.createSpyObj('factoryResolver', ['resolveComponentFactory']);
3755
factoryResolver.resolveComponentFactory.and.returnValue(factory);
38-
injector.get.and.returnValue(factoryResolver);
56+
injectables.set(ComponentFactoryResolver, factoryResolver);
3957

4058
const strategyFactory = new ComponentNgElementStrategyFactory(FakeComponent, injector);
4159
expect(strategyFactory.create(injector)).toBeTruthy();
@@ -266,6 +284,30 @@ describe('ComponentFactoryNgElementStrategy', () => {
266284
expect(componentRef.destroy).toHaveBeenCalledTimes(1);
267285
}));
268286
});
287+
288+
describe('runInZone', () => {
289+
const param = 'foofoo';
290+
const fn = () => param;
291+
292+
it('should run the callback directly when invoked in element\'s zone', () => {
293+
expect(strategy['runInZone'](fn)).toEqual('foofoo');
294+
expect(ngZone.run).not.toHaveBeenCalled();
295+
});
296+
297+
it('should run the callback inside the element\'s zone when invoked in a different zone',
298+
() => {
299+
expect(Zone.root.run(() => (strategy['runInZone'](fn)))).toEqual('foofoo');
300+
expect(ngZone.run).toHaveBeenCalledWith(fn);
301+
});
302+
303+
it('should run the callback directly when called without zone.js loaded', () => {
304+
// simulate no zone.js loaded
305+
(strategy as any)['elementZone'] = null;
306+
307+
expect(Zone.root.run(() => (strategy['runInZone'](fn)))).toEqual('foofoo');
308+
expect(ngZone.run).not.toHaveBeenCalled();
309+
});
310+
});
269311
});
270312

271313
export class FakeComponentWithoutNgOnChanges {

0 commit comments

Comments
 (0)