├── .gitignore ├── .npmignore ├── .storybook ├── addons.js └── config.js ├── .travis.yml ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── benchmark └── index.js ├── docs ├── Installation.md ├── Plugins.md ├── Presets.md ├── plugins │ ├── dispatch.md │ ├── getState.md │ ├── observable.md │ ├── reducer.md │ ├── replaceReducer.md │ ├── subscribe.md │ └── thunk.md └── presets │ └── redux.md ├── package.json ├── src ├── __tests__ │ ├── combineEpics.test.js │ ├── counter.test.js │ ├── index.test.js │ ├── ofType.test.js │ └── setup.js ├── combineEpics.js ├── index.js ├── ofType.js ├── plugins │ ├── __tests__ │ │ ├── actions.test.js │ │ ├── dispatch.test.js │ │ ├── dispatchInit.test.js │ │ ├── effectQueue.test.js │ │ ├── effects.test.js │ │ ├── epic.test.js │ │ ├── getState.test.js │ │ ├── persist.test.js │ │ ├── reducer.test.js │ │ ├── rematch.test.js │ │ ├── replaceReducer.test.js │ │ ├── subscribe.test.js │ │ ├── table.test.js │ │ ├── thunk.test.js │ │ └── use.test.js │ ├── actions.js │ ├── async.js.wip │ ├── autopersist.js │ ├── dispatch.js │ ├── dispatchInit.js │ ├── effectQueue.js │ ├── effects.js │ ├── epic.js │ ├── getState.js │ ├── observable.js │ ├── online.js │ ├── online │ │ ├── createNetworkSensor.js │ │ ├── reducer.js │ │ └── util.js │ ├── persist.js │ ├── reducer.js │ ├── rematch.js │ ├── replaceReducer.js │ ├── subscribe.js │ ├── subscribeToEffects.js │ ├── table.js │ ├── thunk.js │ └── use.js └── presets │ ├── __tests__ │ ├── helpers │ │ ├── actionCreators.js │ │ ├── actionTypes.js │ │ └── reducers.js │ ├── redux.test.js │ └── reduxSpec.test.js │ └── redux.js └── stories ├── bench.stories.js └── connect.stories.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | /.idea 4 | .nyc_output 5 | coverage 6 | package-lock.json 7 | yarn.lock 8 | yarn-error.log 9 | dist/ 10 | .DS_Store 11 | lerna-debug.log 12 | /modules/ 13 | /dist/ 14 | _book/ 15 | /demo/ 16 | /lib/ 17 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /test 3 | .idea 4 | .idea/ 5 | __tests__/ 6 | __mocks__/ 7 | __stories__/ 8 | .nyc_output 9 | coverage 10 | package-lock.json 11 | yarn.lock 12 | yarn-error.log 13 | tsconfig.json 14 | .vscode/ 15 | /docs/ 16 | .storybook/ 17 | /build/ 18 | /dist/ 19 | /demo/ 20 | /demo/analyzer/ 21 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register'; 2 | import '@storybook/addon-links/register'; 3 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react' 2 | 3 | // automatically import all files ending in *.stories.js 4 | const req = require.context('../stories', true, /.stories.js$/) 5 | function loadStories () { 6 | req.keys().forEach(filename => req(filename)) 7 | } 8 | 9 | configure(loadStories, module) 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | os: 3 | - linux 4 | cache: 5 | yarn: true 6 | directories: 7 | - ~/.npm 8 | notifications: 9 | email: false 10 | node_js: 11 | - '10' # Have to use v10 for async generators 12 | script: 13 | - yarn test 14 | - yarn build 15 | matrix: 16 | allow_failures: [] 17 | fast_finish: true 18 | after_success: 19 | - npx ci-scripts github-post 20 | - npx ci-scripts slack 21 | - yarn semantic-release 22 | branches: 23 | except: 24 | - /^v\d+\.\d+\.\d+$/ 25 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [1.7.0](https://github.com/streamich/three-ducks/compare/v1.6.0...v1.7.0) (2018-06-17) 2 | 3 | 4 | ### Features 5 | 6 | * 🎸 add ofType() rxjs operator ([544842b](https://github.com/streamich/three-ducks/commit/544842b)) 7 | 8 | # [1.6.0](https://github.com/streamich/three-ducks/compare/v1.5.0...v1.6.0) (2018-06-17) 9 | 10 | 11 | ### Features 12 | 13 | * 🎸 add combineEpics() function ([a9b84eb](https://github.com/streamich/three-ducks/commit/a9b84eb)) 14 | * 🎸 add dev check so all epics return an observable ([9ca2c41](https://github.com/streamich/three-ducks/commit/9ca2c41)) 15 | 16 | # [1.5.0](https://github.com/streamich/three-ducks/compare/v1.4.0...v1.5.0) (2018-06-17) 17 | 18 | 19 | ### Bug Fixes 20 | 21 | * 🐛 improve epic plugin, standartize observable ([3f4ccc3](https://github.com/streamich/three-ducks/commit/3f4ccc3)) 22 | 23 | 24 | ### Features 25 | 26 | * 🎸 add epic plugin implementation ([60c7a78](https://github.com/streamich/three-ducks/commit/60c7a78)) 27 | * 🎸 user rxjs for epic observables ([98b1ac6](https://github.com/streamich/three-ducks/commit/98b1ac6)) 28 | * 🎸 WIP add async epics ([acc534b](https://github.com/streamich/three-ducks/commit/acc534b)) 29 | 30 | # [1.4.0](https://github.com/streamich/three-ducks/compare/v1.3.0...v1.4.0) (2018-06-13) 31 | 32 | 33 | ### Features 34 | 35 | * 🎸 allow thunks to return undefined ([4b76807](https://github.com/streamich/three-ducks/commit/4b76807)) 36 | 37 | # [1.3.0](https://github.com/streamich/three-ducks/compare/v1.2.0...v1.3.0) (2018-06-12) 38 | 39 | 40 | ### Features 41 | 42 | * 🎸 make persist plugin synchronous ([5638754](https://github.com/streamich/three-ducks/commit/5638754)) 43 | 44 | # [1.2.0](https://github.com/streamich/three-ducks/compare/v1.1.0...v1.2.0) (2018-06-11) 45 | 46 | 47 | ### Bug Fixes 48 | 49 | * 🐛 fix WIP online plugin test ([84bed0e](https://github.com/streamich/three-ducks/commit/84bed0e)) 50 | 51 | 52 | ### Features 53 | 54 | * WIP work on online effects ([b80999b](https://github.com/streamich/three-ducks/commit/b80999b)) 55 | 56 | # [1.1.0](https://github.com/streamich/three-ducks/compare/v1.0.1...v1.1.0) (2018-06-11) 57 | 58 | 59 | ### Features 60 | 61 | * 🎸 add dispatchInit plugin ([a608ff6](https://github.com/streamich/three-ducks/commit/a608ff6)) 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # three-ducks 2 | 3 | [![][npm-badge]][npm-url] [![][travis-badge]][travis-url] 4 | 5 | Tiny Redux clone with plugins. 6 | 7 | - __Tiny__ — core is only [5 lines of code](./src/index.js), [`redux` preset](./docs/presets/redux.md) is 0.6Kb 8 | - __All Redux APIs__ — `.subscribe()`, `.getState()`, `.dispatch()`, etc. — as separate plugins 9 | - __Faster than Redux__ — see [benchmark results](#benchmark) 10 | - __100% compatible__ — works with [`react-redux`](https://github.com/reactjs/react-redux), passes [Redux `createStore()` test suite](./src/presets/__tests__/reduxSpec.test.js) 11 | - __Public domain__ — [Unlicense license](./LICENSE) 12 | 13 | 14 | ## Reference 15 | 16 | - [Installation](./docs/Installation.md) 17 | - [Plugins](./docs/Plugins.md) 18 | - [`dispatch`](./docs/plugins/dispatch.md) 19 | - [`reducer`](./docs/plugins/reducer.md) 20 | - [`subscribe`](./docs/plugins/subscribe.md) 21 | - [`replaceReducer`](./docs/plugins/replaceReducer.md) 22 | - [`getState`](./docs/plugins/getState.md) 23 | - [`observable`](./docs/plugins/observable.md) 24 | - [`thunk`](./docs/plugins/thunk.md) 25 | - [Presets](./docs/Presets.md) 26 | - [`redux`](/docs/presets/redux.md) 27 | 28 | 29 | ## See also 30 | 31 | - [`nano-css`](https://github.com/streamich/nano-css) — Distilled CSS-in-JS for gourmet developers 32 | 33 | 34 | ## Benchmark 35 | 36 | Running a simple ["counter" micro-benchmark](./benchmark/index.js). 37 | 38 | ``` 39 | three-ducks (with redux preset) x 1,076 ops/sec ±2.58% (47 runs sampled) 40 | redux x 494 ops/sec ±4.40% (43 runs sampled) 41 | Fastest is three-ducks (with redux preset) 42 | ``` 43 | 44 | 45 | [npm-url]: https://www.npmjs.com/package/three-ducks 46 | [npm-badge]: https://img.shields.io/npm/v/three-ducks.svg 47 | [travis-url]: https://travis-ci.org/streamich/three-ducks 48 | [travis-badge]: https://travis-ci.org/streamich/three-ducks.svg?branch=master 49 | -------------------------------------------------------------------------------- /benchmark/index.js: -------------------------------------------------------------------------------- 1 | import createStore3DucksRedux from '../src/presets/redux' 2 | import {createStore as createStoreRedux} from 'redux' 3 | import Benchmark from 'benchmark' 4 | 5 | export function runBenchmark () { 6 | const suite = new Benchmark.Suite() 7 | 8 | const increment = (value) => ({ 9 | value, 10 | type: 'INCREMENT' 11 | }) 12 | 13 | const decrement = (value) => ({ 14 | value, 15 | type: 'DECREMENT' 16 | }) 17 | 18 | const reducer = (state, action) => { 19 | switch (action.type) { 20 | case 'INCREMENT': 21 | return {...state, cnt: state.cnt + action.value} 22 | case 'DECREMENT': 23 | return {...state, cnt: state.cnt - action.value} 24 | default: 25 | return state 26 | } 27 | } 28 | 29 | const run = (createStore) => { 30 | const store = createStore(reducer, { 31 | cnt: 0 32 | }) 33 | 34 | for (let i = 0; i < 1000; i++) { 35 | store.dispatch(increment(i)) 36 | store.dispatch(decrement(1)) 37 | } 38 | } 39 | 40 | suite 41 | .add('three-ducks (with redux preset)', function () { 42 | run(createStore3DucksRedux) 43 | }) 44 | .add('redux', function () { 45 | run(createStoreRedux) 46 | }) 47 | .on('cycle', function (event) { 48 | console.log(String(event.target)) 49 | }) 50 | .on('complete', function () { 51 | console.log('Fastest is ' + this.filter('fastest').map('name')) 52 | }) 53 | .run() 54 | } 55 | -------------------------------------------------------------------------------- /docs/Installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Install `three-ducks`. 4 | 5 |
 6 | npm i three-ducks --save
 7 | 
8 | 9 | 10 | Create a new store using `createStore` utility. 11 | 12 | ```js 13 | import {createStore} from 'three-ducks'; 14 | 15 | const store = createStore({}, [ 16 | // plugins 17 | ]); 18 | ``` 19 | 20 | You use `three-ducks` with [plugins](./Plugins.md), without plugins it cannot do anything. 21 | 22 | ```js 23 | import {createStore} from 'three-ducks'; 24 | import pluginDispatch from 'three-ducks/lib/plugins/dispatch'; 25 | import pluginReducer from 'three-ducks/lib/plugins/reducer'; 26 | import pluginSubscribe from 'three-ducks/lib/plugins/subscribe'; 27 | 28 | const store = createStore({}, [ 29 | pluginDispatch(), 30 | pluginReducer(reducer), 31 | pluginSubscribe(), 32 | ]); 33 | ``` 34 | 35 | To avoid installing plugins manually, pick one of the [presets](./Presets.md), such as *"Redux"*: 36 | 37 | ```js 38 | import createStore from 'three-ducks/lib/presets/redux'; 39 | 40 | // Use it the same as Redux's createStore() 41 | const store = createStore(reducer, initialState, enhancer); 42 | ``` 43 | -------------------------------------------------------------------------------- /docs/Plugins.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | `three-ducks` is all about plugins, all its functionality is in its plugins, which 4 | can be found in `three-ducks/lib/plugins/` folder. 5 | 6 | - [`dispatch`](./plugins/dispatch.md) — adds `.dispatch()` method 7 | - [`reducer`](./plugins/reducer.md) — sets a reducer middleware to run on each "dispatch" 8 | - [`subscribe`](./plugins/subscribe.md) — adds `.subscribe()` method 9 | - [`replaceReducer`](./plugins/replaceReducer.md) — adds `.replaceReducer()` method 10 | - [`getState`](./plugins/getState.md) — adds `.getState()` method 11 | - [`observable`](./plugins/observable.md) — makes store an [observable](https://github.com/tc39/proposal-observable) 12 | - [`thunk`](./plugins/thunk.md) — similar to [`redux-thunk`](https://github.com/gaearon/redux-thunk) package 13 | -------------------------------------------------------------------------------- /docs/Presets.md: -------------------------------------------------------------------------------- 1 | # Presets 2 | 3 | Presets combine multiple plugins to build a store. 4 | 5 | - [`redux`](./presets/redux.md) — 100% compatible Redux clone 6 | -------------------------------------------------------------------------------- /docs/plugins/dispatch.md: -------------------------------------------------------------------------------- 1 | # `dispatch` Plugin 2 | 3 | Adds `.dispatch()` method to your store. 4 | 5 | Usage: 6 | 7 | 8 | ```js 9 | import {createStore} from 'three-ducks'; 10 | import pluginDispatch from 'three-ducks/lib/plugins/dispatch'; 11 | 12 | const store = createStore({}, [ 13 | pluginDispatch(), 14 | ]); 15 | ``` 16 | -------------------------------------------------------------------------------- /docs/plugins/getState.md: -------------------------------------------------------------------------------- 1 | # `getState` Plugin 2 | 3 | Adds `.getState()` method to your store. 4 | -------------------------------------------------------------------------------- /docs/plugins/observable.md: -------------------------------------------------------------------------------- 1 | # `observable` Plugin 2 | 3 | Makes store an [observable](https://github.com/tc39/proposal-observable), this allows you to use it 4 | with Rxjs, for example. 5 | -------------------------------------------------------------------------------- /docs/plugins/reducer.md: -------------------------------------------------------------------------------- 1 | # `reducer` Plugin 2 | 3 | Adds root reducer to your store to be ran after each "dispatch". 4 | 5 | Usage: 6 | 7 | 8 | ```js 9 | import {createStore} from 'three-ducks'; 10 | import pluginDispatch from 'three-ducks/lib/plugins/dispatch'; 11 | import pluginReducer from 'three-ducks/lib/plugins/reducer'; 12 | 13 | const store = createStore({}, [ 14 | pluginDispatch(), 15 | pluginReducer(reducer), 16 | ]); 17 | ``` 18 | 19 | , where `reducer` is your reducer function. 20 | 21 | -------------------------------------------------------------------------------- /docs/plugins/replaceReducer.md: -------------------------------------------------------------------------------- 1 | # `replaceReducer` Plugin 2 | 3 | Adds `.replaceReducer()` method to your store. 4 | -------------------------------------------------------------------------------- /docs/plugins/subscribe.md: -------------------------------------------------------------------------------- 1 | # `subscribe` Plugin 2 | 3 | Adds `.subscribe()` method to your store. 4 | -------------------------------------------------------------------------------- /docs/plugins/thunk.md: -------------------------------------------------------------------------------- 1 | # `thunk` Plugin 2 | 3 | Allows your action creators to return functions, which return an action. Install 4 | this plugin before `reducer` plugin. 5 | 6 | ```js 7 | import {createStore} from 'three-ducks'; 8 | import pluginDispatch from 'three-ducks/lib/plugins/dispatch'; 9 | import pluginThunk from 'three-ducks/lib/plugins/thunk'; 10 | import pluginReducer from 'three-ducks/lib/plugins/reducer'; 11 | 12 | const store = createStore({}, [ 13 | pluginDispatch(), 14 | pluginThunk() 15 | pluginReducer(reducer), 16 | ]); 17 | 18 | const INCREMENT_COUNTER = 'INCREMENT_COUNTER'; 19 | 20 | function increment() { 21 | return { 22 | type: INCREMENT_COUNTER 23 | }; 24 | } 25 | 26 | function incrementAsync({dispatch}) { 27 | return dispatch => { 28 | setTimeout(() => { 29 | dispatch(increment()); 30 | }, 1000); 31 | }; 32 | } 33 | 34 | store.dispatch(incrementAsync()); 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/presets/redux.md: -------------------------------------------------------------------------------- 1 | # `redux` Preset 2 | 3 | Clone of Redux `createState()`, passes all Redux unit tests. 4 | 5 | ```js 6 | import createStore from 'three-ducks/lib/presets/redux' 7 | 8 | const store = createStore(reducer, initialState); 9 | ``` 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "three-ducks", 3 | "version": "1.7.0", 4 | "description": "As if Redux was written as plugins", 5 | "main": "lib/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/streamich/three-ducks.git" 9 | }, 10 | "scripts": { 11 | "start": "npm run storybook", 12 | "clean": "rimraf lib", 13 | "test": "standard && jest", 14 | "build": "npm run clean && babel src --out-dir lib --ignore __tests__", 15 | "test:coverage": "jest --coverage", 16 | "test:watch": "jest --watch", 17 | "precommit": "lint-staged", 18 | "semantic-release": "semantic-release", 19 | "lint": "standard", 20 | "prettier": "standard --fix", 21 | "storybook": "NODE_ENV=production start-storybook -p 6006", 22 | "build-storybook": "build-storybook", 23 | "bench": "NODE_ENV=production babel-node benchmark/index.js", 24 | "travis-deploy-once": "travis-deploy-once" 25 | }, 26 | "lint-staged": { 27 | "**/*.js": [ 28 | "npm run prettier", 29 | "git add" 30 | ] 31 | }, 32 | "standard": { 33 | "env": [ 34 | "jest" 35 | ] 36 | }, 37 | "dependencies": { 38 | "symbol-observable": "^1.2.0" 39 | }, 40 | "peerDependencies": { 41 | "rxjs": "^6.2.1" 42 | }, 43 | "devDependencies": { 44 | "husky": "0.14.3", 45 | "jest": "22.4.4", 46 | "lint-staged": "7.2.0", 47 | "rimraf": "2.6.2", 48 | "git-cz": "^1.7.0", 49 | "semantic-release": "^15.5.2", 50 | "@semantic-release/changelog": "^2.0.2", 51 | "@semantic-release/npm": "^3.3.1", 52 | "@semantic-release/git": "^5.0.0", 53 | "babel-cli": "6.26.0", 54 | "babel-core": "6.26.3", 55 | "babel-polyfill": "6.26.0", 56 | "babel-preset-es2015": "6.24.1", 57 | "babel-preset-es2016": "6.24.1", 58 | "babel-preset-es2017": "6.24.1", 59 | "babel-preset-flow": "6.23.0", 60 | "babel-preset-stage-0": "6.24.1", 61 | "standard": "11.0.1", 62 | "redux": "3.7.2", 63 | "react-redux": "5.0.7", 64 | "react": "16.4.0", 65 | "react-dom": "16.4.0", 66 | "@storybook/react": "3.4.7", 67 | "@storybook/addon-actions": "3.4.7", 68 | "@storybook/addon-links": "3.4.7", 69 | "@storybook/addons": "3.4.7", 70 | "babel-runtime": "6.26.0", 71 | "benchmark": "2.1.4", 72 | "travis-deploy-once": "4.4.1", 73 | "rxjs": "^6.2.1" 74 | }, 75 | "config": { 76 | "commitizen": { 77 | "path": "git-cz" 78 | } 79 | }, 80 | "jest": { 81 | "transformIgnorePatterns": [], 82 | "testRegex": ".*/__tests__/.*\\.(test|spec)\\.(jsx?)$", 83 | "setupFiles": [ 84 | "./src/__tests__/setup.js" 85 | ], 86 | "moduleFileExtensions": [ 87 | "js", 88 | "jsx" 89 | ] 90 | }, 91 | "babel": { 92 | "presets": [ 93 | "es2015", 94 | "es2016", 95 | "es2017", 96 | "stage-0", 97 | "flow" 98 | ], 99 | "comments": false 100 | }, 101 | "release": { 102 | "verifyConditions": [ 103 | "@semantic-release/changelog", 104 | "@semantic-release/npm", 105 | "@semantic-release/git" 106 | ], 107 | "prepare": [ 108 | "@semantic-release/changelog", 109 | "@semantic-release/npm", 110 | "@semantic-release/git" 111 | ] 112 | }, 113 | "keywords": [ 114 | "three-ducks", 115 | "tree", 116 | "3", 117 | "ducks", 118 | "redux", 119 | "flux", 120 | "state", 121 | "management", 122 | "container" 123 | ] 124 | } 125 | -------------------------------------------------------------------------------- /src/__tests__/combineEpics.test.js: -------------------------------------------------------------------------------- 1 | import {from, empty} from 'rxjs' 2 | import {map, filter} from 'rxjs/operators' 3 | import combineEpics from '../combineEpics' 4 | 5 | process.env.NODE_ENV = 'development' 6 | 7 | describe('combineEpics', () => { 8 | it('exists', () => { 9 | expect(typeof combineEpics).toBe('function') 10 | }) 11 | 12 | it('throws if epic does not return observable', () => { 13 | expect(() => { 14 | combineEpics(() => undefined)(empty()) 15 | }).toThrow() 16 | 17 | combineEpics(() => empty())(empty()) 18 | }) 19 | 20 | it('works', () => { 21 | const epic1 = (action$) => 22 | action$.pipe( 23 | filter(({type}) => type === 'PING'), 24 | map(() => ({type: 'PONG'})) 25 | ) 26 | const epic2 = (action$) => 27 | action$.pipe( 28 | map(() => ({type: 'ALWAYS'})) 29 | ) 30 | 31 | const log = [] 32 | 33 | const action$ = from([ 34 | { 35 | type: 'PING' 36 | }, 37 | { 38 | type: 'LOL' 39 | } 40 | ]) 41 | 42 | const epic = combineEpics(epic1, epic2) 43 | 44 | epic(action$).subscribe({ 45 | next: (value) => log.push(value) 46 | }) 47 | 48 | expect(log).toEqual([ { type: 'PONG' }, { type: 'ALWAYS' }, { type: 'ALWAYS' } ]) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/__tests__/counter.test.js: -------------------------------------------------------------------------------- 1 | import createReduxStore from '../presets/redux' 2 | 3 | describe('counter', () => { 4 | it('using Redux preset', () => { 5 | const increment = (value) => ({ 6 | value, 7 | type: 'INCREMENT' 8 | }) 9 | 10 | const decrement = (value) => ({ 11 | value, 12 | type: 'DECREMENT' 13 | }) 14 | 15 | const reducer = (state, action) => { 16 | switch (action.type) { 17 | case 'INCREMENT': 18 | return {...state, cnt: state.cnt + action.value} 19 | case 'DECREMENT': 20 | return {...state, cnt: state.cnt - action.value} 21 | default: 22 | return state 23 | } 24 | } 25 | 26 | const store = createReduxStore(reducer, {cnt: 5}) 27 | 28 | expect(store.getState()).toEqual({cnt: 5}) 29 | store.dispatch(increment(1)) 30 | expect(store.getState()).toEqual({cnt: 6}) 31 | store.dispatch(increment(5)) 32 | expect(store.getState()).toEqual({cnt: 11}) 33 | store.dispatch(decrement(-1)) 34 | expect(store.getState()).toEqual({cnt: 12}) 35 | store.dispatch({ 36 | type: 'UNKNOWN' 37 | }) 38 | expect(store.getState()).toEqual({cnt: 12}) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | import {createStore} from '..' 2 | 3 | describe('index', () => { 4 | describe('createStore()', () => { 5 | it('exists', () => { 6 | expect(typeof createStore).toBe('function') 7 | }) 8 | 9 | it('expects plugins to be an array of functions', () => { 10 | expect(() => { 11 | createStore() 12 | }).toThrow() 13 | 14 | expect(() => { 15 | createStore(null) 16 | }).toThrow() 17 | 18 | expect(() => { 19 | createStore({}) 20 | }).toThrow() 21 | 22 | expect(() => { 23 | createStore({}, 123) 24 | }).toThrow() 25 | 26 | expect(() => { 27 | createStore({}, []) 28 | }).not.toThrow() 29 | }) 30 | 31 | it('creates store with initial state', () => { 32 | const state = {} 33 | const store = createStore(state, []) 34 | 35 | expect(typeof store).toBe('object') 36 | expect(store.state).toBe(state) 37 | }) 38 | 39 | it('applies plugins', () => { 40 | const state = {} 41 | const plugins = [jest.fn(), jest.fn(), jest.fn()] 42 | const store = createStore(state, plugins) 43 | 44 | for (const plugin of plugins) { 45 | expect(plugin).toHaveBeenCalledTimes(1) 46 | expect(plugin).toHaveBeenCalledWith(store) 47 | } 48 | }) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/__tests__/ofType.test.js: -------------------------------------------------------------------------------- 1 | import {from} from 'rxjs' 2 | import ofType from '../ofType' 3 | 4 | describe('ofType', () => { 5 | it('exists', () => { 6 | expect(typeof ofType).toBe('function') 7 | }) 8 | 9 | it('filters by type', () => { 10 | const observable = from([ 11 | { 12 | type: 'PING' 13 | }, 14 | { 15 | type: 'PONG' 16 | } 17 | ]) 18 | const arr = [] 19 | observable 20 | .pipe(ofType('PONG')) 21 | .subscribe(value => arr.push(value)) 22 | 23 | expect(arr).toEqual([ { type: 'PONG' } ]) 24 | }) 25 | 26 | it('can provide multiple types in a list', () => { 27 | const observable = from([ 28 | { 29 | type: 'PING' 30 | }, 31 | { 32 | type: 'PONG' 33 | }, 34 | { 35 | type: 'LOL' 36 | }, 37 | { 38 | type: 'PONG' 39 | }, 40 | { 41 | type: 'GAGA' 42 | } 43 | ]) 44 | const arr = [] 45 | observable 46 | .pipe(ofType('PONG', 'PING')) 47 | .subscribe(value => arr.push(value)) 48 | 49 | expect(arr).toEqual([{'type': 'PING'}, {'type': 'PONG'}, {'type': 'PONG'}]) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /src/__tests__/setup.js: -------------------------------------------------------------------------------- 1 | window.localStorage = {} 2 | -------------------------------------------------------------------------------- /src/combineEpics.js: -------------------------------------------------------------------------------- 1 | import {merge, isObservable} from 'rxjs' 2 | 3 | const combineEpics = (...epics) => (...args) => { 4 | const observables = [] 5 | 6 | for (const epic of epics) { observables.push(epic(...args)) } 7 | 8 | if (process.env.NODE_ENV !== 'production') { 9 | let shouldThrow = false 10 | 11 | for (const obs of observables) { 12 | if (!isObservable(obs)) { 13 | shouldThrow = true 14 | } 15 | } 16 | 17 | if (shouldThrow) { 18 | throw new Error('One of the epics does not return an observable.') 19 | } 20 | } 21 | 22 | return merge(...observables) 23 | } 24 | 25 | export default combineEpics 26 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * This is a dummy function to check if the function name has been altered by minification. 4 | * If the function has been minified and NODE_ENV !== 'production', warn the user. 5 | */ 6 | function isCrushed () {} 7 | 8 | if (process.env.NODE_ENV !== 'production') { 9 | if ( 10 | typeof isCrushed.name === 'string' && 11 | isCrushed.name !== 'isCrushed' 12 | ) { 13 | console.error( 14 | "You are currently using minified code outside of NODE_ENV === 'production'. " + 15 | 'This means that you are running a slower development build. ' + 16 | 'You can use loose-envify (https://github.com/zertosh/loose-envify) for browserify ' + 17 | 'or DefinePlugin for webpack (http://stackoverflow.com/questions/30030031) ' + 18 | 'to ensure you have the correct code for your production build.' 19 | ) 20 | } 21 | } 22 | 23 | export const createStore = (state, plugins) => { 24 | const store = {state} 25 | 26 | if (process.env.NODE_ENV !== 'production') { 27 | if (!(plugins instanceof Array)) { 28 | throw new TypeError('createStore() expects second argument to be a list of plugins.') 29 | } 30 | } 31 | 32 | for (const plugin of plugins) plugin(store) 33 | 34 | return store 35 | } 36 | -------------------------------------------------------------------------------- /src/ofType.js: -------------------------------------------------------------------------------- 1 | import {filter} from 'rxjs/operators' 2 | 3 | const ofType = (...types) => filter(({type}) => types.indexOf(type) > -1) 4 | 5 | export default ofType 6 | -------------------------------------------------------------------------------- /src/plugins/__tests__/actions.test.js: -------------------------------------------------------------------------------- 1 | import {createStore} from '../../' 2 | import pluginActions from '../actions' 3 | 4 | describe('plugin', () => { 5 | describe('actions', () => { 6 | it('adds .actions object', () => { 7 | const store = createStore({}, [pluginActions()]) 8 | 9 | expect(store.actions).toEqual({}) 10 | }) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /src/plugins/__tests__/dispatch.test.js: -------------------------------------------------------------------------------- 1 | import {createStore} from '../../' 2 | import pluginDispatch from '../dispatch' 3 | 4 | const action = { 5 | type: 'UNKNOWN' 6 | } 7 | 8 | describe('plugin', () => { 9 | describe('dispatch', () => { 10 | it('adds .dipatch() method', () => { 11 | const store = createStore({}, [pluginDispatch()]) 12 | 13 | expect(typeof store.dispatch).toBe('function') 14 | }) 15 | 16 | it('adds .middlewares list', () => { 17 | const store = createStore({}, [pluginDispatch()]) 18 | 19 | expect(store.middlewares).toEqual([]) 20 | }) 21 | 22 | it('calls to dispatch does not crash', () => { 23 | const store = createStore({}, [pluginDispatch()]) 24 | 25 | store.dispatch(action) 26 | }) 27 | 28 | it('calls middlewares with dispatched action and store', () => { 29 | const store = createStore({}, [pluginDispatch()]) 30 | 31 | store.middlewares = [ 32 | jest.fn(), 33 | jest.fn(), 34 | jest.fn() 35 | ] 36 | 37 | store.dispatch(action) 38 | 39 | for (const middleware of store.middlewares) { 40 | expect(middleware).toHaveBeenCalledTimes(1) 41 | expect(middleware).toHaveBeenCalledWith(action, store) 42 | } 43 | }) 44 | 45 | it('stops middleware chain if middleware returns non-undefined', () => { 46 | const store = createStore({}, [pluginDispatch()]) 47 | 48 | store.middlewares = [ 49 | jest.fn(), 50 | jest.fn(), 51 | jest.fn() 52 | ] 53 | 54 | store.middlewares[1].mockImplementation(() => false) 55 | 56 | store.dispatch(action) 57 | 58 | expect(store.middlewares[0]).toHaveBeenCalledTimes(1) 59 | expect(store.middlewares[0]).toHaveBeenCalledWith(action, store) 60 | 61 | expect(store.middlewares[1]).toHaveBeenCalledTimes(1) 62 | expect(store.middlewares[1]).toHaveBeenCalledWith(action, store) 63 | 64 | expect(store.middlewares[2]).toHaveBeenCalledTimes(0) 65 | }) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /src/plugins/__tests__/dispatchInit.test.js: -------------------------------------------------------------------------------- 1 | import {createStore} from '../../' 2 | import pluginDipatch from '../dispatch' 3 | import pluginReducer from '../reducer' 4 | import pluginDispatchInit from '../dispatchInit' 5 | 6 | const createStoreWithPlugin = (reducer = state => state, event) => { 7 | return createStore({}, [ 8 | pluginDipatch(), 9 | pluginReducer(reducer), 10 | pluginDispatchInit(event) 11 | ]) 12 | } 13 | 14 | describe('plugin', () => { 15 | describe('dispatchInit', () => { 16 | it('exists', () => { 17 | expect(typeof pluginDispatchInit).toBe('function') 18 | }) 19 | 20 | it('dispatches init action', () => { 21 | const reducer = jest.fn() 22 | createStoreWithPlugin(reducer) 23 | 24 | expect(reducer).toHaveBeenCalledTimes(1) 25 | expect(reducer.mock.calls[0][1]).toEqual({ 26 | type: '@@INIT-1.2.3' 27 | }) 28 | }) 29 | 30 | it('can specify custom evnet type', () => { 31 | const reducer = jest.fn() 32 | createStoreWithPlugin(reducer, 'foobar') 33 | 34 | expect(reducer).toHaveBeenCalledTimes(1) 35 | expect(reducer.mock.calls[0][1]).toEqual({ 36 | type: 'foobar' 37 | }) 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/plugins/__tests__/effectQueue.test.js: -------------------------------------------------------------------------------- 1 | import {createStore} from '../../' 2 | import pluginDipatch from '../dispatch' 3 | import pluginReducer from '../reducer' 4 | import pluginEffectQueue from '../effectQueue' 5 | 6 | const createStoreWithPlugin = (reducer = state => state) => { 7 | return createStore({}, [ 8 | pluginDipatch(), 9 | pluginReducer(reducer), 10 | pluginEffectQueue() 11 | ]) 12 | } 13 | 14 | describe('plugin', () => { 15 | describe('effectQueue', () => { 16 | it('exists', () => { 17 | expect(typeof pluginEffectQueue).toBe('function') 18 | }) 19 | 20 | it('does not crash', () => { 21 | const store = createStoreWithPlugin() 22 | 23 | store.dispatch({ 24 | type: 'UNKNOWN' 25 | }) 26 | }) 27 | 28 | it('installs interfaces', () => { 29 | const store = createStoreWithPlugin() 30 | 31 | expect(typeof store.withEffect).toBe('function') 32 | expect(store.effectListeners instanceof Array).toBe(true) 33 | }) 34 | 35 | it('does nothing for simple actions', () => { 36 | const reducer = jest.fn() 37 | 38 | reducer.mockImplementation((state, action) => ({foo: 'bar'})) 39 | 40 | const store = createStoreWithPlugin(reducer) 41 | const action = { 42 | type: 'UNKNOWN' 43 | } 44 | 45 | store.dispatch(action) 46 | 47 | expect(reducer).toHaveBeenCalledTimes(1) 48 | expect(reducer.mock.calls[0][1]).toBe(action) 49 | expect(store.state).toEqual({ 50 | foo: 'bar' 51 | }) 52 | }) 53 | 54 | it('calls effect listener with queued effect', () => { 55 | const store = createStoreWithPlugin(() => {}) 56 | const reducer = jest.fn() 57 | 58 | store.reducer = reducer 59 | 60 | const effect = {} 61 | 62 | reducer.mockImplementation((state, action) => store.withEffect( 63 | {foo: 'bar'}, 64 | effect 65 | )) 66 | 67 | const listener = jest.fn() 68 | 69 | store.effectListeners.push(listener) 70 | 71 | const action = { 72 | type: 'UNKNOWN' 73 | } 74 | 75 | store.dispatch(action) 76 | 77 | expect(reducer).toHaveBeenCalledTimes(1) 78 | expect(reducer.mock.calls[0][1]).toBe(action) 79 | expect(store.state).toEqual({ 80 | foo: 'bar' 81 | }) 82 | 83 | expect(listener).toHaveBeenCalledTimes(1) 84 | expect(listener).toHaveBeenCalledWith([effect]) 85 | }) 86 | 87 | it('queues multiple effects', () => { 88 | const store = createStoreWithPlugin(() => {}) 89 | const reducer = jest.fn() 90 | 91 | store.reducer = reducer 92 | 93 | const effect1 = {} 94 | const effect2 = {} 95 | 96 | reducer.mockImplementation((state, action) => { 97 | store.withEffect(123, effect1) 98 | 99 | return store.withEffect({foo: 'bar'}, effect2) 100 | }) 101 | 102 | const listener = jest.fn() 103 | 104 | store.effectListeners.push(listener) 105 | 106 | const action = { 107 | type: 'UNKNOWN' 108 | } 109 | 110 | store.dispatch(action) 111 | 112 | expect(reducer).toHaveBeenCalledTimes(1) 113 | expect(reducer.mock.calls[0][1]).toBe(action) 114 | expect(store.state).toEqual({ 115 | foo: 'bar' 116 | }) 117 | 118 | expect(listener).toHaveBeenCalledTimes(1) 119 | expect(listener).toHaveBeenCalledWith([effect1, effect2]) 120 | }) 121 | }) 122 | }) 123 | -------------------------------------------------------------------------------- /src/plugins/__tests__/effects.test.js: -------------------------------------------------------------------------------- 1 | import {createStore} from '../../' 2 | import pluginDipatch from '../dispatch' 3 | import pluginReducer from '../reducer' 4 | import pluginEffectQueue from '../effectQueue' 5 | import pluginEffects from '../effects' 6 | 7 | const createStoreWithPlugin = (reducer = state => state) => { 8 | return createStore({}, [ 9 | pluginDipatch(), 10 | pluginReducer(reducer), 11 | pluginEffectQueue(), 12 | pluginEffects() 13 | ]) 14 | } 15 | 16 | describe('plugin', () => { 17 | describe('effects', () => { 18 | it('exists', () => { 19 | expect(typeof pluginEffects).toBe('function') 20 | }) 21 | 22 | it('installs expected interfaces', () => { 23 | const store = createStoreWithPlugin() 24 | 25 | expect(typeof store.effects).toBe('object') 26 | expect(typeof store.effects.action).toBe('function') 27 | expect(typeof store.effects.run).toBe('function') 28 | expect(typeof store.effects.list).toBe('function') 29 | }) 30 | 31 | it('.action() creates ACTION side-effect', () => { 32 | const {action} = createStoreWithPlugin().effects 33 | const myAction = () => {} 34 | 35 | expect(action(myAction)).toMatchObject({ 36 | action: myAction, 37 | type: 'A' 38 | }) 39 | }) 40 | 41 | it('.run() creates RUN side-effect', () => { 42 | const {run} = createStoreWithPlugin().effects 43 | const fn = () => {} 44 | const a = 1 45 | const b = 2 46 | const c = null 47 | 48 | expect(run(fn, a, b, c)).toMatchObject({ 49 | fn: fn, 50 | args: [a, b, c], 51 | type: 'R' 52 | }) 53 | }) 54 | 55 | it('.list() creates LIST side-effect', () => { 56 | const {action, run, list} = createStoreWithPlugin().effects 57 | const actionInc = () => ({type: 'INCREMENT'}) 58 | const fn = () => {} 59 | const opts = { 60 | sequential: true 61 | } 62 | 63 | const effect = list([ 64 | run(fn, 1, 2, 3, 4), 65 | action(actionInc()) 66 | ], opts) 67 | 68 | expect(effect).toMatchObject({ 69 | list: [ 70 | run(fn, 1, 2, 3, 4), 71 | action(actionInc()) 72 | ], 73 | opts: opts, 74 | type: 'L' 75 | }) 76 | }) 77 | 78 | it('executes action side-effect', () => { 79 | const store = createStoreWithPlugin() 80 | const dispatch = jest.fn() 81 | const realDispatch = store.dispatch 82 | 83 | dispatch.mockImplementation(realDispatch) 84 | store.dispatch = dispatch 85 | 86 | const nextAction = () => ({type: 'NEXT_ACTION'}) 87 | 88 | store.reducer = (state, action) => { 89 | if (action.type === 'GO') return store.withEffect({foo: 'bar'}, nextAction()) 90 | else return state 91 | } 92 | 93 | store.dispatch({type: 'GO'}) 94 | 95 | expect(dispatch).toHaveBeenCalledTimes(2) 96 | expect(store.state).toEqual({foo: 'bar'}) 97 | expect(dispatch).toHaveBeenCalledWith({type: 'GO'}) 98 | expect(dispatch).toHaveBeenCalledWith(nextAction()) 99 | }) 100 | 101 | describe('run side-effect', () => { 102 | it('executes run side-effect', () => { 103 | const store = createStoreWithPlugin() 104 | const dispatch = jest.fn() 105 | const realDispatch = store.dispatch 106 | 107 | dispatch.mockImplementation(realDispatch) 108 | store.dispatch = dispatch 109 | 110 | const fn = jest.fn() 111 | 112 | store.reducer = (state, action) => { 113 | if (action.type === 'GO') return store.withEffect({foo: 'bar'}, store.effects.run(fn, 1)) 114 | else return state 115 | } 116 | 117 | store.dispatch({type: 'GO'}) 118 | 119 | expect(dispatch).toHaveBeenCalledTimes(1) 120 | expect(store.state).toEqual({foo: 'bar'}) 121 | expect(dispatch).toHaveBeenCalledWith({type: 'GO'}) 122 | expect(fn).toHaveBeenCalledTimes(1) 123 | expect(fn).toHaveBeenCalledWith(1) 124 | }) 125 | }) 126 | 127 | describe('list side-effect', () => { 128 | it('executes all actions', () => { 129 | const store = createStoreWithPlugin() 130 | const dispatch = jest.fn() 131 | const realDispatch = store.dispatch 132 | 133 | dispatch.mockImplementation(realDispatch) 134 | store.dispatch = dispatch 135 | 136 | const {withEffect, run, list} = store.effects 137 | 138 | const fn1 = jest.fn() 139 | const fn2 = jest.fn() 140 | 141 | store.reducer = (state, action) => { 142 | if (action.type === 'GO') { 143 | return withEffect( 144 | {foo: 'bar'}, 145 | list([ 146 | run(fn1, 1), 147 | { 148 | type: 'SECOND' 149 | }, 150 | run(fn2, 2) 151 | ]) 152 | ) 153 | } 154 | if (action.type === 'SECOND') { return {second: true} } else return state 155 | } 156 | 157 | store.dispatch({type: 'GO'}) 158 | 159 | expect(dispatch).toHaveBeenCalledTimes(2) 160 | expect(store.state).toEqual({ 161 | second: true 162 | }) 163 | expect(dispatch).toHaveBeenCalledWith({ 164 | type: 'GO' 165 | }) 166 | expect(dispatch).toHaveBeenCalledWith({ 167 | type: 'SECOND' 168 | }) 169 | expect(fn1).toHaveBeenCalledTimes(1) 170 | expect(fn1).toHaveBeenCalledWith(1) 171 | expect(fn2).toHaveBeenCalledTimes(1) 172 | expect(fn2).toHaveBeenCalledWith(2) 173 | }) 174 | }) 175 | 176 | describe('branch() side-effect', () => { 177 | it('executes success effect', () => { 178 | const store = createStoreWithPlugin() 179 | const dispatch = jest.fn() 180 | const realDispatch = store.dispatch 181 | 182 | dispatch.mockImplementation(realDispatch) 183 | store.dispatch = dispatch 184 | 185 | const {withEffect, run, branch} = store.effects 186 | const test = jest.fn() 187 | const success = jest.fn() 188 | const failure = jest.fn() 189 | 190 | test.mockImplementation(() => 123) 191 | 192 | store.reducer = (state, action) => { 193 | return withEffect( 194 | {foo: 'bar'}, 195 | branch( 196 | run(test), 197 | run(success), 198 | run(failure) 199 | ) 200 | ) 201 | } 202 | 203 | store.dispatch({ 204 | type: 'SOMETHING' 205 | }) 206 | 207 | expect(store.dispatch).toHaveBeenCalledTimes(1) 208 | expect(test).toHaveBeenCalledTimes(1) 209 | expect(success).toHaveBeenCalledTimes(1) 210 | expect(failure).toHaveBeenCalledTimes(0) 211 | expect(success).toHaveBeenCalledWith(123) 212 | }) 213 | 214 | it('executes failure effect', () => { 215 | const store = createStoreWithPlugin() 216 | const dispatch = jest.fn() 217 | const realDispatch = store.dispatch 218 | 219 | dispatch.mockImplementation(realDispatch) 220 | store.dispatch = dispatch 221 | 222 | const {withEffect, run, branch} = store.effects 223 | const test = jest.fn() 224 | const success = jest.fn() 225 | const failure = jest.fn() 226 | 227 | test.mockImplementation(() => { 228 | // eslint-disable-next-line 229 | throw 'foobar' 230 | }) 231 | 232 | store.reducer = (state, action) => { 233 | return withEffect( 234 | {foo: 'bar'}, 235 | branch( 236 | run(test), 237 | run(success), 238 | run(failure) 239 | ) 240 | ) 241 | } 242 | 243 | store.dispatch({ 244 | type: 'SOMETHING' 245 | }) 246 | 247 | expect(store.dispatch).toHaveBeenCalledTimes(1) 248 | expect(test).toHaveBeenCalledTimes(1) 249 | expect(success).toHaveBeenCalledTimes(0) 250 | expect(failure).toHaveBeenCalledTimes(1) 251 | expect(failure).toHaveBeenCalledWith('foobar') 252 | }) 253 | 254 | it('executes async test effect', async () => { 255 | const store = createStoreWithPlugin() 256 | const dispatch = jest.fn() 257 | const realDispatch = store.dispatch 258 | 259 | dispatch.mockImplementation(realDispatch) 260 | store.dispatch = dispatch 261 | 262 | const {withEffect, run, branch} = store.effects 263 | const test = jest.fn() 264 | const success = jest.fn() 265 | const failure = jest.fn() 266 | 267 | test.mockImplementation(() => Promise.resolve(999)) 268 | 269 | store.reducer = (state, action) => { 270 | return withEffect( 271 | {foo: 'bar'}, 272 | branch( 273 | run(test), 274 | run(success) 275 | ) 276 | ) 277 | } 278 | 279 | store.dispatch({ 280 | type: 'SOMETHING' 281 | }) 282 | 283 | await Promise.resolve() 284 | 285 | expect(store.dispatch).toHaveBeenCalledTimes(1) 286 | expect(test).toHaveBeenCalledTimes(1) 287 | expect(success).toHaveBeenCalledTimes(1) 288 | expect(failure).toHaveBeenCalledTimes(0) 289 | expect(success).toHaveBeenCalledWith(999) 290 | }) 291 | 292 | it('executes async test effect and failure effect', async () => { 293 | const store = createStoreWithPlugin() 294 | const dispatch = jest.fn() 295 | const realDispatch = store.dispatch 296 | 297 | dispatch.mockImplementation(realDispatch) 298 | store.dispatch = dispatch 299 | 300 | const {withEffect, run, branch} = store.effects 301 | const test = jest.fn() 302 | const success = jest.fn() 303 | const failure = jest.fn() 304 | 305 | // eslint-disable-next-line 306 | test.mockImplementation(() => Promise.reject(-1)) 307 | 308 | store.reducer = (state, action) => { 309 | return withEffect( 310 | {foo: 'bar'}, 311 | branch( 312 | run(test), 313 | null, 314 | run(failure) 315 | ) 316 | ) 317 | } 318 | 319 | store.dispatch({ 320 | type: 'SOMETHING' 321 | }) 322 | 323 | await Promise.resolve() 324 | 325 | expect(store.dispatch).toHaveBeenCalledTimes(1) 326 | expect(test).toHaveBeenCalledTimes(1) 327 | expect(success).toHaveBeenCalledTimes(0) 328 | expect(failure).toHaveBeenCalledTimes(1) 329 | expect(failure).toHaveBeenCalledWith(-1) 330 | }) 331 | }) 332 | }) 333 | }) 334 | -------------------------------------------------------------------------------- /src/plugins/__tests__/epic.test.js: -------------------------------------------------------------------------------- 1 | import {from} from 'rxjs' 2 | import {filter, map} from 'rxjs/operators' 3 | import {createStore} from '../../' 4 | import pluginDispatch from '../dispatch' 5 | import pluginReducer from '../reducer' 6 | import pluginEpic from '../epic' 7 | import combineEpics from '../../combineEpics' 8 | 9 | const createStoreWithPlugin = (epic, reducer = state => state) => { 10 | return createStore({}, [ 11 | pluginDispatch(), 12 | pluginReducer(reducer), 13 | pluginEpic(epic) 14 | ]) 15 | } 16 | 17 | describe('plugin', () => { 18 | describe('epics', () => { 19 | it('is a function', () => { 20 | expect(typeof pluginEpic).toBe('function') 21 | }) 22 | 23 | it('throws if epic is not a function', () => { 24 | expect(() => { 25 | createStore(123) 26 | }).toThrow() 27 | }) 28 | 29 | it('works', () => { 30 | const log = [] 31 | const epic = (observable, store) => { 32 | return { 33 | subscribe (sink) { 34 | observable.subscribe({ 35 | next: action => { 36 | if (action.type === 'PING') { 37 | sink.next({type: 'PONG'}) 38 | } 39 | } 40 | }) 41 | } 42 | } 43 | } 44 | const store = createStoreWithPlugin(epic) 45 | const dispatch = store.dispatch 46 | 47 | store.dispatch = (action) => { 48 | log.push(action) 49 | dispatch(action) 50 | } 51 | 52 | store.dispatch({ 53 | type: 'PING' 54 | }) 55 | 56 | store.dispatch({ 57 | type: 'NOT_PING' 58 | }) 59 | 60 | expect(log).toEqual([ 61 | { type: 'PING' }, 62 | { type: 'PONG' }, 63 | { type: 'NOT_PING' } 64 | ]) 65 | }) 66 | 67 | it('works with rxjs', () => { 68 | const log = [] 69 | const epic = (observable, store) => 70 | from(observable).pipe( 71 | filter(({type}) => type === 'PING'), 72 | map(() => ({type: 'PONG'})) 73 | ) 74 | const store = createStoreWithPlugin(epic) 75 | const dispatch = store.dispatch 76 | 77 | store.dispatch = (action) => { 78 | log.push(action) 79 | dispatch(action) 80 | } 81 | 82 | store.dispatch({ 83 | type: 'PING' 84 | }) 85 | 86 | store.dispatch({ 87 | type: 'NOT_PING' 88 | }) 89 | 90 | expect(log).toEqual([ 91 | { type: 'PING' }, 92 | { type: 'PONG' }, 93 | { type: 'NOT_PING' } 94 | ]) 95 | }) 96 | 97 | describe('combineEpics', () => { 98 | it('works', () => { 99 | const log = [] 100 | const epic1 = (observable, store) => 101 | from(observable).pipe( 102 | filter(({type}) => type === 'PING'), 103 | map(() => ({type: 'PONG1'})) 104 | ) 105 | const epic2 = (observable, store) => 106 | from(observable).pipe( 107 | filter(({type}) => type === 'PING'), 108 | map(() => ({type: 'PONG2'})) 109 | ) 110 | const store = createStoreWithPlugin(combineEpics(epic1, epic2)) 111 | const dispatch = store.dispatch 112 | 113 | store.dispatch = (action) => { 114 | log.push(action) 115 | dispatch(action) 116 | } 117 | 118 | store.dispatch({ 119 | type: 'PING' 120 | }) 121 | 122 | store.dispatch({ 123 | type: 'NOT_PING' 124 | }) 125 | 126 | expect(log).toEqual([{'type': 'PING'}, {'type': 'PONG1'}, {'type': 'PONG2'}, {'type': 'NOT_PING'}]) 127 | }) 128 | }) 129 | }) 130 | }) 131 | -------------------------------------------------------------------------------- /src/plugins/__tests__/getState.test.js: -------------------------------------------------------------------------------- 1 | import {createStore} from '../../' 2 | import pluginGetState from '../getState' 3 | 4 | describe('plugin', () => { 5 | describe('.getState()', () => { 6 | it('add .getState() method', () => { 7 | const store = createStore({}, [pluginGetState()]) 8 | 9 | expect(typeof store.getState).toBe('function') 10 | }) 11 | 12 | it('returns state', () => { 13 | const state = {} 14 | const store = createStore(state, [pluginGetState()]) 15 | 16 | expect(store.getState()).toBe(state) 17 | }) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/plugins/__tests__/persist.test.js: -------------------------------------------------------------------------------- 1 | import {createStore} from '../../' 2 | import pluginDipatch from '../dispatch' 3 | import pluginReducer from '../reducer' 4 | import pluginPersist from '../persist' 5 | 6 | const createStoreWithPlugins = (reducer = state => state) => { 7 | return createStore({}, [ 8 | pluginDipatch(), 9 | pluginReducer(reducer) 10 | ]) 11 | } 12 | 13 | describe('plugin', () => { 14 | describe('persist', () => { 15 | it('adds .save() and .load() methods', () => { 16 | const store = createStoreWithPlugins() 17 | 18 | pluginPersist()(store) 19 | 20 | expect(typeof store.save).toBe('function') 21 | expect(typeof store.load).toBe('function') 22 | expect(typeof store.clean).toBe('function') 23 | }) 24 | 25 | it('saves and reloads state', () => { 26 | const store = createStoreWithPlugins() 27 | 28 | pluginPersist()(store) 29 | 30 | const state = { 31 | foo: 'bar', 32 | users: { 33 | 'adsf-asdfasdf': { 34 | id: 123, 35 | name: 'Test user' 36 | } 37 | } 38 | } 39 | 40 | store.state = state 41 | store.save() 42 | store.state = {} 43 | 44 | expect(store.state).not.toEqual(state) 45 | 46 | store.load() 47 | 48 | expect(store.state).toEqual(state) 49 | }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /src/plugins/__tests__/reducer.test.js: -------------------------------------------------------------------------------- 1 | import {createStore} from '../../' 2 | import pluginDipatch from '../dispatch' 3 | import pluginReducer from '../reducer' 4 | 5 | const action = { 6 | type: 'UNKNOWN' 7 | } 8 | 9 | describe('plugin', () => { 10 | describe('reducer', () => { 11 | it('sets default reducer', () => { 12 | const reducer = jest.fn() 13 | const store = createStore({}, [ 14 | pluginDipatch(), 15 | pluginReducer(reducer) 16 | ]) 17 | 18 | expect(store.reducer).toBe(reducer) 19 | }) 20 | 21 | it('adds empty listener list', () => { 22 | const reducer = jest.fn() 23 | const store = createStore({}, [ 24 | pluginDipatch(), 25 | pluginReducer(reducer) 26 | ]) 27 | 28 | expect(store.listeners).toEqual([]) 29 | }) 30 | 31 | it('adds reducer middleware', () => { 32 | const reducer = jest.fn() 33 | const store = createStore({}, [ 34 | pluginDipatch(), 35 | pluginReducer(reducer) 36 | ]) 37 | 38 | expect(store.middlewares.length).toBe(1) 39 | }) 40 | 41 | it('calls reducer with action and store', () => { 42 | const reducer = jest.fn() 43 | const store = createStore({}, [ 44 | pluginDipatch(), 45 | pluginReducer(reducer) 46 | ]) 47 | const state = store.state 48 | 49 | store.dispatch(action) 50 | 51 | expect(reducer).toHaveBeenCalledTimes(1) 52 | expect(reducer).toHaveBeenCalledWith(state, action) 53 | }) 54 | 55 | it('calls listeners with store and old state, if state changes', () => { 56 | const oldState = {} 57 | const newState = {} 58 | const reducer = () => newState 59 | const store = createStore(oldState, [ 60 | pluginDipatch(), 61 | pluginReducer(reducer) 62 | ]) 63 | store.listeners = [ 64 | jest.fn(), 65 | jest.fn(), 66 | jest.fn() 67 | ] 68 | 69 | store.dispatch(action) 70 | 71 | for (const listener of store.listeners) { 72 | expect(listener).toHaveBeenCalledTimes(1) 73 | expect(listener).toHaveBeenCalledWith(store, oldState) 74 | } 75 | }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /src/plugins/__tests__/rematch.test.js: -------------------------------------------------------------------------------- 1 | import {createStore} from '../../' 2 | import pluginDipatch from '../dispatch' 3 | import pluginReducer from '../reducer' 4 | import pluginRematch from '../rematch' 5 | 6 | const createStoreWithPlugins = (reducer = state => state) => { 7 | return createStore({}, [ 8 | pluginDipatch(), 9 | pluginReducer(reducer), 10 | pluginRematch() 11 | ]) 12 | } 13 | 14 | describe('plugin', () => { 15 | describe('rematch', () => { 16 | it('exists', () => { 17 | expect(typeof pluginRematch).toBe('function') 18 | }) 19 | 20 | it('exposes .rematch() method', () => { 21 | const store = createStoreWithPlugins() 22 | 23 | expect(typeof store.rematch).toBe('function') 24 | }) 25 | 26 | it('adds action dispatcher to .dispatch() method', () => { 27 | const store = createStoreWithPlugins() 28 | 29 | store.rematch({ 30 | state: 0, 31 | reducers: { 32 | increment: (state, value) => state + value 33 | } 34 | }, 'counter') 35 | 36 | expect(typeof store.dispatch.counter).toBe('object') 37 | expect(typeof store.dispatch.counter.increment).toBe('function') 38 | }) 39 | 40 | it('works', () => { 41 | const store = createStoreWithPlugins() 42 | 43 | store.rematch({ 44 | name: 'counter', 45 | state: 0, 46 | reducers: { 47 | increment: (state, value) => { 48 | return state + value 49 | } 50 | } 51 | }) 52 | 53 | store.dispatch.counter.increment(3) 54 | 55 | expect(store.state).toEqual({ 56 | counter: 3 57 | }) 58 | }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /src/plugins/__tests__/replaceReducer.test.js: -------------------------------------------------------------------------------- 1 | import {createStore} from '../../' 2 | import pluginReplaceReducer from '../replaceReducer' 3 | 4 | describe('plugin', () => { 5 | describe('.pluginReplaceReducer()', () => { 6 | it('add .pluginReplaceReducer() method', () => { 7 | const store = createStore({}, [pluginReplaceReducer()]) 8 | 9 | expect(typeof store.replaceReducer).toBe('function') 10 | }) 11 | 12 | it('sets new reducer', () => { 13 | const reducer = () => {} 14 | const store = createStore({}, [pluginReplaceReducer()]) 15 | 16 | expect(store.reducer).not.toBe(reducer) 17 | 18 | store.replaceReducer(reducer) 19 | 20 | expect(store.reducer).toBe(reducer) 21 | }) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /src/plugins/__tests__/subscribe.test.js: -------------------------------------------------------------------------------- 1 | import {createStore} from '../../' 2 | import pluginDispatch from '../dispatch' 3 | import pluginReducer from '../reducer' 4 | import pluginSubscribe from '../subscribe' 5 | 6 | const action = { 7 | type: 'UNKNOWN' 8 | } 9 | 10 | describe('plugin', () => { 11 | describe('.subscribe()', () => { 12 | it('adds .subscribe() method', () => { 13 | const store = createStore({}, [ 14 | pluginSubscribe() 15 | ]) 16 | 17 | expect(typeof store.subscribe).toBe('function') 18 | }) 19 | 20 | it('listener called when state could have changed', () => { 21 | const oldState = {} 22 | const newState = {} 23 | const reducer = () => newState 24 | const store = createStore(oldState, [ 25 | pluginDispatch(), 26 | pluginReducer(reducer), 27 | pluginSubscribe() 28 | ]) 29 | 30 | const listener = jest.fn() 31 | 32 | store.subscribe(listener) 33 | 34 | expect(listener).toHaveBeenCalledTimes(0) 35 | store.dispatch(action) 36 | expect(listener).toHaveBeenCalledTimes(1) 37 | store.dispatch(action) 38 | expect(listener).toHaveBeenCalledTimes(2) 39 | store.dispatch(action) 40 | expect(listener).toHaveBeenCalledTimes(3) 41 | }) 42 | 43 | it('listener not called when unsubscribed', () => { 44 | const oldState = {} 45 | const newState = {} 46 | const reducer = () => newState 47 | const store = createStore(oldState, [ 48 | pluginDispatch(), 49 | pluginReducer(reducer), 50 | pluginSubscribe() 51 | ]) 52 | 53 | const listener1 = jest.fn() 54 | const listener2 = jest.fn() 55 | 56 | const unsubscribe1 = store.subscribe(listener1) 57 | store.subscribe(listener2) 58 | 59 | unsubscribe1() 60 | 61 | expect(listener1).toHaveBeenCalledTimes(0) 62 | expect(listener2).toHaveBeenCalledTimes(0) 63 | store.dispatch(action) 64 | expect(listener1).toHaveBeenCalledTimes(0) 65 | expect(listener2).toHaveBeenCalledTimes(1) 66 | store.dispatch(action) 67 | expect(listener1).toHaveBeenCalledTimes(0) 68 | expect(listener2).toHaveBeenCalledTimes(2) 69 | }) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /src/plugins/__tests__/table.test.js: -------------------------------------------------------------------------------- 1 | import {createStore} from '../../' 2 | import pluginDipatch from '../dispatch' 3 | import pluginReducer from '../reducer' 4 | import pluginTable from '../table' 5 | 6 | const createStoreWithTable = (reducer = state => state) => { 7 | return createStore({}, [ 8 | pluginDipatch(), 9 | pluginReducer(reducer), 10 | pluginTable() 11 | ]) 12 | } 13 | 14 | describe('plugin', () => { 15 | describe('table', () => { 16 | it('exists', () => { 17 | expect(typeof pluginTable).toBe('function') 18 | }) 19 | 20 | it('exposes .createModel() method', () => { 21 | const store = createStoreWithTable() 22 | 23 | expect(typeof store.createModel).toBe('function') 24 | }) 25 | 26 | it('creates a model with expected interface', () => { 27 | const store = createStoreWithTable() 28 | const User = store.createModel({ 29 | name: 'User', 30 | fields: [ 31 | 'id', 32 | 'name' 33 | ] 34 | }) 35 | 36 | expect(typeof User).toBe('function') 37 | 38 | const user = new User() 39 | 40 | expect(typeof user).toBe('object') 41 | expect(typeof user.select).toBe('function') 42 | expect(typeof user.path).toBe('function') 43 | expect(typeof user.patch).toBe('function') 44 | expect(typeof user.delete).toBe('function') 45 | }) 46 | 47 | xit('updates field', () => { 48 | const store = createStoreWithTable() 49 | const User = store.createModel({ 50 | name: 'User', 51 | fields: [ 52 | 'id', 53 | 'name' 54 | ] 55 | }) 56 | 57 | expect(typeof User).toBe('function') 58 | 59 | const user = new User() 60 | 61 | user.id = '123' 62 | user.name = 'Tester' 63 | 64 | expect(store.state.User.byId['123'].name).toBe('Tester') 65 | }) 66 | 67 | xit('model enhancer', () => { 68 | const store = createStoreWithTable() 69 | 70 | const MyUser = class MyUser {} 71 | 72 | const MyUser_ = store.model({ 73 | name: 'MyUser', 74 | fields: [ 75 | 'id', 76 | 'tags' 77 | ] 78 | })(MyUser) 79 | 80 | const user = new MyUser_() 81 | 82 | user.id = '1' 83 | user.tags = [1, 2, 3] 84 | 85 | expect(store.state.MyUser.byId['1'].tags).toEqual([1, 2, 3]) 86 | }) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /src/plugins/__tests__/thunk.test.js: -------------------------------------------------------------------------------- 1 | import {createStore} from '../../' 2 | import pluginDipatch from '../dispatch' 3 | import pluginReducer from '../reducer' 4 | import pluginThunk from '../thunk' 5 | 6 | const createStoreWithPlugin = (reducer = state => state) => { 7 | return createStore({}, [ 8 | pluginDipatch(), 9 | pluginThunk(), 10 | pluginReducer(reducer) 11 | ]) 12 | } 13 | 14 | describe('plugin', () => { 15 | describe('thunk', () => { 16 | it('exists', () => { 17 | expect(typeof pluginThunk).toBe('function') 18 | }) 19 | 20 | it('does not crash', () => { 21 | createStoreWithPlugin() 22 | }) 23 | 24 | it('can pass a function as an action', () => { 25 | const store = createStoreWithPlugin() 26 | 27 | store.dispatch(() => ({ 28 | type: 'UNKNOWN' 29 | })) 30 | }) 31 | 32 | it('executes function with correct arguments', () => { 33 | const effect = jest.fn() 34 | 35 | effect.mockImplementation(() => ({ 36 | type: 'UNKNOWN' 37 | })) 38 | 39 | const store = createStoreWithPlugin() 40 | 41 | store.dispatch(effect) 42 | 43 | expect(effect).toHaveBeenCalledTimes(1) 44 | expect(effect).toHaveBeenCalledWith(store) 45 | }) 46 | 47 | it('passes though plain actions', () => { 48 | const reducer = jest.fn() 49 | 50 | reducer.mockImplementation((state, action) => state) 51 | 52 | const store = createStoreWithPlugin(reducer) 53 | const action = { 54 | type: 'UNKNOWN' 55 | } 56 | 57 | store.dispatch(action) 58 | 59 | expect(reducer).toHaveBeenCalledTimes(1) 60 | expect(reducer.mock.calls[0][1]).toBe(action) 61 | }) 62 | 63 | it('passes to reducer returned actions', () => { 64 | const reducer = jest.fn() 65 | 66 | reducer.mockImplementation((state, action) => state) 67 | 68 | const store = createStoreWithPlugin(reducer) 69 | const action = { 70 | type: 'UNKNOWN' 71 | } 72 | 73 | store.dispatch(() => action) 74 | 75 | expect(reducer).toHaveBeenCalledTimes(1) 76 | expect(reducer.mock.calls[0][1]).toBe(action) 77 | }) 78 | 79 | it('can return undefined', () => { 80 | const reducer = jest.fn() 81 | 82 | reducer.mockImplementation((state, action) => state) 83 | 84 | const store = createStoreWithPlugin(reducer) 85 | 86 | store.dispatch(() => undefined) 87 | }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /src/plugins/__tests__/use.test.js: -------------------------------------------------------------------------------- 1 | import {createStore} from '../../' 2 | import pluginDipatch from '../dispatch' 3 | import pluginReducer from '../reducer' 4 | import pluginUse from '../use' 5 | 6 | const createStoreWithPlugins = (reducer = state => state) => { 7 | return createStore({}, [ 8 | pluginDipatch(), 9 | pluginReducer(reducer), 10 | pluginUse() 11 | ]) 12 | } 13 | 14 | describe('plugin', () => { 15 | describe('use', () => { 16 | it('exists', () => { 17 | expect(typeof pluginUse).toBe('function') 18 | }) 19 | 20 | it('installs .use() method', () => { 21 | const store = createStoreWithPlugins() 22 | 23 | expect(typeof store.use).toBe('function') 24 | }) 25 | 26 | it('can add logger middleware', () => { 27 | const log = jest.fn() 28 | const logger = (store) => (next) => (action) => { 29 | log(store.state) // old state 30 | next(action) 31 | log(store.state) // new state 32 | } 33 | const reducer = () => ({foo: 'bar'}) 34 | const store = createStoreWithPlugins(reducer) 35 | 36 | const returnForChaining = store.use(logger) 37 | 38 | expect(returnForChaining).toBe(store) 39 | 40 | store.dispatch({ 41 | type: 'SOMETHING' 42 | }) 43 | 44 | expect(log).toHaveBeenCalledTimes(2) 45 | expect(log.mock.calls[0][0]).toEqual({}) 46 | expect(log.mock.calls[1][0]).toEqual({foo: 'bar'}) 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /src/plugins/actions.js: -------------------------------------------------------------------------------- 1 | const plugin = () => store => { 2 | store.actions = {} 3 | } 4 | 5 | export default plugin 6 | -------------------------------------------------------------------------------- /src/plugins/async.js.wip: -------------------------------------------------------------------------------- 1 | const plugin = (epic) => async (store) => { 2 | const generator = () => { 3 | const dispatch = store.dispatch 4 | const newDispatch = function * (action) { 5 | dispatch(action) 6 | yield action 7 | } 8 | 9 | store.dispatch = newDispatch 10 | 11 | return { 12 | done: false, 13 | next: () => {} 14 | } 15 | } 16 | const stop = () => {} 17 | const dispatch = store.dispatch 18 | const iterator = epic({generator, stop}) 19 | 20 | for await (const action of iterator) 21 | dispatch(action) 22 | } 23 | 24 | // export default plugin 25 | 26 | 27 | const timeout = time => new Promise(resolve => setTimeout(resolve, time)) 28 | 29 | const filter = async function * ({iterator}) { 30 | for await (const value of iterator) 31 | if (value) yield value 32 | } 33 | 34 | const map = async function * (iterator, mapper) { 35 | for await (const value of iterator) 36 | yield mapper(value) 37 | } 38 | 39 | const mapWithIndex = async function * (iterator, mapper) { 40 | let index = 0 41 | 42 | for await (const value of iterator) 43 | yield mapper(value, index++) 44 | } 45 | 46 | const interval = (period) => { 47 | let counter = 0 48 | let live = true 49 | 50 | return { 51 | generator: (async function * () { 52 | while (true) { 53 | await timeout(period) 54 | 55 | if (!live) return 56 | 57 | yield counter 58 | 59 | counter++ 60 | } 61 | })(), 62 | stop: () => { 63 | live = false 64 | } 65 | } 66 | } 67 | 68 | class Generator { 69 | next = new Promise((resolve, reject) => { 70 | this.__resolve = resolve 71 | this.__reject = reject 72 | }) 73 | } 74 | 75 | const observableToGenerator = (observable) => { 76 | let generator = new Generator 77 | 78 | observable.subscribe({ 79 | next: (value) => { 80 | generator.__resolve(value) 81 | generator = new Generator 82 | } 83 | }) 84 | } 85 | 86 | const main = async function * main () { 87 | console.log(2) 88 | const {generator, stop} = interval(1000) 89 | 90 | for await (const value of generator) { 91 | console.log(value) 92 | if (value > 2) { 93 | stop() 94 | } 95 | } 96 | 97 | } 98 | 99 | console.log(1) 100 | main().next() 101 | -------------------------------------------------------------------------------- /src/plugins/autopersist.js: -------------------------------------------------------------------------------- 1 | const plugin = () => store => { 2 | if (typeof window !== 'object') { 3 | return 4 | } 5 | 6 | store.load() 7 | 8 | window.addEventListener('beforeunload', () => { 9 | store.save() 10 | }) 11 | } 12 | 13 | export default plugin 14 | -------------------------------------------------------------------------------- /src/plugins/dispatch.js: -------------------------------------------------------------------------------- 1 | const plugin = () => store => { 2 | store.middlewares = [] 3 | store.dispatch = action => { 4 | if (process.env.NODE_ENV !== 'production') { 5 | if (store.isDispatching) { 6 | throw new Error('Reducers may not dispatch actions.') 7 | } 8 | } 9 | 10 | let result 11 | 12 | for (const middleware of store.middlewares) { 13 | result = middleware(action, store) 14 | 15 | if (result !== undefined) { 16 | if (result) { 17 | action = result 18 | } else { 19 | return result 20 | } 21 | } 22 | } 23 | } 24 | } 25 | 26 | export default plugin 27 | -------------------------------------------------------------------------------- /src/plugins/dispatchInit.js: -------------------------------------------------------------------------------- 1 | const plugin = (type = '@@INIT-1.2.3') => store => { 2 | store.dispatch({type}) 3 | } 4 | 5 | export default plugin 6 | -------------------------------------------------------------------------------- /src/plugins/effectQueue.js: -------------------------------------------------------------------------------- 1 | const plugin = () => (store) => { 2 | store.effectListeners = [] 3 | 4 | let effectQueue = [] 5 | 6 | const dispatchEffect = (effect) => { 7 | effectQueue.push(effect) 8 | } 9 | 10 | store.effect = dispatchEffect 11 | store.withEffect = (state, effect) => { 12 | dispatchEffect(effect) 13 | 14 | return state 15 | } 16 | 17 | const dispatch = store.dispatch 18 | 19 | store.dispatch = function () { 20 | effectQueue = [] 21 | 22 | const result = dispatch.apply(this, arguments) 23 | 24 | for (const listener of store.effectListeners) { listener(effectQueue) } 25 | 26 | return result 27 | } 28 | } 29 | 30 | export default plugin 31 | -------------------------------------------------------------------------------- /src/plugins/effects.js: -------------------------------------------------------------------------------- 1 | import effectQueuePlugin from './effectQueue' 2 | 3 | const $$effect = '@@effect' 4 | 5 | const createEffect = (obj) => { 6 | obj[$$effect] = 1 7 | 8 | return obj 9 | } 10 | 11 | const isPromise = (a) => (typeof a === 'object') && (typeof a.then === 'function') 12 | 13 | const plugin = () => (store) => { 14 | if (!store.withEffect) { 15 | effectQueuePlugin(store) 16 | } 17 | 18 | const executeActionEffect = ({action}) => store.dispatch(action) 19 | 20 | const executeFnEffect = ({fn, args}, ...moreArgs) => { 21 | return fn.apply(null, (args || []).concat(moreArgs)) 22 | } 23 | 24 | const executeListEffect = ({list, opts}) => { 25 | for (const effect of list) { execEffect(effect) } 26 | } 27 | 28 | const executeBranchEffect = ({test, success, failure}, ...args) => { 29 | try { 30 | const result = execEffect(test, ...args) 31 | 32 | if (isPromise(result)) { 33 | return result.then(promiseResult => { 34 | return success 35 | ? execEffect(success, promiseResult) 36 | : promiseResult 37 | }, error => failure ? execEffect(failure, error) : error) 38 | } else { 39 | if (success) { 40 | return execEffect(success, result) 41 | } 42 | } 43 | } catch (error) { 44 | if (failure) { 45 | return execEffect(failure, error) 46 | } 47 | } 48 | } 49 | 50 | const effectMap = store.effectMap = { 51 | A: executeActionEffect, 52 | R: executeFnEffect, 53 | L: executeListEffect, 54 | B: executeBranchEffect 55 | } 56 | 57 | const execEffect = store.execEffect = (effect, ...args) => { 58 | if (process.env.NODE_ENV !== 'production') { 59 | if (typeof effect !== 'object') { 60 | throw new TypeError('Effect must be either effect object or action object.') 61 | } 62 | } 63 | 64 | if (effect[$$effect]) { 65 | return effectMap[effect.type](effect, ...args) 66 | } else { 67 | return store.dispatch(effect) 68 | } 69 | } 70 | 71 | store.effectListeners.push((effects) => { 72 | for (const effect of effects) { execEffect(effect) } 73 | }) 74 | 75 | const action = (action) => createEffect({ 76 | action, 77 | type: 'A' 78 | }) 79 | 80 | const run = (fn, ...args) => createEffect({ 81 | fn, 82 | args, 83 | type: 'R' 84 | }) 85 | 86 | const list = (list, opts) => { 87 | if (process.env.NODE_EVN !== 'production') { 88 | if (!Array.isArray(list)) { 89 | throw new TypeError('list() effect creator expects first argument to be a list of effects.') 90 | } 91 | } 92 | 93 | return createEffect({ 94 | list, 95 | opts, 96 | type: 'L' 97 | }) 98 | } 99 | 100 | const branch = (test, success, failure) => createEffect({ 101 | test, 102 | success, 103 | failure, 104 | type: 'B' 105 | }) 106 | 107 | store.effects = { 108 | withEffect: store.withEffect, 109 | createEffect, 110 | action, 111 | run, 112 | list, 113 | branch 114 | } 115 | } 116 | 117 | export default plugin 118 | -------------------------------------------------------------------------------- /src/plugins/epic.js: -------------------------------------------------------------------------------- 1 | import {Observable} from 'rxjs/internal/Observable' 2 | 3 | const plugin = (epic) => store => { 4 | if (process.env.NODE_ENV !== 'production') { 5 | if (typeof epic !== 'function') { 6 | throw new TypeError('epic must be a function that receives an observable and returns and observable.') 7 | } 8 | } 9 | 10 | const observable = Observable.create(observer => { 11 | const dispatch = store.dispatch 12 | let live = true 13 | 14 | store.dispatch = (action) => { 15 | dispatch(action) 16 | 17 | if (live) { 18 | observer.next(action) 19 | } 20 | } 21 | 22 | const unsubscribe = () => { 23 | live = false 24 | } 25 | 26 | return {unsubscribe} 27 | }) 28 | 29 | epic(observable, store).subscribe({ 30 | next: (action) => store.dispatch(action) 31 | }) 32 | } 33 | 34 | export default plugin 35 | -------------------------------------------------------------------------------- /src/plugins/getState.js: -------------------------------------------------------------------------------- 1 | const plugin = () => store => { 2 | store.getState = () => { 3 | if (process.env.NODE_ENV !== 'production') { 4 | if (store.isDispatching) { 5 | throw new Error( 6 | 'You may not call store.getState() while the reducer is executing. ' + 7 | 'The reducer has already received the state as an argument. ' + 8 | 'Pass it down from the top reducer instead of reading it from the store.' 9 | ) 10 | } 11 | } 12 | 13 | return store.state 14 | } 15 | } 16 | 17 | export default plugin 18 | -------------------------------------------------------------------------------- /src/plugins/observable.js: -------------------------------------------------------------------------------- 1 | import $$observable from 'symbol-observable' 2 | 3 | const plugin = () => (store) => { 4 | function observable () { 5 | const outerSubscribe = store.subscribe 6 | 7 | return { 8 | subscribe (observer) { 9 | if (typeof observer !== 'object') { 10 | throw new TypeError('Expected the observer to be an object.') 11 | } 12 | 13 | function observeState () { 14 | if (observer.next) { 15 | observer.next(store.getState()) 16 | } 17 | } 18 | 19 | observeState() 20 | return {unsubscribe: outerSubscribe(observeState)} 21 | }, 22 | 23 | [$$observable] () { 24 | return this 25 | } 26 | } 27 | } 28 | 29 | store[$$observable] = observable 30 | } 31 | 32 | export default plugin 33 | -------------------------------------------------------------------------------- /src/plugins/online.js: -------------------------------------------------------------------------------- 1 | import effectsPlugin from './effects' 2 | import {queueEffect} from './online/reducer' 3 | import createNetworkSensor from './online/createNetworkSensor' 4 | 5 | const plugin = (opts) => (store) => { 6 | if (!store.effects) { 7 | effectsPlugin(store) 8 | } 9 | 10 | const { 11 | sensor = createNetworkSensor() 12 | } = opts || {} 13 | 14 | const startProcessingQueue = () => { 15 | 16 | } 17 | 18 | const stopProcessingQueue = () => { 19 | 20 | } 21 | 22 | sensor.onchange = (isOnline) => { 23 | if (isOnline) startProcessingQueue() 24 | else stopProcessingQueue() 25 | } 26 | 27 | store.effects.whenOnline = (effect) => store.createEffect({ 28 | effect, 29 | type: 'O' 30 | }) 31 | 32 | store.effectMap.O = ({effect}) => { 33 | store.dispatch(queueEffect(effect)) 34 | } 35 | } 36 | 37 | export default plugin 38 | -------------------------------------------------------------------------------- /src/plugins/online/createNetworkSensor.js: -------------------------------------------------------------------------------- 1 | const nextCycle = (callback) => setTimeout(callback, 20) 2 | 3 | const createNetworkSensor = () => (listener) => { 4 | const sensor = { 5 | get onLine () { 6 | return navigator.onLine 7 | }, 8 | 9 | onchange: () => {} 10 | } 11 | 12 | window.addEventListener('online', () => nextCycle(() => sensor.onchange(true))) 13 | window.addEventListener('offline', () => nextCycle(() => sensor.onchange(false))) 14 | 15 | return sensor 16 | } 17 | 18 | export default createNetworkSensor 19 | -------------------------------------------------------------------------------- /src/plugins/online/reducer.js: -------------------------------------------------------------------------------- 1 | import {sym} from './util' 2 | 3 | const ENQUEUE = sym('ENQUEUE') 4 | 5 | export const queueEffect = (effect) => ({ 6 | effect, 7 | type: ENQUEUE 8 | }) 9 | 10 | export const reducer = (state, action) => { 11 | /* 12 | switch (action.type) { 13 | case ENQUEUE: 14 | return { 15 | ...state, 16 | effects: [ 17 | ...state.effects, 18 | action.effect 19 | ] 20 | } 21 | case ENQUEUE: 22 | return { 23 | ...state, 24 | effects 25 | } 26 | default: 27 | return state 28 | } 29 | */ 30 | } 31 | -------------------------------------------------------------------------------- /src/plugins/online/util.js: -------------------------------------------------------------------------------- 1 | export const sym = (name) => `@@online/${name}` 2 | -------------------------------------------------------------------------------- /src/plugins/persist.js: -------------------------------------------------------------------------------- 1 | const plugin = opts => store => { 2 | if (typeof window !== 'object') { 3 | return 4 | } 5 | 6 | const LS = window.localStorage 7 | 8 | const { 9 | key = '@@persist', 10 | filter = state => state, 11 | stringify = JSON.stringify, 12 | parse = JSON.parse 13 | } = opts || {} 14 | 15 | store.save = () => { 16 | try { 17 | LS[key] = stringify(filter(store.state)) 18 | } catch (error) {} 19 | } 20 | 21 | store.load = () => { 22 | try { 23 | const obj = parse(LS[key]) 24 | 25 | if (obj) { 26 | store.state = obj 27 | } 28 | } catch (error) {} 29 | } 30 | 31 | store.clean = () => { 32 | try { 33 | delete LS[key] 34 | } catch (error) {} 35 | } 36 | } 37 | 38 | export default plugin 39 | -------------------------------------------------------------------------------- /src/plugins/reducer.js: -------------------------------------------------------------------------------- 1 | const plugin = reducer => store => { 2 | if (process.env.NODE_ENV !== 'production') { 3 | store.isDispatching = false 4 | 5 | if (typeof reducer !== 'function') { 6 | throw new TypeError('reducer must be a function') 7 | } 8 | } 9 | 10 | store.reducer = reducer 11 | store.listeners = [] 12 | store.middlewares.push((action, store) => { 13 | if (process.env.NODE_ENV !== 'production') { 14 | /* eslint-disable */ 15 | function isPlainObject (obj) { 16 | if (typeof obj !== 'object' || obj === null) return false 17 | 18 | let proto = obj 19 | while (Object.getPrototypeOf(proto) !== null) { 20 | proto = Object.getPrototypeOf(proto) 21 | } 22 | 23 | return Object.getPrototypeOf(obj) === proto 24 | } 25 | /* eslint-enable */ 26 | 27 | if (!isPlainObject(action)) { 28 | throw new Error( 29 | 'Actions must be plain objects. ' + 30 | 'Use custom middleware for async actions.' 31 | ) 32 | } 33 | 34 | if (typeof action.type === 'undefined') { 35 | throw new Error( 36 | 'Actions may not have an undefined "type" property. ' + 37 | 'Have you misspelled a constant?' 38 | ) 39 | } 40 | } 41 | 42 | const oldState = store.state 43 | 44 | if (process.env.NODE_ENV === 'production') { 45 | store.state = store.reducer(oldState, action) 46 | } else { 47 | try { 48 | store.isDispatching = true 49 | store.state = store.reducer(oldState, action) 50 | } finally { 51 | store.isDispatching = false 52 | } 53 | } 54 | 55 | const {listeners} = store 56 | 57 | for (const listener of listeners) listener(store, oldState) 58 | }) 59 | } 60 | 61 | export default plugin 62 | -------------------------------------------------------------------------------- /src/plugins/rematch.js: -------------------------------------------------------------------------------- 1 | const INIT = '@@rematch/INIT' 2 | 3 | const createReducer = (store, {name, reducers, state: initialState}) => { 4 | const oldReducer = store.reducer 5 | const reducerMap = {} 6 | 7 | for (const reducerName in reducers) { 8 | reducerMap[`${name}/${reducerName}`] = reducers[reducerName] 9 | } 10 | 11 | return (state, action) => { 12 | if (action.type === INIT) { 13 | return { 14 | ...state, 15 | [name]: initialState 16 | } 17 | } 18 | 19 | const reducer = reducerMap[action.type] 20 | 21 | if (reducer) { 22 | return { 23 | ...state, 24 | [name]: reducer(state[name], ...action.args) 25 | } 26 | } 27 | 28 | return oldReducer(state, action) 29 | } 30 | } 31 | 32 | const plugin = () => (store) => { 33 | store.rematch = (model, name) => { 34 | if (!model.name) { 35 | model.name = name 36 | } 37 | 38 | name = model.name 39 | 40 | const ctx = {} 41 | 42 | if (model.reducers) { 43 | for (const prop in model.reducers) { 44 | ctx[prop] = (...args) => store.dispatch({ 45 | args, 46 | type: `${name}/${prop}` 47 | }) 48 | } 49 | 50 | store.reducer = createReducer(store, model) 51 | store.dispatch({ 52 | type: INIT 53 | }) 54 | } 55 | 56 | store.dispatch[name] = ctx 57 | } 58 | } 59 | 60 | export default plugin 61 | -------------------------------------------------------------------------------- /src/plugins/replaceReducer.js: -------------------------------------------------------------------------------- 1 | const plugin = () => store => { 2 | store.replaceReducer = nextReducer => { 3 | if (process.env.NODE_ENV !== 'producrtion') { 4 | if (typeof nextReducer !== 'function') { 5 | throw new Error('Expected the nextReducer to be a function.') 6 | } 7 | } 8 | 9 | store.reducer = nextReducer 10 | } 11 | } 12 | 13 | export default plugin 14 | -------------------------------------------------------------------------------- /src/plugins/subscribe.js: -------------------------------------------------------------------------------- 1 | const plugin = () => store => { 2 | store.subscribe = listener => { 3 | if (process.env.NODE_ENV !== 'production') { 4 | if (typeof listener !== 'function') { 5 | throw new Error('Expected the listener to be a function.') 6 | } 7 | 8 | if (store.isDispatching) { 9 | throw new Error( 10 | 'You may not call store.subscribe() while the reducer is executing. ' + 11 | 'If you would like to be notified after the store has been updated, subscribe from a ' + 12 | 'component and invoke store.getState() in the callback to access the latest state. ' + 13 | 'See http://redux.js.org/docs/api/Store.html#subscribe for more details.' 14 | ) 15 | } 16 | } 17 | 18 | const wrap = () => listener() 19 | 20 | store.listeners = [...store.listeners, wrap] 21 | 22 | return function unsubscribe () { 23 | if (process.env.NODE_ENV !== 'production') { 24 | if (store.isDispatching) { 25 | throw new Error( 26 | 'You may not unsubscribe from a store listener while the reducer is executing. ' + 27 | 'See http://redux.js.org/docs/api/Store.html#subscribe for more details.' 28 | ) 29 | } 30 | } 31 | 32 | store.listeners = store.listeners.filter(l => l !== wrap) 33 | } 34 | } 35 | } 36 | 37 | export default plugin 38 | -------------------------------------------------------------------------------- /src/plugins/subscribeToEffects.js: -------------------------------------------------------------------------------- 1 | const plugin = () => (store) => { 2 | store.subscribeToEffects = () => { 3 | 4 | } 5 | } 6 | 7 | export default plugin 8 | -------------------------------------------------------------------------------- /src/plugins/table.js: -------------------------------------------------------------------------------- 1 | const PATH_PATCH = '@@PATH_PATCH' 2 | const PATH_DELETE = '@@PATH_DELETE' 3 | 4 | const pathPatch = (path, patch) => ({ 5 | path, 6 | patch, 7 | type: PATH_PATCH 8 | }) 9 | 10 | const pathDelete = (path) => ({ 11 | path, 12 | type: PATH_DELETE 13 | }) 14 | 15 | const reducerPathPatch = (state, {path, patch}) => { 16 | if (!path.length) return {...state, ...patch} 17 | else { 18 | const [step, ...restPath] = path 19 | 20 | return { 21 | ...state, 22 | [step]: reducerPathPatch(state[step] || {}, {path: restPath, patch}) 23 | } 24 | } 25 | } 26 | 27 | const reducerPathDelete = (state, {path}) => { 28 | if (path.length === 1) { 29 | const [step] = path 30 | const {[step]: omit, ...newState} = state 31 | return newState 32 | } else { 33 | const [step, ...restPath] = path 34 | return { 35 | ...state, 36 | [step]: reducerPathDelete(state[step] || {}, {path: restPath}) 37 | } 38 | } 39 | } 40 | 41 | const crateReducer = (reducer) => (state, action) => { 42 | switch (action.type) { 43 | case PATH_PATCH: 44 | return reducerPathPatch(state, action) 45 | case PATH_DELETE: 46 | return reducerPathDelete(state, action) 47 | default: 48 | return reducer(state, action) 49 | } 50 | } 51 | 52 | const selectPath = (state, path) => { 53 | let curr = state 54 | for (let i = 0; i < path.length; i++) { 55 | if (!curr || typeof curr !== 'object') return void 0 56 | curr = curr[path[i]] 57 | } 58 | return curr 59 | } 60 | 61 | const plugin = () => (store) => { 62 | const dispatchPatch = (path, patch) => 63 | store.dispatch(pathPatch(path, patch)) 64 | 65 | store.reducer = crateReducer(store.reducer) 66 | 67 | store.link = (path, fields, obj) => { 68 | for (const field of fields) { 69 | Object.defineProperty(obj, field, { 70 | enumerable: true, 71 | get: function () { 72 | return this.select()[field] 73 | }, 74 | set: function (value) { 75 | this.patch({[field]: value}) 76 | } 77 | }) 78 | } 79 | } 80 | 81 | store.enhanceModel = (Model, {name, idField = 'id', fields, path}) => { 82 | Model.displayName = name 83 | store[name] = Model 84 | 85 | Model.prototype.path = function () { 86 | return path ? path(this) : [name, 'byId', this.id] 87 | } 88 | 89 | Model.prototype.select = function () { 90 | return selectPath(store.state, this.path()) || {} 91 | } 92 | 93 | Model.prototype.patch = function (patch) { 94 | dispatchPatch(this.path(), patch) 95 | } 96 | 97 | Model.prototype.delete = function () { 98 | store.dispatch(pathDelete(this.path())) 99 | } 100 | 101 | Object.defineProperty(Model.prototype, idField, { 102 | enumerable: true, 103 | get: function () { 104 | return this.__id 105 | }, 106 | set: function (id) { 107 | this.__id = id 108 | this.patch({[idField]: id}) 109 | } 110 | }) 111 | 112 | return Model 113 | } 114 | 115 | store.createModel = (schema) => 116 | store.enhanceModel(class {}, schema) 117 | 118 | // Class decorator 119 | store.model = (schema) => (Model) => 120 | store.enhanceModel(Model, schema) 121 | } 122 | 123 | export default plugin 124 | -------------------------------------------------------------------------------- /src/plugins/thunk.js: -------------------------------------------------------------------------------- 1 | const plugin = () => store => { 2 | store.middlewares.push((action, store) => { 3 | if (typeof action === 'function') { 4 | const result = action(store) 5 | const isAction = typeof result === 'object' 6 | 7 | // Stop looping through middleware if thunk did not return an action. 8 | return isAction ? result : false 9 | } 10 | }) 11 | } 12 | 13 | export default plugin 14 | -------------------------------------------------------------------------------- /src/plugins/use.js: -------------------------------------------------------------------------------- 1 | const plugin = () => (store) => { 2 | // Redux middleware has the following signature: 3 | // (store) => (next) => (action) => result 4 | store.use = function (middleware) { 5 | const dispatch = store.dispatch 6 | const dispatchWithMiddleware = middleware(store)(a => dispatch(a)) 7 | 8 | store.dispatch = action => dispatchWithMiddleware(action) 9 | 10 | return store 11 | } 12 | } 13 | 14 | export default plugin 15 | -------------------------------------------------------------------------------- /src/presets/__tests__/helpers/actionCreators.js: -------------------------------------------------------------------------------- 1 | import { 2 | ADD_TODO, 3 | DISPATCH_IN_MIDDLE, 4 | GET_STATE_IN_MIDDLE, 5 | SUBSCRIBE_IN_MIDDLE, 6 | UNSUBSCRIBE_IN_MIDDLE, 7 | THROW_ERROR, 8 | UNKNOWN_ACTION 9 | } from './actionTypes' 10 | 11 | export function addTodo (text) { 12 | return { type: ADD_TODO, text } 13 | } 14 | 15 | export function addTodoAsync (text) { 16 | return dispatch => 17 | new Promise(resolve => 18 | setImmediate(() => { 19 | dispatch(addTodo(text)) 20 | resolve() 21 | }) 22 | ) 23 | } 24 | 25 | export function addTodoIfEmpty (text) { 26 | return (dispatch, getState) => { 27 | if (!getState().length) { 28 | dispatch(addTodo(text)) 29 | } 30 | } 31 | } 32 | 33 | export function dispatchInMiddle (boundDispatchFn) { 34 | return { 35 | type: DISPATCH_IN_MIDDLE, 36 | boundDispatchFn 37 | } 38 | } 39 | 40 | export function getStateInMiddle (boundGetStateFn) { 41 | return { 42 | type: GET_STATE_IN_MIDDLE, 43 | boundGetStateFn 44 | } 45 | } 46 | 47 | export function subscribeInMiddle (boundSubscribeFn) { 48 | return { 49 | type: SUBSCRIBE_IN_MIDDLE, 50 | boundSubscribeFn 51 | } 52 | } 53 | 54 | export function unsubscribeInMiddle (boundUnsubscribeFn) { 55 | return { 56 | type: UNSUBSCRIBE_IN_MIDDLE, 57 | boundUnsubscribeFn 58 | } 59 | } 60 | 61 | export function throwError () { 62 | return { 63 | type: THROW_ERROR 64 | } 65 | } 66 | 67 | export function unknownAction () { 68 | return { 69 | type: UNKNOWN_ACTION 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/presets/__tests__/helpers/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const ADD_TODO = 'ADD_TODO' 2 | export const DISPATCH_IN_MIDDLE = 'DISPATCH_IN_MIDDLE' 3 | export const GET_STATE_IN_MIDDLE = 'GET_STATE_IN_MIDDLE' 4 | export const SUBSCRIBE_IN_MIDDLE = 'SUBSCRIBE_IN_MIDDLE' 5 | export const UNSUBSCRIBE_IN_MIDDLE = 'UNSUBSCRIBE_IN_MIDDLE' 6 | export const THROW_ERROR = 'THROW_ERROR' 7 | export const UNKNOWN_ACTION = 'UNKNOWN_ACTION' 8 | -------------------------------------------------------------------------------- /src/presets/__tests__/helpers/reducers.js: -------------------------------------------------------------------------------- 1 | import { 2 | ADD_TODO, 3 | DISPATCH_IN_MIDDLE, 4 | GET_STATE_IN_MIDDLE, 5 | SUBSCRIBE_IN_MIDDLE, 6 | UNSUBSCRIBE_IN_MIDDLE, 7 | THROW_ERROR 8 | } from './actionTypes' 9 | 10 | function id (state = []) { 11 | return ( 12 | state.reduce((result, item) => (item.id > result ? item.id : result), 0) + 1 13 | ) 14 | } 15 | 16 | export function todos (state = [], action) { 17 | switch (action.type) { 18 | case ADD_TODO: 19 | return [ 20 | ...state, 21 | { 22 | id: id(state), 23 | text: action.text 24 | } 25 | ] 26 | default: 27 | return state 28 | } 29 | } 30 | 31 | export function todosReverse (state = [], action) { 32 | switch (action.type) { 33 | case ADD_TODO: 34 | return [ 35 | { 36 | id: id(state), 37 | text: action.text 38 | }, 39 | ...state 40 | ] 41 | default: 42 | return state 43 | } 44 | } 45 | 46 | export function dispatchInTheMiddleOfReducer (state = [], action) { 47 | switch (action.type) { 48 | case DISPATCH_IN_MIDDLE: 49 | action.boundDispatchFn() 50 | return state 51 | default: 52 | return state 53 | } 54 | } 55 | 56 | export function getStateInTheMiddleOfReducer (state = [], action) { 57 | switch (action.type) { 58 | case GET_STATE_IN_MIDDLE: 59 | action.boundGetStateFn() 60 | return state 61 | default: 62 | return state 63 | } 64 | } 65 | 66 | export function subscribeInTheMiddleOfReducer (state = [], action) { 67 | switch (action.type) { 68 | case SUBSCRIBE_IN_MIDDLE: 69 | action.boundSubscribeFn() 70 | return state 71 | default: 72 | return state 73 | } 74 | } 75 | 76 | export function unsubscribeInTheMiddleOfReducer (state = [], action) { 77 | switch (action.type) { 78 | case UNSUBSCRIBE_IN_MIDDLE: 79 | action.boundUnsubscribeFn() 80 | return state 81 | default: 82 | return state 83 | } 84 | } 85 | 86 | export function errorThrowingReducer (state = [], action) { 87 | switch (action.type) { 88 | case THROW_ERROR: 89 | throw new Error() 90 | default: 91 | return state 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/presets/__tests__/redux.test.js: -------------------------------------------------------------------------------- 1 | import createReduxStore from '../redux' 2 | 3 | describe('presets', () => { 4 | describe('redux', () => { 5 | it('exists', () => { 6 | expect(typeof createReduxStore).toBe('function') 7 | }) 8 | 9 | it('returns a store object with Redux API', () => { 10 | const reducer = () => {} 11 | const store = createReduxStore(reducer) 12 | 13 | expect(typeof store).toBe('object') 14 | expect(typeof store.getState).toBe('function') 15 | expect(typeof store.dispatch).toBe('function') 16 | expect(typeof store.subscribe).toBe('function') 17 | expect(typeof store.replaceReducer).toBe('function') 18 | }) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /src/presets/__tests__/reduxSpec.test.js: -------------------------------------------------------------------------------- 1 | // This test suite is taken from: 2 | // https://github.com/reactjs/redux/blob/master/test/createStore.spec.js 3 | 4 | import {combineReducers} from 'redux' 5 | import createStore from '../redux' 6 | import { 7 | addTodo, 8 | dispatchInMiddle, 9 | getStateInMiddle, 10 | subscribeInMiddle, 11 | unsubscribeInMiddle, 12 | throwError, 13 | unknownAction 14 | } from './helpers/actionCreators' 15 | import * as reducers from './helpers/reducers' 16 | import {from} from 'rxjs' 17 | import {map} from 'rxjs/operators' 18 | import $$observable from 'symbol-observable' 19 | 20 | describe('createStore', () => { 21 | it('exposes the public API', () => { 22 | const store = createStore(combineReducers(reducers)) 23 | const methods = Object.keys(store) 24 | 25 | // TODO: Do we need to support this "feature"? 26 | // expect(methods.length).toBe(4) 27 | 28 | expect(methods).toContain('subscribe') 29 | expect(methods).toContain('dispatch') 30 | expect(methods).toContain('getState') 31 | expect(methods).toContain('replaceReducer') 32 | }) 33 | 34 | it('throws if reducer is not a function', () => { 35 | expect(() => createStore()).toThrow() 36 | 37 | expect(() => createStore('test')).toThrow() 38 | 39 | expect(() => createStore({})).toThrow() 40 | 41 | expect(() => createStore(() => {})).not.toThrow() 42 | }) 43 | 44 | it('passes the initial state', () => { 45 | const store = createStore(reducers.todos, [ 46 | { 47 | id: 1, 48 | text: 'Hello' 49 | } 50 | ]) 51 | expect(store.getState()).toEqual([ 52 | { 53 | id: 1, 54 | text: 'Hello' 55 | } 56 | ]) 57 | }) 58 | 59 | it('applies the reducer to the previous state', () => { 60 | const store = createStore(reducers.todos) 61 | expect(store.getState()).toEqual([]) 62 | 63 | store.dispatch(unknownAction()) 64 | expect(store.getState()).toEqual([]) 65 | 66 | store.dispatch(addTodo('Hello')) 67 | expect(store.getState()).toEqual([ 68 | { 69 | id: 1, 70 | text: 'Hello' 71 | } 72 | ]) 73 | 74 | store.dispatch(addTodo('World')) 75 | expect(store.getState()).toEqual([ 76 | { 77 | id: 1, 78 | text: 'Hello' 79 | }, 80 | { 81 | id: 2, 82 | text: 'World' 83 | } 84 | ]) 85 | }) 86 | 87 | it('applies the reducer to the initial state', () => { 88 | const store = createStore(reducers.todos, [ 89 | { 90 | id: 1, 91 | text: 'Hello' 92 | } 93 | ]) 94 | expect(store.getState()).toEqual([ 95 | { 96 | id: 1, 97 | text: 'Hello' 98 | } 99 | ]) 100 | 101 | store.dispatch(unknownAction()) 102 | expect(store.getState()).toEqual([ 103 | { 104 | id: 1, 105 | text: 'Hello' 106 | } 107 | ]) 108 | 109 | store.dispatch(addTodo('World')) 110 | expect(store.getState()).toEqual([ 111 | { 112 | id: 1, 113 | text: 'Hello' 114 | }, 115 | { 116 | id: 2, 117 | text: 'World' 118 | } 119 | ]) 120 | }) 121 | 122 | it('preserves the state when replacing a reducer', () => { 123 | const store = createStore(reducers.todos) 124 | store.dispatch(addTodo('Hello')) 125 | store.dispatch(addTodo('World')) 126 | expect(store.getState()).toEqual([ 127 | { 128 | id: 1, 129 | text: 'Hello' 130 | }, 131 | { 132 | id: 2, 133 | text: 'World' 134 | } 135 | ]) 136 | 137 | store.replaceReducer(reducers.todosReverse) 138 | expect(store.getState()).toEqual([ 139 | { 140 | id: 1, 141 | text: 'Hello' 142 | }, 143 | { 144 | id: 2, 145 | text: 'World' 146 | } 147 | ]) 148 | 149 | store.dispatch(addTodo('Perhaps')) 150 | expect(store.getState()).toEqual([ 151 | { 152 | id: 3, 153 | text: 'Perhaps' 154 | }, 155 | { 156 | id: 1, 157 | text: 'Hello' 158 | }, 159 | { 160 | id: 2, 161 | text: 'World' 162 | } 163 | ]) 164 | 165 | store.replaceReducer(reducers.todos) 166 | expect(store.getState()).toEqual([ 167 | { 168 | id: 3, 169 | text: 'Perhaps' 170 | }, 171 | { 172 | id: 1, 173 | text: 'Hello' 174 | }, 175 | { 176 | id: 2, 177 | text: 'World' 178 | } 179 | ]) 180 | 181 | store.dispatch(addTodo('Surely')) 182 | expect(store.getState()).toEqual([ 183 | { 184 | id: 3, 185 | text: 'Perhaps' 186 | }, 187 | { 188 | id: 1, 189 | text: 'Hello' 190 | }, 191 | { 192 | id: 2, 193 | text: 'World' 194 | }, 195 | { 196 | id: 4, 197 | text: 'Surely' 198 | } 199 | ]) 200 | }) 201 | 202 | it('supports multiple subscriptions', () => { 203 | const store = createStore(reducers.todos) 204 | const listenerA = jest.fn() 205 | const listenerB = jest.fn() 206 | 207 | let unsubscribeA = store.subscribe(listenerA) 208 | store.dispatch(unknownAction()) 209 | expect(listenerA.mock.calls.length).toBe(1) 210 | expect(listenerB.mock.calls.length).toBe(0) 211 | 212 | store.dispatch(unknownAction()) 213 | expect(listenerA.mock.calls.length).toBe(2) 214 | expect(listenerB.mock.calls.length).toBe(0) 215 | 216 | const unsubscribeB = store.subscribe(listenerB) 217 | expect(listenerA.mock.calls.length).toBe(2) 218 | expect(listenerB.mock.calls.length).toBe(0) 219 | 220 | store.dispatch(unknownAction()) 221 | expect(listenerA.mock.calls.length).toBe(3) 222 | expect(listenerB.mock.calls.length).toBe(1) 223 | 224 | unsubscribeA() 225 | expect(listenerA.mock.calls.length).toBe(3) 226 | expect(listenerB.mock.calls.length).toBe(1) 227 | 228 | store.dispatch(unknownAction()) 229 | expect(listenerA.mock.calls.length).toBe(3) 230 | expect(listenerB.mock.calls.length).toBe(2) 231 | 232 | unsubscribeB() 233 | expect(listenerA.mock.calls.length).toBe(3) 234 | expect(listenerB.mock.calls.length).toBe(2) 235 | 236 | store.dispatch(unknownAction()) 237 | expect(listenerA.mock.calls.length).toBe(3) 238 | expect(listenerB.mock.calls.length).toBe(2) 239 | 240 | unsubscribeA = store.subscribe(listenerA) 241 | expect(listenerA.mock.calls.length).toBe(3) 242 | expect(listenerB.mock.calls.length).toBe(2) 243 | 244 | store.dispatch(unknownAction()) 245 | expect(listenerA.mock.calls.length).toBe(4) 246 | expect(listenerB.mock.calls.length).toBe(2) 247 | }) 248 | 249 | it('only removes listener once when unsubscribe is called', () => { 250 | const store = createStore(reducers.todos) 251 | const listenerA = jest.fn() 252 | const listenerB = jest.fn() 253 | 254 | const unsubscribeA = store.subscribe(listenerA) 255 | store.subscribe(listenerB) 256 | 257 | unsubscribeA() 258 | unsubscribeA() 259 | 260 | store.dispatch(unknownAction()) 261 | expect(listenerA.mock.calls.length).toBe(0) 262 | expect(listenerB.mock.calls.length).toBe(1) 263 | }) 264 | 265 | it('only removes relevant listener when unsubscribe is called', () => { 266 | const store = createStore(reducers.todos) 267 | const listener = jest.fn() 268 | 269 | store.subscribe(listener) 270 | const unsubscribeSecond = store.subscribe(listener) 271 | 272 | unsubscribeSecond() 273 | unsubscribeSecond() 274 | 275 | store.dispatch(unknownAction()) 276 | expect(listener.mock.calls.length).toBe(1) 277 | }) 278 | 279 | it('supports removing a subscription within a subscription', () => { 280 | const store = createStore(reducers.todos) 281 | const listenerA = jest.fn() 282 | const listenerB = jest.fn() 283 | const listenerC = jest.fn() 284 | 285 | store.subscribe(listenerA) 286 | const unSubB = store.subscribe(() => { 287 | listenerB() 288 | unSubB() 289 | }) 290 | store.subscribe(listenerC) 291 | 292 | store.dispatch(unknownAction()) 293 | store.dispatch(unknownAction()) 294 | 295 | expect(listenerA.mock.calls.length).toBe(2) 296 | expect(listenerB.mock.calls.length).toBe(1) 297 | expect(listenerC.mock.calls.length).toBe(2) 298 | }) 299 | 300 | it('notifies all subscribers about current dispatch regardless if any of them gets unsubscribed in the process', () => { 301 | const store = createStore(reducers.todos) 302 | 303 | const unsubscribeHandles = [] 304 | const doUnsubscribeAll = () => 305 | unsubscribeHandles.forEach(unsubscribe => unsubscribe()) 306 | 307 | const listener1 = jest.fn() 308 | const listener2 = jest.fn() 309 | const listener3 = jest.fn() 310 | 311 | unsubscribeHandles.push(store.subscribe(() => listener1())) 312 | unsubscribeHandles.push( 313 | store.subscribe(() => { 314 | listener2() 315 | doUnsubscribeAll() 316 | }) 317 | ) 318 | unsubscribeHandles.push(store.subscribe(() => listener3())) 319 | 320 | store.dispatch(unknownAction()) 321 | expect(listener1.mock.calls.length).toBe(1) 322 | expect(listener2.mock.calls.length).toBe(1) 323 | expect(listener3.mock.calls.length).toBe(1) 324 | 325 | store.dispatch(unknownAction()) 326 | expect(listener1.mock.calls.length).toBe(1) 327 | expect(listener2.mock.calls.length).toBe(1) 328 | expect(listener3.mock.calls.length).toBe(1) 329 | }) 330 | 331 | it('notifies only subscribers active at the moment of current dispatch', () => { 332 | const store = createStore(reducers.todos) 333 | 334 | const listener1 = jest.fn() 335 | const listener2 = jest.fn() 336 | const listener3 = jest.fn() 337 | 338 | let listener3Added = false 339 | const maybeAddThirdListener = () => { 340 | if (!listener3Added) { 341 | listener3Added = true 342 | store.subscribe(() => listener3()) 343 | } 344 | } 345 | 346 | store.subscribe(() => listener1()) 347 | store.subscribe(() => { 348 | listener2() 349 | maybeAddThirdListener() 350 | }) 351 | 352 | store.dispatch(unknownAction()) 353 | expect(listener1.mock.calls.length).toBe(1) 354 | expect(listener2.mock.calls.length).toBe(1) 355 | expect(listener3.mock.calls.length).toBe(0) 356 | 357 | store.dispatch(unknownAction()) 358 | expect(listener1.mock.calls.length).toBe(2) 359 | expect(listener2.mock.calls.length).toBe(2) 360 | expect(listener3.mock.calls.length).toBe(1) 361 | }) 362 | 363 | it('uses the last snapshot of subscribers during nested dispatch', () => { 364 | const store = createStore(reducers.todos) 365 | 366 | const listener1 = jest.fn() 367 | const listener2 = jest.fn() 368 | const listener3 = jest.fn() 369 | const listener4 = jest.fn() 370 | 371 | let unsubscribe4 372 | const unsubscribe1 = store.subscribe(() => { 373 | listener1() 374 | expect(listener1.mock.calls.length).toBe(1) 375 | expect(listener2.mock.calls.length).toBe(0) 376 | expect(listener3.mock.calls.length).toBe(0) 377 | expect(listener4.mock.calls.length).toBe(0) 378 | 379 | unsubscribe1() 380 | unsubscribe4 = store.subscribe(listener4) 381 | store.dispatch(unknownAction()) 382 | 383 | expect(listener1.mock.calls.length).toBe(1) 384 | expect(listener2.mock.calls.length).toBe(1) 385 | expect(listener3.mock.calls.length).toBe(1) 386 | expect(listener4.mock.calls.length).toBe(1) 387 | }) 388 | store.subscribe(listener2) 389 | store.subscribe(listener3) 390 | 391 | store.dispatch(unknownAction()) 392 | expect(listener1.mock.calls.length).toBe(1) 393 | expect(listener2.mock.calls.length).toBe(2) 394 | expect(listener3.mock.calls.length).toBe(2) 395 | expect(listener4.mock.calls.length).toBe(1) 396 | 397 | unsubscribe4() 398 | store.dispatch(unknownAction()) 399 | expect(listener1.mock.calls.length).toBe(1) 400 | expect(listener2.mock.calls.length).toBe(3) 401 | expect(listener3.mock.calls.length).toBe(3) 402 | expect(listener4.mock.calls.length).toBe(1) 403 | }) 404 | 405 | it('provides an up-to-date state when a subscriber is notified', done => { 406 | const store = createStore(reducers.todos) 407 | store.subscribe(() => { 408 | expect(store.getState()).toEqual([ 409 | { 410 | id: 1, 411 | text: 'Hello' 412 | } 413 | ]) 414 | done() 415 | }) 416 | store.dispatch(addTodo('Hello')) 417 | }) 418 | 419 | it('does not leak private listeners array', done => { 420 | const store = createStore(reducers.todos) 421 | store.subscribe(function () { 422 | expect(this).toBe(undefined) 423 | done() 424 | }) 425 | store.dispatch(addTodo('Hello')) 426 | }) 427 | 428 | it('only accepts plain object actions', () => { 429 | const store = createStore(reducers.todos) 430 | expect(() => store.dispatch(unknownAction())).not.toThrow() 431 | 432 | function AwesomeMap () {} 433 | ;[null, undefined, 42, 'hey', new AwesomeMap()].forEach(nonObject => 434 | expect(() => store.dispatch(nonObject)).toThrow(/plain/) 435 | ) 436 | }) 437 | 438 | it('handles nested dispatches gracefully', () => { 439 | function foo (state = 0, action) { 440 | return action.type === 'foo' ? 1 : state 441 | } 442 | 443 | function bar (state = 0, action) { 444 | return action.type === 'bar' ? 2 : state 445 | } 446 | 447 | const store = createStore(combineReducers({ foo, bar })) 448 | 449 | store.subscribe(function kindaComponentDidUpdate () { 450 | const state = store.getState() 451 | if (state.bar === 0) { 452 | store.dispatch({ type: 'bar' }) 453 | } 454 | }) 455 | 456 | store.dispatch({ type: 'foo' }) 457 | expect(store.getState()).toEqual({ 458 | foo: 1, 459 | bar: 2 460 | }) 461 | }) 462 | 463 | it('does not allow dispatch() from within a reducer', () => { 464 | const store = createStore(reducers.dispatchInTheMiddleOfReducer) 465 | 466 | expect(() => 467 | store.dispatch( 468 | dispatchInMiddle(store.dispatch.bind(store, unknownAction())) 469 | ) 470 | ).toThrow(/may not dispatch/) 471 | }) 472 | 473 | it('does not allow getState() from within a reducer', () => { 474 | const store = createStore(reducers.getStateInTheMiddleOfReducer) 475 | 476 | expect(() => 477 | store.dispatch(getStateInMiddle(store.getState.bind(store))) 478 | ).toThrow(/You may not call store.getState()/) 479 | }) 480 | 481 | it('does not allow subscribe() from within a reducer', () => { 482 | const store = createStore(reducers.subscribeInTheMiddleOfReducer) 483 | 484 | expect(() => 485 | store.dispatch(subscribeInMiddle(store.subscribe.bind(store, () => {}))) 486 | ).toThrow(/You may not call store.subscribe()/) 487 | }) 488 | 489 | it('does not allow unsubscribe from subscribe() from within a reducer', () => { 490 | const store = createStore(reducers.unsubscribeInTheMiddleOfReducer) 491 | const unsubscribe = store.subscribe(() => {}) 492 | 493 | expect(() => 494 | store.dispatch(unsubscribeInMiddle(unsubscribe.bind(store))) 495 | ).toThrow(/You may not unsubscribe from a store/) 496 | }) 497 | 498 | it('recovers from an error within a reducer', () => { 499 | const store = createStore(reducers.errorThrowingReducer) 500 | expect(() => store.dispatch(throwError())).toThrow() 501 | 502 | expect(() => store.dispatch(unknownAction())).not.toThrow() 503 | }) 504 | 505 | it('throws if action type is missing', () => { 506 | const store = createStore(reducers.todos) 507 | expect(() => store.dispatch({})).toThrow( 508 | /Actions may not have an undefined "type" property/ 509 | ) 510 | }) 511 | 512 | it('throws if action type is undefined', () => { 513 | const store = createStore(reducers.todos) 514 | expect(() => store.dispatch({ type: undefined })).toThrow( 515 | /Actions may not have an undefined "type" property/ 516 | ) 517 | }) 518 | 519 | it('does not throw if action type is falsy', () => { 520 | const store = createStore(reducers.todos) 521 | expect(() => store.dispatch({ type: false })).not.toThrow() 522 | expect(() => store.dispatch({ type: 0 })).not.toThrow() 523 | expect(() => store.dispatch({ type: null })).not.toThrow() 524 | expect(() => store.dispatch({ type: '' })).not.toThrow() 525 | }) 526 | 527 | it('accepts enhancer as the third argument', () => { 528 | const emptyArray = [] 529 | const spyEnhancer = vanillaCreateStore => (...args) => { 530 | expect(args[0]).toBe(reducers.todos) 531 | expect(args[1]).toBe(emptyArray) 532 | expect(args.length).toBe(2) 533 | const vanillaStore = vanillaCreateStore(...args) 534 | return { 535 | ...vanillaStore, 536 | dispatch: jest.fn(vanillaStore.dispatch) 537 | } 538 | } 539 | 540 | const store = createStore(reducers.todos, emptyArray, spyEnhancer) 541 | const action = addTodo('Hello') 542 | store.dispatch(action) 543 | expect(store.dispatch).toBeCalledWith(action) 544 | expect(store.getState()).toEqual([ 545 | { 546 | id: 1, 547 | text: 'Hello' 548 | } 549 | ]) 550 | }) 551 | it('accepts enhancer as the second argument if initial state is missing', () => { 552 | const spyEnhancer = vanillaCreateStore => (...args) => { 553 | expect(args[0]).toBe(reducers.todos) 554 | expect(args[1]).toBe(undefined) 555 | expect(args.length).toBe(2) 556 | const vanillaStore = vanillaCreateStore(...args) 557 | return { 558 | ...vanillaStore, 559 | dispatch: jest.fn(vanillaStore.dispatch) 560 | } 561 | } 562 | 563 | const store = createStore(reducers.todos, spyEnhancer) 564 | const action = addTodo('Hello') 565 | store.dispatch(action) 566 | expect(store.dispatch).toBeCalledWith(action) 567 | expect(store.getState()).toEqual([ 568 | { 569 | id: 1, 570 | text: 'Hello' 571 | } 572 | ]) 573 | }) 574 | 575 | it('throws if enhancer is neither undefined nor a function', () => { 576 | expect(() => createStore(reducers.todos, undefined, {})).toThrow() 577 | 578 | expect(() => createStore(reducers.todos, undefined, [])).toThrow() 579 | 580 | expect(() => createStore(reducers.todos, undefined, null)).toThrow() 581 | 582 | expect(() => createStore(reducers.todos, undefined, false)).toThrow() 583 | 584 | expect(() => 585 | createStore(reducers.todos, undefined, undefined) 586 | ).not.toThrow() 587 | 588 | expect(() => createStore(reducers.todos, undefined, x => x)).not.toThrow() 589 | 590 | expect(() => createStore(reducers.todos, x => x)).not.toThrow() 591 | 592 | expect(() => createStore(reducers.todos, [])).not.toThrow() 593 | 594 | expect(() => createStore(reducers.todos, {})).not.toThrow() 595 | }) 596 | 597 | it('throws if nextReducer is not a function', () => { 598 | const store = createStore(reducers.todos) 599 | 600 | expect(() => store.replaceReducer()).toThrow( 601 | 'Expected the nextReducer to be a function.' 602 | ) 603 | 604 | expect(() => store.replaceReducer(() => {})).not.toThrow() 605 | }) 606 | 607 | it('throws if listener is not a function', () => { 608 | const store = createStore(reducers.todos) 609 | 610 | expect(() => store.subscribe()).toThrow() 611 | 612 | expect(() => store.subscribe('')).toThrow() 613 | 614 | expect(() => store.subscribe(null)).toThrow() 615 | 616 | expect(() => store.subscribe(undefined)).toThrow() 617 | }) 618 | 619 | describe('Symbol.observable interop point', () => { 620 | it('should exist', () => { 621 | const store = createStore(() => {}) 622 | expect(typeof store[$$observable]).toBe('function') 623 | }) 624 | 625 | describe('returned value', () => { 626 | it('should be subscribable', () => { 627 | const store = createStore(() => {}) 628 | const obs = store[$$observable]() 629 | expect(typeof obs.subscribe).toBe('function') 630 | }) 631 | 632 | it('should throw a TypeError if an observer object is not supplied to subscribe', () => { 633 | const store = createStore(() => {}) 634 | const obs = store[$$observable]() 635 | 636 | expect(function () { 637 | obs.subscribe() 638 | }).toThrow() 639 | 640 | expect(function () { 641 | obs.subscribe(() => {}) 642 | }).toThrow() 643 | 644 | expect(function () { 645 | obs.subscribe({}) 646 | }).not.toThrow() 647 | }) 648 | 649 | it('should return a subscription object when subscribed', () => { 650 | const store = createStore(() => {}) 651 | const obs = store[$$observable]() 652 | const sub = obs.subscribe({}) 653 | expect(typeof sub.unsubscribe).toBe('function') 654 | }) 655 | }) 656 | 657 | it('should pass an integration test with no unsubscribe', () => { 658 | function foo (state = 0, action) { 659 | return action.type === 'foo' ? 1 : state 660 | } 661 | 662 | function bar (state = 0, action) { 663 | return action.type === 'bar' ? 2 : state 664 | } 665 | 666 | const store = createStore(combineReducers({ foo, bar })) 667 | const observable = store[$$observable]() 668 | const results = [] 669 | 670 | observable.subscribe({ 671 | next (state) { 672 | results.push(state) 673 | } 674 | }) 675 | 676 | store.dispatch({ type: 'foo' }) 677 | store.dispatch({ type: 'bar' }) 678 | 679 | expect(results).toEqual([ 680 | { foo: 0, bar: 0 }, 681 | { foo: 1, bar: 0 }, 682 | { foo: 1, bar: 2 } 683 | ]) 684 | }) 685 | 686 | it('should pass an integration test with an unsubscribe', () => { 687 | function foo (state = 0, action) { 688 | return action.type === 'foo' ? 1 : state 689 | } 690 | 691 | function bar (state = 0, action) { 692 | return action.type === 'bar' ? 2 : state 693 | } 694 | 695 | const store = createStore(combineReducers({ foo, bar })) 696 | const observable = store[$$observable]() 697 | const results = [] 698 | 699 | const sub = observable.subscribe({ 700 | next (state) { 701 | results.push(state) 702 | } 703 | }) 704 | 705 | store.dispatch({ type: 'foo' }) 706 | sub.unsubscribe() 707 | store.dispatch({ type: 'bar' }) 708 | 709 | expect(results).toEqual([{ foo: 0, bar: 0 }, { foo: 1, bar: 0 }]) 710 | }) 711 | 712 | it('should pass an integration test with a common library (RxJS)', () => { 713 | function foo (state = 0, action) { 714 | return action.type === 'foo' ? 1 : state 715 | } 716 | 717 | function bar (state = 0, action) { 718 | return action.type === 'bar' ? 2 : state 719 | } 720 | 721 | const store = createStore(combineReducers({ foo, bar })) 722 | const observable = from(store) 723 | const results = [] 724 | 725 | const sub = observable.pipe( 726 | map(state => ({ fromRx: true, ...state })) 727 | ).subscribe(state => results.push(state)) 728 | 729 | store.dispatch({ type: 'foo' }) 730 | sub.unsubscribe() 731 | store.dispatch({ type: 'bar' }) 732 | 733 | expect(results).toEqual([ 734 | { foo: 0, bar: 0, fromRx: true }, 735 | { foo: 1, bar: 0, fromRx: true } 736 | ]) 737 | }) 738 | }) 739 | 740 | it('does not log an error if parts of the current state will be ignored by a nextReducer using combineReducers', () => { 741 | const originalConsoleError = console.error 742 | console.error = jest.fn() 743 | 744 | const store = createStore( 745 | combineReducers({ 746 | x: (s = 0, a) => s, 747 | y: combineReducers({ 748 | z: (s = 0, a) => s, 749 | w: (s = 0, a) => s 750 | }) 751 | }) 752 | ) 753 | 754 | store.replaceReducer( 755 | combineReducers({ 756 | y: combineReducers({ 757 | z: (s = 0, a) => s 758 | }) 759 | }) 760 | ) 761 | 762 | expect(console.error.mock.calls.length).toBe(0) 763 | console.error = originalConsoleError 764 | }) 765 | }) 766 | -------------------------------------------------------------------------------- /src/presets/redux.js: -------------------------------------------------------------------------------- 1 | import {createStore} from '..' 2 | import pluginDispatch from '../plugins/dispatch' 3 | import pluginReducer from '../plugins/reducer' 4 | import pluginGetState from '../plugins/getState' 5 | import pluginReplaceReducer from '../plugins/replaceReducer' 6 | import pluginSubscribe from '../plugins/subscribe' 7 | import pluginObservable from '../plugins/observable' 8 | 9 | const createReduxStore = (reducer, preloadedState, enhancer) => { 10 | if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') { 11 | enhancer = preloadedState 12 | preloadedState = undefined 13 | } 14 | 15 | if (typeof enhancer !== 'undefined') { 16 | if (process.env.NODE_ENV !== 'production') { 17 | if (typeof enhancer !== 'function') { 18 | throw new Error('Expected the enhancer to be a function.') 19 | } 20 | } 21 | 22 | return enhancer(createReduxStore)(reducer, preloadedState) 23 | } 24 | 25 | if (process.env.NODE_ENV !== 'production') { 26 | if (typeof reducer !== 'function') { 27 | throw new Error('Expected the reducer to be a function.') 28 | } 29 | } 30 | 31 | const store = createStore(preloadedState, [ 32 | pluginDispatch(), 33 | pluginReducer(reducer), 34 | pluginGetState(), 35 | pluginReplaceReducer(), 36 | pluginSubscribe(), 37 | pluginObservable() 38 | ]) 39 | 40 | // When a store is created, an "INIT" action is dispatched so that every 41 | // reducer returns their initial state. This effectively populates 42 | // the initial state tree. 43 | store.dispatch({ 44 | type: '@@redux/INIT' + Math.random().toString(36).substring(7).split('').join('.') 45 | }) 46 | 47 | return store 48 | } 49 | 50 | export default createReduxStore 51 | -------------------------------------------------------------------------------- /stories/bench.stories.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | import {storiesOf} from '@storybook/react' 3 | import {runBenchmark} from '../benchmark' 4 | 5 | class Benchmark extends Component { 6 | componentDidMount () { 7 | setTimeout(() => { 8 | runBenchmark() 9 | }) 10 | } 11 | 12 | render () { 13 | return
Open console to see benchmark results. (NODE_ENV: {process.env.NODE_ENV})
14 | } 15 | } 16 | 17 | storiesOf('Benchmark', module) 18 | .add('default', () => 19 | 20 | ) 21 | -------------------------------------------------------------------------------- /stories/connect.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {storiesOf} from '@storybook/react' 3 | import createStore3DuxRedux from '../src/presets/redux' 4 | // import {createStore} from 'redux' 5 | import {connect, Provider} from 'react-redux' 6 | 7 | const reducer = state => state 8 | const store = createStore3DuxRedux(reducer, { 9 | foo: 'bar 2' 10 | }) 11 | 12 | const Demo = connect(({foo}) => ({foo}))(({foo}) => 13 |
foo: {foo}
14 | ) 15 | 16 | storiesOf('Connect', module) 17 | .add('default', () => 18 | 19 | 20 | 21 | ) 22 | --------------------------------------------------------------------------------