├── .babelrc ├── .editorconfig ├── .gitignore ├── .jshintrc ├── .travis.yml ├── LICENSE.txt ├── Makefile ├── README.md ├── package.json └── src ├── __tests__ ├── createMiddleware-test.js ├── index-test.js └── reducer-test.js ├── actions.js ├── constants.js ├── createLoader.js ├── createMiddleware.js ├── index.js └── reducer.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "cjs":{ 4 | "presets": [ 5 | [ "modern-node", { "version": "0.12", "modules": "commonjs" } ], 6 | "stage-2" 7 | ] 8 | }, 9 | "es":{ 10 | "presets": [ 11 | [ "modern-node", { "version": "0.12", "modules": false } ], 12 | "stage-2" 13 | ] 14 | }, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [package.json] 12 | indent_size = 2 13 | 14 | [Makefile] 15 | indent_style = tab 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build artefacts 2 | /build/ 3 | /build-es/ 4 | 5 | # npm 6 | /node_modules/ 7 | /npm-debug.log 8 | /redux-storage-*.tgz 9 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esnext": true 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5.5" 4 | - "5.4" 5 | - "5.3" 6 | - "5.2" 7 | - "5.1" 8 | - "5.0" 9 | - "4.2" 10 | - "4.1" 11 | - "0.12" 12 | - "0.10" 13 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Michael Contento 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN = ./node_modules/.bin 2 | NPM = npm --loglevel=error 3 | 4 | # 5 | # INSTALL 6 | # 7 | 8 | install: node_modules/ 9 | 10 | node_modules/: package.json 11 | echo "> Installing ..." 12 | $(NPM) --ignore-scripts install > /dev/null 13 | touch node_modules/ 14 | 15 | # 16 | # CLEAN 17 | # 18 | 19 | clean: 20 | echo "> Cleaning ..." 21 | rm -rf build/ 22 | 23 | mrproper: clean 24 | echo "> Cleaning deep ..." 25 | rm -rf node_modules/ 26 | 27 | # 28 | # BUILD 29 | # 30 | 31 | build: clean install 32 | echo "> Building ..." 33 | BABEL_ENV=cjs $(BIN)/babel src/ --out-dir build/ 34 | BABEL_ENV=es $(BIN)/babel src/ --out-dir build-es/ 35 | 36 | build-watch: clean install 37 | echo "> Building forever ..." 38 | BABEL_ENV=cjs $(BIN)/babel src/ --out-dir build/ --watch 39 | 40 | # 41 | # TEST 42 | # 43 | 44 | lint: install 45 | echo "> Linting ..." 46 | $(BIN)/eslint src/ 47 | 48 | test: install 49 | echo "> Testing ..." 50 | BABEL_ENV=cjs $(BIN)/mocca 51 | 52 | test-watch: install 53 | echo "> Testing forever ..." 54 | BABEL_ENV=cjs $(BIN)/mocca --watch 55 | 56 | # 57 | # PUBLISH 58 | # 59 | 60 | _publish : NODE_ENV ?= production 61 | _publish : BABEL_ENV=cjs 62 | _publish: lint test build 63 | 64 | publish-fix: _publish 65 | $(BIN)/release-it --increment patch 66 | 67 | publish-feature: _publish 68 | $(BIN)/release-it --increment minor 69 | 70 | publish-breaking: _publish 71 | $(BIN)/release-it --increment major 72 | 73 | # 74 | # MAKEFILE 75 | # 76 | 77 | .PHONY: \ 78 | install \ 79 | clean mrproper \ 80 | build build-watch \ 81 | lint test test-watch \ 82 | publish-fix publish-feature publish-breaking 83 | 84 | .SILENT: 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [redux-storage][] 2 | 3 | [![build](https://travis-ci.org/michaelcontento/redux-storage.svg?branch=master)](https://travis-ci.org/michaelcontento/redux-storage) 4 | [![dependencies](https://david-dm.org/michaelcontento/redux-storage.svg)](https://david-dm.org/michaelcontento/redux-storage) 5 | [![devDependencies](https://david-dm.org/michaelcontento/redux-storage/dev-status.svg)](https://david-dm.org/michaelcontento/redux-storage#info=devDependencies) 6 | 7 | [![license](https://img.shields.io/npm/l/redux-storage.svg?style=flat-square)](https://www.npmjs.com/package/redux-storage) 8 | [![npm version](https://img.shields.io/npm/v/redux-storage.svg?style=flat-square)](https://www.npmjs.com/package/redux-storage) 9 | [![npm downloads](https://img.shields.io/npm/dm/redux-storage.svg?style=flat-square)](https://www.npmjs.com/package/redux-storage) 10 | [![Code Climate](https://codeclimate.com/github/michaelcontento/redux-storage/badges/gpa.svg)](https://codeclimate.com/github/michaelcontento/redux-storage) 11 | 12 | Save and load the [Redux][] state with ease. 13 | 14 | # Moved to the react-stack organisation 15 | 16 | My focus has left the node / react ecosystem and this module has got a new home 17 | over at [react-stack](https://github.com/react-stack/redux-storage)! 18 | ## Features 19 | 20 | * Flexible storage engines 21 | * [localStorage][]: based on window.localStorage 22 | * Or for environments without `Promise` support [localStorageFakePromise][] 23 | * [reactNativeAsyncStorage][]: based on `react-native/AsyncStorage` 24 | * Flexible state merger functions 25 | * [simple][merger-simple]: merge plain old JS structures (default) 26 | * [immutablejs][merger-immutablejs]: merge plain old JS **and** [Immutable][] 27 | objects 28 | * Storage engines can be async 29 | * Load and save actions that can be observed 30 | * [SAVE][]: `{ type: 'REDUX_STORAGE_SAVE', payload: /* state tree */ }` 31 | * [LOAD][]: `{ type: 'REDUX_STORAGE_LOAD', payload: /* state tree */ }` 32 | * Various engine decorators 33 | * [debounce][]: batch multiple save operations 34 | * [engines][]: use different storage types 35 | * [filter][]: only store a subset of the whole state tree 36 | * [immutablejs][]: load parts of the state tree as [Immutable][] objects 37 | * [migrate][]: versioned storage with migrations 38 | * Black- and whitelist actions from issuing a save operation 39 | 40 | ## Installation 41 | 42 | npm install --save redux-storage 43 | 44 | And you need to install at least one [redux-storage-engine][npm-engine], as 45 | [redux-storage][] is only the *"management core"*. 46 | 47 | ## Usage 48 | 49 | ```js 50 | import * as storage from 'redux-storage' 51 | 52 | // Import redux and all your reducers as usual 53 | import { createStore, applyMiddleware, combineReducers } from 'redux'; 54 | import * as reducers from './reducers'; 55 | 56 | // We need to wrap the base reducer, as this is the place where the loaded 57 | // state will be injected. 58 | // 59 | // Note: The reducer does nothing special! It just listens for the LOAD 60 | // action and merge in the provided state :) 61 | // Note: A custom merger function can be passed as second argument 62 | const reducer = storage.reducer(combineReducers(reducers)); 63 | 64 | // Now it's time to decide which storage engine should be used 65 | // 66 | // Note: The arguments to `createEngine` are different for every engine! 67 | import createEngine from 'redux-storage-engine-localstorage'; 68 | const engine = createEngine('my-save-key'); 69 | 70 | // And with the engine we can create our middleware function. The middleware 71 | // is responsible for calling `engine.save` with the current state afer 72 | // every dispatched action. 73 | // 74 | // Note: You can provide a list of action types as second argument, those 75 | // actions will be filtered and WON'T trigger calls to `engine.save`! 76 | const middleware = storage.createMiddleware(engine); 77 | 78 | // As everything is prepared, we can go ahead and combine all parts as usual 79 | const createStoreWithMiddleware = applyMiddleware(middleware)(createStore); 80 | const store = createStoreWithMiddleware(reducer); 81 | 82 | // At this stage the whole system is in place and every action will trigger 83 | // a save operation. 84 | // 85 | // BUT (!) an existing old state HAS NOT been restored yet! It's up to you to 86 | // decide when this should happen. Most of the times you can/should do this 87 | // right after the store object has been created. 88 | 89 | // To load the previous state we create a loader function with our prepared 90 | // engine. The result is a function that can be used on any store object you 91 | // have at hand :) 92 | const load = storage.createLoader(engine); 93 | load(store); 94 | 95 | // Notice that our load function will return a promise that can also be used 96 | // to respond to the restore event. 97 | load(store) 98 | .then((newState) => console.log('Loaded state:', newState)) 99 | .catch(() => console.log('Failed to load previous state')); 100 | ``` 101 | 102 | ## Details 103 | 104 | ### Engines, Decorators & Mergers 105 | 106 | They all are published as own packages on npm. But as a convention all engines 107 | share the keyword [redux-storage-engine][npm-engine], decorators can be found 108 | with [redux-storage-decorator][npm-decorator] and mergers with 109 | [redux-storage-merger][npm-merger]. So it's pretty trivial to find all 110 | the additions to [redux-storage][] you need :smile: 111 | 112 | ### Actions 113 | 114 | [redux-storage][] will trigger actions after every load or save operation from 115 | the underlying engine. 116 | 117 | You can use this, for example, to display a loading screen until the old state 118 | has been restored like this: 119 | 120 | ```js 121 | import { LOAD, SAVE } from 'redux-storage'; 122 | 123 | function storageAwareReducer(state = { loaded: false }, action) { 124 | switch (action.type) { 125 | case LOAD: 126 | return { ...state, loaded: true }; 127 | 128 | case SAVE: 129 | console.log('Something has changed and written to disk!'); 130 | 131 | default: 132 | return state; 133 | } 134 | } 135 | ``` 136 | 137 | ### Middleware 138 | 139 | If you pass an array of action types as second argument to `createMiddleware`, 140 | those will be added to a internal blacklist and won't trigger calls to 141 | `engine.save`. 142 | 143 | ```js 144 | import { createMiddleware } from 'redux-storage' 145 | 146 | import { APP_START } from './constants'; 147 | 148 | const middleware = createMiddleware(engine, [ APP_START ]); 149 | ``` 150 | 151 | If you want to whitelist all actions that are allowed to issue a `engine.save`, 152 | just specify them as third argument. 153 | 154 | ```js 155 | import { createMiddleware } from 'redux-storage' 156 | 157 | import { SHOULD_SAVE } from './constants'; 158 | 159 | const middleware = createMiddleware(engine, [], [ SHOULD_SAVE ]); 160 | ``` 161 | 162 | ## License 163 | 164 | The MIT License (MIT) 165 | 166 | Copyright (c) 2015 Michael Contento 167 | 168 | Permission is hereby granted, free of charge, to any person obtaining a copy of 169 | this software and associated documentation files (the "Software"), to deal in 170 | the Software without restriction, including without limitation the rights to 171 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 172 | the Software, and to permit persons to whom the Software is furnished to do so, 173 | subject to the following conditions: 174 | 175 | The above copyright notice and this permission notice shall be included in all 176 | copies or substantial portions of the Software. 177 | 178 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 179 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 180 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 181 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 182 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 183 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 184 | 185 | [merger-simple]: https://github.com/michaelcontento/redux-storage-merger-simple 186 | [merger-immutablejs]: https://github.com/michaelcontento/redux-storage-merger-immutablejs 187 | [npm-engine]: https://www.npmjs.com/browse/keyword/redux-storage-engine 188 | [npm-decorator]: https://www.npmjs.com/browse/keyword/redux-storage-decorator 189 | [npm-merger]: https://www.npmjs.com/browse/keyword/redux-storage-merger 190 | [Redux]: https://github.com/gaearon/redux 191 | [Immutable]: https://github.com/facebook/immutable-js 192 | [redux-storage]: https://github.com/michaelcontento/redux-storage 193 | [react-native]: https://facebook.github.io/react-native/ 194 | [localStorage]: https://github.com/michaelcontento/redux-storage-engine-localStorage 195 | [localStorageFakePromise]: https://github.com/michaelcontento/redux-storage-engine-localStorageFakePromise 196 | [reactNativeAsyncStorage]: https://github.com/michaelcontento/redux-storage-engine-reactNativeAsyncStorage 197 | [LOAD]: https://github.com/michaelcontento/redux-storage/blob/master/src/constants.js#L1 198 | [SAVE]: https://github.com/michaelcontento/redux-storage/blob/master/src/constants.js#L2 199 | [debounce]: https://github.com/michaelcontento/redux-storage-decorator-debounce 200 | [engines]: https://github.com/allegro/redux-storage-decorator-engines 201 | [filter]: https://github.com/michaelcontento/redux-storage-decorator-filter 202 | [migrate]: https://github.com/mathieudutour/redux-storage-decorator-migrate 203 | [immutablejs]: https://github.com/michaelcontento/redux-storage-decorator-immutablejs 204 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-storage", 3 | "version": "4.1.1", 4 | "description": "Persistence layer for redux with flexible backends", 5 | "main": "build/index.js", 6 | "jsnext:main": "build-es/index.js", 7 | "scripts": { 8 | "test": "make lint test build" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/michaelcontento/redux-storage.git" 13 | }, 14 | "homepage": "https://github.com/michaelcontento/redux-storage", 15 | "keywords": [ 16 | "redux", 17 | "redux-middleware", 18 | "fsa", 19 | "flux-standard-action", 20 | "flux", 21 | "immutable", 22 | "persistent", 23 | "data", 24 | "localstorage" 25 | ], 26 | "author": "Michael Contento ", 27 | "files": [ 28 | "build/", 29 | "build-es/", 30 | "src", 31 | "!**/__tests__/**" 32 | ], 33 | "eslintConfig": { 34 | "extends": "michaelcontento" 35 | }, 36 | "license": "MIT", 37 | "devDependencies": { 38 | "babel-cli": "^6.11.4", 39 | "babel-core": "^6.11.4", 40 | "babel-polyfill": "^6.9.1", 41 | "babel-preset-modern-node": "^3.0.0", 42 | "babel-preset-stage-2": "^6.11.0", 43 | "eslint": "^1.10.3", 44 | "eslint-config-michaelcontento": "^1.1.1", 45 | "eslint-plugin-mocha-only": "0.0.3", 46 | "mocca": "^1.0.3", 47 | "release-it": "^2.4.1" 48 | }, 49 | "dependencies": { 50 | "lodash.isfunction": "^3.0.8", 51 | "lodash.isobject": "^3.0.2", 52 | "loose-envify": "^1.2.0", 53 | "redux-actions": "^0.10.1", 54 | "redux-storage-merger-simple": "^1.0.2" 55 | }, 56 | "peerDependencies": { 57 | "redux": "^3.0.0 || ^2.0.0 || ^1.0.0 || 1.0.0-alpha || 1.0.0-rc" 58 | }, 59 | "browserify": { 60 | "transform": [ 61 | "loose-envify" 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/__tests__/createMiddleware-test.js: -------------------------------------------------------------------------------- 1 | import createMiddleware from '../createMiddleware'; 2 | import { LOAD, SAVE } from '../constants'; 3 | 4 | describe('createMiddleware', () => { 5 | let oldEnv; 6 | beforeEach(() => { 7 | oldEnv = process.env.NODE_ENV; 8 | process.env.NODE_ENV = 'production'; 9 | }); 10 | 11 | afterEach(() => { 12 | process.env.NODE_ENV = oldEnv; 13 | }); 14 | 15 | function describeConsoleWarnInNonProduction(msg, cb, msgCheck) { 16 | describe(msg, () => { 17 | let warn; 18 | 19 | beforeEach(() => { 20 | warn = sinon.stub(console, 'warn'); 21 | }); 22 | 23 | afterEach(() => { 24 | warn.restore(); 25 | }); 26 | 27 | it('should warn if NODE_ENV != production', () => { 28 | process.env.NODE_ENV = 'develop'; 29 | cb(); 30 | warn.should.have.been.called; 31 | if (msgCheck) { 32 | msgCheck(warn.firstCall.args[0]); 33 | } 34 | }); 35 | 36 | it('should NOT warn if NODE_ENV == production', () => { 37 | process.env.NODE_ENV = 'production'; 38 | cb(); 39 | warn.should.not.have.been.called; 40 | }); 41 | }); 42 | } 43 | 44 | it('should call next with the given action', () => { 45 | const engine = { save: sinon.stub().resolves() }; 46 | const store = { getState: sinon.spy() }; 47 | const next = sinon.spy(); 48 | const action = { type: 'dummy' }; 49 | 50 | createMiddleware(engine)(store)(next)(action); 51 | 52 | next.should.have.been.calledWith(action); 53 | }); 54 | 55 | it('should return the result of next', () => { 56 | const engine = { save: sinon.stub().resolves() }; 57 | const store = { getState: sinon.spy() }; 58 | const next = sinon.stub().returns('nextResult'); 59 | const action = { type: 'dummy' }; 60 | 61 | const result = createMiddleware(engine)(store)(next)(action); 62 | 63 | result.should.equal('nextResult'); 64 | }); 65 | 66 | it('should ignore blacklisted actions', () => { 67 | const engine = { save: sinon.spy() }; 68 | const store = {}; 69 | const next = sinon.spy(); 70 | const action = { type: 'IGNORE_ME' }; 71 | 72 | createMiddleware(engine, ['IGNORE_ME'])(store)(next)(action); 73 | 74 | engine.save.should.not.have.been.called; 75 | }); 76 | 77 | it('should ignore non-whitelisted actions', () => { 78 | const engine = { save: sinon.spy() }; 79 | const store = {}; 80 | const next = sinon.spy(); 81 | const action = { type: 'IGNORE_ME' }; 82 | 83 | createMiddleware(engine, [], ['ALLOWED'])(store)(next)(action); 84 | 85 | engine.save.should.not.have.been.called; 86 | }); 87 | 88 | it('should process whitelisted actions', () => { 89 | const engine = { save: sinon.stub().resolves() }; 90 | const store = { getState: sinon.spy() }; 91 | const next = sinon.spy(); 92 | const action = { type: 'ALLOWED' }; 93 | 94 | createMiddleware(engine, [], ['ALLOWED'])(store)(next)(action); 95 | 96 | engine.save.should.have.been.called; 97 | }); 98 | 99 | it('should allow whitelist function', () => { 100 | const engine = { save: sinon.stub().resolves() }; 101 | const store = { getState: sinon.spy() }; 102 | const next = sinon.spy(); 103 | const action = { type: 'ALLOWED' }; 104 | const whitelistFn = () => true; 105 | 106 | createMiddleware(engine, [], whitelistFn)(store)(next)(action); 107 | 108 | engine.save.should.have.been.called; 109 | }); 110 | 111 | it('should ignore actions if the whitelist function returns false', () => { 112 | const engine = { save: sinon.stub().resolves() }; 113 | const store = { getState: sinon.spy() }; 114 | const next = sinon.spy(); 115 | const action = { type: 'ALLOWED' }; 116 | const whitelistFn = () => false; 117 | 118 | createMiddleware(engine, [], whitelistFn)(store)(next)(action); 119 | 120 | engine.save.should.not.have.been.called; 121 | }); 122 | 123 | it('should pass the whole action to the whitelist function', (done) => { 124 | const engine = { save: sinon.stub().resolves() }; 125 | const store = { getState: sinon.spy() }; 126 | const next = sinon.spy(); 127 | const action = { type: 'ALLOWED' }; 128 | const whitelistFn = (checkAction) => { 129 | checkAction.should.deep.equal(action); 130 | done(); 131 | }; 132 | 133 | createMiddleware(engine, [], whitelistFn)(store)(next)(action); 134 | }); 135 | 136 | describeConsoleWarnInNonProduction( 137 | 'should not process functions', 138 | () => { 139 | const engine = { save: sinon.stub().resolves() }; 140 | const store = { getState: sinon.spy() }; 141 | const next = sinon.spy(); 142 | const action = () => {}; 143 | 144 | createMiddleware(engine)(store)(next)(action); 145 | 146 | engine.save.should.not.have.been.called; 147 | }, 148 | (msg) => { 149 | msg.should.contain('ACTION IGNORED!'); 150 | msg.should.contain('but received a function'); 151 | } 152 | ); 153 | 154 | describeConsoleWarnInNonProduction( 155 | 'should not process strings', 156 | () => { 157 | const engine = { save: sinon.stub().resolves() }; 158 | const store = { getState: sinon.spy() }; 159 | const next = sinon.spy(); 160 | const action = 'haha'; 161 | 162 | createMiddleware(engine)(store)(next)(action); 163 | 164 | engine.save.should.not.have.been.called; 165 | }, 166 | (msg) => { 167 | msg.should.contain('ACTION IGNORED!'); 168 | msg.should.contain('but received: haha'); 169 | } 170 | ); 171 | 172 | describeConsoleWarnInNonProduction( 173 | 'should not process objects without a type', 174 | () => { 175 | const engine = { save: sinon.stub().resolves() }; 176 | const store = { getState: sinon.spy() }; 177 | const next = sinon.spy(); 178 | const action = { noType: 'damn it' }; 179 | 180 | createMiddleware(engine)(store)(next)(action); 181 | 182 | engine.save.should.not.have.been.called; 183 | }, 184 | (msg) => { 185 | msg.should.contain('ACTION IGNORED!'); 186 | msg.should.contain('objects should have a type property'); 187 | } 188 | ); 189 | 190 | describeConsoleWarnInNonProduction( 191 | 'should warn about action on both black- and whitelist', 192 | () => { 193 | const engine = {}; 194 | createMiddleware(engine, ['A'], ['A']); 195 | } 196 | ); 197 | 198 | it('should pass the current state to engine.save', () => { 199 | const engine = { save: sinon.stub().resolves() }; 200 | const state = { x: 42 }; 201 | const store = { getState: sinon.stub().returns(state) }; 202 | const next = sinon.spy(); 203 | const action = { type: 'dummy' }; 204 | 205 | createMiddleware(engine)(store)(next)(action); 206 | 207 | engine.save.should.have.been.calledWith(state); 208 | }); 209 | 210 | it('should trigger a SAVE action after engine.save', (done) => { 211 | const engine = { save: sinon.stub().resolves() }; 212 | const state = { x: 42 }; 213 | const store = { 214 | getState: sinon.stub().returns(state), 215 | dispatch: sinon.spy() 216 | }; 217 | const next = sinon.spy(); 218 | const action = { type: 'dummy' }; 219 | 220 | createMiddleware(engine)(store)(next)(action); 221 | 222 | setTimeout(() => { 223 | const saveAction = { payload: state, type: SAVE }; 224 | store.dispatch.should.have.been.calledWith(saveAction); 225 | done(); 226 | }, 5); 227 | }); 228 | 229 | it('should add the parent action as meta.origin to the saveAction', (done) => { 230 | process.env.NODE_ENV = 'develop'; 231 | 232 | const engine = { save: sinon.stub().resolves() }; 233 | const state = { x: 42 }; 234 | const store = { 235 | getState: sinon.stub().returns(state), 236 | dispatch: sinon.spy() 237 | }; 238 | const next = sinon.spy(); 239 | const action = { type: 'dummy' }; 240 | 241 | createMiddleware(engine)(store)(next)(action); 242 | 243 | setTimeout(() => { 244 | const saveAction = { payload: state, type: SAVE, meta: { origin: action } }; 245 | store.dispatch.should.have.been.calledWith(saveAction); 246 | done(); 247 | }, 5); 248 | }); 249 | 250 | it('should do nothing if engine.save fails', () => { 251 | const engine = { save: sinon.stub().rejects() }; 252 | const store = { getState: sinon.spy() }; 253 | const next = sinon.spy(); 254 | const action = { type: 'dummy' }; 255 | 256 | createMiddleware(engine)(store)(next)(action); 257 | }); 258 | 259 | it('should always ignore SAVE action', () => { 260 | const engine = { save: sinon.spy() }; 261 | const store = {}; 262 | const next = sinon.spy(); 263 | const action = { type: SAVE }; 264 | 265 | createMiddleware(engine)(store)(next)(action); 266 | 267 | engine.save.should.not.have.been.called; 268 | }); 269 | 270 | it('should always ignore LOAD action', () => { 271 | const engine = { save: sinon.spy() }; 272 | const store = {}; 273 | const next = sinon.spy(); 274 | const action = { type: LOAD }; 275 | 276 | createMiddleware(engine)(store)(next)(action); 277 | 278 | engine.save.should.not.have.been.called; 279 | }); 280 | }); 281 | -------------------------------------------------------------------------------- /src/__tests__/index-test.js: -------------------------------------------------------------------------------- 1 | import defaultImport from '../'; 2 | import * as fullImport from '../'; 3 | import { LOAD, SAVE, createLoader, createMiddleware, reducer } from '../'; 4 | 5 | describe('index', () => { 6 | it('should export everything by default', () => { 7 | // NOTE: the new object is created to include the "default" key 8 | // that exists in fullImport 9 | fullImport.should.be.deep.equal({ ...defaultImport, default: defaultImport }); 10 | }); 11 | 12 | it('should export LOAD', () => { 13 | LOAD.should.be.a.string; 14 | }); 15 | 16 | it('should export SAVE', () => { 17 | SAVE.should.be.a.string; 18 | }); 19 | 20 | it('should export createLoader', () => { 21 | createLoader.should.be.a.func; 22 | }); 23 | 24 | it('should export createMiddleware', () => { 25 | createMiddleware.should.be.a.func; 26 | }); 27 | 28 | it('should export reducer', () => { 29 | reducer.should.be.a.func; 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/__tests__/reducer-test.js: -------------------------------------------------------------------------------- 1 | import reducer from '../reducer'; 2 | import { LOAD } from '../constants'; 3 | 4 | describe('reducer', () => { 5 | it('should do nothing for non LOAD actions', () => { 6 | const spy = sinon.spy(); 7 | const oldState = {}; 8 | const action = { type: 'SOMETHING', payload: {} }; 9 | 10 | reducer(spy)(oldState, action); 11 | 12 | spy.should.have.been.calledWith(oldState, action); 13 | }); 14 | 15 | it('should have a default merger in place', () => { 16 | const spy = sinon.spy(); 17 | const oldState = { x: 0, y: 0 }; 18 | const action = { type: LOAD, payload: { y: 42 } }; 19 | 20 | reducer(spy)(oldState, action); 21 | 22 | spy.should.have.been.calledWith({ x: 0, y: 42 }, action); 23 | }); 24 | 25 | it('should allow me to change the merger', () => { 26 | const spy = sinon.spy(); 27 | const oldState = { x: 0, y: 0 }; 28 | const action = { type: LOAD, payload: { y: 42 } }; 29 | 30 | const merger = (a, b) => { 31 | a.should.equal(oldState); 32 | b.should.deep.equal({ y: 42 }); 33 | return { c: 1 }; 34 | }; 35 | 36 | reducer(spy, merger)(oldState, action); 37 | 38 | spy.should.have.been.calledWith({ c: 1 }, action); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions'; 2 | 3 | import * as constants from './constants'; 4 | 5 | export const load = createAction(constants.LOAD); 6 | export const save = createAction(constants.SAVE); 7 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const LOAD = 'REDUX_STORAGE_LOAD'; 2 | export const SAVE = 'REDUX_STORAGE_SAVE'; 3 | -------------------------------------------------------------------------------- /src/createLoader.js: -------------------------------------------------------------------------------- 1 | import { load as actionLoad } from './actions'; 2 | 3 | export default (engine) => (store) => { 4 | const dispatchLoad = (state) => store.dispatch(actionLoad(state)); 5 | return engine.load().then((newState) => { 6 | dispatchLoad(newState); 7 | return newState; 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /src/createMiddleware.js: -------------------------------------------------------------------------------- 1 | import isFunction from 'lodash.isfunction'; 2 | import isObject from 'lodash.isobject'; 3 | 4 | import { save as actionSave } from './actions'; 5 | import { LOAD, SAVE } from './constants'; 6 | 7 | function swallow() { 8 | } 9 | 10 | function warnAboutConfusingFiltering(blacklist, whitelist) { 11 | blacklist 12 | .filter((item) => whitelist.indexOf(item) !== -1) 13 | .forEach((item) => { 14 | console.warn( // eslint-disable-line no-console 15 | `[redux-storage] Action ${item} is on BOTH black- and whitelist.` 16 | + ` This is most likely a mistake!` 17 | ); 18 | }); 19 | } 20 | 21 | function isValidAction(action) { 22 | const isFunc = isFunction(action); 23 | const isObj = isObject(action); 24 | const hasType = isObj && action.hasOwnProperty('type'); 25 | 26 | if (!isFunc && isObj && hasType) { 27 | return true; 28 | } 29 | 30 | if (process.env.NODE_ENV !== 'production') { 31 | if (isFunc) { 32 | console.warn( // eslint-disable-line no-console 33 | `[redux-storage] ACTION IGNORED! Actions should be objects` 34 | + ` with a type property but received a function! Your` 35 | + ` function resolving middleware (e.g. redux-thunk) must be` 36 | + ` placed BEFORE redux-storage!` 37 | ); 38 | } else if (!isObj) { 39 | console.warn( // eslint-disable-line no-console 40 | `[redux-storage] ACTION IGNORED! Actions should be objects` 41 | + ` with a type property but received: ${action}` 42 | ); 43 | } else if (!hasType) { 44 | console.warn( // eslint-disable-line no-console 45 | `[redux-storage] ACTION IGNORED! Action objects should have` 46 | + ` a type property.` 47 | ); 48 | } 49 | } 50 | 51 | return false; 52 | } 53 | 54 | function handleWhitelist(action, actionWhitelist) { 55 | if (Array.isArray(actionWhitelist)) { 56 | return actionWhitelist.length === 0 57 | ? true // Don't filter if the whitelist is empty 58 | : actionWhitelist.indexOf(action.type) !== -1; 59 | } 60 | 61 | // actionWhitelist is a function that returns true or false 62 | return actionWhitelist(action); 63 | } 64 | 65 | export default (engine, actionBlacklist = [], actionWhitelist = []) => { 66 | // Also don't save if we process our own actions 67 | const blacklistedActions = [...actionBlacklist, LOAD, SAVE]; 68 | 69 | if (process.env.NODE_ENV !== 'production' && Array.isArray(actionWhitelist)) { 70 | warnAboutConfusingFiltering(actionBlacklist, actionWhitelist); 71 | } 72 | 73 | return ({ dispatch, getState }) => { 74 | return (next) => (action) => { 75 | const result = next(action); 76 | 77 | if (!isValidAction(action)) { 78 | return result; 79 | } 80 | 81 | const isOnBlacklist = blacklistedActions.indexOf(action.type) !== -1; 82 | const isOnWhitelist = handleWhitelist(action, actionWhitelist); 83 | 84 | // Skip blacklisted actions 85 | if (!isOnBlacklist && isOnWhitelist) { 86 | const saveState = getState(); 87 | const saveAction = actionSave(saveState); 88 | 89 | if (process.env.NODE_ENV !== 'production') { 90 | if (!saveAction.meta) { 91 | saveAction.meta = {}; 92 | } 93 | saveAction.meta.origin = action; 94 | } 95 | 96 | const dispatchSave = () => dispatch(saveAction); 97 | engine.save(saveState).then(dispatchSave).catch(swallow); 98 | } 99 | 100 | return result; 101 | }; 102 | }; 103 | }; 104 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as createLoader } from './createLoader'; 2 | export { default as createMiddleware } from './createMiddleware'; 3 | export { default as reducer } from './reducer'; 4 | export { LOAD, SAVE } from './constants'; 5 | 6 | // The full default export is required to be BC with redux-storage <= v1.3.2 7 | export default { 8 | ...require('./constants'), 9 | createLoader: require('./createLoader').default, 10 | createMiddleware: require('./createMiddleware').default, 11 | reducer: require('./reducer').default 12 | }; 13 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import simpleMerger from 'redux-storage-merger-simple'; 2 | 3 | import { LOAD } from './constants'; 4 | 5 | export default (reducer, merger = simpleMerger) => { 6 | return (state, action) => reducer( 7 | action.type === LOAD 8 | ? merger(state, action.payload) 9 | : state, 10 | action 11 | ); 12 | }; 13 | --------------------------------------------------------------------------------