diff --git a/src/cdk/observers/observe-content.spec.ts b/src/cdk/observers/observe-content.spec.ts index 189252618d45..8c3077645689 100644 --- a/src/cdk/observers/observe-content.spec.ts +++ b/src/cdk/observers/observe-content.spec.ts @@ -1,11 +1,11 @@ -import {Component} from '@angular/core'; -import {async, TestBed, ComponentFixture, fakeAsync, tick} from '@angular/core/testing'; -import {ObserversModule, MutationObserverFactory} from './observe-content'; +import {Component, ElementRef, ViewChild} from '@angular/core'; +import {async, ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing'; +import {ContentObserver, MutationObserverFactory, ObserversModule} from './observe-content'; // TODO(elad): `ProxyZone` doesn't seem to capture the events raised by // `MutationObserver` and needs to be investigated -describe('Observe content', () => { +describe('Observe content directive', () => { describe('basic usage', () => { beforeEach(async(() => { TestBed.configureTestingModule({ @@ -120,6 +120,85 @@ describe('Observe content', () => { }); }); +describe('ContentObserver injectable', () => { + describe('basic usage', () => { + let callbacks: Function[]; + let invokeCallbacks = (args?: any) => callbacks.forEach(callback => callback(args)); + let contentObserver: ContentObserver; + + beforeEach(fakeAsync(() => { + callbacks = []; + + TestBed.configureTestingModule({ + imports: [ObserversModule], + declarations: [UnobservedComponentWithTextContent], + providers: [{ + provide: MutationObserverFactory, + useValue: { + create: function(callback: Function) { + callbacks.push(callback); + + return { + observe: () => {}, + disconnect: () => {} + }; + } + } + }] + }); + + TestBed.compileComponents(); + })); + + beforeEach(inject([ContentObserver], (co: ContentObserver) => { + contentObserver = co; + })); + + it('should trigger the callback when the content of the element changes', fakeAsync(() => { + const spy = jasmine.createSpy('content observer'); + const fixture = TestBed.createComponent(UnobservedComponentWithTextContent); + fixture.detectChanges(); + + contentObserver.observe(fixture.componentInstance.contentEl.nativeElement) + .subscribe(() => spy()); + + expect(spy).not.toHaveBeenCalled(); + + fixture.componentInstance.text = 'text'; + invokeCallbacks(); + + expect(spy).toHaveBeenCalled(); + })); + + it('should only create one MutationObserver when observing the same element twice', + fakeAsync(inject([MutationObserverFactory], (mof: MutationObserverFactory) => { + const spy = jasmine.createSpy('content observer'); + spyOn(mof, 'create').and.callThrough(); + const fixture = TestBed.createComponent(UnobservedComponentWithTextContent); + fixture.detectChanges(); + + const sub1 = contentObserver.observe(fixture.componentInstance.contentEl.nativeElement) + .subscribe(() => spy()); + contentObserver.observe(fixture.componentInstance.contentEl.nativeElement) + .subscribe(() => spy()); + + expect(mof.create).toHaveBeenCalledTimes(1); + + fixture.componentInstance.text = 'text'; + invokeCallbacks(); + + expect(spy).toHaveBeenCalledTimes(2); + + spy.calls.reset(); + sub1.unsubscribe(); + fixture.componentInstance.text = 'text text'; + invokeCallbacks(); + + expect(spy).toHaveBeenCalledTimes(1); + }))); + }); +}); + @Component({ template: ` @@ -134,7 +213,7 @@ class ComponentWithTextContent { doSomething() {} } -@Component({ template: `
{{text}}
` }) +@Component({ template: `
{{text}}
` }) class ComponentWithChildTextContent { text = ''; doSomething() {} @@ -147,3 +226,11 @@ class ComponentWithDebouncedListener { debounce = 500; spy = jasmine.createSpy('MutationObserver callback'); } + +@Component({ + template: `
{{text}}
` +}) +class UnobservedComponentWithTextContent { + @ViewChild('contentEl') contentEl: ElementRef; + text = ''; +} diff --git a/src/cdk/observers/observe-content.ts b/src/cdk/observers/observe-content.ts index 2ecd20b1ee4e..bef0fa804ca5 100644 --- a/src/cdk/observers/observe-content.ts +++ b/src/cdk/observers/observe-content.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {coerceBooleanProperty} from '@angular/cdk/coercion'; +import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion'; import { AfterContentInit, Directive, @@ -16,12 +16,10 @@ import { Input, NgModule, NgZone, - OnChanges, OnDestroy, Output, - SimpleChanges, } from '@angular/core'; -import {Subject} from 'rxjs'; +import {Observable, Subject, Subscription} from 'rxjs'; import {debounceTime} from 'rxjs/operators'; /** @@ -35,6 +33,88 @@ export class MutationObserverFactory { } } + +/** An injectable service that allows watching elements for changes to their content. */ +@Injectable({providedIn: 'root'}) +export class ContentObserver implements OnDestroy { + /** Keeps track of the existing MutationObservers so they can be reused. */ + private _observedElements = new Map, + count: number + }>(); + + constructor(private _mutationObserverFactory: MutationObserverFactory) {} + + ngOnDestroy() { + this._observedElements.forEach((_, element) => this._cleanupObserver(element)); + } + + /** + * Observe content changes on an element. + * @param element The element to observe for content changes. + */ + observe(element: Element): Observable { + return Observable.create(observer => { + const stream = this._observeElement(element); + const subscription = stream.subscribe(observer); + + return () => { + subscription.unsubscribe(); + this._unobserveElement(element); + }; + }); + } + + /** + * Observes the given element by using the existing MutationObserver if available, or creating a + * new one if not. + */ + private _observeElement(element: Element): Subject { + if (!this._observedElements.has(element)) { + const stream = new Subject(); + const observer = this._mutationObserverFactory.create(mutations => stream.next(mutations)); + if (observer) { + observer.observe(element, { + characterData: true, + childList: true, + subtree: true + }); + } + this._observedElements.set(element, {observer, stream, count: 1}); + } else { + this._observedElements.get(element)!.count++; + } + return this._observedElements.get(element)!.stream; + } + + /** + * Un-observes the given element and cleans up the underlying MutationObserver if nobody else is + * observing this element. + */ + private _unobserveElement(element: Element) { + if (this._observedElements.has(element)) { + this._observedElements.get(element)!.count--; + if (!this._observedElements.get(element)!.count) { + this._cleanupObserver(element); + } + } + } + + /** Clean up the underlying MutationObserver for the specified element. */ + private _cleanupObserver(element: Element) { + if (this._observedElements.has(element)) { + const {observer, stream} = this._observedElements.get(element)!; + if (observer) { + observer.disconnect(); + } + stream.complete(); + this._observedElements.delete(element); + } + } +} + + /** * Directive that triggers a callback whenever the content of * its associated element has changed. @@ -43,10 +123,7 @@ export class MutationObserverFactory { selector: '[cdkObserveContent]', exportAs: 'cdkObserveContent', }) -export class CdkObserveContent implements AfterContentInit, OnChanges, OnDestroy { - private _observer: MutationObserver | null; - private _disabled = false; - +export class CdkObserveContent implements AfterContentInit, OnDestroy { /** Event emitted for each change in the element's content. */ @Output('cdkObserveContent') event = new EventEmitter(); @@ -58,64 +135,55 @@ export class CdkObserveContent implements AfterContentInit, OnChanges, OnDestroy get disabled() { return this._disabled; } set disabled(value: any) { this._disabled = coerceBooleanProperty(value); + if (this._disabled) { + this._unsubscribe(); + } else { + this._subscribe(); + } } - - /** Used for debouncing the emitted values to the observeContent event. */ - private _debouncer = new Subject(); + private _disabled = false; /** Debounce interval for emitting the changes. */ - @Input() debounce: number; - - constructor( - private _mutationObserverFactory: MutationObserverFactory, - private _elementRef: ElementRef, - private _ngZone: NgZone) { } + @Input() + get debounce(): number { return this._debounce; } + set debounce(value: number) { + this._debounce = coerceNumberProperty(value); + this._subscribe(); + } + private _debounce: number; - ngAfterContentInit() { - if (this.debounce > 0) { - this._ngZone.runOutsideAngular(() => { - this._debouncer.pipe(debounceTime(this.debounce)) - .subscribe((mutations: MutationRecord[]) => this.event.emit(mutations)); - }); - } else { - this._debouncer.subscribe(mutations => this.event.emit(mutations)); - } + private _currentSubscription: Subscription | null = null; - this._observer = this._ngZone.runOutsideAngular(() => { - return this._mutationObserverFactory.create((mutations: MutationRecord[]) => { - this._debouncer.next(mutations); - }); - }); + constructor(private _contentObserver: ContentObserver, private _elementRef: ElementRef, + private _ngZone: NgZone) {} - if (!this.disabled) { - this._enable(); - } - } - - ngOnChanges(changes: SimpleChanges) { - if (changes['disabled']) { - changes['disabled'].currentValue ? this._disable() : this._enable(); + ngAfterContentInit() { + if (!this._currentSubscription && !this.disabled) { + this._subscribe(); } } ngOnDestroy() { - this._disable(); - this._debouncer.complete(); + this._unsubscribe(); } - private _disable() { - if (this._observer) { - this._observer.disconnect(); - } + private _subscribe() { + this._unsubscribe(); + const stream = this._contentObserver.observe(this._elementRef.nativeElement); + + // TODO(mmalerba): We shouldn't be emitting on this @Output() outside the zone. + // Consider brining it back inside the zone next time we're making breaking changes. + // Bringing it back inside can cause things like infinite change detection loops and changed + // after checked errors if people's code isn't handling it properly. + this._ngZone.runOutsideAngular(() => { + this._currentSubscription = + (this.debounce ? stream.pipe(debounceTime(this.debounce)) : stream).subscribe(this.event); + }); } - private _enable() { - if (this._observer) { - this._observer.observe(this._elementRef.nativeElement, { - characterData: true, - childList: true, - subtree: true - }); + private _unsubscribe() { + if (this._currentSubscription) { + this._currentSubscription.unsubscribe(); } } }