Repository: vigetlabs/microcosm
Pull request #523 (2018-06-27 13:16:07)

[microcosm] Add flow types for scheduler
**What**

Adds [flow type](https://flow.org/en/) definitions for Microcosm's scheduler.

**Why**

The scheduler is a critical piece of Microcosm 13, and it didn't have any flow type coverage.

Adding flow types uncovered a bug where Observable was accessing the scheduler's error handler from the wrong namespace. This PR additionally adds test coverage for that case.


Patch:
diff --git a/packages/microcosm/src/observable.js b/packages/microcosm/src/observable.js
index 8b252c62..88ce4e7b 100644
--- a/packages/microcosm/src/observable.js
+++ b/packages/microcosm/src/observable.js
@@ -102,7 +102,7 @@ export class Subscription implements Unsubscribable {
       // obs.subscribe(next => {}) // Raises an exception with the scheduler
       // obs.subscribe(next => {}, error => {}) // Doesn't raise
       if (observer.error === noop) {
-        scheduler().raise(error)
+        scheduler()._raise(error)
       }
     }
 
diff --git a/packages/microcosm/src/scheduler.js b/packages/microcosm/src/scheduler.js
index 02f769c5..90b3d63c 100644
--- a/packages/microcosm/src/scheduler.js
+++ b/packages/microcosm/src/scheduler.js
@@ -2,16 +2,32 @@
  * @fileoverview The central scheduler for Microcosm work. This is
  * based upon Scheduler from the ChooJS project:
  * https://github.com/choojs/nanoscheduler/blob/master/index.js
+ * @flow
  */
 
 import assert from 'assert'
 
-const hasWindow = typeof window !== 'undefined'
-const root = hasWindow ? window : global
+/**
+ * The scheduler utilizes requestIdleCallback when it can. This
+ * browser API exposes a time allotment to let developers know how
+ * much time they have remaining before they will block the browser.
+ *
+ * Microcosm uses this API to prevent work from interfearing with
+ * critical browser activities.
+ */
+type TimeShare = {
+  timeRemaining: () => number
+}
+
+type Job = (...params: *[]) => void
+
+type ErrorCallback = (error: Error) => void
 
 const key = '__MICROCOSM_SCHEDULER__'
+const hasWindow = typeof window !== 'undefined'
+const root = hasWindow ? window : global
 
-export function scheduler() {
+export function scheduler(): Scheduler {
   // TODO: This should probably be based upon the repo. Some sort of
   // root context
   if (!root[key]) {
@@ -21,16 +37,22 @@ export function scheduler() {
   return root[key]
 }
 
-const untilEmpty = {
+const untilEmpty: TimeShare = {
   timeRemaining: () => 1
 }
 
-function idleFallback(callback) {
-  setTimeout(callback, 0, untilEmpty)
+function idleFallback(callback: *): void {
+  setTimeout(callback.bind(null, untilEmpty), 0)
 }
 
 class Scheduler {
-  constructor(hasWindow) {
+  _method: *
+  _tick: *
+  _scheduled: boolean
+  _queue: Job[]
+  _errorCallbacks: ErrorCallback[]
+
+  constructor(hasWindow: boolean) {
     let hasIdle = hasWindow && 'requestIdleCallback' in root
 
     this._method = hasIdle ? root.requestIdleCallback.bind(root) : idleFallback
@@ -40,34 +62,34 @@ class Scheduler {
     this._errorCallbacks = []
   }
 
-  push(cb) {
+  push(job: Job): void {
     assert.equal(
-      typeof cb,
+      typeof job,
       'function',
       'scheduler.push: callback should be a function'
     )
 
-    this._queue.push(cb)
+    this._queue.push(job)
     this._schedule()
   }
 
-  flush() {
+  flush(): void {
     this._tick(untilEmpty)
   }
 
-  onError(fn) {
-    this._errorCallbacks.push(fn)
+  onError(callback: ErrorCallback): void {
+    this._errorCallbacks.push(callback)
   }
 
-  offError(fn) {
-    let index = this._errorCallbacks.indexOf(fn)
+  offError(callback: ErrorCallback): void {
+    let index = this._errorCallbacks.indexOf(callback)
 
     if (index >= 0) {
       this._errorCallbacks.splice(index, 1)
     }
   }
 
-  then(pass, fail) {
+  then(pass: *, fail: *): Promise<void, Error> {
     return new Promise((resolve, reject) => {
       this.onError(reject)
       this.push(() => {
@@ -79,18 +101,18 @@ class Scheduler {
 
   // Private -------------------------------------------------- //
 
-  _raise(error) {
+  _raise(error: Error): void {
     if (this._errorCallbacks.length) {
       this._errorCallbacks.forEach(callback => callback(error))
       this._errorCallbacks.length = 0
     } else {
       setTimeout(() => {
         throw error
-      })
+      }, 0)
     }
   }
 
-  _schedule() {
+  _schedule(): void {
     if (this._scheduled) {
       return
     }
@@ -99,7 +121,7 @@ class Scheduler {
     this._method(this._tick)
   }
 
-  _tick(idleDeadline) {
+  _tick(idleDeadline: TimeShare): void {
     try {
       while (this._queue.length && idleDeadline.timeRemaining() > 0) {
         this._queue.shift()()


Test patch:
diff --git a/packages/microcosm/test/setup.js b/packages/microcosm/test/setup.js
index d784cdc2..c006b155 100644
--- a/packages/microcosm/test/setup.js
+++ b/packages/microcosm/test/setup.js
@@ -6,20 +6,20 @@ scheduler().onError(error => {
 })
 
 // Make a strict-only test flag
-it.dev = function(description, test) {
+it.dev = function(...args) {
   if (!process.env.BUNDLED) {
-    return it(description, test)
+    return it(...args)
   }
 
-  return it.skip(description, test)
+  return it.skip(...args)
 }
 
-describe.dev = function(description, suite) {
+describe.dev = function(...args) {
   if (!process.env.BUNDLED) {
-    return describe(description, test)
+    return describe(...args)
   }
 
-  return describe.skip(description, test)
+  return describe.skip(...args)
 }
 
 expect.extend({
diff --git a/packages/microcosm/test/unit/observable.test.js b/packages/microcosm/test/unit/observable.test.js
index dfe3578f..eb275384 100644
--- a/packages/microcosm/test/unit/observable.test.js
+++ b/packages/microcosm/test/unit/observable.test.js
@@ -370,4 +370,27 @@ describe('Observable', function() {
       )
     })
   })
+
+  describe.dev('scheduler error handling', () => {
+    let raise = null
+
+    beforeEach(() => {
+      raise = scheduler()._raise
+      scheduler()._raise = jest.fn()
+    })
+
+    afterEach(() => {
+      scheduler()._raise = raise
+    })
+
+    it('raises a scheduler error on unhandled errors', () => {
+      let failure = new Observable(observer => {
+        throw new Error('Oops')
+      })
+
+      failure.subscribe()
+
+      expect(scheduler()._raise).toHaveBeenCalledWith(new Error('Oops'))
+    })
+  })
 })