Repository: vigetlabs/microcosm
Pull request #351 (2017-06-20 13:30:35)

Add flow types to utilities. Optimize merge.
This commit adds flow types to the utilities module. It also uncovers an optimization where we can avoid returning a new object when the right-most value of a `merge` call is a superset. For example:

```javascript
let left = { green: true }
let right = { green: true, blue: true }
let answer = merge(left, right)

expect(answer).toBe(right)
```

We can just return the right-most value because it contains no differences with the left.


Patch:
diff --git a/flow/registry.js b/flow/registry.js
index e6636f25..703d659a 100644
--- a/flow/registry.js
+++ b/flow/registry.js
@@ -6,7 +6,7 @@
 import type Microcosm from '../src/microcosm'
 import type Registration from '../src/registration'
 
-declare type Handler = (last?: *, next?: *) => mixed
+declare type Handler = <T>(last?: T, next?: *) => T
 
 declare type Registrations = Array<Registration>
 
diff --git a/src/domain-engine.js b/src/domain-engine.js
index 44a017d6..284536dd 100644
--- a/src/domain-engine.js
+++ b/src/domain-engine.js
@@ -129,7 +129,7 @@ class DomainEngine {
     return next
   }
 
-  dispatch(state: Object, action: Action) {
+  dispatch(state: Object, action: Action): Object {
     let handlers = this.register(action)
 
     for (var i = 0, len = handlers.length; i < len; i++) {
diff --git a/src/utils.js b/src/utils.js
index 6c5bfbde..0c1c8b88 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -1,11 +1,16 @@
-import { castPath } from './key-path'
+/**
+ * @flow
+ */
+
+import type Microcosm from './microcosm'
+import { castPath, type KeyPath } from './key-path'
 
 /**
  * Generate a unique id
  * @private
  */
 let uidStepper = 0
-export function uid(prefix) {
+export function uid(prefix: string): string {
   return `${prefix}${uidStepper++}`
 }
 
@@ -13,11 +18,11 @@ export function uid(prefix) {
  * Shallow copy an object
  * @private
  */
-export function clone(target) {
+export function clone(target: ?Object): Object {
   if (Array.isArray(target)) {
     return target.slice(0)
   } else if (isObject(target) === false) {
-    return target
+    return {}
   }
 
   let copy = {}
@@ -33,7 +38,7 @@ export function clone(target) {
  * Merge any number of objects into a provided object.
  * @private
  */
-export function merge() {
+export function merge(): Object {
   let copy = null
   let subject = null
 
@@ -54,7 +59,7 @@ export function merge() {
     }
   }
 
-  return copy
+  return copy || {}
 }
 
 /**
@@ -62,7 +67,7 @@ export function merge() {
  * object.
  * @private
  */
-export function get(object, keyPath, fallback) {
+export function get(object: ?Object, keyPath: string | KeyPath, fallback?: *) {
   if (object == null) {
     return fallback
   }
@@ -87,7 +92,7 @@ export function get(object, keyPath, fallback) {
  * value is the same, don't do anything. Otherwise return a new object.
  * @private
  */
-export function set(object, key, value) {
+export function set(object: Object, key: string | KeyPath, value: *): Object {
   // Ensure we're working with a key path, like: ['a', 'b', 'c']
   let path = castPath(key)
 
@@ -136,7 +141,7 @@ export function set(object, key, value) {
  * @return {boolean}
  * @private
  */
-export function isPromise(obj) {
+export function isPromise(obj: *): boolean {
   return (isObject(obj) || isFunction(obj)) && isFunction(obj.then)
 }
 
@@ -146,8 +151,8 @@ export function isPromise(obj) {
  * @return {boolean}
  * @private
  */
-export function isObject(target) {
-  return !!target && typeof target === 'object'
+export function isObject(target: *): boolean {
+  return !(!target || typeof target !== 'object')
 }
 
 /**
@@ -156,7 +161,7 @@ export function isObject(target) {
  * @return {boolean}
  * @private
  */
-export function isFunction(target) {
+export function isFunction(target: any): boolean {
   return !!target && typeof target === 'function'
 }
 
@@ -166,7 +171,7 @@ export function isFunction(target) {
  * @return {boolean}
  * @private
  */
-export function isString(target) {
+export function isString(target: any): boolean {
   return typeof target === 'string'
 }
 
@@ -178,8 +183,12 @@ export function isString(target) {
  */
 const $Symbol = typeof Symbol === 'function' ? Symbol : {}
 const toStringTagSymbol = $Symbol.toStringTag || '@@toStringTag'
-export function toStringTag(value) {
-  return get(value, toStringTagSymbol, '')
+export function toStringTag(value: any): string {
+  if (!value) {
+    return ''
+  }
+
+  return value[toStringTagSymbol] || ''
 }
 
 /**
@@ -189,14 +198,14 @@ export function toStringTag(value) {
  * @return {boolean}
  * @private
  */
-export function isGeneratorFn(value) {
+export function isGeneratorFn(value: any): boolean {
   return toStringTag(value) === 'GeneratorFunction'
 }
 
 /**
  * @private
  */
-export function createOrClone(target, options, repo) {
+export function createOrClone(target: any, options: ?Object, repo: Microcosm) {
   if (isFunction(target)) {
     return new target(options, repo)
   }
@@ -212,7 +221,12 @@ export function createOrClone(target, options, repo) {
  * @param {*} fallback value
  * @private
  */
-export function update(state, keyPath, updater, fallback) {
+export function update(
+  state: *,
+  keyPath: string | KeyPath,
+  updater: *,
+  fallback?: *
+) {
   let path = castPath(keyPath)
 
   if (isFunction(updater) === false) {


Test patch:
diff --git a/test/unit/utils/clone.test.js b/test/unit/utils/clone.test.js
index 5b4d19c8..dff4a89e 100644
--- a/test/unit/utils/clone.test.js
+++ b/test/unit/utils/clone.test.js
@@ -26,23 +26,25 @@ describe('Utils.clone', function() {
     expect(copy).not.toBe(original)
   })
 
-  it('does not clone strings', function() {
-    expect(clone('a')).toBe('a')
-  })
-
-  it('does not clone numbers', function() {
-    expect(clone(1)).toBe(1)
-  })
-
-  it('does not clone booleans', function() {
-    expect(clone(true)).toBe(true)
-  })
-
-  it('does not clone null', function() {
-    expect(clone(null)).toBe(null)
-  })
-
-  it('does not clone undefined', function() {
-    expect(clone(undefined)).toBe(undefined)
+  describe('non-object values', function() {
+    it('does not clone strings', function() {
+      expect(clone('a')).toEqual({})
+    })
+
+    it('does not clone numbers', function() {
+      expect(clone(1)).toEqual({})
+    })
+
+    it('does not clone booleans', function() {
+      expect(clone(true)).toEqual({})
+    })
+
+    it('does not clone null', function() {
+      expect(clone(null)).toEqual({})
+    })
+
+    it('does not clone undefined', function() {
+      expect(clone(undefined)).toEqual({})
+    })
   })
 })
diff --git a/test/unit/utils/merge.test.js b/test/unit/utils/merge.test.js
index 0e26f565..568edb14 100644
--- a/test/unit/utils/merge.test.js
+++ b/test/unit/utils/merge.test.js
@@ -30,6 +30,16 @@ describe('Utils.merge', function() {
     expect(merge(a, b, c)).toBe(b)
   })
 
+  it('returns a new copy when the left most value is an empty object', function() {
+    const a = {}
+    const b = { foo: 'bar' }
+
+    const answer = merge(a, b)
+
+    expect(answer).not.toBe(b)
+    expect(answer).toEqual(b)
+  })
+
   it('merges many arguments', function() {
     const a = { red: true }
     const b = { green: true }
@@ -45,13 +55,11 @@ describe('Utils.merge', function() {
     expect(merge(null, a, null, b)).toBe(a)
   })
 
-  it('copys even with falsy middle arguments', function() {
+  it('copies even with falsy middle arguments', function() {
     const a = { foo: 'bar' }
     const b = { foo: 'baz' }
     const c = merge(null, a, null, b)
 
-    expect(c).not.toBe(a)
-    expect(c).not.toBe(b)
     expect(c).toEqual(b)
   })
 
@@ -60,18 +68,88 @@ describe('Utils.merge', function() {
     const b = { foo: 'baz' }
     const c = merge(null, null, a, b)
 
-    expect(c).not.toBe(a)
     expect(c).not.toBe(b)
     expect(c).toEqual(b)
   })
 
   it('handles mixtures of undefined and null', function() {
+    const a = { green: true }
+    const b = { blue: true }
+    const c = merge(a, null, b, undefined)
+
+    expect(c).not.toBe(a)
+    expect(c).not.toBe(b)
+    expect(c).toEqual({ green: true, blue: true })
+  })
+
+  it('when there are no unique keys, it returns the last object', function() {
     const a = { foo: 'bar' }
     const b = { foo: 'baz' }
     const c = merge(a, null, b, undefined)
 
     expect(c).not.toBe(a)
-    expect(c).not.toBe(b)
     expect(c).toEqual(b)
   })
+
+  it('returns the last object when the left most value is a superset', function() {
+    const a = { red: true }
+    const b = { red: true, green: true }
+    const c = merge(a, null, b, undefined)
+
+    expect(c).not.toBe(a)
+    expect(c).toEqual(b)
+  })
+
+  it('returns a new object when the last object is not a superset', function() {
+    const a = { red: false, blue: true }
+    const b = { red: true, green: true }
+    const c = merge(a, null, b, undefined)
+
+    expect(c).toEqual({ red: true, green: true, blue: true })
+  })
+
+  it('uses the right value when it is a superset of the left', function() {
+    const a = { red: true, green: true, blue: true }
+    const b = { red: true, green: true }
+    const c = merge(a, b)
+
+    expect(c).toBe(a)
+  })
+
+  it('an empty object does not cause keys to drop', function() {
+    const a = { color: 'red' }
+    const b = {}
+
+    expect(merge(a, b)).toBe(a)
+  })
+
+  it('undefined in the middle of different keys does not cause keys to drop', function() {
+    const a = { parent: null, maxHistory: 0, batch: false }
+    const b = undefined
+    const c = { parent: true }
+
+    expect(merge(a, b, c)).toEqual({
+      parent: true,
+      maxHistory: 0,
+      batch: false
+    })
+  })
+
+  describe('arrays', function() {
+    it('merges a larger array into a smaller array', function() {
+      let answer = merge([1, 2], [1, 2, 3])
+
+      expect(Array.isArray(answer)).toBe(true)
+
+      expect(answer).toEqual([1, 2, 3])
+    })
+
+    it('merges a smaller array into a larger array', function() {
+      let answer = merge([1, 2, 3], [1, 2])
+
+      expect(Array.isArray(answer)).toBe(true)
+
+      expect(answer).toEqual([1, 2, 3])
+    })
+  })
 })