From e987183227d541821b10ee29aba19f21cf94cb52 Mon Sep 17 00:00:00 2001 From: Ed Hager Date: Fri, 3 Nov 2017 09:01:50 -0700 Subject: [PATCH] Guarantee util.throttle delay (#360) --- src/util.ts | 35 ++++- tests/unit/util.ts | 381 ++++++++++++++++++++++++--------------------- 2 files changed, 233 insertions(+), 183 deletions(-) diff --git a/src/util.ts b/src/util.ts index b0afb5f0..f84a48b5 100644 --- a/src/util.ts +++ b/src/util.ts @@ -29,15 +29,15 @@ export function createTimer(callback: (...args: any[]) => void, delay?: number): export function debounce void>(callback: T, delay: number): T { // node.d.ts clobbers setTimeout/clearTimeout with versions that return/receive NodeJS.Timer, // but browsers return/receive a number - let timer: any; + let timer: Handle | null; return function () { - timer && clearTimeout(timer); + timer && timer.destroy(); let context = this; let args: IArguments | null = arguments; - timer = setTimeout(function () { + timer = guaranteeMinimumTimeout(function () { callback.apply(context, args); args = context = timer = null; }, delay); @@ -62,7 +62,7 @@ export function throttle void>(callback ran = true; callback.apply(this, arguments); - setTimeout(function () { + guaranteeMinimumTimeout(function () { ran = null; }, delay); }; @@ -89,9 +89,34 @@ export function throttleAfter void>(cal let context = this; let args: IArguments | null = arguments; - setTimeout(function () { + guaranteeMinimumTimeout(function () { callback.apply(context, args); args = context = ran = null; }, delay); }; } + +export function guaranteeMinimumTimeout(callback: (...args: any[]) => void, delay?: number): Handle { + const startTime = Date.now(); + let timerId: number | null; + + function timeoutHandler() { + const delta = Date.now() - startTime; + if (delay == null || delta >= delay) { + callback(); + } else { + // Cast setTimeout return value to fix TypeScript parsing bug. Without it, + // it thinks we are using the Node version of setTimeout. + // Revisit this with the next TypeScript update. + // Set another timer for the mount of time that we came up short. + timerId = setTimeout(timeoutHandler, delay - delta); + } + } + timerId = setTimeout(timeoutHandler, delay); + return createHandle(() => { + if (timerId != null) { + clearTimeout(timerId); + timerId = null; + } + }); +} diff --git a/tests/unit/util.ts b/tests/unit/util.ts index 0ec0bd3a..d55d3938 100644 --- a/tests/unit/util.ts +++ b/tests/unit/util.ts @@ -24,26 +24,29 @@ import { Handle } from '@dojo/interfaces/core'; import * as util from '../../src/util'; const TIMEOUT = 3000; +let timerHandle: Handle | null; -registerSuite('utility functions', { - createTimer: (function () { - let timer: Handle | null; +function destroyTimerHandle() { + if (timerHandle) { + timerHandle.destroy(); + timerHandle = null; + } +} - return { - afterEach() { - timer && timer.destroy(); - timer = null; - }, +registerSuite('utility functions', { + afterEach() { + destroyTimerHandle(); + }, + tests: { + createTimer: { destroy(this: any) { const dfd = this.async(1000); const spy = sinon.spy(); - timer = util.createTimer(spy, 100); + timerHandle = util.createTimer(spy, 100); setTimeout(function () { - if (timer) { - timer.destroy(); - } + destroyTimerHandle(); }, 50); setTimeout(dfd.callback(function () { @@ -54,203 +57,225 @@ registerSuite('utility functions', { timeout(this: any) { const dfd = this.async(1000); const spy = sinon.spy(); - timer = util.createTimer(spy, 100); + timerHandle = util.createTimer(spy, 100); setTimeout(dfd.callback(function () { assert.strictEqual(spy.callCount, 1); }), 110); } - }; - })(), - - debounce: { - 'preserves context'(this: any) { - const dfd = this.async(TIMEOUT); - // FIXME - var foo = { - bar: util.debounce(dfd.callback(function (this: any) { - assert.strictEqual(this, foo, 'Function should be executed with correct context'); - }), 0) - }; - - foo.bar(); }, - 'receives arguments'(this: any) { - const dfd = this.async(TIMEOUT); - const testArg1 = 5; - const testArg2 = 'a'; - const debouncedFunction = util.debounce(dfd.callback(function (a: number, b: string) { - assert.strictEqual(a, testArg1, 'Function should receive correct arguments'); - assert.strictEqual(b, testArg2, 'Function should receive correct arguments'); - }), 0); + guaranteeMinimumTimeout: { + destroy(this: any) { + const dfd = this.async(1000); + const spy = sinon.spy(); + timerHandle = util.guaranteeMinimumTimeout(spy, 100); - debouncedFunction(testArg1, testArg2); - }, + setTimeout(function () { + destroyTimerHandle(); + }, 50); - 'debounces callback'(this: any) { - const dfd = this.async(TIMEOUT); - const debouncedFunction = util.debounce(dfd.callback(function () { - assert.isAbove(Date.now() - lastCallTick, 10, 'Function should not be called until period has elapsed without further calls'); - - // Typically, we expect the 3rd invocation to be the one that is executed. - // Although the setTimeout in 'run' specifies a delay of 5ms, a very slow test environment may - // take longer. If 25+ ms has actually elapsed, then the first or second invocation may end up - // being eligible for execution. - // If the first or second invocation has been called there's no need to let the run loop continue. - clearTimeout(handle); - }), 25); - - let runCount = 1; - let lastCallTick: number; - let handle: any; - - function run() { - lastCallTick = Date.now(); - debouncedFunction(); - runCount += 1; - - if (runCount < 4) { - handle = setTimeout(run, 5); - } - } + setTimeout(dfd.callback(function () { + assert.strictEqual(spy.callCount, 0); + }), 110); + }, - run(); - } - }, + timeout(this: any) { + const dfd = this.async(1000); + const startTime = Date.now(); + timerHandle = util.guaranteeMinimumTimeout(dfd.callback(function () { + const dif = Date.now() - startTime; + assert.isTrue(dif >= 100, 'Delay was ' + dif + 'ms.'); + }), 100); + }, + + 'timeout no delay'(this: any) { + const dfd = this.async(1000); + timerHandle = util.guaranteeMinimumTimeout(dfd.callback(function () { + // test will timeout if not called + })); + }, - throttle: { - 'preserves context'(this: any) { - const dfd = this.async(TIMEOUT); - // FIXME - var foo = { - bar: util.throttle(dfd.callback(function (this: any) { - assert.strictEqual(this, foo, 'Function should be executed with correct context'); - }), 0) - }; - - foo.bar(); + 'timeout zero delay'(this: any) { + const dfd = this.async(1000); + timerHandle = util.guaranteeMinimumTimeout(dfd.callback(function () { + // test will timeout if not called + }), 0); + } }, - 'receives arguments'(this: any) { - const dfd = this.async(TIMEOUT); - const testArg1 = 5; - const testArg2 = 'a'; - const throttledFunction = util.throttle(dfd.callback(function (a: number, b: string) { - assert.strictEqual(a, testArg1, 'Function should receive correct arguments'); - assert.strictEqual(b, testArg2, 'Function should receive correct arguments'); - }), 0); + debounce: { + 'preserves context'(this: any) { + const dfd = this.async(TIMEOUT); + // FIXME + let foo = { + bar: util.debounce(dfd.callback(function (this: any) { + assert.strictEqual(this, foo, 'Function should be executed with correct context'); + }), 0) + }; + + foo.bar(); + }, + + 'receives arguments'(this: any) { + const dfd = this.async(TIMEOUT); + const testArg1 = 5; + const testArg2 = 'a'; + const debouncedFunction = util.debounce(dfd.callback(function (a: number, b: string) { + assert.strictEqual(a, testArg1, 'Function should receive correct arguments'); + assert.strictEqual(b, testArg2, 'Function should receive correct arguments'); + }), 0); - throttledFunction(testArg1, testArg2); - }, + debouncedFunction(testArg1, testArg2); + }, - 'throttles callback'(this: any) { - const dfd = this.async(TIMEOUT); - // FIXME - - let callCount = 0; - let cleared = false; - const throttledFunction = util.throttle(dfd.rejectOnError(function (a: string) { - callCount++; - assert.notStrictEqual(a, 'b', 'Second invocation should be throttled'); - // Rounding errors? - // Technically, the time diff should be greater than 24ms, but in some cases - // it is equal to 24ms. - assert.isAbove(Date.now() - lastRunTick, 23, - 'Function should not be called until throttle delay has elapsed'); - - lastRunTick = Date.now(); - if (callCount > 1) { - clearTimeout(handle); - cleared = true; - dfd.resolve(); - } - }), 25); + 'debounces callback'(this: any) { + const dfd = this.async(TIMEOUT); + const debouncedFunction = util.debounce(dfd.callback(function () { + assert.isAbove(Date.now() - lastCallTick, 10, 'Function should not be called until period has elapsed without further calls'); - let runCount = 1; - let lastRunTick = 0; - let handle: any; + // Typically, we expect the 3rd invocation to be the one that is executed. + // Although the setTimeout in 'run' specifies a delay of 5ms, a very slow test environment may + // take longer. If 25+ ms has actually elapsed, then the first or second invocation may end up + // being eligible for execution. + }), 25); - function run() { - throttledFunction('a'); - throttledFunction('b'); - runCount += 1; + let runCount = 1; + let lastCallTick: number; - if (runCount < 10 && !cleared) { - handle = setTimeout(run, 5); + function run() { + lastCallTick = Date.now(); + debouncedFunction(); + runCount += 1; + + if (runCount < 4) { + setTimeout(run, 5); + } } + + run(); } + }, - run(); - assert.strictEqual(callCount, 1, - 'Function should be called as soon as it is first invoked'); - } - }, + throttle: { + 'preserves context'(this: any) { + const dfd = this.async(TIMEOUT); + // FIXME + const foo = { + bar: util.throttle(dfd.callback(function (this: any) { + assert.strictEqual(this, foo, 'Function should be executed with correct context'); + }), 0) + }; + + foo.bar(); + }, - throttleAfter: { - 'preserves context'(this: any) { - const dfd = this.async(TIMEOUT); - // FIXME - var foo = { - bar: util.throttleAfter(dfd.callback(function(this: any) { - assert.strictEqual(this, foo, 'Function should be executed with correct context'); - }), 0) - }; - - foo.bar(); - }, + 'receives arguments'(this: any) { + const dfd = this.async(TIMEOUT); + const testArg1 = 5; + const testArg2 = 'a'; + const throttledFunction = util.throttle(dfd.callback(function (a: number, b: string) { + assert.strictEqual(a, testArg1, 'Function should receive correct arguments'); + assert.strictEqual(b, testArg2, 'Function should receive correct arguments'); + }), 0); - 'receives arguments'(this: any) { - const dfd = this.async(TIMEOUT); - const testArg1 = 5; - const testArg2 = 'a'; - const throttledFunction = util.throttleAfter(dfd.callback(function (a: number, b: string) { - assert.strictEqual(a, testArg1, 'Function should receive correct arguments'); - assert.strictEqual(b, testArg2, 'Function should receive correct arguments'); - }), 0); + throttledFunction(testArg1, testArg2); + }, - throttledFunction(testArg1, testArg2); - }, + 'throttles callback'(this: any) { + const dfd = this.async(TIMEOUT); + let callCount = 0; + let cleared = false; + const throttledFunction = util.throttle(dfd.rejectOnError(function (a: string) { + callCount++; + assert.notStrictEqual(a, 'b', 'Second invocation should be throttled'); + // Rounding errors? + // Technically, the time diff should be greater than 24ms, but in some cases + // it is equal to 24ms. + assert.isAbove(Date.now() - lastRunTick, 23, + 'Function should not be called until throttle delay has elapsed'); + + lastRunTick = Date.now(); + if (callCount > 1) { + destroyTimerHandle(); + cleared = true; + dfd.resolve(); + } + }), 25); - 'throttles callback'(this: any) { - const dfd = this.async(TIMEOUT); - // FIXME - let callCount = 0; - let cleared = false; - const throttledFunction = util.throttle(dfd.rejectOnError(function (a: string) { - callCount++; - assert.notStrictEqual(a, 'b', 'Second invocation should be throttled'); - // Rounding errors? - // Technically, the time diff should be greater than 24ms, but in some cases - // it is equal to 24ms. - assert.isAbove(Date.now() - lastRunTick, 23, - 'Function should not be called until throttle delay has elapsed'); - - lastRunTick = Date.now(); - if (callCount > 1) { - clearTimeout(handle); - cleared = true; - dfd.resolve(); + let runCount = 1; + let lastRunTick = 0; + + function run() { + throttledFunction('a'); + throttledFunction('b'); + runCount += 1; + + if (runCount < 10 && !cleared) { + timerHandle = util.guaranteeMinimumTimeout(run, 5); + } } - }), 25); - let runCount = 1; - let lastRunTick = 0; - let handle: any; + run(); + assert.strictEqual(callCount, 1, + 'Function should be called as soon as it is first invoked'); + } + }, + + throttleAfter: { + 'preserves context'(this: any) { + const dfd = this.async(TIMEOUT); + // FIXME + const foo = { + bar: util.throttleAfter(dfd.callback(function (this: any) { + assert.strictEqual(this, foo, 'Function should be executed with correct context'); + }), 0) + }; + + foo.bar(); + }, + + 'receives arguments'(this: any) { + const dfd = this.async(TIMEOUT); + const testArg1 = 5; + const testArg2 = 'a'; + const throttledFunction = util.throttleAfter(dfd.callback(function (a: number, b: string) { + assert.strictEqual(a, testArg1, 'Function should receive correct arguments'); + assert.strictEqual(b, testArg2, 'Function should receive correct arguments'); + }), 0); + + throttledFunction(testArg1, testArg2); + }, - function run() { - throttledFunction('a'); - throttledFunction('b'); - runCount += 1; + 'throttles callback'(this: any) { + const dfd = this.async(TIMEOUT); + + let callCount = 0; + let lastRunTick = 0; + const throttledFunction = util.throttleAfter(dfd.rejectOnError(function (a: string) { + callCount++; + assert.notStrictEqual(a, 'b', 'Second invocation should be throttled'); + assert.isAbove(Date.now() - lastRunTick, 23, + 'Function should not be called until throttle delay has elapsed'); + + lastRunTick = Date.now(); + if (callCount > 2) { + destroyTimerHandle(); + dfd.resolve(); + } + }), 25); - if (runCount < 10 && !cleared) { - handle = setTimeout(run, 5); + function run() { + throttledFunction('a'); + throttledFunction('b'); + + timerHandle = util.guaranteeMinimumTimeout(dfd.rejectOnError(run), 5); } - } - run(); - assert.strictEqual(callCount, 1, - 'Function should be called as soon as it is first invoked'); + run(); + assert.strictEqual(callCount, 0, + 'Function should not be called as soon as it is first invoked'); + } } } });