├── index.js ├── .babelrc ├── .travis.yml ├── .npmignore ├── .gitignore ├── package.json ├── LICENSE ├── README.md ├── src └── index.js └── test └── index.js /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/index.js'); 2 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "optional": ["es7.objectRestSpread"] 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | pids 2 | logs 3 | npm-debug.log 4 | node_modules 5 | /src 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pids 2 | logs 3 | npm-debug.log 4 | node_modules 5 | /lib 6 | coverage 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-optimist", 3 | "version": "1.0.0", 4 | "description": "Optimistically apply actions that can be later commited or reverted.", 5 | "keywords": [], 6 | "dependencies": {}, 7 | "devDependencies": { 8 | "babel": "^5.8.23", 9 | "babel-istanbul": "^0.3.20", 10 | "chalk": "^1.1.1", 11 | "diff": "^2.1.2", 12 | "testit": "^2.0.2" 13 | }, 14 | "scripts": { 15 | "prepublish": "npm run build", 16 | "build": "babel src --out-dir lib", 17 | "test": "babel-node test/index.js", 18 | "coverage": "babel-node node_modules/.bin/babel-istanbul cover test/index.js" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/ForbesLindesay/redux-optimist.git" 23 | }, 24 | "author": "ForbesLindesay", 25 | "license": "MIT" 26 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Forbes Lindesay 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-optimist 2 | 3 | Optimistically apply actions that can be later commited or reverted. 4 | 5 | [![Build Status](https://img.shields.io/travis/ForbesLindesay/redux-optimist/master.svg)](https://travis-ci.org/ForbesLindesay/redux-optimist) 6 | [![Dependency Status](https://img.shields.io/david/ForbesLindesay/redux-optimist.svg)](https://david-dm.org/ForbesLindesay/redux-optimist) 7 | [![NPM version](https://img.shields.io/npm/v/redux-optimist.svg)](https://www.npmjs.org/package/redux-optimist) 8 | 9 | 10 | 11 | ## Installation 12 | 13 | npm install redux-optimist 14 | 15 | ## Usage 16 | 17 | ### Step 1: Wrap your top level reducer in redux-optimist 18 | 19 | #### `reducers/todos.js` 20 | 21 | ```js 22 | export default function todos(state = [], action) { 23 | switch (action.type) { 24 | case 'ADD_TODO': 25 | return state.concat([action.text]); 26 | default: 27 | return state; 28 | } 29 | } 30 | ``` 31 | 32 | #### `reducers/status.js` 33 | 34 | ```js 35 | export default function status(state = {writing: false, error: null}, action) { 36 | switch (action.type) { 37 | case 'ADD_TODO': 38 | return {writing: true, error: null}; 39 | case 'ADD_TODO_COMPLETE': 40 | return {writing: false, error: null}; 41 | case 'ADD_TODO_FAILED': 42 | return {writing: false, error: action.error}; 43 | default: 44 | return state; 45 | } 46 | } 47 | ``` 48 | 49 | #### `reducers/index.js` 50 | 51 | ```js 52 | import optimist from 'redux-optimist'; 53 | import { combineReducers } from 'redux'; 54 | import todos from './todos'; 55 | import status from './status'; 56 | 57 | export default optimist(combineReducers({ 58 | todos, 59 | status 60 | })); 61 | ``` 62 | 63 | As long as your top-level reducer returns a plain object, you can use optimist. You don't 64 | have to use `Redux.combineReducers`. 65 | 66 | ### Step 2: Mark your optimistic actions with the `optimist` key 67 | 68 | #### `middleware/api.js` 69 | 70 | ```js 71 | import {BEGIN, COMMIT, REVERT} from 'redux-optimist'; 72 | import request from 'then-request'; 73 | 74 | let nextTransactionID = 0; 75 | export default function (store) { 76 | return next => action => { 77 | if (action.type !== 'ADD_TODO') { 78 | return next(action); 79 | } 80 | let transactionID = nextTransactionID++; 81 | next({ 82 | type: 'ADD_TODO', 83 | text: action.text, 84 | optimist: {type: BEGIN, id: transactionID} 85 | }); 86 | request('POST', '/add_todo', {text: action.text}).getBody().done( 87 | res => next({ 88 | type: 'ADD_TODO_COMPLETE', 89 | text: action.text, 90 | response: res, 91 | optimist: {type: COMMIT, id: transactionID} 92 | }), 93 | err => next({ 94 | type: 'ADD_TODO_FAILED', 95 | text: action.text, 96 | error: err, 97 | optimist: {type: REVERT, id: transactionID} 98 | }) 99 | ); 100 | } 101 | }; 102 | ``` 103 | 104 | Note how we always follow up by either COMMITing the transaction or REVERTing it. If you do neither, you will get a memory leak. Also note that we use a serialisable transactionID such as a number. These should always 105 | be unique accross the entire system. 106 | 107 | ### Step 3: 108 | 109 | Using this, we can safely fire off `ADD_TODO` actions in the knowledge that the UI will update optimisticly, but will revert if the write to the server fails. 110 | 111 | `App.js` 112 | 113 | ```js 114 | import { createStore, applyMiddleware } from 'redux'; 115 | import api from './middleware/api'; 116 | import reducer from './reducers'; 117 | 118 | // Note: passing middleware as the last argument to createStore requires redux@>=3.1.0 119 | let store = createStore(reducer, applyMiddleware(api)); 120 | console.log(store.getState()); 121 | // { 122 | // optimist: {...}, 123 | // todos: [], 124 | // status: {writing: false, error: null} 125 | // } 126 | 127 | store.dispatch({ 128 | type: 'ADD_TODO', 129 | text: 'Use Redux' 130 | }); 131 | console.log(store.getState()); 132 | // { 133 | // optimist: {...}, 134 | // todos: ['Use Redux'], 135 | // status: {writing: true, error: null} 136 | // } 137 | 138 | // You can apply other actions here and their updates won't get lost 139 | // even if the original ADD_TODO action gets reverted. 140 | 141 | // Some time later... 142 | console.log(store.getState()); 143 | // either 144 | // { 145 | // optimist: {...}, 146 | // todos: ['Use Redux'], 147 | // status: {writing: false, error: null} 148 | // } 149 | // or 150 | // { 151 | // optimist: {...}, 152 | // todos: [], 153 | // status: {writing: false, error: Error} 154 | // } 155 | ``` 156 | 157 | ## License 158 | 159 | MIT 160 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var BEGIN = 'BEGIN'; 4 | var COMMIT = 'COMMIT'; 5 | var REVERT = 'REVERT'; 6 | // Array({transactionID: string or null, beforeState: {object}, action: {object}} 7 | var INITIAL_OPTIMIST = []; 8 | 9 | module.exports = optimist; 10 | module.exports.BEGIN = BEGIN; 11 | module.exports.COMMIT = COMMIT; 12 | module.exports.REVERT = REVERT; 13 | function optimist(fn) { 14 | function beginReducer(state, action) { 15 | let {optimist, innerState} = separateState(state); 16 | optimist = optimist.concat([{beforeState: innerState, action}]); 17 | innerState = fn(innerState, action); 18 | validateState(innerState, action); 19 | return {optimist, ...innerState}; 20 | } 21 | function commitReducer(state, action) { 22 | let {optimist, innerState} = separateState(state); 23 | var newOptimist = [], started = false, committed = false; 24 | optimist.forEach(function (entry) { 25 | if (started) { 26 | if ( 27 | entry.beforeState && 28 | matchesTransaction(entry.action, action.optimist.id) 29 | ) { 30 | committed = true; 31 | newOptimist.push({action: entry.action}); 32 | } else { 33 | newOptimist.push(entry); 34 | } 35 | } else if ( 36 | entry.beforeState && 37 | !matchesTransaction(entry.action, action.optimist.id) 38 | ) { 39 | started = true; 40 | newOptimist.push(entry); 41 | } else if ( 42 | entry.beforeState && 43 | matchesTransaction(entry.action, action.optimist.id) 44 | ) { 45 | committed = true; 46 | } 47 | }); 48 | if (!committed) { 49 | console.error('Cannot commit transaction with id "' + action.optimist.id + '" because it does not exist'); 50 | } 51 | optimist = newOptimist; 52 | return baseReducer(optimist, innerState, action); 53 | } 54 | function revertReducer(state, action) { 55 | let {optimist, innerState} = separateState(state); 56 | var newOptimist = [], started = false, gotInitialState = false, currentState = innerState; 57 | optimist.forEach(function (entry) { 58 | if ( 59 | entry.beforeState && 60 | matchesTransaction(entry.action, action.optimist.id) 61 | ) { 62 | currentState = entry.beforeState; 63 | gotInitialState = true; 64 | } 65 | if (!matchesTransaction(entry.action, action.optimist.id)) { 66 | if ( 67 | entry.beforeState 68 | ) { 69 | started = true; 70 | } 71 | if (started) { 72 | if (gotInitialState && entry.beforeState) { 73 | newOptimist.push({ 74 | beforeState: currentState, 75 | action: entry.action 76 | }); 77 | } else { 78 | newOptimist.push(entry); 79 | } 80 | } 81 | if (gotInitialState) { 82 | currentState = fn(currentState, entry.action); 83 | validateState(innerState, action); 84 | } 85 | } 86 | }); 87 | if (!gotInitialState) { 88 | console.error('Cannot revert transaction with id "' + action.optimist.id + '" because it does not exist'); 89 | } 90 | optimist = newOptimist; 91 | return baseReducer(optimist, currentState, action); 92 | } 93 | function baseReducer(optimist, innerState, action) { 94 | if (optimist.length) { 95 | optimist = optimist.concat([{action}]); 96 | } 97 | innerState = fn(innerState, action); 98 | validateState(innerState, action); 99 | return {optimist, ...innerState}; 100 | } 101 | return function (state, action) { 102 | if (action.optimist) { 103 | switch (action.optimist.type) { 104 | case BEGIN: 105 | return beginReducer(state, action); 106 | case COMMIT: 107 | return commitReducer(state, action); 108 | case REVERT: 109 | return revertReducer(state, action); 110 | } 111 | } 112 | 113 | let {optimist, innerState} = separateState(state); 114 | if (state && !optimist.length) { 115 | let nextState = fn(innerState, action); 116 | if (nextState === innerState) { 117 | return state; 118 | } 119 | validateState(nextState, action); 120 | return {optimist, ...nextState}; 121 | } 122 | return baseReducer(optimist, innerState, action); 123 | }; 124 | } 125 | 126 | function matchesTransaction(action, id) { 127 | return ( 128 | action.optimist && 129 | action.optimist.id === id 130 | ); 131 | } 132 | 133 | function validateState(newState, action) { 134 | if (!newState || typeof newState !== 'object' || Array.isArray(newState)) { 135 | throw new TypeError( 136 | 'Error while handling "' + 137 | action.type + 138 | '": Optimist requires that state is always a plain object.' 139 | ); 140 | } 141 | } 142 | 143 | function separateState(state) { 144 | if (!state) { 145 | return {optimist: INITIAL_OPTIMIST, innerState: state}; 146 | } else { 147 | let {optimist = INITIAL_OPTIMIST, ...innerState} = state; 148 | return {optimist, innerState}; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import assert from 'assert'; 4 | import test from 'testit'; 5 | import {diffLines} from 'diff'; 6 | import chalk from 'chalk'; 7 | import optimist from '../src'; 8 | 9 | function deepEqual(expected, actual) { 10 | expected = JSON.stringify(expected, null, ' '); 11 | actual = JSON.stringify(actual, null, ' '); 12 | if (expected !== actual) { 13 | var diff = diffLines(actual, expected); 14 | var err = ''; 15 | diff.forEach(function (chunk) { 16 | err += chunk.added ? chalk.red(chunk.value) : chunk.removed ? chalk.green(chunk.value) : chunk.value; 17 | }); 18 | throw err; 19 | } 20 | } 21 | function getWarnings(fn) { 22 | var warnings = []; 23 | var ce = console.error; 24 | console.error = err => warnings.push(err); 25 | fn(); 26 | console.error = ce; 27 | return warnings; 28 | } 29 | 30 | test('errors and warnings', () => { 31 | test('with a non-object return type it throws', () => { 32 | try { 33 | optimist(function () { })(undefined, {type: 'foo'}); 34 | } catch (ex) { 35 | assert(ex instanceof TypeError); 36 | assert(ex.message === 'Error while handling "foo": Optimist requires that state is always a plain object.'); 37 | return; 38 | } 39 | throw new Error('Optimist should have thrown an exception'); 40 | }); 41 | test('with an object it mixes in the initial state', () => { 42 | let action = {type: 'foo'}; 43 | let res = optimist(function (state, a) { 44 | assert(state === undefined); 45 | assert(a === action); 46 | return {lastAction: a}; 47 | })(undefined, action); 48 | assert.deepEqual(res, {optimist: [], lastAction: action}); 49 | }); 50 | test('when you attempt to commit a non-existent transaction it warns', () => { 51 | let action = {type: 'foo', optimist: {type: optimist.COMMIT, id: 'my-transaction'}}; 52 | let res; 53 | let warnings = getWarnings(() => { 54 | res = optimist(function (state, a) { 55 | assert(state === undefined); 56 | assert(a === action); 57 | return {lastAction: a}; 58 | })(undefined, action); 59 | }); 60 | assert.deepEqual( 61 | warnings, 62 | ['Cannot commit transaction with id "my-transaction" because it does not exist'] 63 | ); 64 | assert.deepEqual(res, {optimist: {}, lastAction: action}); 65 | }); 66 | test('when you attempt to revert a non-existent transaction it warns', () => { 67 | let action = {type: 'foo', optimist: {type: optimist.REVERT, id: 'my-transaction'}}; 68 | let res; 69 | let warnings = getWarnings(() => { 70 | res = optimist(function (state, a) { 71 | assert(state === undefined); 72 | assert(a === action); 73 | return {lastAction: a}; 74 | })(undefined, action); 75 | }); 76 | assert.deepEqual( 77 | warnings, 78 | ['Cannot revert transaction with id "my-transaction" because it does not exist'] 79 | ); 80 | assert.deepEqual(res, {optimist: {}, lastAction: action}); 81 | }); 82 | }); 83 | 84 | basic('beginning a transaction', { 85 | reducer: (state, a) => ({lastAction: a}), 86 | before: {initial: 'state'}, 87 | action: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}}, 88 | after: { 89 | optimist: [ 90 | { 91 | beforeState: {initial: 'state'}, 92 | action: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}} 93 | } 94 | ], 95 | lastAction: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}} 96 | } 97 | }); 98 | basic('within a transaction', { 99 | reducer: (state, a) => ({lastAction: a}), 100 | before: { 101 | optimist: [ 102 | { 103 | beforeState: {initial: 'state'}, 104 | action: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}} 105 | } 106 | ], 107 | lastAction: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}} 108 | }, 109 | action: {type: 'foo'}, 110 | after: { 111 | optimist: [ 112 | { 113 | beforeState: {initial: 'state'}, 114 | action: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}} 115 | }, 116 | {action: {type: 'foo'}} 117 | ], 118 | lastAction: {type: 'foo'} 119 | } 120 | }); 121 | basic('nest a transaction', { 122 | reducer: (state, a) => ({lastAction: a}), 123 | before: { 124 | optimist: [ 125 | { 126 | beforeState: {initial: 'state'}, 127 | action: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}} 128 | } 129 | ], 130 | lastAction: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}} 131 | }, 132 | action: {type: 'bar', optimist: {type: optimist.BEGIN, id: 'my-other-transaction'}}, 133 | after: { 134 | optimist: [ 135 | { 136 | beforeState: {initial: 'state'}, 137 | action: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}} 138 | }, 139 | { 140 | beforeState: {lastAction: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}}}, 141 | action: {type: 'bar', optimist: {type: optimist.BEGIN, id: 'my-other-transaction'}} 142 | }, 143 | ], 144 | lastAction: {type: 'bar', optimist: {type: optimist.BEGIN, id: 'my-other-transaction'}} 145 | } 146 | }); 147 | basic('revert a transaction', { 148 | reducer: (state, a) => ({lastAction: a}), 149 | before: { 150 | optimist: [ 151 | { 152 | beforeState: {initial: 'state'}, 153 | action: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}} 154 | }, 155 | { 156 | beforeState: {lastAction: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}}}, 157 | action: {type: 'bar', optimist: {type: optimist.BEGIN, id: 'my-other-transaction'}} 158 | }, 159 | ], 160 | lastAction: {type: 'bar', optimist: {type: optimist.BEGIN, id: 'my-other-transaction'}} 161 | }, 162 | action: {type: 'foo', optimist: {type: optimist.REVERT, id: 'my-transaction'}}, 163 | after: { 164 | optimist: [ 165 | { 166 | beforeState: {initial: 'state'}, 167 | action: {type: 'bar', optimist: {type: optimist.BEGIN, id: 'my-other-transaction'}} 168 | }, 169 | { 170 | action: {type: 'foo', optimist: {type: optimist.REVERT, id: 'my-transaction'}}, 171 | } 172 | ], 173 | lastAction: {type: 'foo', optimist: {type: optimist.REVERT, id: 'my-transaction'}}, 174 | } 175 | }); 176 | basic('revert other transaction', { 177 | reducer: (state, a) => ({lastAction: a}), 178 | before: { 179 | optimist: [ 180 | { 181 | beforeState: {initial: 'state'}, 182 | action: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}} 183 | }, 184 | { 185 | beforeState: {lastAction: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}}}, 186 | action: {type: 'bar', optimist: {type: optimist.BEGIN, id: 'my-other-transaction'}} 187 | }, 188 | ], 189 | lastAction: {type: 'bar', optimist: {type: optimist.BEGIN, id: 'my-other-transaction'}} 190 | }, 191 | action: {type: 'foo', optimist: {type: optimist.REVERT, id: 'my-other-transaction'}}, 192 | after: { 193 | optimist: [ 194 | { 195 | beforeState: {initial: 'state'}, 196 | action: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}} 197 | }, 198 | { 199 | action: {type: 'foo', optimist: {type: optimist.REVERT, id: 'my-other-transaction'}}, 200 | } 201 | ], 202 | lastAction: {type: 'foo', optimist: {type: optimist.REVERT, id: 'my-other-transaction'}}, 203 | } 204 | }); 205 | basic('commit a transaction', { 206 | reducer: (state, a) => ({lastAction: a}), 207 | before: { 208 | optimist: [ 209 | { 210 | beforeState: {initial: 'state'}, 211 | action: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}} 212 | }, 213 | { 214 | beforeState: {lastAction: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}}}, 215 | action: {type: 'bar', optimist: {type: optimist.BEGIN, id: 'my-other-transaction'}} 216 | }, 217 | ], 218 | lastAction: {type: 'bar', optimist: {type: optimist.BEGIN, id: 'my-other-transaction'}} 219 | }, 220 | action: {type: 'foo', optimist: {type: optimist.COMMIT, id: 'my-transaction'}}, 221 | after: { 222 | optimist: [ 223 | { 224 | beforeState: {lastAction: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}}}, 225 | action: {type: 'bar', optimist: {type: optimist.BEGIN, id: 'my-other-transaction'}} 226 | }, 227 | { 228 | action: {type: 'foo', optimist: {type: optimist.COMMIT, id: 'my-transaction'}}, 229 | } 230 | ], 231 | lastAction: {type: 'foo', optimist: {type: optimist.COMMIT, id: 'my-transaction'}}, 232 | } 233 | }); 234 | basic('commit other transaction', { 235 | reducer: (state, a) => ({lastAction: a}), 236 | before: { 237 | optimist: [ 238 | { 239 | beforeState: {initial: 'state'}, 240 | action: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}} 241 | }, 242 | { 243 | beforeState: {lastAction: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}}}, 244 | action: {type: 'bar', optimist: {type: optimist.BEGIN, id: 'my-other-transaction'}} 245 | }, 246 | ], 247 | lastAction: {type: 'bar', optimist: {type: optimist.BEGIN, id: 'my-other-transaction'}} 248 | }, 249 | action: {type: 'foo', optimist: {type: optimist.COMMIT, id: 'my-other-transaction'}}, 250 | after: { 251 | optimist: [ 252 | { 253 | beforeState: {initial: 'state'}, 254 | action: {type: 'foo', optimist: {type: optimist.BEGIN, id: 'my-transaction'}} 255 | }, 256 | { 257 | action: {type: 'bar', optimist: {type: optimist.BEGIN, id: 'my-other-transaction'}} 258 | }, 259 | { 260 | action: {type: 'foo', optimist: {type: optimist.COMMIT, id: 'my-other-transaction'}}, 261 | } 262 | ], 263 | lastAction: {type: 'foo', optimist: {type: optimist.COMMIT, id: 'my-other-transaction'}}, 264 | } 265 | }); 266 | 267 | test('omits optimist from original reducer', () => { 268 | function originalReducer(state = {value: 0}, action) { 269 | assert(state.value === 0); 270 | assert(!state.hasOwnProperty('optimist')); 271 | return state; 272 | } 273 | let reducer = optimist(originalReducer); 274 | let state; 275 | state = reducer(state, {type: 'foo'}); 276 | state = reducer(state, {type: 'foo'}); 277 | }); 278 | 279 | test('real world example', () => { 280 | function originalReducer(state = {value: 0}, action) { 281 | switch (action.type) { 282 | case 'SET': 283 | return {value: action.value}; 284 | case 'INCREMENT': 285 | return {value: state.value + 1}; 286 | case 'INCREMENT_IF_EVEN': 287 | return state.value % 2 === 0 ? {value: state.value + 1} : state; 288 | default: 289 | return state; 290 | } 291 | } 292 | let reducer = optimist(originalReducer); 293 | let actionCreators = { 294 | set(value, transactionID) { 295 | return { 296 | type: 'SET', 297 | value: value, 298 | optimist: transactionID ? {type: optimist.BEGIN, id: transactionID} : undefined 299 | }; 300 | }, 301 | increment(transactionID) { 302 | return { 303 | type: 'INCREMENT', 304 | optimist: transactionID ? {type: optimist.BEGIN, id: transactionID} : undefined 305 | }; 306 | }, 307 | incrementIfEven(transactionID) { 308 | return { 309 | type: 'INCREMENT_IF_EVEN', 310 | optimist: transactionID ? {type: optimist.BEGIN, id: transactionID} : undefined 311 | }; 312 | }, 313 | commit(transactionID) { 314 | return {type: 'COMMIT', optimist: {type: optimist.COMMIT, id: transactionID}}; 315 | }, 316 | revert(transactionID) { 317 | return {type: 'REVERT', optimist: {type: optimist.REVERT, id: transactionID}}; 318 | }, 319 | }; 320 | let actions = [ 321 | {action: {type: '@@init'}, value: 0}, 322 | {action: actionCreators.set(2), value: 2}, 323 | {action: actionCreators.set(1, 'start-at-1'), value: 1}, 324 | {action: actionCreators.incrementIfEven(), value: 1}, 325 | {action: actionCreators.increment('inc'), value: 2}, 326 | {action: actionCreators.commit('inc'), value: 2}, 327 | {action: actionCreators.revert('start-at-1'), value: 4}, 328 | ]; 329 | let state; 330 | actions.forEach(({action, value}) => { 331 | state = reducer(state, action); 332 | assert(state.value === value); 333 | }); 334 | deepEqual(state, {optimist: [], value: 4}); 335 | }); 336 | 337 | test('real world example 2', () => { 338 | function originalReducer(state = {value: 0}, action) { 339 | switch (action.type) { 340 | case 'SET': 341 | return {value: action.value}; 342 | case 'INCREMENT': 343 | return {value: state.value + 1}; 344 | case 'INCREMENT_IF_EVEN': 345 | return state.value % 2 === 0 ? {value: state.value + 1} : state; 346 | default: 347 | return state; 348 | } 349 | } 350 | let reducer = optimist(originalReducer); 351 | let actionCreators = { 352 | set(value, transactionID) { 353 | return { 354 | type: 'SET', 355 | value: value, 356 | optimist: transactionID ? {id: transactionID} : undefined 357 | }; 358 | }, 359 | increment(transactionID) { 360 | return { 361 | type: 'INCREMENT', 362 | optimist: transactionID ? {id: transactionID} : undefined 363 | }; 364 | }, 365 | incrementIfEven(transactionID) { 366 | return { 367 | type: 'INCREMENT_IF_EVEN', 368 | optimist: transactionID ? {id: transactionID} : undefined 369 | }; 370 | }, 371 | begin(transactionID) { 372 | return {type: 'BEGIN', optimist: {type: optimist.BEGIN, id: transactionID}}; 373 | }, 374 | commit(transactionID) { 375 | return {type: 'COMMIT', optimist: {type: optimist.COMMIT, id: transactionID}}; 376 | }, 377 | revert(transactionID) { 378 | return {type: 'REVERT', optimist: {type: optimist.REVERT, id: transactionID}}; 379 | }, 380 | }; 381 | let actions = [ 382 | {action: {type: '@@init'}, value: 0}, 383 | {action: actionCreators.set(2), value: 2}, 384 | {action: actionCreators.begin('start-at-1'), value: 2}, 385 | {action: actionCreators.set(1, 'start-at-1'), value: 1}, 386 | {action: actionCreators.incrementIfEven(), value: 1}, 387 | {action: actionCreators.increment('start-at-1'), value: 2}, 388 | {action: actionCreators.begin('inc'), value: 2}, 389 | {action: actionCreators.increment('inc'), value: 3}, 390 | {action: actionCreators.commit('inc'), value: 3}, 391 | {action: actionCreators.revert('start-at-1'), value: 4}, 392 | ]; 393 | let state; 394 | actions.forEach(({action, value}) => { 395 | state = reducer(state, action); 396 | assert(state.value === value); 397 | }); 398 | assert.deepEqual(state, {optimist: [], value: 4}); 399 | }); 400 | 401 | test('calls original reducer max of one time per action', () => { 402 | let calls = 0; 403 | function originalReducer(state) { 404 | calls++; 405 | return {}; 406 | } 407 | let reducer = optimist(originalReducer); 408 | let state; 409 | state = reducer(state, {type: '@@init'}); 410 | state = reducer(state, {type: 'foo'}); 411 | assert.equal(calls, 2); 412 | }); 413 | 414 | test('unhandled action state reference', () => { 415 | let originalReducer = (state = {}) => state; 416 | let reducer = optimist(originalReducer); 417 | let initState = reducer(undefined, {type: '@@init'}); 418 | let originalState = reducer(initState, {type: 'foo'}); 419 | let nextState = reducer(originalState, {type: 'foo'}); 420 | assert.strictEqual(originalState, nextState); 421 | }); 422 | 423 | function basic(name, {reducer, before, action, after}) { 424 | test(name, () => { 425 | let res = optimist(function (state, a) { 426 | /* 427 | if (before) { 428 | let {optimist, ...filteredBefore} = before; 429 | assert.deepEqual(state, filteredBefore); 430 | } else { 431 | assert(state === before); 432 | } 433 | assert(a === action, 'action should be passed through to reducer'); 434 | */ 435 | return reducer(state, a); 436 | })(before, action); 437 | deepEqual(res, after); 438 | }); 439 | } 440 | --------------------------------------------------------------------------------