Skip to content

Commit

Permalink
feat(observe-content): refactor so logic can be used without directive (
Browse files Browse the repository at this point in the history
#11170)

* feat(observe-content): refactor so logic can be used without directive

* address comments

* address comments

* move @Ouput() back outside the zone to prevent breaking changes
  • Loading branch information
mmalerba authored and tinayuangao committed May 15, 2018
1 parent efe37f5 commit ba57852
Show file tree
Hide file tree
Showing 2 changed files with 212 additions and 57 deletions.
97 changes: 92 additions & 5 deletions src/cdk/observers/observe-content.spec.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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: `
Expand All @@ -134,7 +213,7 @@ class ComponentWithTextContent {
doSomething() {}
}

@Component({ template: `<div (cdkObserveContent)="doSomething()"><div>{{text}}<div></div>` })
@Component({ template: `<div (cdkObserveContent)="doSomething()"><div>{{text}}</div></div>` })
class ComponentWithChildTextContent {
text = '';
doSomething() {}
Expand All @@ -147,3 +226,11 @@ class ComponentWithDebouncedListener {
debounce = 500;
spy = jasmine.createSpy('MutationObserver callback');
}

@Component({
template: `<div #contentEl>{{text}}</div>`
})
class UnobservedComponentWithTextContent {
@ViewChild('contentEl') contentEl: ElementRef;
text = '';
}
172 changes: 120 additions & 52 deletions src/cdk/observers/observe-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';

/**
Expand All @@ -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<Element, {
observer: MutationObserver | null,
stream: Subject<MutationRecord[]>,
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<MutationRecord[]> {
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<MutationRecord[]> {
if (!this._observedElements.has(element)) {
const stream = new Subject<MutationRecord[]>();
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.
Expand All @@ -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<MutationRecord[]>();

Expand All @@ -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<MutationRecord[]>();
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();
}
}
}
Expand Down

0 comments on commit ba57852

Please sign in to comment.