fix(upgrade): do not break if `onMicrotaskEmpty` emits while a `$digest` is in progress (#29794)

Previously, under certain circumstances, `NgZone#onMicrotaskEmpty` could
emit while a `$digest` was in progress, thus triggering another
`$digest`, which in turn would throw a `$digest already in progress`
error. Furthermore, throwing an error from inside the `onMicrotaskEmpty`
subscription would result in unsubscribing and stop triggering further
`$digest`s, when `onMicrotaskEmpty` emitted.

Usually, emitting while a `$digest` was already in progress was a result
of unintentionally running some part of AngularJS outside the Angular
zone, but there are valid (if rare) usecases where this can happen
(see #24680 for details).

This commit addresses the issue as follows:
- If a `$digest` is in progress when `onMicrotaskEmpty` emits, do not
  trigger another `$digest` (to avoid the error). `$evalAsync()` is used
  instead, to ensure that the bindings are evaluated at least once more.
- Since there is still a high probability that the situation is a result
  of programming error (i.e. some AngularJS part running outside the
  Angular Zone), a warning will be logged, but only if the app is in
  [dev mode][1].

[1]: https://github.com/angular/angular/blob/78146c189/packages/core/src/util/ng_dev_mode.ts#L12

Fixes #24680

PR Close #29794
This commit is contained in:
Sam Julien 2019-04-23 11:13:50 +03:00 committed by Andrew Kushnir
parent 2dc4e8801c
commit 0ddf2e7a5b
4 changed files with 124 additions and 9 deletions

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Compiler, CompilerOptions, Injector, NgModule, NgModuleRef, NgZone, StaticProvider, Testability, Type, resolveForwardRef} from '@angular/core';
import {Compiler, CompilerOptions, Injector, NgModule, NgModuleRef, NgZone, StaticProvider, Testability, Type, isDevMode, resolveForwardRef} from '@angular/core';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {IAngularBootstrapConfig, IAugmentedJQuery, IInjectorService, IModule, IProvideService, IRootScopeService, ITestabilityService, bootstrap, element as angularElement, module_ as angularModule} from '../../common/src/angular1';
@ -598,8 +598,20 @@ export class UpgradeAdapter {
})
.then(() => this.ng2BootstrapDeferred.resolve(ng1Injector), onError)
.then(() => {
let subscription =
this.ngZone.onMicrotaskEmpty.subscribe({next: () => rootScope.$digest()});
let subscription = this.ngZone.onMicrotaskEmpty.subscribe({
next: () => {
if (rootScope.$$phase) {
if (isDevMode()) {
console.warn(
'A digest was triggered while one was already in progress. This may mean that something is triggering digests outside the Angular zone.');
}
return rootScope.$evalAsync(() => {});
}
return rootScope.$digest();
}
});
rootScope.$on('$destroy', () => { subscription.unsubscribe(); });
});
})

View File

@ -169,7 +169,46 @@ withEachNg1Version(() => {
}));
});
describe('scope/component change-detection', () => {
describe('change-detection', () => {
it('should not break if a $digest is already in progress', async(() => {
@Component({selector: 'my-app', template: ''})
class AppComponent {
}
@NgModule({declarations: [AppComponent], imports: [BrowserModule]})
class Ng2Module {
}
const ng1Module = angular.module('ng1', []);
const adapter: UpgradeAdapter = new UpgradeAdapter(forwardRef(() => Ng2Module));
const element = html('<my-app></my-app>');
adapter.bootstrap(element, [ng1Module.name]).ready((ref) => {
const $rootScope: any = ref.ng1RootScope;
const ngZone: NgZone = ref.ng2ModuleRef.injector.get<NgZone>(NgZone);
const digestSpy = spyOn($rootScope, '$digest').and.callThrough();
// Step 1: Ensure `$digest` is run on `onMicrotaskEmpty`.
ngZone.onMicrotaskEmpty.emit(null);
expect(digestSpy).toHaveBeenCalledTimes(1);
digestSpy.calls.reset();
// Step 2: Cause the issue.
$rootScope.$apply(() => ngZone.onMicrotaskEmpty.emit(null));
// With the fix, `$digest` will only be run once (for `$apply()`).
// Without the fix, `$digest()` would have been run an extra time (`onMicrotaskEmpty`).
expect(digestSpy).toHaveBeenCalledTimes(1);
digestSpy.calls.reset();
// Step 3: Ensure that `$digest()` is still executed on `onMicrotaskEmpty`.
ngZone.onMicrotaskEmpty.emit(null);
expect(digestSpy).toHaveBeenCalledTimes(1);
});
}));
it('should interleave scope and component expressions', async(() => {
const adapter: UpgradeAdapter = new UpgradeAdapter(forwardRef(() => Ng2Module));
const ng1Module = angular.module_('ng1', []);
@ -215,7 +254,6 @@ withEachNg1Version(() => {
});
}));
it('should propagate changes to a downgraded component inside the ngZone', async(() => {
let appComponent: AppComponent;
let upgradeRef: UpgradeAdapterRef;

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Injector, NgModule, NgZone, Testability} from '@angular/core';
import {Injector, NgModule, NgZone, Testability, isDevMode} from '@angular/core';
import {IInjectorService, IIntervalService, IProvideService, ITestabilityService, bootstrap, element as angularElement, module_ as angularModule} from '../../src/common/src/angular1';
import {$$TESTABILITY, $DELEGATE, $INJECTOR, $INTERVAL, $PROVIDE, INJECTOR_KEY, LAZY_MODULE_REF, UPGRADE_APP_TYPE_KEY, UPGRADE_MODULE_NAME} from '../../src/common/src/constants';
@ -255,8 +255,18 @@ export class UpgradeModule {
// stabilizing
setTimeout(() => {
const $rootScope = $injector.get('$rootScope');
const subscription =
this.ngZone.onMicrotaskEmpty.subscribe(() => $rootScope.$digest());
const subscription = this.ngZone.onMicrotaskEmpty.subscribe(() => {
if ($rootScope.$$phase) {
if (isDevMode()) {
console.warn(
'A digest was triggered while one was already in progress. This may mean that something is triggering digests outside the Angular zone.');
}
return $rootScope.$evalAsync();
}
return $rootScope.$digest();
});
$rootScope.$on('$destroy', () => { subscription.unsubscribe(); });
}, 0);
}

View File

@ -18,10 +18,65 @@ import {html, withEachNg1Version} from '../../../src/common/test/helpers/common_
import {bootstrap} from './static_test_helpers';
withEachNg1Version(() => {
describe('scope/component change-detection', () => {
describe('change-detection', () => {
beforeEach(() => destroyPlatform());
afterEach(() => destroyPlatform());
it('should not break if a $digest is already in progress', async(() => {
const element = html('<my-app></my-app>');
@Component({selector: 'my-app', template: ''})
class AppComponent {
}
@NgModule({
declarations: [AppComponent],
entryComponents: [AppComponent],
imports: [BrowserModule, UpgradeModule]
})
class Ng2Module {
ngDoBootstrap() {}
}
const ng1Module = angular.module('ng1', []).directive(
'myApp', downgradeComponent({component: AppComponent}));
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then((upgrade) => {
const $rootScope = upgrade.$injector.get('$rootScope') as angular.IRootScopeService;
const ngZone: NgZone = upgrade.ngZone;
// Wrap in a setTimeout to ensure all boostrap operations have completed.
setTimeout(
// Run inside the Angular zone, so that operations such as emitting
// `onMicrotaskEmpty` do not trigger entering/existing the zone (and thus another
// `$digest`). This also closer simulates what would happen in a real app.
() => ngZone.run(() => {
const digestSpy = spyOn($rootScope, '$digest').and.callThrough();
// Step 1: Ensure `$digest` is run on `onMicrotaskEmpty`.
ngZone.onMicrotaskEmpty.emit(null);
expect(digestSpy).toHaveBeenCalledTimes(1);
digestSpy.calls.reset();
// Step 2: Cause the issue.
$rootScope.$apply(() => ngZone.onMicrotaskEmpty.emit(null));
// With the fix, `$digest` will only be run once (for `$apply()`).
// Without the fix, `$digest()` would have been run an extra time
// (`onMicrotaskEmpty`).
expect(digestSpy).toHaveBeenCalledTimes(1);
digestSpy.calls.reset();
// Step 3: Ensure that `$digest()` is still executed on `onMicrotaskEmpty`.
ngZone.onMicrotaskEmpty.emit(null);
expect(digestSpy).toHaveBeenCalledTimes(1);
}),
0);
});
}));
it('should interleave scope and component expressions', async(() => {
const log: string[] = [];
const l = (value: string) => {