From c4817988cad2b097d4a7a5933fc68cae3a4f2667 Mon Sep 17 00:00:00 2001 From: Vikram Subramanian Date: Fri, 3 Feb 2017 05:42:22 -0800 Subject: [PATCH] feat(core): add isStable Observable property to ApplicationRef to indicate when it's stable and unstable (#14337) PR Close #14337 --- modules/@angular/core/src/application_ref.ts | 58 ++++++- .../core/test/application_ref_spec.ts | 145 +++++++++++++++++- tools/public_api_guard/core/index.d.ts | 1 + 3 files changed, 197 insertions(+), 7 deletions(-) diff --git a/modules/@angular/core/src/application_ref.ts b/modules/@angular/core/src/application_ref.ts index c5e12e4f86..13b18dfca2 100644 --- a/modules/@angular/core/src/application_ref.ts +++ b/modules/@angular/core/src/application_ref.ts @@ -6,9 +6,16 @@ * found in the LICENSE file at https://angular.io/license */ +import {Observable} from 'rxjs/Observable'; +import {Observer} from 'rxjs/Observer'; +import {Subject} from 'rxjs/Subject'; +import {Subscription} from 'rxjs/Subscription'; +import {merge} from 'rxjs/observable/merge'; +import {share} from 'rxjs/operator/share'; + import {ErrorHandler} from '../src/error_handler'; import {ListWrapper} from '../src/facade/collection'; -import {stringify} from '../src/facade/lang'; +import {scheduleMicroTask, stringify} from '../src/facade/lang'; import {isPromise} from '../src/util/lang'; import {ApplicationInitStatus} from './application_init'; @@ -395,6 +402,11 @@ export abstract class ApplicationRef { * Returns the number of attached views. */ abstract get viewCount(): number; + + /** + * Returns an Observable that indicates when the application is stable or unstable. + */ + abstract get isStable(): Observable; } /** @@ -412,6 +424,8 @@ export class ApplicationRef_ extends ApplicationRef { private _views: AppView[] = []; private _runningTick: boolean = false; private _enforceNoNewChanges: boolean = false; + private _isStable: Observable; + private _stable = true; constructor( private _zone: NgZone, private _console: Console, private _injector: Injector, @@ -425,6 +439,46 @@ export class ApplicationRef_ extends ApplicationRef { this._zone.onMicrotaskEmpty.subscribe( {next: () => { this._zone.run(() => { this.tick(); }); }}); + + const isCurrentlyStable = new Observable((observer: Observer) => { + this._stable = this._zone.isStable && !this._zone.hasPendingMacrotasks && + !this._zone.hasPendingMicrotasks; + this._zone.runOutsideAngular(() => { + observer.next(this._stable); + observer.complete(); + }); + }); + + const isStable = new Observable((observer: Observer) => { + const stableSub: Subscription = this._zone.onStable.subscribe(() => { + NgZone.assertNotInAngularZone(); + + // Check whether there are no pending macro/micro tasks in the next tick + // to allow for NgZone to update the state. + scheduleMicroTask(() => { + if (!this._stable && !this._zone.hasPendingMacrotasks && + !this._zone.hasPendingMicrotasks) { + this._stable = true; + observer.next(true); + } + }); + }); + + const unstableSub: Subscription = this._zone.onUnstable.subscribe(() => { + NgZone.assertInAngularZone(); + if (this._stable) { + this._stable = false; + this._zone.runOutsideAngular(() => { observer.next(false); }); + } + }); + + return () => { + stableSub.unsubscribe(); + unstableSub.unsubscribe(); + }; + }); + + this._isStable = merge(isCurrentlyStable, share.call(isStable)); } attachView(viewRef: ViewRef): void { @@ -510,4 +564,6 @@ export class ApplicationRef_ extends ApplicationRef { get componentTypes(): Type[] { return this._rootComponentTypes; } get components(): ComponentRef[] { return this._rootComponents; } + + get isStable(): Observable { return this._isStable; } } diff --git a/modules/@angular/core/test/application_ref_spec.ts b/modules/@angular/core/test/application_ref_spec.ts index 9689edd605..499c1beeee 100644 --- a/modules/@angular/core/test/application_ref_spec.ts +++ b/modules/@angular/core/test/application_ref_spec.ts @@ -6,17 +6,18 @@ * found in the LICENSE file at https://angular.io/license */ -import {APP_BOOTSTRAP_LISTENER, APP_INITIALIZER, CompilerFactory, Component, NgModule, PlatformRef, TemplateRef, Type, ViewChild, ViewContainerRef} from '@angular/core'; +import {APP_BOOTSTRAP_LISTENER, APP_INITIALIZER, CompilerFactory, Component, NgModule, NgZone, PlatformRef, TemplateRef, Type, ViewChild, ViewContainerRef} from '@angular/core'; import {ApplicationRef, ApplicationRef_} from '@angular/core/src/application_ref'; import {ErrorHandler} from '@angular/core/src/error_handler'; import {ComponentRef} from '@angular/core/src/linker/component_factory'; import {BrowserModule} from '@angular/platform-browser'; import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter'; import {DOCUMENT} from '@angular/platform-browser/src/dom/dom_tokens'; +import {dispatchEvent} from '@angular/platform-browser/testing/browser_util'; import {expect} from '@angular/platform-browser/testing/matchers'; import {ServerModule} from '@angular/platform-server'; -import {ComponentFixtureNoNgZone, TestBed, async, inject, withModule} from '../testing'; +import {ComponentFixture, ComponentFixtureNoNgZone, TestBed, async, inject, withModule} from '../testing'; @Component({selector: 'comp', template: 'hello'}) class SomeComponent { @@ -123,7 +124,6 @@ export function main() { 'Cannot bootstrap as there are still asynchronous initializers running. Bootstrap components in the `ngDoBootstrap` method of the root module.'); }))); }); - }); describe('bootstrapModule', () => { @@ -363,10 +363,143 @@ export function main() { }); }); }); -} -@Component({selector: 'my-comp', template: ''}) -class MyComp6 { + describe('AppRef', () => { + @Component({selector: 'sync-comp', template: `{{text}}`}) + class SyncComp { + text: string = '1'; + } + + @Component({selector: 'click-comp', template: `{{text}}`}) + class ClickComp { + text: string = '1'; + + onClick() { this.text += '1'; } + } + + @Component({selector: 'micro-task-comp', template: `{{text}}`}) + class MicroTaskComp { + text: string = '1'; + + ngOnInit() { + Promise.resolve(null).then((_) => { this.text += '1'; }); + } + } + + @Component({selector: 'macro-task-comp', template: `{{text}}`}) + class MacroTaskComp { + text: string = '1'; + + ngOnInit() { + setTimeout(() => { this.text += '1'; }, 10); + } + } + + @Component({selector: 'micro-macro-task-comp', template: `{{text}}`}) + class MicroMacroTaskComp { + text: string = '1'; + + ngOnInit() { + Promise.resolve(null).then((_) => { + this.text += '1'; + setTimeout(() => { this.text += '1'; }, 10); + }); + } + } + + @Component({selector: 'macro-micro-task-comp', template: `{{text}}`}) + class MacroMicroTaskComp { + text: string = '1'; + + ngOnInit() { + setTimeout(() => { + this.text += '1'; + Promise.resolve(null).then((_: any) => { this.text += '1'; }); + }, 10); + } + } + + let stableCalled = false; + + beforeEach(() => { + stableCalled = false; + TestBed.configureTestingModule({ + declarations: [ + SyncComp, MicroTaskComp, MacroTaskComp, MicroMacroTaskComp, MacroMicroTaskComp, ClickComp + ], + }); + }); + + afterEach(() => { expect(stableCalled).toBe(true, 'isStable did not emit true on stable'); }); + + function expectStableTexts(component: Type, expected: string[]) { + const fixture = TestBed.createComponent(component); + const appRef: ApplicationRef = TestBed.get(ApplicationRef); + const zone: NgZone = TestBed.get(NgZone); + appRef.attachView(fixture.componentRef.hostView); + zone.run(() => appRef.tick()); + + let i = 0; + appRef.isStable.subscribe({ + next: (stable: boolean) => { + if (stable) { + expect(i).toBeLessThan(expected.length); + expect(fixture.nativeElement).toHaveText(expected[i++]); + stableCalled = true; + } + } + }); + } + + it('isStable should fire on synchronous component loading', + async(() => { expectStableTexts(SyncComp, ['1']); })); + + it('isStable should fire after a microtask on init is completed', + async(() => { expectStableTexts(MicroTaskComp, ['11']); })); + + it('isStable should fire after a macrotask on init is completed', + async(() => { expectStableTexts(MacroTaskComp, ['11']); })); + + it('isStable should fire only after chain of micro and macrotasks on init are completed', + async(() => { expectStableTexts(MicroMacroTaskComp, ['111']); })); + + it('isStable should fire only after chain of macro and microtasks on init are completed', + async(() => { expectStableTexts(MacroMicroTaskComp, ['111']); })); + + describe('unstable', () => { + let unstableCalled = false; + + afterEach( + () => { expect(unstableCalled).toBe(true, 'isStable did not emit false on unstable'); }); + + function expectUnstable(appRef: ApplicationRef) { + appRef.isStable.subscribe({ + next: (stable: boolean) => { + if (stable) { + stableCalled = true; + } + if (!stable) { + unstableCalled = true; + } + } + }); + } + + it('should be fired after app becomes unstable', async(() => { + const fixture = TestBed.createComponent(ClickComp); + const appRef: ApplicationRef = TestBed.get(ApplicationRef); + const zone: NgZone = TestBed.get(NgZone); + appRef.attachView(fixture.componentRef.hostView); + zone.run(() => appRef.tick()); + + fixture.whenStable().then(() => { + expectUnstable(appRef); + const element = fixture.debugElement.children[0]; + dispatchEvent(element.nativeElement, 'click'); + }); + })); + }); + }); } class MockConsole { diff --git a/tools/public_api_guard/core/index.d.ts b/tools/public_api_guard/core/index.d.ts index 92acf9419c..b816bbbc14 100644 --- a/tools/public_api_guard/core/index.d.ts +++ b/tools/public_api_guard/core/index.d.ts @@ -171,6 +171,7 @@ export declare class ApplicationModule { export declare abstract class ApplicationRef { readonly abstract componentTypes: Type[]; readonly abstract components: ComponentRef[]; + readonly abstract isStable: Observable; readonly abstract viewCount: number; abstract attachView(view: ViewRef): void; abstract bootstrap(componentFactory: ComponentFactory | Type): ComponentRef;