Repository: vigetlabs/microcosm
Pull request #407 (2017-09-18 11:43:14)

Move Presenter batching to Model. Use History scheduler in Model.
I realized that the Preact Presenter wasn't updated with the batched scheduling update from the React Presenter. This commit moves all of that logic into the Model, adding a few optimizations along the way:

1. Models use the same update scheduler as history
2. Models do not queue an update if nothing has changed, so:
3. The Presenter mediator no longer needs to extend from PureComponent

For this to work, I had to make it so that the scheduler used by nHistory could be cancelled. For this, I borrowed some ideas from https://github.com/yoshuawuyts/on-idle.

`on-idle` does some neat stuff to re-dispatch a job if it completes too fast, which is nice because you create a minimum window to collect changes. I hit some strangeness when I set this up, but I'd like to explore it in the future.

While I was in there, I've also privatized most of the Model API. This also improves minification.

codecov-io: # [Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/407?src=pr&el=h1) Report
> Merging [#407](https://codecov.io/gh/vigetlabs/microcosm/pull/407?src=pr&el=desc) into [master](https://codecov.io/gh/vigetlabs/microcosm/commit/59b92ed034d8bfa69cbc8c6f7f74be4d48957ef1?src=pr&el=desc) will **increase** coverage by `8.25%`.
> The diff coverage is `97.01%`.

[![Impacted file tree graph](https://codecov.io/gh/vigetlabs/microcosm/pull/407/graphs/tree.svg?height=150&width=650&token=AaE0FLz9sU&src=pr)](https://codecov.io/gh/vigetlabs/microcosm/pull/407?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #407      +/-   ##
==========================================
+ Coverage   78.77%   87.02%   +8.25%     
==========================================
  Files          47       45       -2     
  Lines        1286     1256      -30     
  Branches      238      225      -13     
==========================================
+ Hits         1013     1093      +80     
+ Misses        226      137      -89     
+ Partials       47       26      -21
```


| [Impacted Files](https://codecov.io/gh/vigetlabs/microcosm/pull/407?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [packages/microcosm/src/addons/model.js](https://codecov.io/gh/vigetlabs/microcosm/pull/407?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9hZGRvbnMvbW9kZWwuanM=) | `100% <100%> (ø)` | :arrow_up: |
| [packages/microcosm/src/env.js](https://codecov.io/gh/vigetlabs/microcosm/pull/407?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9lbnYuanM=) | `100% <100%> (ø)` | |
| [packages/microcosm-preact/src/presenter.js](https://codecov.io/gh/vigetlabs/microcosm/pull/407?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtLXByZWFjdC9zcmMvcHJlc2VudGVyLmpz) | `100% <100%> (+3.94%)` | :arrow_up: |
| [packages/microcosm/src/history.js](https://codecov.io/gh/vigetlabs/microcosm/pull/407?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9oaXN0b3J5Lmpz) | `100% <100%> (ø)` | :arrow_up: |
| [packages/microcosm/src/addons/presenter.js](https://codecov.io/gh/vigetlabs/microcosm/pull/407?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9hZGRvbnMvcHJlc2VudGVyLmpz) | `100% <100%> (ø)` | :arrow_up: |
| [packages/microcosm/src/default-update-strategy.js](https://codecov.io/gh/vigetlabs/microcosm/pull/407?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtL3NyYy9kZWZhdWx0LXVwZGF0ZS1zdHJhdGVneS5qcw==) | `80% <77.77%> (-20%)` | :arrow_down: |
| [packages/microcosm-preact/src/index.js](https://codecov.io/gh/vigetlabs/microcosm/pull/407?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtLXByZWFjdC9zcmMvaW5kZXguanM=) | | |
| [packages/microcosm-preact/src/action-form.js](https://codecov.io/gh/vigetlabs/microcosm/pull/407?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtLXByZWFjdC9zcmMvYWN0aW9uLWZvcm0uanM=) | `100% <0%> (+100%)` | :arrow_up: |
| [packages/microcosm-preact/src/action-button.js](https://codecov.io/gh/vigetlabs/microcosm/pull/407?src=pr&el=tree#diff-cGFja2FnZXMvbWljcm9jb3NtLXByZWFjdC9zcmMvYWN0aW9uLWJ1dHRvbi5qcw==) | `100% <0%> (+100%)` | :arrow_up: |
| ... and [1 more](https://codecov.io/gh/vigetlabs/microcosm/pull/407?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/407?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/407?src=pr&el=footer). Last update [59b92ed...32da786](https://codecov.io/gh/vigetlabs/microcosm/pull/407?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/packages/microcosm-preact/src/frame.js b/packages/microcosm-preact/src/frame.js
deleted file mode 100644
index baff0f61..00000000
--- a/packages/microcosm-preact/src/frame.js
+++ /dev/null
@@ -1,14 +0,0 @@
-// Cross platform request animation frame
-
-const hasFrame = typeof requestAnimationFrame !== 'undefined'
-
-let requestFrame = setTimeout
-let cancelFrame = clearTimeout
-
-/* istanbul ignore if */
-if (hasFrame) {
-  requestFrame = requestAnimationFrame
-  cancelFrame = cancelAnimationFrame
-}
-
-export { requestFrame, cancelFrame }
diff --git a/packages/microcosm-preact/src/presenter.js b/packages/microcosm-preact/src/presenter.js
index 2adda82a..be6ce4e4 100644
--- a/packages/microcosm-preact/src/presenter.js
+++ b/packages/microcosm-preact/src/presenter.js
@@ -11,7 +11,6 @@
 import { h, Component } from 'preact'
 import Microcosm, { merge, tag, getRegistration } from 'microcosm'
 import Model from 'microcosm/addons/model'
-import { requestFrame, cancelFrame } from './frame'
 
 function passChildren() {
   return this.props.children[0]
@@ -139,13 +138,11 @@ class PresenterMediator extends Component {
 
     this.presenter = props.presenter
     this.repo = this.presenter._requestRepo(context.repo)
-    this._lastRevision = -Infinity
 
     this.model = new Model(this.repo, this.presenter)
 
     // The following methods are autobound to protect scope
     this.send = this.send.bind(this)
-    this._scheduleUpdate = this._scheduleUpdate.bind(this)
   }
 
   getChildContext() {
@@ -163,8 +160,8 @@ class PresenterMediator extends Component {
   componentDidMount() {
     this.presenter.refs = this.refs
 
-    this.model.on('change', this._queueUpdate, this)
-    this.model.on('will-change', this._preUpdate, this)
+    this.model.on('change', this._modelDidUpdate, this)
+    this.model.on('will-change', this._modelWillUpdate, this)
   }
 
   componentWillUnmount() {
@@ -177,8 +174,6 @@ class PresenterMediator extends Component {
     this.model.teardown()
 
     this.presenter._beginTeardown()
-
-    this._stopUpdate()
   }
 
   render() {
@@ -186,8 +181,6 @@ class PresenterMediator extends Component {
     const { model, view } = this.presenter
     const { presenterProps } = this.props
 
-    this._lastRevision = this.model.revision
-
     if (view != null) {
       return h(
         view,
@@ -222,31 +215,12 @@ class PresenterMediator extends Component {
 
   /* Private ------------------------------------------------------ */
 
-  _preUpdate(value, patch) {
+  _modelWillUpdate(value, patch) {
     this.presenter.modelWillUpdate(this.repo, value, patch)
   }
 
-  _scheduleUpdate() {
-    if (this.model.revision > this._lastRevision) {
-      this.forceUpdate()
-    }
-
-    this._scheduledFrame = null
-  }
-
-  _queueUpdate() {
-    if (this.repo.history.batch && !this._scheduledFrame) {
-      this._scheduledFrame = requestFrame(this._scheduleUpdate)
-    } else {
-      this._scheduleUpdate()
-    }
-  }
-
-  _stopUpdate() {
-    if (this._scheduledFrame) {
-      cancelFrame(this._scheduledFrame)
-      this._scheduledFrame = null
-    }
+  _modelDidUpdate() {
+    this.forceUpdate()
   }
 
   _getParent() {
diff --git a/packages/microcosm/src/addons/frame.js b/packages/microcosm/src/addons/frame.js
deleted file mode 100644
index baff0f61..00000000
--- a/packages/microcosm/src/addons/frame.js
+++ /dev/null
@@ -1,14 +0,0 @@
-// Cross platform request animation frame
-
-const hasFrame = typeof requestAnimationFrame !== 'undefined'
-
-let requestFrame = setTimeout
-let cancelFrame = clearTimeout
-
-/* istanbul ignore if */
-if (hasFrame) {
-  requestFrame = requestAnimationFrame
-  cancelFrame = cancelAnimationFrame
-}
-
-export { requestFrame, cancelFrame }
diff --git a/packages/microcosm/src/addons/model.js b/packages/microcosm/src/addons/model.js
index 180cd7e7..e11f53dd 100644
--- a/packages/microcosm/src/addons/model.js
+++ b/packages/microcosm/src/addons/model.js
@@ -5,6 +5,7 @@
  */
 
 import { type Microcosm, Emitter, merge } from '../index'
+import { type Job } from '../default-update-strategy'
 
 function isObservable(binding: *): boolean {
   return binding && typeof binding.subscribe === 'function'
@@ -14,116 +15,190 @@ function isCallable(binding: *): boolean {
   return binding && typeof binding.call === 'function'
 }
 
-function invoke(binding: *, repo: Microcosm, scope: Object): * {
-  if (isCallable(binding)) {
-    return binding.call(scope, repo.state, repo)
-  }
-
-  return binding
-}
+type Answer = { [string]: * }
 
 export default class Model extends Emitter {
-  repo: Microcosm
-  scope: Object
-  bindings: { [key: string]: * }
-  subscriptions: { [key: string]: * }
-  revision: number
-  value: *
+  _scope: Object
+  _repo: Microcosm
+  _bindings: *
+  _subscriptions: { [string]: * }
+  _pendingUpdate: ?Job
+  _publish: () => void
+  _patch: Answer
+  _watching: boolean
+
+  value: Answer
 
   constructor(repo: Microcosm, scope: Object) {
     super()
 
-    this.repo = repo
-    this.scope = scope
-    this.bindings = {}
-    this.subscriptions = {}
+    this._repo = repo
+    this._scope = scope
+    this._bindings = {}
+    this._subscriptions = {}
+    this._patch = {}
+    this._publish = this._publish.bind(this)
+    this._pendingUpdate = null
+    this._watching = false
+
     this.value = {}
-    this.revision = 0
+  }
+
+  bind(bindings: Object) {
+    let next = null
+    let recompute = false
 
-    this.repo.on('change', this.compute, this)
+    for (var key in bindings) {
+      var callback = bindings[key]
+
+      if (isObservable(callback)) {
+        // Presenters support observables. Updates to observables
+        // assign to a single key/value pair
+        this._track(key, callback)
+      } else if (isCallable(callback)) {
+        // Presenters will "call" anything that looks like it's a function.
+        // It does this by checking the "call" property.
+        //
+        // If the binding is new, rebind! Otherwise we don't want to
+        // recalculate this model if nothing has changed.
+        if (this._bindings[key] !== callback) {
+          recompute = true
+
+          if (next == null) {
+            next = {}
+          }
+
+          next[key] = callback
+        }
+      } else if (this.value[key] !== callback) {
+        // Otherwise we just have a primitive value. If the value is
+        // different, push the key/value pair into the next patch and
+        // queue a recompute.
+        recompute = true
+        this._patch[key] = callback
+      }
+    }
+
+    this._rebind(next)
+
+    if (recompute) {
+      this._compute()
+    }
+
+    // If we've recalculated the model and it resulted in a change to the value,
+    // we need to publish a change. This powers Presenter::modelWillUpdate
+    this._publish()
   }
 
   /**
-   * Track an observable. Sending updates to a given key.
+   * Dispose a model, removing all _subscriptions and unsubscribing
+   * from the repo.
    */
-  track(key: string, observable: *) {
-    let last = this.subscriptions[key]
-    let next = observable.subscribe(value => this.set(key, value))
+  teardown() {
+    for (var key in this._subscriptions) {
+      this._subscriptions[key].unsubscribe()
+    }
 
-    this.subscriptions[key] = next
+    this._empty()
 
-    if (last) {
-      last.unsubscribe()
+    this._repo.off('change', this._compute, this)
+  }
+
+  /* Private ------------------------------------------------------ */
+
+  _rebind(bindings: ?Object) {
+    if (bindings) {
+      this._bindings = bindings
+      this._watchRepo()
+    } else {
+      this._ignoreRepo()
     }
   }
 
-  bind(bindings: { [key: string]: * }) {
-    this.bindings = {}
+  _watchRepo() {
+    if (!this._watching) {
+      this._repo.on('change', this._compute, this)
+      this._watching = true
+    }
+  }
 
-    for (var key in bindings) {
-      let binding = bindings[key]
+  _ignoreRepo() {
+    if (this._watching) {
+      this._repo.off('change', this._compute, this)
+      this._watching = false
+    }
+  }
 
-      if (isObservable(binding)) {
-        this.track(key, binding)
-      } else {
-        this.bindings[key] = binding
-      }
+  _enqueue() {
+    if (!this._pendingUpdate) {
+      this._pendingUpdate = this._repo.history.updater(this._publish)
     }
+  }
 
-    this.compute()
+  _set(key: string, value: *) {
+    if (this.value[key] !== value) {
+      this._patch[key] = value
+      this._enqueue()
+    }
   }
 
-  publish(patch: *) {
-    let next = merge(this.value, patch)
+  _publish() {
+    let next = merge(this.value, this._patch)
 
     if (next !== this.value) {
-      this._emit('will-change', next, patch)
+      this._emit('will-change', next, this._patch)
+      this._commit(next)
+      this._emit('change', next)
+    }
+  }
+
+  _commit(value: Answer) {
+    this.value = value
+    this._empty()
+  }
 
-      this.value = next
-      this.revision += 1
+  _empty() {
+    this._patch = {}
 
-      this._emit('change', this.value)
+    if (this._pendingUpdate) {
+      this._pendingUpdate.cancel()
+      this._pendingUpdate = null
     }
-
-    return this.value
   }
 
   /**
-   * Update a specific model key. Emits a change event
+   * Track an observable. Sending updates to a given key.
    */
-  set(key: string, value: *) {
-    return this.publish({ [key]: value })
+  _track(key: string, observable: *) {
+    let last = this._subscriptions[key]
+    let next = observable.subscribe(value => this._set(key, value))
+
+    this._subscriptions[key] = next
+
+    if (last) {
+      last.unsubscribe()
+    }
   }
 
   /**
-   * Run through each invokable binding, recomputing the model
-   * for their associated keys.
+   * Run through each callable binding, recomputing the model for
+   * their associated keys.
    */
-  compute() {
-    let patch = {}
+  _compute() {
     let dirty = false
 
-    for (var key in this.bindings) {
-      var value = invoke(this.bindings[key], this.repo, this.scope)
+    for (var key in this._bindings) {
+      var callback = this._bindings[key]
+      var value = callback.call(this._scope, this._repo.state, this._repo)
 
       if (this.value[key] !== value) {
-        patch[key] = value
+        this._patch[key] = value
         dirty = true
       }
     }
 
-    return dirty ? this.publish(patch) : this.value
-  }
-
-  /**
-   * Dispose a model, removing all subscriptions and unsubscribing
-   * from the repo.
-   */
-  teardown() {
-    for (var key in this.subscriptions) {
-      this.subscriptions[key].unsubscribe()
+    if (dirty) {
+      this._enqueue()
     }
-
-    this.repo.off('change', this.compute, this)
   }
 }
diff --git a/packages/microcosm/src/addons/presenter.js b/packages/microcosm/src/addons/presenter.js
index 870e0be0..644064bc 100644
--- a/packages/microcosm/src/addons/presenter.js
+++ b/packages/microcosm/src/addons/presenter.js
@@ -12,7 +12,6 @@
 import React from 'react'
 import { Microcosm, merge, tag, getRegistration } from '../index'
 import Model from './model'
-import { requestFrame, cancelFrame } from './frame'
 
 function passChildren() {
   return this.props.children ? React.Children.only(this.props.children) : null
@@ -185,27 +184,22 @@ class Presenter extends React.PureComponent<Props, State> {
   }
 }
 
-class PresenterMediator extends React.PureComponent<Props> {
+class PresenterMediator extends React.Component<Props> {
   repo: Microcosm
   send: *
   presenter: Presenter
   model: Model
-  _lastRevision: number
-  _scheduledFrame: *
-  _scheduleUpdate: *
 
   constructor(props: Props, context: Object) {
     super(props, context)
 
     this.presenter = props.presenter
     this.repo = this.presenter._requestRepo(context.repo)
-    this._lastRevision = -Infinity
 
     this.model = new Model(this.repo, this.presenter)
 
     // The following methods are autobound to protect scope
     this.send = this.send.bind(this)
-    this._scheduleUpdate = this._scheduleUpdate.bind(this)
   }
 
   getChildContext() {
@@ -223,8 +217,8 @@ class PresenterMediator extends React.PureComponent<Props> {
   componentDidMount() {
     this.presenter.refs = this.refs
 
-    this.model.on('change', this._queueUpdate, this)
-    this.model.on('will-change', this._preUpdate, this)
+    this.model.on('change', this._modelDidUpdate, this)
+    this.model.on('will-change', this._modelWillUpdate, this)
   }
 
   componentWillUnmount() {
@@ -237,8 +231,6 @@ class PresenterMediator extends React.PureComponent<Props> {
     this.model.teardown()
 
     this.presenter._beginTeardown()
-
-    this._stopUpdate()
   }
 
   render() {
@@ -246,8 +238,6 @@ class PresenterMediator extends React.PureComponent<Props> {
     const { model, view } = this.presenter
     const { presenterProps } = this.props
 
-    this._lastRevision = this.model.revision
-
     if (view != null) {
       return React.createElement(
         view,
@@ -278,31 +268,12 @@ class PresenterMediator extends React.PureComponent<Props> {
 
   /* Private ------------------------------------------------------ */
 
-  _preUpdate(value: Object, patch: Object) {
+  _modelWillUpdate(value: Object, patch: Object) {
     this.presenter.modelWillUpdate(this.repo, value, patch)
   }
 
-  _scheduleUpdate() {
-    if (this.model.revision > this._lastRevision) {
-      this.forceUpdate()
-    }
-
-    this._scheduledFrame = null
-  }
-
-  _queueUpdate() {
-    if (this.repo.history.batch && !this._scheduledFrame) {
-      this._scheduledFrame = requestFrame(this._scheduleUpdate)
-    } else {
-      this._scheduleUpdate()
-    }
-  }
-
-  _stopUpdate() {
-    if (this._scheduledFrame) {
-      cancelFrame(this._scheduledFrame)
-      this._scheduledFrame = null
-    }
+  _modelDidUpdate() {
+    this.forceUpdate()
   }
 
   _getParent(): ?PresenterMediator {
@@ -318,7 +289,6 @@ class PresenterMediator extends React.PureComponent<Props> {
   }
 }
 
-/* istanbul ignore next */
 const noop = () => {}
 
 PresenterMediator.contextTypes = {
diff --git a/packages/microcosm/src/default-update-strategy.js b/packages/microcosm/src/default-update-strategy.js
index 0e6a3fef..71483787 100644
--- a/packages/microcosm/src/default-update-strategy.js
+++ b/packages/microcosm/src/default-update-strategy.js
@@ -2,34 +2,49 @@
  * @fileoverview This is the default update strategy for Microcosm. It
  * can be overriden by passing the `updater` option when creating a
  * Microcosm.
+ *
+ * The two types of updating provided by Microcosm are "immediate" and
+ * "batching".
+ *
+ * When using batching, Microcosm leans on the requestIdleCallback
+ * browser API. This batches together updates. It is configured with a
+ * timeout such that a user never waits longer than 36 milliseconds
+ * for an update.
+ *
+ * @private
  * @flow
  */
 
-export type Updater = (update: Function, options: Object) => void | *
+import { merge } from './utils'
+import { hasWindow, hasIdle } from './env'
 
-type UpdateOptions = {
-  batch: boolean
-}
+export type Updater = (update: Function, options: Object) => void | *
 
-const scheduler: Updater =
-  typeof requestIdleCallback !== 'undefined'
-    ? /* istanbul ignore next */ requestIdleCallback
-    : (updater, options) => setTimeout(updater, options.timeout)
+export type Job = { cancel: () => void }
 
-/**
- * When using requestIdleCallback, batch together updates until the
- * browser is ready for them, but never make the user wait longer than
- * 36 milliseconds.
- * @private
- */
 const BATCH_OPTIONS = { timeout: 36 }
 
-export default function defaultUpdateStrategy(options: UpdateOptions): Updater {
-  return update => {
-    if (options.batch === true) {
-      scheduler(update, BATCH_OPTIONS)
-    } else {
-      update()
+function schedule(callback, options): ?Job {
+  if (options.batch !== true) {
+    callback()
+    return null
+  }
+
+  if (hasWindow && hasIdle) {
+    let job = window.requestIdleCallback(callback, options)
+
+    return {
+      cancel: window.cancelIdleCallback.bind(null, job)
     }
   }
+
+  return {
+    cancel: clearTimeout.bind(null, setTimeout(callback))
+  }
+}
+
+export default function defaultUpdateStrategy(options: Object): Updater {
+  let settings = merge(BATCH_OPTIONS, options)
+
+  return update => schedule(update, settings)
 }
diff --git a/packages/microcosm/src/env.js b/packages/microcosm/src/env.js
new file mode 100644
index 00000000..b7d6094c
--- /dev/null
+++ b/packages/microcosm/src/env.js
@@ -0,0 +1,7 @@
+/**
+* A common place to store data about the current environment
+*/
+
+export const hasWindow = typeof window !== 'undefined'
+
+export const hasIdle = hasWindow && window.requestIdleCallback
diff --git a/packages/microcosm/src/history.js b/packages/microcosm/src/history.js
index 22eeb80c..43bfd7d5 100644
--- a/packages/microcosm/src/history.js
+++ b/packages/microcosm/src/history.js
@@ -308,7 +308,7 @@ class History extends Emitter {
    * Batch releases by "queuing" an update. See `closeRelease`.
    */
   queueRelease() {
-    if (this.releasing === false) {
+    if (!this.releasing) {
       this.releasing = true
       this.updater(this.release)
     }
@@ -320,6 +320,11 @@ class History extends Emitter {
    * enabled, it will be asynchronous.
    */
   closeRelease() {
+    console.assert(
+      this.releasing,
+      'Close release should never be called when not releasing.'
+    )
+
     this.releasing = false
     this._emit('release')
   }


Test patch:
diff --git a/packages/microcosm-preact/test/unit/presenter.test.js b/packages/microcosm-preact/test/unit/presenter.test.js
index 1165a801..4d45dd61 100644
--- a/packages/microcosm-preact/test/unit/presenter.test.js
+++ b/packages/microcosm-preact/test/unit/presenter.test.js
@@ -1,7 +1,8 @@
 import { h, Component } from 'preact'
 import { mount, unmount, remount } from '../helpers'
 import Microcosm from 'microcosm'
-import Presenter from '../../src/presenter'
+import Observable from 'zen-observable'
+import { Presenter } from '../../src'
 import withSend from '../../src/with-send'
 
 const View = withSend(function({ send }) {
@@ -1248,3 +1249,72 @@ describe('::children', function() {
     remount(<Test>{children}</Test>, wrapper)
   })
 })
+
+describe('::batching', function() {
+  it('bundles together repo actions when in batch mode', done => {
+    let repo = new Repo({ batch: true })
+
+    class Test extends Presenter {
+      getModel() {
+        return {
+          color: state => state.color
+        }
+      }
+
+      modelWillUpdate(repo, state, change) {
+        if (change.color === 'pink') {
+          done()
+        } else {
+          done(new Error('Expected color to be pink'))
+        }
+      }
+
+      render(props, state, model) {
+        return <p>{model.color}</p>
+      }
+    }
+
+    mount(<Test repo={repo} />)
+
+    repo.patch({ color: 'red' })
+    repo.patch({ color: 'blue' })
+    repo.patch({ color: 'pink' })
+  })
+
+  it('bundles together model calls in batch mode', done => {
+    let repo = new Repo({ batch: true })
+
+    class Test extends Presenter {
+      getModel() {
+        return {
+          color: new Observable(observer => {
+            observer.next('red')
+
+            setTimeout(function() {
+              observer.next('blue')
+              observer.next('pink')
+            })
+          })
+        }
+      }
+
+      modelWillUpdate(repo, state, { color }) {
+        if (color === 'pink') {
+          done()
+        } else {
+          done(new Error('Expected color to be pink instead of ' + color))
+        }
+      }
+
+      render(props, state, model) {
+        return <p>{model.color}</p>
+      }
+    }
+
+    let text = mount(<Test repo={repo} />).textContent
+
+    if (text !== 'red') {
+      throw new Error('Expected initial text to be red instead of ' + text)
+    }
+  })
+})
diff --git a/packages/microcosm/test/addons/model.test.js b/packages/microcosm/test/addons/model.test.js
index e6cc0176..f17edaa4 100644
--- a/packages/microcosm/test/addons/model.test.js
+++ b/packages/microcosm/test/addons/model.test.js
@@ -75,13 +75,26 @@ describe('Model', function() {
 
       repo.patch({ style: { color: 'red' } })
     })
+
+    it('does not duplicate listening to the same handlers', function() {
+      let repo = new Repo()
+      let model = new Model(repo)
+      let handler = jest.fn(n => true)
+
+      model.bind({ handler })
+      model.bind({ handler })
+      model.bind({ handler })
+
+      repo.patch({ style: { color: 'red' } })
+
+      expect(handler).toHaveBeenCalledTimes(1)
+    })
   })
 
   describe('Observables', function() {
     it('binds to observables', function() {
       let repo = new Repo()
       let model = new Model(repo)
-
       let handler = jest.fn()
 
       model.on('change', handler)
@@ -97,6 +110,24 @@ describe('Model', function() {
       expect(handler).toHaveBeenCalledTimes(3)
     })
 
+    it('does not trigger on duplicate updates', function() {
+      let repo = new Repo()
+      let model = new Model(repo)
+      let handler = jest.fn()
+
+      model.on('change', handler)
+
+      model.bind({
+        count: Observable.of(1, 1, 1, 2, 2, 3, 3, 3)
+      })
+
+      expect(handler).toHaveBeenCalledWith({ count: 1 })
+      expect(handler).toHaveBeenCalledWith({ count: 2 })
+      expect(handler).toHaveBeenCalledWith({ count: 3 })
+
+      expect(handler).toHaveBeenCalledTimes(3)
+    })
+
     it('gets the current observable value', function() {
       let repo = new Repo()
       let model = new Model(repo)
diff --git a/packages/microcosm/test/addons/presenter.test.js b/packages/microcosm/test/addons/presenter.test.js
index 65fdaa18..db8cc5e4 100644
--- a/packages/microcosm/test/addons/presenter.test.js
+++ b/packages/microcosm/test/addons/presenter.test.js
@@ -4,6 +4,7 @@
 
 import React from 'react'
 import Microcosm from 'microcosm'
+import Observable from 'zen-observable'
 import Presenter from 'microcosm/addons/presenter'
 import ActionButton from 'microcosm/addons/action-button'
 import withSend from 'microcosm/addons/with-send'
@@ -1557,3 +1558,71 @@ describe('::modelWillUpdate', function() {
     mount(<Test id="2" />).setProps({ id: '3' })
   })
 })
+describe('::batching', function() {
+  it('bundles together repo actions when in batch mode', done => {
+    let repo = new Repo({ batch: true })
+
+    class Test extends Presenter {
+      getModel() {
+        return {
+          color: state => state.color
+        }
+      }
+
+      modelWillUpdate(repo, state, change) {
+        if (change.color === 'pink') {
+          done()
+        } else {
+          done(new Error('Expected color to be pink'))
+        }
+      }
+
+      render() {
+        return <p>{this.model.color}</p>
+      }
+    }
+
+    mount(<Test repo={repo} />)
+
+    repo.patch({ color: 'red' })
+    repo.patch({ color: 'blue' })
+    repo.patch({ color: 'pink' })
+  })
+
+  it('bundles together model calls in batch mode', done => {
+    let repo = new Repo({ batch: true })
+
+    class Test extends Presenter {
+      getModel() {
+        return {
+          color: new Observable(observer => {
+            observer.next('red')
+
+            setTimeout(function() {
+              observer.next('blue')
+              observer.next('pink')
+            })
+          })
+        }
+      }
+
+      modelWillUpdate(repo, state, { color }) {
+        if (color === 'pink') {
+          done()
+        } else {
+          done(new Error('Expected color to be pink instead of ' + color))
+        }
+      }
+
+      render() {
+        return <p>{this.model.color}</p>
+      }
+    }
+
+    let text = mount(<Test repo={repo} />).text()
+
+    if (text !== 'red') {
+      throw new Error('Expected initial text to be red instead of ' + text)
+    }
+  })
+})