From 5db36e904d8ff9cef4326156a8163e2e8d38976b Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sun, 25 Dec 2016 14:52:57 +0200 Subject: [PATCH] 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. --- .../observe-content/observe-content.spec.ts | 60 ++++++++++++++++++- .../core/observe-content/observe-content.ts | 31 +++++++++- src/lib/core/util/debounce.spec.ts | 41 +++++++++++++ src/lib/core/util/debounce.ts | 22 +++++++ 4 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 src/lib/core/util/debounce.spec.ts create mode 100644 src/lib/core/util/debounce.ts diff --git a/src/lib/core/observe-content/observe-content.spec.ts b/src/lib/core/observe-content/observe-content.spec.ts index 888a37391a0e..aee9f7dcba8a 100644 --- a/src/lib/core/observe-content/observe-content.spec.ts +++ b/src/lib/core/observe-content/observe-content.spec.ts @@ -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'; /** @@ -11,7 +11,11 @@ describe('Observe content', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ObserveContentModule], - declarations: [ComponentWithTextContent, ComponentWithChildTextContent], + declarations: [ + ComponentWithTextContent, + ComponentWithChildTextContent, + ComponentWithDebouncedListener + ], }); TestBed.compileComponents(); @@ -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; + 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); + }); + }); }); @@ -66,3 +113,12 @@ class ComponentWithChildTextContent { text = ''; doSomething() {} } + +@Component({ + template: `
{{text}}
` +}) +class ComponentWithDebouncedListener { + text = ''; + debounce = 10; + spy = jasmine.createSpy('MutationObserver callback'); +} diff --git a/src/lib/core/observe-content/observe-content.ts b/src/lib/core/observe-content/observe-content.ts index efa57db98462..ac0cba5e695f 100644 --- a/src/lib/core/observe-content/observe-content.ts +++ b/src/lib/core/observe-content/observe-content.ts @@ -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. @@ -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(); + @Output('cdkObserveContent') event = new EventEmitter(); + + /** 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, @@ -37,6 +63,7 @@ export class ObserveContent implements AfterContentInit, OnDestroy { ngOnDestroy() { if (this._observer) { this._observer.disconnect(); + this._observer = null; } } } diff --git a/src/lib/core/util/debounce.spec.ts b/src/lib/core/util/debounce.spec.ts new file mode 100644 index 000000000000..3ca88689abe6 --- /dev/null +++ b/src/lib/core/util/debounce.spec.ts @@ -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); + })); +}); diff --git a/src/lib/core/util/debounce.ts b/src/lib/core/util/debounce.ts new file mode 100644 index 000000000000..fba7393d8cc2 --- /dev/null +++ b/src/lib/core/util/debounce.ts @@ -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); + }; +};