Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 15, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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