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: `
` })
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();
}
}
}