Skip to content

Commit 3351381

Browse files
committed
feat(observe-content): add debounce option and other improvements
* 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.
1 parent 026c70a commit 3351381

File tree

4 files changed

+150
-4
lines changed

4 files changed

+150
-4
lines changed

src/lib/core/observe-content/observe-content.spec.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {Component} from '@angular/core';
2-
import {async, TestBed} from '@angular/core/testing';
2+
import {async, TestBed, ComponentFixture} from '@angular/core/testing';
33
import {ObserveContentModule} from './observe-content';
44

55
/**
@@ -11,7 +11,11 @@ describe('Observe content', () => {
1111
beforeEach(async(() => {
1212
TestBed.configureTestingModule({
1313
imports: [ObserveContentModule],
14-
declarations: [ComponentWithTextContent, ComponentWithChildTextContent],
14+
declarations: [
15+
ComponentWithTextContent,
16+
ComponentWithChildTextContent,
17+
ComponentWithDebouncedListener
18+
],
1519
});
1620

1721
TestBed.compileComponents();
@@ -52,6 +56,49 @@ describe('Observe content', () => {
5256
fixture.detectChanges();
5357
});
5458
});
59+
60+
// Note that these tests need to use real timeouts, instead of fakeAsync, because Angular doens't
61+
// mock out the MutationObserver, in addition to it being async. Perhaps we should find a way to
62+
// stub the MutationObserver for tests?
63+
describe('debounced', () => {
64+
let fixture: ComponentFixture<ComponentWithDebouncedListener>;
65+
let instance: ComponentWithDebouncedListener;
66+
67+
const setText = (text: string, delay: number) => {
68+
setTimeout(() => {
69+
instance.text = text;
70+
fixture.detectChanges();
71+
}, delay);
72+
};
73+
74+
beforeEach(() => {
75+
fixture = TestBed.createComponent(ComponentWithDebouncedListener);
76+
instance = fixture.componentInstance;
77+
fixture.detectChanges();
78+
});
79+
80+
it('should debounce the content changes', (done: any) => {
81+
setText('a', 5);
82+
setText('b', 10);
83+
setText('c', 15);
84+
85+
setTimeout(() => {
86+
expect(instance.spy).toHaveBeenCalledTimes(1);
87+
done();
88+
}, 50);
89+
});
90+
91+
it('should should keep track of all of the mutation records', (done: any) => {
92+
setText('a', 5);
93+
setText('b', 10);
94+
setText('c', 15);
95+
96+
setTimeout(() => {
97+
expect(instance.spy.calls.mostRecent().args[0].length).toBeGreaterThanOrEqual(1);
98+
done();
99+
}, 50);
100+
});
101+
});
55102
});
56103

57104

@@ -66,3 +113,12 @@ class ComponentWithChildTextContent {
66113
text = '';
67114
doSomething() {}
68115
}
116+
117+
@Component({
118+
template: `<div (cdkObserveContent)="spy($event)" [debounce]="debounce">{{text}}</div>`
119+
})
120+
class ComponentWithDebouncedListener {
121+
text = '';
122+
debounce = 15;
123+
spy = jasmine.createSpy('MutationObserver callback');
124+
}

src/lib/core/observe-content/observe-content.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import {
44
NgModule,
55
ModuleWithProviders,
66
Output,
7+
Input,
78
EventEmitter,
89
OnDestroy,
910
AfterContentInit
1011
} from '@angular/core';
1112

13+
import {debounce} from '../util/debounce';
14+
1215
/**
1316
* Directive that triggers a callback whenever the content of
1417
* its associated element has changed.
@@ -19,13 +22,36 @@ import {
1922
export class ObserveContent implements AfterContentInit, OnDestroy {
2023
private _observer: MutationObserver;
2124

25+
/** Collects any MutationRecords that haven't been emitted yet. */
26+
private _pendingRecords: MutationRecord[] = [];
27+
2228
/** Event emitted for each change in the element's content. */
23-
@Output('cdkObserveContent') event = new EventEmitter<void>();
29+
@Output('cdkObserveContent') event = new EventEmitter<MutationRecord[]>();
30+
31+
/** Debounce interval for emitting the changes. */
32+
@Input() debounce: number;
2433

2534
constructor(private _elementRef: ElementRef) {}
2635

2736
ngAfterContentInit() {
28-
this._observer = new MutationObserver(mutations => mutations.forEach(() => this.event.emit()));
37+
let callback: MutationCallback;
38+
39+
// If a debounce interval is specified, keep track of the mutations and debounce the emit.
40+
if (this.debounce > 0) {
41+
let debouncedEmit = debounce((mutations: MutationRecord[]) => {
42+
this.event.emit(this._pendingRecords);
43+
this._pendingRecords = [];
44+
}, this.debounce);
45+
46+
callback = (mutations: MutationRecord[]) => {
47+
this._pendingRecords.push.apply(this._pendingRecords, mutations);
48+
debouncedEmit();
49+
};
50+
} else {
51+
callback = (mutations: MutationRecord[]) => this.event.emit(mutations);
52+
}
53+
54+
this._observer = new MutationObserver(callback);
2955

3056
this._observer.observe(this._elementRef.nativeElement, {
3157
characterData: true,
@@ -37,6 +63,7 @@ export class ObserveContent implements AfterContentInit, OnDestroy {
3763
ngOnDestroy() {
3864
if (this._observer) {
3965
this._observer.disconnect();
66+
this._observer = null;
4067
}
4168
}
4269
}

src/lib/core/util/debounce.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import {fakeAsync, tick} from '@angular/core/testing';
2+
import {debounce} from './debounce';
3+
4+
describe('debounce', () => {
5+
let func: jasmine.Spy;
6+
7+
beforeEach(() => func = jasmine.createSpy('test function'));
8+
9+
it('should debounce calls to a function', fakeAsync(() => {
10+
let debounced = debounce(func, 100);
11+
12+
debounced();
13+
debounced();
14+
debounced();
15+
16+
tick(100);
17+
18+
expect(func).toHaveBeenCalledTimes(1);
19+
}));
20+
21+
it('should pass the arguments to the debounced function', fakeAsync(() => {
22+
let debounced = debounce(func, 250);
23+
24+
debounced(1, 2, 3);
25+
debounced(4, 5, 6);
26+
27+
tick(250);
28+
29+
expect(func).toHaveBeenCalledWith(4, 5, 6);
30+
}));
31+
32+
it('should be able to invoke a function with a context', fakeAsync(() => {
33+
let context = { name: 'Bilbo' };
34+
let debounced = debounce(func, 300, context);
35+
36+
debounced();
37+
tick(300);
38+
39+
expect(func.calls.mostRecent().object).toBe(context);
40+
}));
41+
});

src/lib/core/util/debounce.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Returns a function that won't be invoked, as long as it keeps being called. It will
3+
* be invoked after it hasn't been called for `delay` milliseconds.
4+
*
5+
* @param func Function to be debounced.
6+
* @param delay Amount of milliseconds to wait before calling the function.
7+
* @param context Context in which to call the function.
8+
*/
9+
export function debounce(func: Function, delay: number, context?: any): Function {
10+
let timer: number;
11+
12+
return function() {
13+
let args = arguments;
14+
15+
clearTimeout(timer);
16+
17+
timer = setTimeout(() => {
18+
timer = null;
19+
func.apply(context, args);
20+
}, delay);
21+
};
22+
};

0 commit comments

Comments
 (0)