From 27690deba7a9122e036088a603f4f0374c3ba2af Mon Sep 17 00:00:00 2001 From: Robert Jackson Date: Tue, 16 Jan 2018 23:32:08 -0500 Subject: [PATCH] Use microtask queue to flush autoruns. * Replace the private `_platform` option, with a new `_buildPlatform` * Add interfaces for the constructor args for the `Backburner` class. * Move the main implementation of `Backburner.prototype.end` into a private method (`_end`) that is aware of if the end is from an autorun or manual run... * Extract steps to schedule an autorun out into stand alone private method (`_scheduleAutorun`) * Leverage microtask queue (via either `promise.then(...)` or `MutationObserver`) to schedule an autorun * Leverage microtask queue to advance from one queue to the next --- lib/backburner/deferred-action-queues.ts | 5 +- lib/backburner/platform.ts | 52 ++++++++ lib/backburner/queue.ts | 2 +- lib/index.ts | 149 +++++++++++++---------- tests/autorun-test.ts | 40 +++++- tests/configurable-timeout-test.ts | 143 ++++++++++++---------- tests/debounce-test.ts | 8 +- tests/multi-turn-test.ts | 27 ++-- tests/queue-test.ts | 4 +- 9 files changed, 280 insertions(+), 150 deletions(-) create mode 100644 lib/backburner/platform.ts diff --git a/lib/backburner/deferred-action-queues.ts b/lib/backburner/deferred-action-queues.ts index 1b05c50e..ef9b398a 100644 --- a/lib/backburner/deferred-action-queues.ts +++ b/lib/backburner/deferred-action-queues.ts @@ -48,7 +48,7 @@ export default class DeferredActionQueues { @method flush DeferredActionQueues.flush() calls Queue.flush() */ - public flush() { + public flush(fromAutorun = false) { let queue; let queueName; let numberOfQueues = this.queueNames.length; @@ -59,6 +59,9 @@ export default class DeferredActionQueues { if (queue.hasWork() === false) { this.queueNameIndex++; + if (fromAutorun) { + return QUEUE_STATE.Pause; + } } else { if (queue.flush(false /* async */) === QUEUE_STATE.Pause) { return QUEUE_STATE.Pause; diff --git a/lib/backburner/platform.ts b/lib/backburner/platform.ts new file mode 100644 index 00000000..15cf4cbd --- /dev/null +++ b/lib/backburner/platform.ts @@ -0,0 +1,52 @@ +export interface IPlatform { + setTimeout(fn: Function, ms: number): any; + clearTimeout(id: any): void; + next(): any; + clearNext(timerId: any): void; + now(): number; +} + +const SET_TIMEOUT = setTimeout; +const NOOP = () => {}; + +export function buildPlatform(flush: () => void): IPlatform { + let next; + let clearNext = NOOP; + + if (typeof MutationObserver === 'function') { + let iterations = 0; + let observer = new MutationObserver(flush); + let node = document.createTextNode(''); + observer.observe(node, { characterData: true }); + + next = () => { + iterations = ++iterations % 2; + node.data = '' + iterations; + return iterations; + }; + + } else if (typeof Promise === 'function') { + const autorunPromise = Promise.resolve(); + next = () => autorunPromise.then(flush); + + } else { + next = () => SET_TIMEOUT(flush, 0); + } + + return { + setTimeout(fn, ms) { + return SET_TIMEOUT(fn, ms); + }, + + clearTimeout(timerId: number) { + return clearTimeout(timerId); + }, + + now() { + return Date.now(); + }, + + next, + clearNext, + }; +} diff --git a/lib/backburner/queue.ts b/lib/backburner/queue.ts index b4ed6b3e..bf6afbe0 100644 --- a/lib/backburner/queue.ts +++ b/lib/backburner/queue.ts @@ -33,7 +33,7 @@ export default class Queue { } } - public flush(sync?) { + public flush(sync?: Boolean) { let { before, after } = this.options; let target; let method; diff --git a/lib/index.ts b/lib/index.ts index 7e526705..e1ab6a93 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,3 +1,12 @@ +export { + buildPlatform, + IPlatform +} from './backburner/platform'; + +import { + buildPlatform, + IPlatform, +} from './backburner/platform'; import { findItem, findTimer, @@ -14,7 +23,6 @@ import Queue, { QUEUE_STATE } from './backburner/queue'; type Timer = any; const noop = function() {}; -const SET_TIMEOUT = setTimeout; function parseArgs() { let length = arguments.length; @@ -126,6 +134,17 @@ let autorunsCompletedCount = 0; let deferredActionQueuesCreatedCount = 0; let nestedDeferredActionQueuesCreated = 0; +export interface IBackburnerOptions { + defaultQueue?: string; + onBegin?: (currentInstance: DeferredActionQueues, previousInstance: DeferredActionQueues) => void; + onEnd?: (currentInstance: DeferredActionQueues, nextInstance: DeferredActionQueues) => void; + onError?: (error: any, errorRecordedForStack?: any) => void; + onErrorTarget?: any; + onErrorMethod?: string; + mustYield?: () => boolean; + _buildPlatform?: (flush: () => void) => IPlatform; +} + export default class Backburner { public static Queue = Queue; @@ -133,7 +152,7 @@ export default class Backburner { public currentInstance: DeferredActionQueues | null = null; - public options: any; + public options: IBackburnerOptions; public get counters() { return { @@ -183,49 +202,45 @@ export default class Backburner { private _timerTimeoutId: number | null = null; private _timers: any[] = []; - private _platform: { - setTimeout(fn: Function, ms: number): number; - clearTimeout(id: number): void; - next(fn: Function): number; - clearNext(id: any): void; - now(): number; - }; + private _platform: IPlatform; private _boundRunExpiredTimers: () => void; private _autorun: number | null = null; private _boundAutorunEnd: () => void; + private _defaultQueue: string; - constructor(queueNames: string[], options: any = {} ) { + constructor(queueNames: string[], options?: IBackburnerOptions) { this.queueNames = queueNames; - this.options = options; - if (!this.options.defaultQueue) { - this.options.defaultQueue = queueNames[0]; + this.options = options || {}; + if (typeof this.options.defaultQueue === 'string') { + this._defaultQueue = this.options.defaultQueue; + } else { + this._defaultQueue = this.queueNames[0]; } this._onBegin = this.options.onBegin || noop; this._onEnd = this.options.onEnd || noop; - let _platform = this.options._platform || {}; - let platform = Object.create(null); - - platform.setTimeout = _platform.setTimeout || ((fn, ms) => setTimeout(fn, ms)); - platform.clearTimeout = _platform.clearTimeout || ((id) => clearTimeout(id)); - platform.next = _platform.next || ((fn) => SET_TIMEOUT(fn, 0)); - platform.clearNext = _platform.clearNext || platform.clearTimeout; - platform.now = _platform.now || (() => Date.now()); - - this._platform = platform; - this._boundRunExpiredTimers = this._runExpiredTimers.bind(this); this._boundAutorunEnd = () => { autorunsCompletedCount++; + + // if the autorun was already flushed, do nothing + if (this._autorun === null) { return; } + this._autorun = null; - this.end(); + this._end(true /* fromAutorun */); }; + + let builder = this.options._buildPlatform || buildPlatform; + this._platform = builder(this._boundAutorunEnd); } + public get defaultQueue() { + return this._defaultQueue; + } /* @method begin @return instantiated class DeferredActionQueues @@ -257,40 +272,7 @@ export default class Backburner { public end() { endCount++; - let currentInstance = this.currentInstance; - let nextInstance: DeferredActionQueues | null = null; - - if (currentInstance === null) { - throw new Error(`end called without begin`); - } - - // Prevent double-finally bug in Safari 6.0.2 and iOS 6 - // This bug appears to be resolved in Safari 6.0.5 and iOS 7 - let finallyAlreadyCalled = false; - let result; - try { - result = currentInstance.flush(); - } finally { - if (!finallyAlreadyCalled) { - finallyAlreadyCalled = true; - - if (result === QUEUE_STATE.Pause) { - autorunsCreatedCount++; - const next = this._platform.next; - this._autorun = next(this._boundAutorunEnd); - } else { - this.currentInstance = null; - - if (this.instanceStack.length > 0) { - nextInstance = this.instanceStack.pop() as DeferredActionQueues; - this.currentInstance = nextInstance; - } - endEventCount++; - this._trigger('end', currentInstance, nextInstance); - this._onEnd(currentInstance, nextInstance); - } - } - } + this._end(false); } public on(eventName, callback) { @@ -564,6 +546,40 @@ export default class Backburner { this._ensureInstance(); } + private _end(fromAutorun: boolean) { + let currentInstance = this.currentInstance; + let nextInstance: DeferredActionQueues | null = null; + + if (currentInstance === null) { + throw new Error(`end called without begin`); + } + + // Prevent double-finally bug in Safari 6.0.2 and iOS 6 + // This bug appears to be resolved in Safari 6.0.5 and iOS 7 + let finallyAlreadyCalled = false; + let result; + try { + result = currentInstance.flush(fromAutorun); + } finally { + if (!finallyAlreadyCalled) { + finallyAlreadyCalled = true; + + if (result === QUEUE_STATE.Pause) { + this._scheduleAutorun(); + } else { + this.currentInstance = null; + + if (this.instanceStack.length > 0) { + nextInstance = this.instanceStack.pop() as DeferredActionQueues; + this.currentInstance = nextInstance; + } + this._trigger('end', currentInstance, nextInstance); + this._onEnd(currentInstance, nextInstance); + } + } + } + } + private _join(target, method, args) { if (this.currentInstance === null) { return this._run(target, method, args); @@ -654,7 +670,7 @@ export default class Backburner { /** Trigger an event. Supports up to two arguments. Designed around triggering transition events from one run loop instance to the - next, which requires an argument for the first instance and then + next, which requires an argument for the instance and then an argument for the next instance. @private @@ -685,7 +701,7 @@ export default class Backburner { let timers = this._timers; let i = 0; let l = timers.length; - let defaultQueue = this.options.defaultQueue; + let defaultQueue = this._defaultQueue; let n = this._platform.now(); for (; i < l; i += 6) { @@ -725,11 +741,16 @@ export default class Backburner { private _ensureInstance(): DeferredActionQueues { let currentInstance = this.currentInstance; if (currentInstance === null) { - autorunsCreatedCount++; currentInstance = this.begin(); - const next = this._platform.next; - this._autorun = next(this._boundAutorunEnd); + this._scheduleAutorun(); } return currentInstance; } + + private _scheduleAutorun() { + autorunsCreatedCount++; + + const next = this._platform.next; + this._autorun = next(); + } } diff --git a/tests/autorun-test.ts b/tests/autorun-test.ts index a3ae1781..6266fba8 100644 --- a/tests/autorun-test.ts +++ b/tests/autorun-test.ts @@ -11,7 +11,7 @@ QUnit.test('autorun', function(assert) { assert.equal(step++, 0); bb.schedule('zomg', null, () => { - assert.equal(step, 2); + assert.equal(step++, 2); setTimeout(() => { assert.ok(!bb.hasTimers(), 'The all timers are cleared'); done(); @@ -58,3 +58,41 @@ QUnit.test('autorun (joins next run if not yet flushed)', function(assert) { two: { count: 1, order: 1 } }); }); + +QUnit.test('autorun completes before items scheduled by later (via microtasks)', function(assert) { + let done = assert.async(); + let bb = new Backburner(['first', 'second']); + let order = new Array(); + + // this later will be scheduled into the `first` queue when + // its timer is up + bb.later(() => { + order.push('second - later'); + }, 0); + + // scheduling this into the second queue so that we can confirm this _still_ + // runs first (due to autorun resolving before scheduled timer) + bb.schedule('second', null, () => { + order.push('first - scheduled'); + }); + + setTimeout(() => { + assert.deepEqual(order, ['first - scheduled', 'second - later']); + done(); + }, 20); +}); + +QUnit.test('can be canceled (private API)', function(assert) { + assert.expect(0); + + let done = assert.async(); + let bb = new Backburner(['zomg']); + + bb.schedule('zomg', null, () => { + assert.notOk(true, 'should not flush'); + }); + + bb['_cancelAutorun'](); + + setTimeout(done, 10); +}); diff --git a/tests/configurable-timeout-test.ts b/tests/configurable-timeout-test.ts index 452befb4..03d1f249 100644 --- a/tests/configurable-timeout-test.ts +++ b/tests/configurable-timeout-test.ts @@ -1,75 +1,81 @@ -import Backburner from 'backburner'; +import Backburner, { buildPlatform } from 'backburner'; QUnit.module('tests/configurable-timeout'); QUnit.test('We can configure a custom platform', function(assert) { assert.expect(1); - let fakePlatform = { - setTimeout() {}, - clearTimeout() {}, - isFakePlatform: true - }; - let bb = new Backburner(['one'], { - _platform: fakePlatform + _buildPlatform(flush) { + let platform = buildPlatform(flush); + platform['isFakePlatform'] = true; + return platform; + } }); - assert.ok(bb.options._platform.isFakePlatform, 'We can pass in a custom platform'); + assert.ok(bb['_platform']!['isFakePlatform'], 'We can pass in a custom platform'); }); QUnit.test('We can use a custom setTimeout', function(assert) { - assert.expect(2); + assert.expect(1); let done = assert.async(); let customNextWasUsed = false; let bb = new Backburner(['one'], { - _platform: { - next() { - throw new TypeError('NOT IMPLEMENTED'); - }, - setTimeout(cb) { - customNextWasUsed = true; - return setTimeout(cb); - }, - clearTimeout(timer) { - return clearTimeout(timer); - }, - isFakePlatform: true + _buildPlatform(flush) { + return { + next() { + throw new TypeError('NOT IMPLEMENTED'); + }, + clearNext() { }, + setTimeout(cb) { + customNextWasUsed = true; + return setTimeout(cb); + }, + clearTimeout(timer) { + return clearTimeout(timer); + }, + now() { + return Date.now(); + }, + isFakePlatform: true + }; } }); bb.setTimeout(() => { - assert.ok(bb.options._platform.isFakePlatform, 'we are using the fake platform'); assert.ok(customNextWasUsed , 'custom later was used'); done(); }); }); QUnit.test('We can use a custom next', function(assert) { - assert.expect(2); + assert.expect(1); let done = assert.async(); let customNextWasUsed = false; let bb = new Backburner(['one'], { - _platform: { - setTimeout() { - throw new TypeError('NOT IMPLEMENTED'); - }, - next(cb) { - // next is used for the autorun - customNextWasUsed = true; - return setTimeout(cb); - }, - clearTimeout(timer) { - return clearTimeout(timer); - }, - isFakePlatform: true + _buildPlatform(flush) { + return { + setTimeout() { + throw new TypeError('NOT IMPLEMENTED'); + }, + clearTimeout(timer) { + return clearTimeout(timer); + }, + next() { + // next is used for the autorun + customNextWasUsed = true; + return setTimeout(flush); + }, + clearNext() { }, + now() { return Date.now(); }, + isFakePlatform: true + }; } }); bb.scheduleOnce('one', () => { - assert.ok(bb.options._platform.isFakePlatform, 'we are using the fake platform'); assert.ok(customNextWasUsed , 'custom later was used'); done(); }); @@ -81,21 +87,26 @@ QUnit.test('We can use a custom clearTimeout', function(assert) { let functionWasCalled = false; let customClearTimeoutWasUsed = false; let bb = new Backburner(['one'], { - _platform: { - setTimeout(method, wait) { - return setTimeout(method, wait); - }, - clearTimeout(timer) { - customClearTimeoutWasUsed = true; - return clearTimeout(timer); - }, - next(method) { - return setTimeout(method, 0); - }, - clearNext(timer) { - customClearTimeoutWasUsed = true; - return clearTimeout(timer); - } + _buildPlatform(flush) { + return { + setTimeout(method, wait) { + return setTimeout(method, wait); + }, + clearTimeout(timer) { + customClearTimeoutWasUsed = true; + return clearTimeout(timer); + }, + next() { + return setTimeout(flush, 0); + }, + clearNext(timer) { + customClearTimeoutWasUsed = true; + return clearTimeout(timer); + }, + now() { + return Date.now(); + } + }; } }); @@ -111,23 +122,33 @@ QUnit.test('We can use a custom clearTimeout', function(assert) { }); QUnit.test('We can use a custom now', function(assert) { - assert.expect(2); + assert.expect(1); let done = assert.async(); let currentTime = 10; let customNowWasUsed = false; let bb = new Backburner(['one'], { - _platform: { - now() { - customNowWasUsed = true; - return currentTime += 10; - }, - isFakePlatform: true + _buildPlatform(flush) { + return { + setTimeout(method, wait) { + return setTimeout(method, wait); + }, + clearTimeout(id) { + clearTimeout(id); + }, + next() { + return setTimeout(flush, 0); + }, + clearNext() { }, + now() { + customNowWasUsed = true; + return currentTime += 10; + }, + }; } }); bb.later(() => { - assert.ok(bb.options._platform.isFakePlatform, 'we are using the fake platform'); assert.ok(customNowWasUsed , 'custom now was used'); done(); }, 10); diff --git a/tests/debounce-test.ts b/tests/debounce-test.ts index 1d04c61a..7ff00ec8 100644 --- a/tests/debounce-test.ts +++ b/tests/debounce-test.ts @@ -23,21 +23,21 @@ QUnit.test('debounce', function(assert) { // let's schedule `debouncee` to run in 10ms setTimeout(() => { assert.equal(step++, 1); - assert.ok(!wasCalled); + assert.ok(!wasCalled, '@10ms, should not yet have been called'); bb.debounce(null, debouncee, 40); }, 10); // let's schedule `debouncee` to run again in 30ms setTimeout(() => { assert.equal(step++, 2); - assert.ok(!wasCalled); + assert.ok(!wasCalled, '@ 30ms, should not yet have been called'); bb.debounce(null, debouncee, 40); }, 30); // let's schedule `debouncee` to run yet again in 60ms setTimeout(() => { assert.equal(step++, 3); - assert.ok(!wasCalled); + assert.ok(!wasCalled, '@ 60ms, should not yet have been called'); bb.debounce(null, debouncee, 40); }, 60); @@ -45,7 +45,7 @@ QUnit.test('debounce', function(assert) { // 10ms after `debouncee` has been called the last time setTimeout(() => { assert.equal(step++, 4); - assert.ok(wasCalled); + assert.ok(wasCalled, '@ 110ms should have been called'); }, 110); // great, we've made it this far, there's one more thing diff --git a/tests/multi-turn-test.ts b/tests/multi-turn-test.ts index c31d6689..9763a600 100644 --- a/tests/multi-turn-test.ts +++ b/tests/multi-turn-test.ts @@ -1,30 +1,25 @@ -import Backburner from 'backburner'; +import Backburner, { buildPlatform } from 'backburner'; QUnit.module('tests/multi-turn'); const queue: any[] = []; -const platform = { - flushSync() { - let current = queue.slice(); - queue.length = 0; - current.forEach((task) => task()); - }, - - // TDB actually implement - next(cb) { - queue.push(cb); - } -}; +let platform; +function buildFakePlatform(flush) { + platform = buildPlatform(flush); + platform.flushSync = function() { + flush(); + }; + return platform; +} QUnit.test('basic', function(assert) { let bb = new Backburner(['zomg'], { - // This is just a place holder for now, but somehow the system needs to // know to when to stop mustYield() { return true; // yield after each step, for now. }, - _platform: platform + _buildPlatform: buildFakePlatform }); let order = -1; @@ -88,7 +83,7 @@ QUnit.test('properly cancel items which are added during flush', function(assert return true; // yield after each step, for now. }, - _platform: platform + _buildPlatform: buildFakePlatform }); let fooCalled = 0; diff --git a/tests/queue-test.ts b/tests/queue-test.ts index aa78d6e2..772e5136 100644 --- a/tests/queue-test.ts +++ b/tests/queue-test.ts @@ -59,7 +59,7 @@ QUnit.test('Queue#flush should be recursive if new items are added', function(as QUnit.test('Default queue is automatically set to first queue if none is provided', function(assert) { let bb = new Backburner(['one', 'two']); - assert.equal(bb.options.defaultQueue, 'one'); + assert.equal(bb.defaultQueue, 'one'); }); QUnit.test('Default queue can be manually configured', function(assert) { @@ -67,7 +67,7 @@ QUnit.test('Default queue can be manually configured', function(assert) { defaultQueue: 'two' }); - assert.equal(bb.options.defaultQueue, 'two'); + assert.equal(bb.defaultQueue, 'two'); }); QUnit.test('onBegin and onEnd are called and passed the correct parameters', function(assert) {