Repository: vigetlabs/microcosm
Pull request #434 (2017-10-10 11:53:50)

Remove compare tree
**What: Removes the CompareTree data binding tool**

The CompareTree class was an addition added in the 12.x release of Microcosm that made it easy to subscribe to fine-grained pathways within a Microcosm state tree:

```javascript
let repo = new Microcosm()
// ...setup

repo.on('change:planets,physics.quantumThreshold', (planets, threshold) => {
  // Do something with planets and quantum threshold (what is that even?)
})
```

It does this extremely efficiently. Subscriptions are compressed into a tree of object key pathways, traversed at `O(log n)`. Our canvas example is able to handle massive grids of pixels:

![screen shot 2017-10-10 at 8 16 42 am](https://user-images.githubusercontent.com/590904/31386028-63baefa4-ad93-11e7-9a1f-405c04f095b4.png)

**So why is it getting moved out of core**

Usage in Microcosm was never publicly documented, and no internal pieces rely on it. It is dead weight. 

As we explore a querying interface for Microcosm, the CompareTree might get more attention, however I want to separate it from Microcosm core in the interim to free up some design decision making.

Long term, I still want to use something _like_ CompareTree. I'm particularly interested in how it could be blended with GraphQL queries. 

**This is a breaking change, and marks the first step towards 13.x**


Patch:
diff --git a/jest.config.js b/jest.config.js
index c28cbf15..23596a76 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -14,8 +14,8 @@ module.exports = {
     '^microcosm(/.+|$)': `<rootDir>/../microcosm/${isBundled
       ? 'build/min'
       : 'src'}$1`,
-    '^microcosm-preact(/.+|$)': `<rootDir>/../microcosm-preact/${isBundled
+    '^microcosm-preact$': `<rootDir>/../microcosm-preact/${isBundled
       ? 'build'
-      : 'src'}$1`
+      : 'src'}`
   }
 }
diff --git a/lib/uglify-name-cache.json b/lib/uglify-name-cache.json
index 73b5728e..18b4e315 100644
--- a/lib/uglify-name-cache.json
+++ b/lib/uglify-name-cache.json
@@ -1,6 +1,6 @@
 {
   "props": {
-    "cname": 56,
+    "cname": 57,
     "props": {
       "$_updateModel": "a",
       "$_beginSetup": "b",
@@ -57,7 +57,8 @@
       "$_queue": "_",
       "$_clean": "aa",
       "$_remove": "ba",
-      "$_origin": "ca"
+      "$_origin": "ca",
+      "$_didChange": "da"
     }
   }
 }
\ No newline at end of file
diff --git a/packages/microcosm-examples/canvas/app/boot.js b/packages/microcosm-examples/canvas/app/boot.js
index 6ed00642..c9d597b1 100644
--- a/packages/microcosm-examples/canvas/app/boot.js
+++ b/packages/microcosm-examples/canvas/app/boot.js
@@ -1,13 +1,17 @@
 import Microcosm, { update } from 'microcosm'
+import CompareTree from 'microcosm/addons/compare-tree'
 import prepareCanvas from './prepare-canvas'
 
 let repo = new Microcosm({ batch: true })
+let tree = new CompareTree()
+
+repo.on('change', tree.update, tree)
 
 let size = 20
 let limit = Infinity
 let rows = Math.min((window.innerWidth / size) | 0, limit)
 let columns = Math.min((window.innerHeight / size) | 0, limit)
-let writes = size * 25
+let writes = size * 50
 
 let advance = (x, y) => ({ x, y })
 
@@ -39,7 +43,7 @@ context.scale(size, size)
 
 for (let x = 0; x < rows; x++) {
   for (let y = 0; y < columns; y++) {
-    repo.on(`change:pixels.${x}.${y}`, hue => {
+    tree.on(`pixels.${x}.${y}`, hue => {
       context.fillStyle = `hsl(${hue}, 70%, 60%)`
       context.fillRect(x, y, 1, 1)
     })
diff --git a/packages/microcosm/CHANGELOG.md b/packages/microcosm/CHANGELOG.md
index 844774cb..3b96eaa4 100644
--- a/packages/microcosm/CHANGELOG.md
+++ b/packages/microcosm/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Changelog 
 
+## Master (13.x)
+
+- Moved internal state tree diffing tool to addon. 
+
 ## 12.14.0 (beta)
 
 - ActionForm action callbacks are given the related form as the second argument.
diff --git a/packages/microcosm/bench/compare-tree-performance.js b/packages/microcosm/bench/compare-tree-performance.js
index c5af5d62..3e73e7e1 100644
--- a/packages/microcosm/bench/compare-tree-performance.js
+++ b/packages/microcosm/bench/compare-tree-performance.js
@@ -1,6 +1,5 @@
 const { Microcosm, update } = require('../build')
-
-let repo = new Microcosm()
+const CompareTree = require('../build/addons/compare-tree')
 
 let advance = (x, y) => ({ x, y })
 
@@ -8,6 +7,8 @@ let SAMPLES = [25, 50, 100, 200]
 let WRITES = 50
 
 let results = SAMPLES.map(function(SIZE) {
+  let repo = new Microcosm()
+  let tree = new CompareTree()
   let stats = {}
 
   repo.addDomain('pixels', {
@@ -27,6 +28,8 @@ let results = SAMPLES.map(function(SIZE) {
     }
   })
 
+  repo.on('change', tree.update, tree)
+
   global.gc()
 
   var memoryBefore = process.memoryUsage().heapUsed
@@ -34,7 +37,7 @@ let results = SAMPLES.map(function(SIZE) {
   var then = process.hrtime()
   for (let x = 0; x < SIZE; x++) {
     for (let y = 0; y < SIZE; y++) {
-      repo.on(`change:pixels.${x}.${y}`, hue => {})
+      tree.on(`pixels.${x}.${y}`, hue => {})
     }
   }
 
diff --git a/packages/microcosm/bin/bundle b/packages/microcosm/bin/bundle
index 675f3820..12aa5a5c 100755
--- a/packages/microcosm/bin/bundle
+++ b/packages/microcosm/bin/bundle
@@ -37,6 +37,7 @@ const configs = {
   ActionButton: path.resolve('src/addons/action-button.js'),
   ActionForm: path.resolve('src/addons/action-form.js'),
   ActionQueue: path.resolve('src/addons/action-queue.js'),
+  CompareTree: path.resolve('src/addons/compare-tree/index.js'),
   Indexing: path.resolve('src/addons/indexing.js'),
   JestMatchers: path.resolve('src/addons/jest-matchers.js'),
   Microcosm: path.resolve('src/index.js'),
diff --git a/packages/microcosm/src/compare-tree/index.js b/packages/microcosm/src/addons/compare-tree/index.js
similarity index 96%
rename from packages/microcosm/src/compare-tree/index.js
rename to packages/microcosm/src/addons/compare-tree/index.js
index d90c78f3..34022c2e 100644
--- a/packages/microcosm/src/compare-tree/index.js
+++ b/packages/microcosm/src/addons/compare-tree/index.js
@@ -5,8 +5,8 @@
 
 import Node from './node'
 import Query from './query'
-import { getKeyString, getKeyPaths, type KeyPath } from '../key-path'
-import { type Callback } from '../emitter'
+import { getKeyString, getKeyPaths, type KeyPath } from '../../key-path'
+import { type Callback } from '../../emitter'
 
 // The root key is an empty string. This can be a little
 // counter-intuitive, so we keep track of them as a named constant.
diff --git a/packages/microcosm/src/compare-tree/node.js b/packages/microcosm/src/addons/compare-tree/node.js
similarity index 100%
rename from packages/microcosm/src/compare-tree/node.js
rename to packages/microcosm/src/addons/compare-tree/node.js
diff --git a/packages/microcosm/src/compare-tree/query.js b/packages/microcosm/src/addons/compare-tree/query.js
similarity index 84%
rename from packages/microcosm/src/compare-tree/query.js
rename to packages/microcosm/src/addons/compare-tree/query.js
index b6d65b27..9703372e 100644
--- a/packages/microcosm/src/compare-tree/query.js
+++ b/packages/microcosm/src/addons/compare-tree/query.js
@@ -4,9 +4,8 @@
  * @flow
  */
 
-import Emitter from '../emitter'
-import { get } from '../utils'
-import { getKeyStrings, type KeyPath } from '../key-path'
+import { Emitter, get } from '../../index'
+import { type KeyPath, getKeyStrings } from '../../key-path'
 
 class Query extends Emitter {
   id: string
diff --git a/packages/microcosm/src/microcosm.js b/packages/microcosm/src/microcosm.js
index ec3d5d5d..f1f4d32f 100644
--- a/packages/microcosm/src/microcosm.js
+++ b/packages/microcosm/src/microcosm.js
@@ -9,7 +9,6 @@ import Emitter from './emitter'
 import History from './history'
 import DomainEngine from './domain-engine'
 import EffectEngine from './effect-engine'
-import CompareTree from './compare-tree'
 import tag from './tag'
 import installDevtools from './install-devtools'
 import { RESET, PATCH, ADD_DOMAIN } from './lifecycle'
@@ -99,7 +98,6 @@ class Microcosm extends Emitter implements Domain {
   actions: Object
   domains: DomainEngine
   effects: EffectEngine
-  changes: CompareTree
   options: Object
   active: boolean
 
@@ -114,7 +112,6 @@ class Microcosm extends Emitter implements Domain {
     this.snapshots = Object.create(this.parent ? this.parent.snapshots : null)
     this.history = this.parent ? this.parent.history : new History(this.options)
     this.actions = Object.create(this.parent ? this.parent.actions : null)
-    this.changes = new CompareTree(this.initial)
 
     if (!this.parent) {
       this._enableDomains()
@@ -186,34 +183,6 @@ class Microcosm extends Emitter implements Domain {
     return this.initial == null ? {} : this.initial
   }
 
-  on(type: *, callback: *, scope?: Object) {
-    let [event, meta] = type.split(':', 2)
-
-    switch (event) {
-      case 'change':
-        this.changes.on(meta || '', callback, scope)
-        break
-      default:
-        Emitter.prototype.on.apply(this, arguments)
-    }
-
-    return this
-  }
-
-  off(type: *, callback: *, scope?: Object) {
-    let [event, meta] = type.split(':', 2)
-
-    switch (event) {
-      case 'change':
-        this.changes.off(meta || '', callback, scope)
-        break
-      default:
-        Emitter.prototype.off.apply(this, arguments)
-    }
-
-    return this
-  }
-
   /**
    * Append an action to history and return it. This is used by push,
    * but also useful for testing action states.
@@ -419,7 +388,9 @@ class Microcosm extends Emitter implements Domain {
   }
 
   _release() {
-    this.changes.update(this.state)
+    if (this._didChange()) {
+      this._emit('change', this.state)
+    }
   }
 
   _createSnapshot(action: Action): Snapshot {
@@ -458,6 +429,13 @@ class Microcosm extends Emitter implements Domain {
     delete this.snapshots[action.id]
   }
 
+  _didChange(): boolean {
+    let head = this.history.head
+    let snap = this.snapshots[head.id]
+
+    return snap && snap.last !== snap.next
+  }
+
   _recall(action: ?Action): Object {
     if (action && this.snapshots[action.id]) {
       return this.snapshots[action.id].next


Test patch:
diff --git a/packages/microcosm/test/unit/compare-tree/compare-tree.test.js b/packages/microcosm/test/addons/compare-tree/compare-tree.test.js
similarity index 78%
rename from packages/microcosm/test/unit/compare-tree/compare-tree.test.js
rename to packages/microcosm/test/addons/compare-tree/compare-tree.test.js
index e36e913e..afb8048d 100644
--- a/packages/microcosm/test/unit/compare-tree/compare-tree.test.js
+++ b/packages/microcosm/test/addons/compare-tree/compare-tree.test.js
@@ -1,4 +1,5 @@
-import Microcosm, { set } from 'microcosm'
+import { Microcosm, set } from 'microcosm'
+import CompareTree from 'microcosm/addons/compare-tree'
 import SOLAR_SYSTEM from './fixtures/solar-system'
 
 class Repo extends Microcosm {
@@ -17,7 +18,9 @@ describe('CompareTree', function() {
 
   beforeEach(function() {
     repo = new Repo()
-    tree = repo.changes
+    tree = new CompareTree(repo.state)
+
+    repo.on('change', tree.update, tree)
   })
 
   describe('::on', function() {
@@ -27,7 +30,7 @@ describe('CompareTree', function() {
       let next = set(SOLAR_SYSTEM, ['meta', 'selected'], 'jupiter')
       let handler = jest.fn()
 
-      repo.on('change:meta.selected', handler)
+      tree.on('meta.selected', handler)
       repo.reset(next)
 
       expect(handler).toHaveBeenCalledWith('jupiter')
@@ -39,7 +42,7 @@ describe('CompareTree', function() {
       let next = set(SOLAR_SYSTEM, ['meta', 'selected'], 'jupiter')
       let handler = jest.fn()
 
-      repo.on('change:meta.selected,planets', handler)
+      tree.on('meta.selected,planets', handler)
       repo.reset(next)
 
       expect(handler).toHaveBeenCalledWith('jupiter', SOLAR_SYSTEM.planets)
@@ -51,7 +54,7 @@ describe('CompareTree', function() {
 
       let handler = jest.fn()
 
-      repo.on('change:meta.selected,meta.focused', handler)
+      tree.on('meta.selected,meta.focused', handler)
       repo.reset(b)
 
       expect(handler).toHaveBeenCalledTimes(1)
@@ -62,7 +65,7 @@ describe('CompareTree', function() {
 
       let next = set(SOLAR_SYSTEM, ['planets', 3], false)
 
-      repo.on('change:planets.3', planet => {
+      tree.on('planets.3', planet => {
         expect(planet).toBe(false)
       })
 
@@ -74,7 +77,7 @@ describe('CompareTree', function() {
 
       repo.reset({})
 
-      repo.on('change:planets.3', planet => {
+      tree.on('planets.3', planet => {
         expect(planet).toBe(SOLAR_SYSTEM.planets[3])
       })
 
@@ -87,8 +90,8 @@ describe('CompareTree', function() {
       let handler = jest.fn()
       let next = set(tree.snapshot, 'meta', {})
 
-      repo.on('change:meta', handler)
-      repo.off('change:meta', handler)
+      tree.on('meta', handler)
+      tree.off('meta', handler)
 
       repo.reset(next)
 
@@ -96,23 +99,23 @@ describe('CompareTree', function() {
     })
 
     it('gracefully ignores removing a missing listener', function() {
-      repo.off('change:meta', n => n)
+      tree.off('meta', n => n)
     })
 
     it('removes the query node if there are no callbacks left', function() {
       let handler = jest.fn()
-      let query = repo.on('change:meta', handler)
+      let query = tree.on('meta', handler)
 
-      repo.off('change:meta', handler)
+      tree.off('meta', handler)
 
       expect(query.id in tree.queries).toBe(false)
     })
 
     it('removes nodes without edges up the chain', function() {
       let handler = jest.fn()
-      let query = repo.on('change:meta.selected', handler)
+      let query = tree.on('meta.selected', handler)
 
-      repo.off('change:meta.selected', handler)
+      tree.off('meta.selected', handler)
 
       expect(tree.nodes['meta.selected']).toBeUndefined()
       expect(tree.nodes['meta']).toBeUndefined()
@@ -121,10 +124,10 @@ describe('CompareTree', function() {
 
     it('does not remove parents with other compares', function() {
       let handler = jest.fn()
-      let query = repo.on('change:meta.selected', handler)
+      let query = tree.on('meta.selected', handler)
 
-      repo.on('change:meta.focused', handler)
-      repo.off('change:meta.selected', handler)
+      tree.on('meta.focused', handler)
+      tree.off('meta.selected', handler)
 
       expect(tree.nodes['meta.selected']).toBeUndefined()
       expect(tree.nodes['meta.focused']).toBeDefined()
@@ -135,11 +138,11 @@ describe('CompareTree', function() {
     it('removes the root node when there are no subscriptions', function() {
       let handler = jest.fn()
 
-      repo.on('change:meta.selected', handler)
+      tree.on('meta.selected', handler)
 
       expect('' in tree.nodes).toBe(true)
 
-      repo.off('change:meta.selected', handler)
+      tree.off('meta.selected', handler)
 
       expect('' in tree.nodes).toBe(false)
     })
@@ -150,10 +153,10 @@ describe('CompareTree', function() {
 
       let next = set(repo.state, ['meta', 'selected'], {})
 
-      repo.on('change:meta.selected', one)
-      repo.on('change:meta.selected', two)
+      tree.on('meta.selected', one)
+      tree.on('meta.selected', two)
 
-      repo.off('change:meta.selected', one)
+      tree.off('meta.selected', one)
 
       repo.reset(next)
 
@@ -165,14 +168,14 @@ describe('CompareTree', function() {
       let one = n => n
       let two = n => n
 
-      repo.on('change:a.b.c', one)
+      tree.on('a.b.c', one)
 
-      let query = repo.on('change:a.b', two)
+      let query = tree.on('a.b', two)
 
       let aB = tree.nodes['a.b.c']
       aB.edges = aB.edges.filter(e => e === query)
 
-      repo.off('change:a.b.c', one)
+      tree.off('a.b.c', one)
 
       expect('a' in tree.nodes).toBe(true)
       expect('a.b' in tree.nodes).toBe(true)
@@ -183,8 +186,8 @@ describe('CompareTree', function() {
     it('gracefully handles nodes that do not exist', function() {
       let callback = n => n
 
-      repo.on('change:a.b.c', callback)
-      repo.off('change:a.b.c', callback)
+      tree.on('a.b.c', callback)
+      tree.off('a.b.c', callback)
 
       expect('a' in tree.nodes).toBe(false)
       expect('a.b' in tree.nodes).toBe(false)
@@ -205,14 +208,14 @@ describe('CompareTree', function() {
 
   describe('::update', function() {
     it('can traverse compares for missing keys', function() {
-      repo.on('change:a.b.c', n => n)
+      tree.on('a.b.c', n => n)
       repo.reset({})
     })
 
     it('can traverse missing state keys', function() {
       let handler = jest.fn()
 
-      repo.on('change:meta.selected', handler)
+      tree.on('meta.selected', handler)
 
       repo.reset({ meta: null })
 
@@ -229,9 +232,9 @@ describe('CompareTree', function() {
       let a = set(repo.state, ['meta', 'selected'], 'jupiter')
       let b = set(a, ['meta', 'focused'], false)
 
-      repo.on('change:meta.selected,meta.focused', () => {})
+      tree.on('meta.selected,meta.focused', () => {})
 
-      repo.on('change:meta', function(meta) {
+      tree.on('meta', function(meta) {
         expect(meta).toBe(b.meta)
       })
 
diff --git a/packages/microcosm/test/unit/compare-tree/fixtures/solar-system.js b/packages/microcosm/test/addons/compare-tree/fixtures/solar-system.js
similarity index 100%
rename from packages/microcosm/test/unit/compare-tree/fixtures/solar-system.js
rename to packages/microcosm/test/addons/compare-tree/fixtures/solar-system.js
diff --git a/packages/microcosm/test/unit/microcosm/events.test.js b/packages/microcosm/test/unit/microcosm/events.test.js
deleted file mode 100644
index 21962ab5..00000000
--- a/packages/microcosm/test/unit/microcosm/events.test.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import Microcosm from 'microcosm'
-
-describe('Microcosm events', function() {
-  describe('::on', function() {
-    it('can add a fine grained event subscription', function() {
-      let repo = new Microcosm()
-      let handler = jest.fn()
-
-      repo.addDomain('styles', {
-        getInitialState() {
-          return { text: { color: 'blue' } }
-        }
-      })
-
-      repo.on('change:styles.text.color', handler)
-
-      repo.patch({ styles: { text: { color: 'red' } } })
-
-      expect(handler).toHaveBeenCalledTimes(1)
-      expect(handler).toHaveBeenCalledWith('red')
-    })
-
-    it('can subscribe to teardown', function() {
-      let repo = new Microcosm()
-      let handler = jest.fn()
-
-      repo.on('teardown', handler)
-
-      repo.shutdown()
-
-      expect(handler).toHaveBeenCalledTimes(1)
-      expect(handler).toHaveBeenCalledWith(repo)
-    })
-  })
-
-  describe('off', function() {
-    it('can remove a fine grained event subscription', function() {
-      let repo = new Microcosm()
-      let handler = jest.fn()
-
-      repo.addDomain('styles', {
-        getInitialState() {
-          return { text: { color: 'blue' } }
-        }
-      })
-
-      repo.on('change:styles.text.color', handler)
-      repo.off('change:styles.text.color', handler)
-
-      repo.patch({ styles: { text: { color: 'red' } } })
-
-      expect(handler).toHaveBeenCalledTimes(0)
-    })
-
-    it('can unsubscribe from teardown', function() {
-      let repo = new Microcosm()
-      let handler = jest.fn()
-
-      repo.on('teardown', handler)
-      repo.off('teardown', handler)
-
-      repo.shutdown()
-
-      expect(handler).toHaveBeenCalledTimes(0)
-    })
-  })
-})