From 16c7388db518c814787f39b3fdfdffb18284df23 Mon Sep 17 00:00:00 2001 From: Nathan Hunzaker Date: Fri, 11 Nov 2016 15:40:35 -0500 Subject: [PATCH] Add the concept of effects --- docs/README.md | 1 + docs/api/effects.md | 79 ++++++++++++++++++++++++++++++++++++ src/action.js | 2 +- src/effects.js | 55 +++++++++++++++++++++++++ src/history.js | 7 +++- src/microcosm.js | 26 ++++++++++++ test/effects.test.js | 95 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 docs/api/effects.md create mode 100644 src/effects.js create mode 100644 test/effects.test.js diff --git a/docs/README.md b/docs/README.md index 62da89fd..78d33582 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ start. Beyond that, check out [the example apps](../examples). 1. [Microcosm](api/microcosm.md) 2. [Domains](api/domains.md) 3. [Actions](api/actions.md) +4. [Effects](api/effects.md) ## Addons diff --git a/docs/api/effects.md b/docs/api/effects.md new file mode 100644 index 00000000..56899c8c --- /dev/null +++ b/docs/api/effects.md @@ -0,0 +1,79 @@ +# Effects + +1. [Overview](#overview) +2. [Subscribing to different action states](#subscribing-to-different-action-states) +3. [API](#api) + +## Overview + +Effects are one-time handlers that are invoked once, after the action +moves into a different state. They are very similar to Domains, +however their purposes is to handle side-effects. Domains are not the +appropriate place to handle this sort of behavior, as they may +dispatch the same handler several times during the reconciliation of +asynchronous actions. + +```javascript +// If an effect implements a setup method, it will receive options as +// the second argument +repo.addEffect(Effect, options) +``` + +## Subscribing to different action states + +Just like Domains, effects can provide a `register` method to dictate +what actions they listen to: + +```javascript +const Effect = { + + handler (repo, payload) { + // side-effect here + }, + + register() { + return { + [action] : this.handler + } + } +} +``` + +`action` referenced directly, like `[action]: callback`, refer to the +`done` state. + +## API + +### `setup(repo, options)` + +Setup runs right after an effect is added to a Microcosm. It receives +that repo and any options passed as the second argument. + +### `teardown(repo)` + +Runs whenever `Microcosm::teardown` is invoked. Useful for cleaning up +work done in `setup()`. + +### `register()` + +Returns an object mapping actions to methods on the effect. This is the +communication point between a effect and the rest of the system. + +```javascript +import { addPlanet } from '../actions/planets' + +class Planets { + //... + register () { + return { + [addPlanet]: this.alert + } + } + + alert (repo, planet) { + alert('A planet was added! ' + planet.name) + } +} + +repo.push(Actions.add, { name: 'earth' }) // this will add Earth +``` diff --git a/src/action.js b/src/action.js index 08644c9a..9769e54b 100644 --- a/src/action.js +++ b/src/action.js @@ -86,7 +86,7 @@ export default class Action extends Emitter { */ reconcile() { if (this.history) { - this.history.reconcile() + this.history.reconcile(this) } return this diff --git a/src/effects.js b/src/effects.js new file mode 100644 index 00000000..4e76f753 --- /dev/null +++ b/src/effects.js @@ -0,0 +1,55 @@ +/** + * A cluster of effects. + */ + +import merge from './merge' + +const EMPTY = {} + +export default class Effects { + + constructor(repo) { + this.repo = repo + this.effects = [] + } + + trigger (action) { + for (var i = 0; i < this.effects.length; i++) { + let effect = this.effects[i] + let handlers = effect.register ? effect.register() : EMPTY + + if (handlers[action.type]) { + handlers[action.type](this.repo, action.payload) + } + } + } + + add (config, options) { + let effect = null + + if (typeof config === 'function') { + effect = new config(this.repo, options) + } else { + effect = merge({ repo: this.repo }, config) + } + + this.effects.push(effect) + + if (typeof effect.setup === 'function') { + effect.setup(this.repo, options) + } + + return this + } + + teardown () { + for (var i = 0; i < this.effects.length; i++) { + let effect = this.effects[i] + + if (typeof effect.teardown === 'function') { + effect.teardown(this.repo) + } + } + } + +} diff --git a/src/history.js b/src/history.js index d24ff0c4..37988d1e 100644 --- a/src/history.js +++ b/src/history.js @@ -109,7 +109,7 @@ export default class History { return this.size <= 0 || this.repos.length <= 0 } - reconcile () { + reconcile (action) { if (this.isDormant()) { return false } @@ -118,6 +118,11 @@ export default class History { this.rollforward() this.archive() this.invoke('release') + + // Play effects after a release so that they can reference the new state + if (action) { + this.invoke('effect', action) + } } rollforward () { diff --git a/src/microcosm.js b/src/microcosm.js index 01edb7cd..8c32e6ef 100644 --- a/src/microcosm.js +++ b/src/microcosm.js @@ -2,6 +2,7 @@ import Emitter from './emitter' import History from './history' import MetaDomain from './domains/meta' import Realm from './realm' +import Effects from './effects' import lifecycle from './lifecycle' import merge from './merge' import shallowEqual from './shallow-equal' @@ -25,6 +26,7 @@ export default class Microcosm extends Emitter { this.history = history || new History(maxHistory) this.realm = new Realm(this) + this.effects = new Effects(this) this.pure = pure this.parent = parent @@ -76,6 +78,9 @@ export default class Microcosm extends Emitter { // Teardown all domains this.realm.teardown(this) + // Teardown all effects + this.effects.teardown(this) + // Remove all listeners this.off() @@ -273,6 +278,27 @@ export default class Microcosm extends Emitter { return this.addDomain.apply(this, arguments) } + /** + * An effect is a one-time handler that fires whenever an action changes. Callbacks + * will only ever fire once, and can not modify state. + * + * @param {Object} config - Configuration for the effect + * @param {Object} options - Options to pass to the effect + * @return {Microcosm} self + */ + addEffect (effect, options) { + this.effects.add(effect, options) + + return this + } + + /** + * Trigger an effect + */ + effect (action) { + this.effects.trigger(action) + } + /** * Push an action to reset the state of the instance. This state is folded * on to the result of `getInitialState()`. diff --git a/test/effects.test.js b/test/effects.test.js new file mode 100644 index 00000000..4046e605 --- /dev/null +++ b/test/effects.test.js @@ -0,0 +1,95 @@ +import Microcosm from '../src/microcosm' + +test('invokes an effect when an action completes', function () { + const repo = new Microcosm() + const test = n => n + + const Effect = { + handler: jest.fn(), + register() { + return { + [test] : this.handler + } + } + } + + repo.addEffect(Effect) + + repo.push(test, true) + + expect(Effect.handler).toHaveBeenCalledWith(repo, true) +}) + +test('an effect is only called once - at reconciliation', function () { + const repo = new Microcosm() + const test = n => n + + const Effect = { + handler: jest.fn(), + register() { + return { + [test] : this.handler + } + } + } + + repo.addEffect(Effect) + + const one = repo.append(test) + const two = repo.append(test) + + two.resolve() + one.resolve() + + expect(Effect.handler).toHaveBeenCalledTimes(2) +}) + +test('an effect may be a class', function () { + const repo = new Microcosm() + const test = n => n + const spy = jest.fn() + + class Effect { + handler = spy + + register() { + return { + [test] : this.handler + } + } + } + + repo.addEffect(Effect) + + repo.push(test, true) + + expect(spy).toHaveBeenCalledWith(repo, true) +}) + +test('an effect is setup with options', function () { + const repo = new Microcosm() + const spy = jest.fn() + + class Effect { + setup = spy + } + + repo.addEffect(Effect, { test: true }) + + expect(spy).toHaveBeenCalledWith(repo, { test: true }) +}) + +test('an effect is torn down with the repo', function () { + const repo = new Microcosm() + const spy = jest.fn() + + class Effect { + teardown = spy + } + + repo.addEffect(Effect) + + repo.teardown() + + expect(spy).toHaveBeenCalledWith(repo) +})