Skip to content

Commit

Permalink
feat(observe-content): add debounce option and other improvements
Browse files Browse the repository at this point in the history
* Adds a reusable utility for debouncing a function.
* Adds the ability to debounce the changes from the `cdkObserveContent` directive.
* Makes the `cdkObserveContent` directive pass back the `MutationRecord` to the `EventEmitter`.
* Fires the callback once per mutation event, instead of once per `MutationRecord`.

Relates to #2372.
  • Loading branch information
crisbeto committed Dec 25, 2016
1 parent 026c70a commit 5db36e9
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 4 deletions.
60 changes: 58 additions & 2 deletions src/lib/core/observe-content/observe-content.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Component} from '@angular/core';
import {async, TestBed} from '@angular/core/testing';
import {async, TestBed, ComponentFixture} from '@angular/core/testing';
import {ObserveContentModule} from './observe-content';

/**
Expand All @@ -11,7 +11,11 @@ describe('Observe content', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [ObserveContentModule],
declarations: [ComponentWithTextContent, ComponentWithChildTextContent],
declarations: [
ComponentWithTextContent,
ComponentWithChildTextContent,
ComponentWithDebouncedListener
],
});

TestBed.compileComponents();
Expand Down Expand Up @@ -52,6 +56,49 @@ describe('Observe content', () => {
fixture.detectChanges();
});
});

// Note that these tests need to use real timeouts, instead of fakeAsync, because Angular doens't
// mock out the MutationObserver, in addition to it being async. Perhaps we should find a way to
// stub the MutationObserver for tests?
describe('debounced', () => {
let fixture: ComponentFixture<ComponentWithDebouncedListener>;
let instance: ComponentWithDebouncedListener;

const setText = (text: string, delay: number) => {
setTimeout(() => {
instance.text = text;
fixture.detectChanges();
}, delay);
};

beforeEach(() => {
fixture = TestBed.createComponent(ComponentWithDebouncedListener);
instance = fixture.componentInstance;
fixture.detectChanges();
});

it('should debounce the content changes', (done: any) => {
setText('a', 0);
setText('b', 5);
setText('c', 10);

setTimeout(() => {
expect(instance.spy).toHaveBeenCalledTimes(1);
done();
}, 50);
});

it('should should keep track of all of the mutation records', (done: any) => {
setText('a', 0);
setText('b', 5);
setText('c', 10);

setTimeout(() => {
expect(instance.spy.calls.mostRecent().args[0].length).toBe(3);
done();
}, 50);
});
});
});


Expand All @@ -66,3 +113,12 @@ class ComponentWithChildTextContent {
text = '';
doSomething() {}
}

@Component({
template: `<div (cdkObserveContent)="spy($event)" [debounce]="debounce">{{text}}</div>`
})
class ComponentWithDebouncedListener {
text = '';
debounce = 10;
spy = jasmine.createSpy('MutationObserver callback');
}
31 changes: 29 additions & 2 deletions src/lib/core/observe-content/observe-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import {
NgModule,
ModuleWithProviders,
Output,
Input,
EventEmitter,
OnDestroy,
AfterContentInit
} from '@angular/core';

import {debounce} from '../util/debounce';

/**
* Directive that triggers a callback whenever the content of
* its associated element has changed.
Expand All @@ -19,13 +22,36 @@ import {
export class ObserveContent implements AfterContentInit, OnDestroy {
private _observer: MutationObserver;

/** Collects any MutationRecords that haven't been emitted yet. */
private _pendingRecords: MutationRecord[] = [];

/** Event emitted for each change in the element's content. */
@Output('cdkObserveContent') event = new EventEmitter<void>();
@Output('cdkObserveContent') event = new EventEmitter<MutationRecord[]>();

/** Debounce interval for emitting the changes. */
@Input() debounce: number;

constructor(private _elementRef: ElementRef) {}

ngAfterContentInit() {
this._observer = new MutationObserver(mutations => mutations.forEach(() => this.event.emit()));
let callback: MutationCallback;

// If a debounce interval is specified, keep track of the mutations and debounce the emit.
if (this.debounce > 0) {
let debouncedEmit = debounce((mutations: MutationRecord[]) => {
this.event.emit(this._pendingRecords);
this._pendingRecords = [];
}, this.debounce);

callback = (mutations: MutationRecord[]) => {
this._pendingRecords.push.apply(this._pendingRecords, mutations);
debouncedEmit();
};
} else {
callback = (mutations: MutationRecord[]) => this.event.emit(mutations);
}

this._observer = new MutationObserver(callback);

this._observer.observe(this._elementRef.nativeElement, {
characterData: true,
Expand All @@ -37,6 +63,7 @@ export class ObserveContent implements AfterContentInit, OnDestroy {
ngOnDestroy() {
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
}
}
Expand Down
41 changes: 41 additions & 0 deletions src/lib/core/util/debounce.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {fakeAsync, tick} from '@angular/core/testing';
import {debounce} from './debounce';

describe('debounce', () => {
let func: jasmine.Spy;

beforeEach(() => func = jasmine.createSpy('test function'));

it('should debounce calls to a function', fakeAsync(() => {
let debounced = debounce(func, 100);

debounced();
debounced();
debounced();

tick(100);

expect(func).toHaveBeenCalledTimes(1);
}));

it('should pass the arguments to the debounced function', fakeAsync(() => {
let debounced = debounce(func, 250);

debounced(1, 2, 3);
debounced(4, 5, 6);

tick(250);

expect(func).toHaveBeenCalledWith(4, 5, 6);
}));

it('should be able to invoke a function with a context', fakeAsync(() => {
let context = { name: 'Bilbo' };
let debounced = debounce(func, 300, context);

debounced();
tick(300);

expect(func.calls.mostRecent().object).toBe(context);
}));
});
22 changes: 22 additions & 0 deletions src/lib/core/util/debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Returns a function that won't be invoked, as long as it keeps being called. It will
* be invoked after it hasn't been called for `delay` milliseconds.
*
* @param func Function to be debounced.
* @param delay Amount of milliseconds to wait before calling the function.
* @param context Context in which to call the function.
*/
export function debounce(func: Function, delay: number, context?: any): Function {
let timer: number;

return function() {
let args = arguments;

clearTimeout(timer);

timer = setTimeout(() => {
timer = null;
func.apply(context, args);
}, delay);
};
};

0 comments on commit 5db36e9

Please sign in to comment.