diff --git a/src/lib/core/common-behaviors/index.ts b/src/lib/core/common-behaviors/index.ts index bfc3317731b7..5e963bda2d85 100644 --- a/src/lib/core/common-behaviors/index.ts +++ b/src/lib/core/common-behaviors/index.ts @@ -12,3 +12,4 @@ export {CanColor, mixinColor, ThemePalette} from './color'; export {CanDisableRipple, mixinDisableRipple} from './disable-ripple'; export {HasTabIndex, mixinTabIndex} from './tabindex'; export {CanUpdateErrorState, mixinErrorState} from './error-state'; +export {HasInitialized, mixinInitialized} from './initialized'; diff --git a/src/lib/core/common-behaviors/initialized.spec.ts b/src/lib/core/common-behaviors/initialized.spec.ts new file mode 100644 index 000000000000..723bc44b414f --- /dev/null +++ b/src/lib/core/common-behaviors/initialized.spec.ts @@ -0,0 +1,50 @@ +import {mixinInitialized} from './initialized'; +import {HasInitialized} from '@angular/material/core'; + +describe('MixinHasInitialized', () => { + class EmptyClass { } + let instance: HasInitialized; + + beforeEach(() => { + const classWithHasInitialized = mixinInitialized(EmptyClass); + instance = new classWithHasInitialized(); + }); + + it('should emit for subscriptions made before the directive was marked as initialized', done => { + // Listen for an event from the initialized stream and mark the test as done when it emits. + instance.initialized.subscribe(() => done()); + + // Mark the class as initialized so that the stream emits and the test completes. + instance._markInitialized(); + }); + + it('should emit for subscriptions made after the directive was marked as initialized', done => { + // Mark the class as initialized so the stream emits when subscribed and the test completes. + instance._markInitialized(); + + // Listen for an event from the initialized stream and mark the test as done when it emits. + instance.initialized.subscribe(() => done()); + }); + + it('should emit for multiple subscriptions made before and after marked as initialized', done => { + // Should expect the number of notifications to match the number of subscriptions. + const expectedNotificationCount = 4; + let currentNotificationCount = 0; + + // Function that completes the test when the number of notifications meets the expectation. + function onNotified() { + currentNotificationCount++; + if (currentNotificationCount === expectedNotificationCount) { + done(); + } + } + + instance.initialized.subscribe(onNotified); // Subscription 1 + instance.initialized.subscribe(onNotified); // Subscription 2 + + instance._markInitialized(); + + instance.initialized.subscribe(onNotified); // Subscription 3 + instance.initialized.subscribe(onNotified); // Subscription 4 + }); +}); diff --git a/src/lib/core/common-behaviors/initialized.ts b/src/lib/core/common-behaviors/initialized.ts new file mode 100644 index 000000000000..166e08ed4c66 --- /dev/null +++ b/src/lib/core/common-behaviors/initialized.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright Google LLC 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 {Constructor} from './constructor'; +import {Observable} from 'rxjs/Observable'; +import {Subscriber} from 'rxjs/Subscriber'; + +/** + * Mixin that adds an initialized property to a directive which, when subscribed to, will emit a + * value once markInitialized has been called, which should be done during the ngOnInit function. + * If the subscription is made after it has already been marked as initialized, then it will trigger + * an emit immediately. + * @docs-private + */ +export interface HasInitialized { + /** Stream that emits once during the directive/component's ngOnInit. */ + initialized: Observable; + + /** + * Sets the state as initialized and must be called during ngOnInit to notify subscribers that + * the directive has been initialized. + * @docs-private + */ + _markInitialized: () => void; +} + +/** Mixin to augment a directive with an initialized property that will emits when ngOnInit ends. */ +export function mixinInitialized>(base: T): + Constructor & T { + return class extends base { + /** Whether this directive has been marked as initialized. */ + _isInitialized = false; + + /** + * List of subscribers that subscribed before the directive was initialized. Should be notified + * during _markInitialized. Set to null after pending subscribers are notified, and should + * not expect to be populated after. + */ + _pendingSubscribers: Subscriber[] | null = []; + + /** + * Observable stream that emits when the directive initializes. If already initialized, the + * subscriber is stored to be notified once _markInitialized is called. + */ + initialized = new Observable(subscriber => { + // If initialized, immediately notify the subscriber. Otherwise store the subscriber to notify + // when _markInitialized is called. + if (this._isInitialized) { + this._notifySubscriber(subscriber); + } else { + this._pendingSubscribers!.push(subscriber); + } + }); + + constructor(...args: any[]) { super(...args); } + + /** + * Marks the state as initialized and notifies pending subscribers. Should be called at the end + * of ngOnInit. + * @docs-private + */ + _markInitialized(): void { + if (this._isInitialized) { + throw Error('This directive has already been marked as initialized and ' + + 'should not be called twice.'); + } + + this._isInitialized = true; + + this._pendingSubscribers!.forEach(this._notifySubscriber); + this._pendingSubscribers = null; + } + + /** Emits and completes the subscriber stream (should only emit once). */ + _notifySubscriber(subscriber: Subscriber): void { + subscriber.next(); + subscriber.complete(); + } + }; +}