diff --git a/docs/api/stores.md b/docs/api/stores.md index 666a495c..e0b882a5 100644 --- a/docs/api/stores.md +++ b/docs/api/stores.md @@ -64,6 +64,11 @@ var Planets = { } ``` +### `setup()` + +Setup runs right after a store is added to a Microcosm, but before it runs +getInitialState. This is useful for one-time setup instructions. + ### `serialize(state)` Allows a store to transform data before it leaves the system. It gives @@ -127,3 +132,44 @@ const Planets = { repo.push(Actions.add, { name: 'earth' }) // this will add Earth ``` + +### `commit(next)` + +How should a store actually write to `repo.state`? This is useful for serializing a complex data structure, such as a `Map`, into form easier for public consumption: + +```javascript +import Immutable from 'immutable' + +const Planets = { + getInitialState() { + return Immutable.Map() + }, + + commit(next) { + return Array.from(next.values()) + } +} +``` + +### `shouldCommit(next, last)` + +Based on the next and last state, should `commit` be called? Useful for +custom change management behavior. + +```javascript +import Immutable from 'immutable' + +const Planets = { + getInitialState() { + return Immutable.Map() + }, + + shouldCommit(next, last) { + return Immutable.is(next, last) + } + + commit(next) { + return Array.from(next.values()) + } +} +``` diff --git a/docs/recipes/immutable-js.md b/docs/recipes/immutable-js.md new file mode 100644 index 00000000..6e5eabaa --- /dev/null +++ b/docs/recipes/immutable-js.md @@ -0,0 +1,95 @@ +# ImmutableJS Integration + +1. [Immutable Everywhere](#immutable-everywhere) +2. [Immutable in, Vanilla out](#immutable-in-vanilla-out) + +## Immutable Everywhere + +The most basic integration method is to simply use ImmutableJS: + +```javascript +import Immutable from 'immutable' +import actions from 'actions' + +const Store = { + getInitialState() { + return Immutable.Map() + }, + + add(state, record) { + return state.set(record.id, record) + }, + + remove(state, id) { + return state.remove(id) + }, + + register() { + return { + [actions.create]: this.add, + [actions.destroy]: this.remove + } + } +} +``` + +## Immutable in, Vanilla out + +We've found it can be much simpler to expose vanilla JavaScript data to our +presentation layer. Unfortunately, this tends to mitigates much of the benefit +of immutable data. It also can impose penalties serializing between the two formats. + +It is worth walking through the phases of state a Microcosm works through in order +to better understand how Microcosm accommodates this use case: + +1. **archive** - For performance, Microcosm purges old actions and writes their +final result to a cache. +2. **staging** - State before a making change. This is a preparatory state allowing +stores the ability to transform data one last time before assigning it publicly. +3. **state** - Publicaly available state. This is what is exposed via `repo.state`, + +Essentially, Microcosm can maintain ImmutableJS data structures internally, exposing +plain JavaScript for public consumption. There are two key methods responsible for this: + +1. **commit** - A middleware function that dictates how a Store assigns to `repo.state`. +2. **shouldCommit** - A predicate function that controls invocation of `commit`. + +In practice, this results in a small adjustment to the store described earlier: + +```javascript +import Immutable from 'immutable' +import actions from 'actions' + +const Store = { + getInitialState() { + return Immutable.Map() + }, + + shouldCommit(next, previous) { + return Immutable.is(next, previous) === false + }, + + commit(state) { + return Array.from(state.values()) + }, + + add(state, record) { + return state.set(record.id, record) + }, + + remove(state, id) { + return state.remove(id) + }, + + register() { + return { + [actions.create]: this.add, + [actions.destroy]: this.remove + } + } +} +``` + +Here we've added a `shouldCommit` that utilizes the `Immutable.is` equality check. +Additionally, `commit` describes how `Immutable` should convert into a regular form. +In this case, it will convert into an Array. diff --git a/package.json b/package.json index c5f77e4f..f40d02fe 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "eslint-plugin-react": "6.2.0", "express": "4.14.0", "happypack": "2.2.1", + "immutable": "3.8.1", "jsdom": "9.5.0", "json-loader": "0.5.4", "microcosm-debugger": "vigetlabs/microcosm-debugger", diff --git a/src/getStoreHandlers.js b/src/getStoreHandlers.js index 3518543f..7e965c39 100644 --- a/src/getStoreHandlers.js +++ b/src/getStoreHandlers.js @@ -9,7 +9,7 @@ function format (string) { function getHandler (key, store, type) { let handler = store[type] - if (handler === undefined && store.register) { + if (handler === undefined) { const registrations = store.register() if (process.env.NODE_ENV !== 'production') { diff --git a/src/microcosm.js b/src/microcosm.js index 130f6b97..c8d528ae 100644 --- a/src/microcosm.js +++ b/src/microcosm.js @@ -1,9 +1,11 @@ import Emitter from './emitter' import MetaStore from './stores/meta' import Tree from './tree' +import Store from './store' import lifecycle from './lifecycle' import getStoreHandlers from './getStoreHandlers' import merge from './merge' +import shallowEqual from './shallow-equal' import update from './update' /** @@ -19,22 +21,34 @@ export default class Microcosm extends Emitter { /** * @param {{maxHistory: Number}} options - Instantiation options. */ - constructor ({ maxHistory = -Infinity } = {}) { + constructor ({ maxHistory = -Infinity, pure = false } = {}) { super() this.history = new Tree() + this.pure = pure this.maxHistory = maxHistory this.stores = [] // cache store registry methods for efficiency this.registry = {} - // cache represents the result of dispatching all complete - // actions. - this.cache = {} - - // state represents the result of dispatching all outstanding - // actions over cache + /** + * State is captured in three phases. Each stage solves a different problem: + * + * 1. archive A cache of completed actions. Since actions will never change, + * writing them to an archive allows the actions to be disposed, + * improving dispatch efficiency. + * 2. staged The "private" state, before writing for public consumption. + * Store may not operate on primitive (like ImmutableJS), this + * allows a store to work with complex data types while still + * exposing a primitive public state + * 3. state Public state. The `willCommit` lifecycle method allows a + * store to transform private state before it changes. This + * is useful for turning something like Immutable.Map() or + * a linked-list into a primitive object or array. + */ + this.archive = {} + this.staged = {} this.state = {} // Standard store reduction behaviors @@ -64,20 +78,20 @@ export default class Microcosm extends Emitter { } /** - * Microcosm maintains a cache of "merged" actions. When an action completes, + * Microcosm maintains an archive of "merged" actions. When an action completes, * it checks the maxHistory property to decide if it should prune the action - * and dispatch it into the cache. + * and dispatch it into the archive. * * @private * @param {Action} action target action to "clean" * @param {Number} size the depth of the tree - * @return {Boolean} Was the action merged into the state cache? + * @return {Boolean} Was the action merged into the archive? */ clean (action, size) { const shouldMerge = size > this.maxHistory && action.is('disposable') if (shouldMerge) { - this.cache = this.dispatch(this.cache, action) + this.archive = this.dispatch(this.archive, action) } return shouldMerge @@ -105,7 +119,10 @@ export default class Microcosm extends Emitter { for (var i = 0, len = handlers.length; i < len; i++) { const { key, store, handler } = handlers[i] - state = update.set(state, key, handler.call(store, update.get(state, key), payload)) + const last = update.get(state, key) + const next = handler.call(store, last, payload) + + state = update.set(state, key, store.stage(last, next)) } return state @@ -121,13 +138,30 @@ export default class Microcosm extends Emitter { rollforward () { this.history.prune(this.clean, this) - this.state = this.history.reduce(this.dispatch, this.cache, this) + const staged = this.history.reduce(this.dispatch, this.archive, this) + + const next = this.stores.reduce((memo, store) => { + return this.commit(memo, store[0], store[1]) + }, merge({}, staged)) - this._emit('change', this.state) + this.staged = staged + + if (!this.pure || shallowEqual(this.state, next) === false) { + this.state = next + this._emit('change', next) + } return this } + commit(staged, key, store) { + const last = update.get(this.staged, key) + const next = update.get(staged, key) + const value = store.shouldCommit(next, last) ? store.commit(next) : update.get(this.state, key) + + return update.set(staged, key, value) + } + /** * Append an action to a microcosm's history. In a production * repo, this is typically reserved for testing. `append` @@ -185,11 +219,18 @@ export default class Microcosm extends Emitter { key = null } + let store = null + if (typeof config === 'function') { - config = { register: config } + store = new config() + } else { + store = merge(new Store(), config) } - this.stores = this.stores.concat([[ key, config ]]) + this.stores = this.stores.concat([[ key, store ]]) + + // Setup is called once, whenever to store is added to the microcosm + store.setup() this.rebase() @@ -253,10 +294,10 @@ export default class Microcosm extends Emitter { } /** - * Recalculate initial state by back-filling the cache object with - * the result of getInitialState(). This is used when a store is - * added to Microcosm to ensure the initial state of the store is - * respected. Emits a "change" event. + * Recalculate initial state by back-filling the archive with the + * result of getInitialState(). This is used when a store is added + * to Microcosm to ensure the initial state of the store is respected + * Emits a "change" event. * * @private * @return {Microcosm} self @@ -264,7 +305,7 @@ export default class Microcosm extends Emitter { rebase () { this.registry = {} - this.cache = merge(this.getInitialState(), this.cache) + this.archive = merge(this.getInitialState(), this.archive) this.rollforward() diff --git a/src/store.js b/src/store.js new file mode 100644 index 00000000..9bcab765 --- /dev/null +++ b/src/store.js @@ -0,0 +1,67 @@ +/** + * Store + * This is the base class with which all stores draw their common + * behavior. It can also be extended. + */ + +const EMPTY = {} + +export default class Store { + + /** + * Setup runs right after a store is added to a Microcosm, but before + * it rebases state to include the store's `getInitialState` value. This + * is useful for one-time setup instructions + */ + setup() { + // NOOP + } + + /** + * A default register function that just returns an empty object. This helps + * keep other code from branching. + */ + register() { + // NOOP + return EMPTY + } + + /** + * Given a next and previous state, should the value be committed + * to the next revision? + * + * @param {any} next - the next state + * @param {any} last - the last state + * + * @return {boolean} Should the state update? + */ + shouldCommit(next, last) { + return true + } + + /** + * This is the actual operation used to write state to a Microcosm. + * Normally this isn't overridden, but it is useful for staging custom + * store behavior. This is currently a private API. + * + * @private + * @param {object} state - The current application state + * @param {any} value - The value to assign to a key + * @return {object} newState - The next state for the Microcosm instance + */ + stage(last, next) { + return next + } + + /** + * A middleware method for determining what exactly is assigned to + * repo.state. This gives libraries such as ImmutableJS a chance to serialize + * into a primitive JavaScript form before being publically exposed. + * + * @param {any} next - The next state for the store + */ + commit(next) { + return next + } + +} diff --git a/test/custom-store.test.js b/test/custom-store.test.js new file mode 100644 index 00000000..e0d5918d --- /dev/null +++ b/test/custom-store.test.js @@ -0,0 +1,66 @@ +import test from 'ava' +import Store from '../src/store' +import Microcosm from '../src/microcosm' + +const create = n => n +const destroy = n => n + +class TestDomain extends Store { + + getInitialState() { + return ['reset'] + } + + stage(state, [operation, path, params]) { + switch (operation) { + case 'reset': + return {} + case 'add': + return { ...state, [path]: params } + case 'remove': + let next = {...state} + delete next[path] + return next + } + + console.warn('Unexpected operation %s', operation) + + return state + } + + add(state, record) { + return ['add', record.id, record] + } + + remove(state, id) { + return ['remove', id] + } + + register() { + return { + [create] : this.add, + [destroy] : this.remove + } + } +} + +test('adds records', t => { + var repo = new Microcosm() + + repo.addStore('users', TestDomain) + + repo.push(create, { id: 'bill', name: 'Bill' }) + + t.is(repo.state.users.hasOwnProperty('bill'), true) +}) + +test('removes records', t => { + var repo = new Microcosm() + + repo.addStore('users', TestDomain) + + repo.push(create, { id: 'bill', name: 'Bill' }) + repo.push(destroy, 'bill') + + t.is(repo.state.users.hasOwnProperty('bill'), false) +}) diff --git a/test/dispatch.test.js b/test/dispatch.test.js index 7d714cbf..35173bc8 100644 --- a/test/dispatch.test.js +++ b/test/dispatch.test.js @@ -1,15 +1,6 @@ import test from 'ava' import Microcosm from '../src/microcosm' -test('returns state if there are no handlers', t => { - const repo = new Microcosm() - const old = repo.state - - repo.push(n => n) - - t.is(repo.state, old) -}) - test('does not mutate base state on prior dispatches', t => { const repo = new Microcosm() diff --git a/test/immutable-interop.test.js b/test/immutable-interop.test.js new file mode 100644 index 00000000..770db9f6 --- /dev/null +++ b/test/immutable-interop.test.js @@ -0,0 +1,74 @@ +import test from 'ava' +import Store from '../src/store' +import Microcosm from '../src/microcosm' + +const create = n => n +const destroy = n => n + +import Immutable from 'immutable' + +class TestDomain extends Store { + + getInitialState() { + return Immutable.Map() + } + + shouldCommit(next, previous) { + return Immutable.is(next, previous) === false + } + + add(state, record) { + return state.set(record.id, record) + } + + remove(state, id) { + return state.remove(id) + } + + commit(state) { + return Array.from(state.values()) + } + + register() { + return { + [create] : this.add, + [destroy] : this.remove + } + } +} + +test('adds records', t => { + var repo = new Microcosm() + + repo.addStore('users', TestDomain) + + repo.push(create, { id: 1, name: 'Bill' }) + + t.is(repo.state.users[0].name, 'Bill') +}) + +test('removes records', t => { + var repo = new Microcosm() + + repo.addStore('users', TestDomain) + + repo.push(create, { id: 1, name: 'Bill' }) + repo.push(destroy, 1) + + t.is(repo.state.users.length, 0) +}) + +test('does not generate a new array if no state changes', t => { + var repo = new Microcosm() + + repo.addStore('users', TestDomain) + + repo.push(create, { id: 1, name: 'Bill' }) + repo.push(destroy, 1) + var a = repo.state + + repo.push(destroy, 1) + var b = repo.state + + t.is(a.users, b.users) +}) diff --git a/test/microcosm.test.js b/test/microcosm.test.js index 3b9f0a07..88296679 100644 --- a/test/microcosm.test.js +++ b/test/microcosm.test.js @@ -4,10 +4,8 @@ import Microcosm from '../src/microcosm' test('deserializes when replace is invoked', t => { const repo = new Microcosm() - repo.addStore('dummy', function() { - return { - deserialize: state => state.toUpperCase() - } + repo.addStore('dummy', { + deserialize: state => state.toUpperCase() }) repo.replace({ dummy: 'different' }) @@ -62,9 +60,11 @@ test('can checkout a prior state', t => { const repo = new Microcosm({ maxHistory: Infinity }) const action = n => n - repo.addStore('number', function() { - return { - [action]: (a, b) => b + repo.addStore('number', { + register() { + return { + [action]: (a, b) => b + } } }) @@ -76,3 +76,47 @@ test('can checkout a prior state', t => { t.is(repo.state.number, 1) }) + +test('if pure, it will not emit a change if state is shallowly equal', t => { + const repo = new Microcosm({ pure: true }) + const identity = n => n + + t.plan(1) + + repo.addStore('test', { + getInitialState() { + return 0 + }, + register() { + return { [identity] : (state, next) => next } + } + }) + + const first = repo.state + + repo.push(identity, 0).onDone(function() { + t.is(first, repo.state) + }) +}) + +test('if pure, it will emit a change if state is not shallowly equal', t => { + const repo = new Microcosm({ pure: true }) + const identity = n => n + + t.plan(1) + + repo.addStore('test', { + getInitialState() { + return 0 + }, + register() { + return { [identity] : (state, next) => next } + } + }) + + const first = repo.state + + repo.push(identity, 1).onDone(function() { + t.not(repo.state, first) + }) +}) diff --git a/test/mutation.test.js b/test/mutation.test.js index 103a9a8a..f317311d 100644 --- a/test/mutation.test.js +++ b/test/mutation.test.js @@ -5,14 +5,16 @@ test.cb('writes to repo state', t => { const action = function() {} const repo = new Microcosm() - repo.addStore(function() { - return { - getInitialState() { - return { test: false } - }, - [action](state) { - state.test = true - return state + repo.addStore({ + getInitialState() { + return { test: false } + }, + register() { + return { + [action](state) { + state.test = true + return state + } } } }) diff --git a/test/store.test.js b/test/store.test.js index 13af8524..d877b80c 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -1,19 +1,8 @@ import test from 'ava' import Microcosm from '../src/microcosm' +import Store from '../src/store' import console from './helpers/console' -test('stores can be functions', t => { - const repo = new Microcosm() - - repo.addStore('key', function() { - return { - getInitialState: () => true - } - }) - - t.is(repo.state.key, true) -}) - test('stores can be objects with lifecycle methods', t => { const repo = new Microcosm() @@ -30,9 +19,11 @@ test('warns if a register handler is undefined', t => { console.record() - repo.addStore('key', function() { - return { - [action]: undefined + repo.addStore('key', { + register() { + return { + [action]: undefined + } } }) @@ -42,3 +33,45 @@ test('warns if a register handler is undefined', t => { console.restore() }) + +test('can control if state should be committed', t => { + const repo = new Microcosm() + const add = n => n + + t.plan(1) + + repo.addStore('count', { + getInitialState() { + return 0 + }, + shouldCommit(next, previous) { + return Math.round(next) !== Math.round(previous) + }, + register() { + return { + [add]: (a, b) => a + b + } + } + }) + + var first = repo.state + + repo.push(add, 0.1).onDone(function() { + t.is(repo.state.count, first.count) + }) + +}) + +test('stores have a setup step', t => { + const repo = new Microcosm() + + t.plan(1) + + class Counter extends Store { + setup() { + t.pass() + } + } + + repo.addStore('count', Counter) +})