├── .editorconfig ├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── README.md ├── devtools.d.ts ├── devtools.js ├── index.d.ts ├── package.json ├── preact.d.ts ├── react.d.ts ├── src ├── combined │ ├── preact.js │ └── react.js ├── index.js ├── integrations │ ├── preact.js │ └── react.js └── util.js └── test ├── fixtures └── preact-8.min.js ├── preact ├── builds.test.js ├── preact-8.test.js └── preact.test.js ├── react ├── .babelrc ├── builds.test.js └── react.test.js └── unistore.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [{package.json,.*rc,*.yml}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | indent_style = space 17 | indent_size = 2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | /dist 4 | /full 5 | package-lock.json 6 | /preact.js 7 | /preact.js.map 8 | /react.js 9 | /react.js.map 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at jason@developit.ca. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | unistore 3 |
4 | npm travis 5 |

6 | 7 | # unistore 8 | 9 | > A tiny 350b centralized state container with component bindings for [Preact] & [React]. 10 | 11 | - **Small** footprint complements Preact nicely _(unistore + unistore/preact is ~650b)_ 12 | - **Familiar** names and ideas from Redux-like libraries 13 | - **Useful** data selectors to extract properties from state 14 | - **Portable** actions can be moved into a common place and imported 15 | - **Functional** actions are just reducers 16 | - **NEW**: seamlessly run Unistore in a worker via [Stockroom](https://github.com/developit/stockroom) 17 | 18 | ## Table of Contents 19 | 20 | - [Install](#install) 21 | - [Usage](#usage) 22 | - [Examples](#examples) 23 | - [API](#api) 24 | - [License](#license) 25 | 26 | ## Install 27 | 28 | This project uses [node](http://nodejs.org) and [npm](https://npmjs.com). Go check them out if you don't have them locally installed. 29 | 30 | ```sh 31 | npm install --save unistore 32 | ``` 33 | 34 | Then with a module bundler like [webpack](https://webpack.js.org) or [rollup](http://rollupjs.org), use as you would anything else: 35 | 36 | ```js 37 | // The store: 38 | import createStore from 'unistore' 39 | 40 | // Preact integration 41 | import { Provider, connect } from 'unistore/preact' 42 | 43 | // React integration 44 | import { Provider, connect } from 'unistore/react' 45 | ``` 46 | 47 | Alternatively, you can import the "full" build for each, which includes both `createStore` and the integration for your library of choice: 48 | 49 | ```js 50 | import { createStore, Provider, connect } from 'unistore/full/preact' 51 | ``` 52 | 53 | The [UMD](https://github.com/umdjs/umd) build is also available on [unpkg](https://unpkg.com): 54 | 55 | ```html 56 | 57 | 58 | 59 | 60 | 61 | 62 | ``` 63 | 64 | You can find the library on `window.unistore`. 65 | 66 | ### Usage 67 | 68 | ```js 69 | import createStore from 'unistore' 70 | import { Provider, connect } from 'unistore/preact' 71 | 72 | let store = createStore({ count: 0, stuff: [] }) 73 | 74 | let actions = { 75 | // Actions can just return a state update: 76 | increment(state) { 77 | // The returned object will be merged into the current state 78 | return { count: state.count+1 } 79 | }, 80 | 81 | // The above example as an Arrow Function: 82 | increment2: ({ count }) => ({ count: count+1 }), 83 | 84 | // Actions receive current state as first parameter and any other params next 85 | // See the "Increment by 10"-button below 86 | incrementBy: ({ count }, incrementAmount) => { 87 | return { count: count+incrementAmount } 88 | }, 89 | } 90 | 91 | // If actions is a function, it gets passed the store: 92 | let actionFunctions = store => ({ 93 | // Async actions can be pure async/promise functions: 94 | async getStuff(state) { 95 | const res = await fetch('/foo.json') 96 | return { stuff: await res.json() } 97 | }, 98 | 99 | // ... or just actions that call store.setState() later: 100 | clearOutStuff(state) { 101 | setTimeout(() => { 102 | store.setState({ stuff: [] }) // clear 'stuff' after 1 second 103 | }, 1000) 104 | } 105 | 106 | // Remember that the state passed to the action function could be stale after 107 | // doing async work, so use getState() instead: 108 | async incrementAfterStuff(state) { 109 | const res = await fetch('foo.json') 110 | const resJson = await res.json() 111 | // the variable 'state' above could now be old, 112 | // better get a new one from the store 113 | const upToDateState = store.getState() 114 | 115 | return { 116 | stuff: resJson, 117 | count: upToDateState.count + resJson.length, 118 | } 119 | } 120 | }) 121 | 122 | // Connecting a react/preact component to get current state and to bind actions 123 | const App1 = connect('count', actions)( 124 | ({ count, increment, incrementBy }) => ( 125 |
126 |

Count: {count}

127 | 128 | 129 |
130 | ) 131 | ) 132 | 133 | // First argument to connect can also be a string, array or function while 134 | // second argument can be an object or a function. Here we pass an array and 135 | // a function. 136 | const App2 = connect(['count', 'stuff'], actionFunctions)( 137 | ({ count, stuff, getStuff, clearOutStuff, incrementAfterStuff }) => ( 138 |
139 |

Count: {count}

140 |

Stuff: 141 |

144 |

145 | 146 | 147 | 148 |
149 | ) 150 | ) 151 | 152 | export const getApp1 = () => ( 153 | 154 | 155 | 156 | ) 157 | 158 | export const getApp2 = () => ( 159 | 160 | 161 | 162 | ) 163 | ``` 164 | 165 | ### Debug 166 | 167 | Make sure to have [Redux devtools extension](https://github.com/zalmoxisus/redux-devtools-extension) previously installed. 168 | 169 | ```js 170 | import createStore from 'unistore' 171 | import devtools from 'unistore/devtools' 172 | 173 | let initialState = { count: 0 }; 174 | let store = process.env.NODE_ENV === 'production' ? createStore(initialState) : devtools(createStore(initialState)); 175 | 176 | // ... 177 | ``` 178 | 179 | ### Examples 180 | 181 | [README Example on CodeSandbox](https://codesandbox.io/s/l7y7w5qkz9) 182 | 183 | ### API 184 | 185 | 186 | 187 | #### createStore 188 | 189 | Creates a new store, which is a tiny evented state container. 190 | 191 | **Parameters** 192 | 193 | - `state` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** Optional initial state (optional, default `{}`) 194 | 195 | **Examples** 196 | 197 | ```javascript 198 | let store = createStore(); 199 | store.subscribe( state => console.log(state) ); 200 | store.setState({ a: 'b' }); // logs { a: 'b' } 201 | store.setState({ c: 'd' }); // logs { a: 'b', c: 'd' } 202 | ``` 203 | 204 | Returns **[store](#store)** 205 | 206 | #### store 207 | 208 | An observable state container, returned from [createStore](#createstore) 209 | 210 | ##### action 211 | 212 | Create a bound copy of the given action function. 213 | The bound returned function invokes action() and persists the result back to the store. 214 | If the return value of `action` is a Promise, the resolved value will be used as state. 215 | 216 | **Parameters** 217 | 218 | - `action` **[Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** An action of the form `action(state, ...args) -> stateUpdate` 219 | 220 | Returns **[Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** boundAction() 221 | 222 | ##### setState 223 | 224 | Apply a partial state object to the current state, invoking registered listeners. 225 | 226 | **Parameters** 227 | 228 | - `update` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** An object with properties to be merged into state 229 | - `overwrite` **[Boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** If `true`, update will replace state instead of being merged into it (optional, default `false`) 230 | 231 | ##### subscribe 232 | 233 | Register a listener function to be called whenever state is changed. Returns an `unsubscribe()` function. 234 | 235 | **Parameters** 236 | 237 | - `listener` **[Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** A function to call when state changes. Gets passed the new state. 238 | 239 | Returns **[Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** unsubscribe() 240 | 241 | ##### unsubscribe 242 | 243 | Remove a previously-registered listener function. 244 | 245 | **Parameters** 246 | 247 | - `listener` **[Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** The callback previously passed to `subscribe()` that should be removed. 248 | 249 | ##### getState 250 | 251 | Retrieve the current state object. 252 | 253 | Returns **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** state 254 | 255 | #### connect 256 | 257 | Wire a component up to the store. Passes state as props, re-renders on change. 258 | 259 | **Parameters** 260 | 261 | - `mapStateToProps` **([Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function) \| [Array](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array) \| [String](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String))** A function mapping of store state to prop values, or an array/CSV of properties to map. 262 | - `actions` **([Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function) \| [Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object))?** Action functions (pure state mappings), or a factory returning them. Every action function gets current state as the first parameter and any other params next 263 | 264 | **Examples** 265 | 266 | ```javascript 267 | const Foo = connect('foo,bar')( ({ foo, bar }) =>
) 268 | ``` 269 | 270 | ```javascript 271 | const actions = { someAction } 272 | const Foo = connect('foo,bar', actions)( ({ foo, bar, someAction }) =>
) 273 | ``` 274 | 275 | Returns **Component** ConnectedComponent 276 | 277 | #### Provider 278 | 279 | **Extends Component** 280 | 281 | Provider exposes a store (passed as `props.store`) into context. 282 | 283 | Generally, an entire application is wrapped in a single `` at the root. 284 | 285 | **Parameters** 286 | 287 | - `props` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** 288 | - `props.store` **Store** A {Store} instance to expose via context. 289 | 290 | ### Reporting Issues 291 | 292 | Found a problem? Want a new feature? First of all, see if your issue or idea has [already been reported](../../issues). 293 | If not, just open a [new clear and descriptive issue](../../issues/new). 294 | 295 | ### License 296 | 297 | [MIT License](https://oss.ninja/mit/developit) © [Jason Miller](https://jasonformat.com) 298 | 299 | [preact]: https://github.com/developit/preact 300 | 301 | [react]: https://github.com/facebook/react 302 | -------------------------------------------------------------------------------- /devtools.d.ts: -------------------------------------------------------------------------------- 1 | import { Store } from "unistore"; 2 | 3 | export default function unistoreDevTools(store: Store): Store; 4 | -------------------------------------------------------------------------------- /devtools.js: -------------------------------------------------------------------------------- 1 | module.exports = function unistoreDevTools(store) { 2 | var extension = window.__REDUX_DEVTOOLS_EXTENSION__ || window.top.__REDUX_DEVTOOLS_EXTENSION__; 3 | var ignoreState = false; 4 | 5 | if (!extension) { 6 | console.warn('Please install/enable Redux devtools extension'); 7 | store.devtools = null; 8 | 9 | return store; 10 | } 11 | 12 | if (!store.devtools) { 13 | store.devtools = extension.connect(); 14 | store.devtools.subscribe(function (message) { 15 | if (message.type === 'DISPATCH' && message.state) { 16 | ignoreState = (message.payload.type === 'JUMP_TO_ACTION' || message.payload.type === 'JUMP_TO_STATE'); 17 | store.setState(JSON.parse(message.state), true); 18 | } 19 | }); 20 | store.devtools.init(store.getState()); 21 | store.subscribe(function (state, action) { 22 | var actionName = action && action.name || 'setState'; 23 | 24 | if (!ignoreState) { 25 | store.devtools.send(actionName, state); 26 | } else { 27 | ignoreState = false; 28 | } 29 | }); 30 | } 31 | 32 | return store; 33 | } 34 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | // T - Wrapped component props 2 | // S - Wrapped component state 3 | // K - Store state 4 | // I - Injected props to wrapped component 5 | 6 | export type Listener = (state: K, action?: Action) => void; 7 | export type Unsubscribe = () => void; 8 | export type Action = (state: K, ...args: any[]) => void; 9 | export type BoundAction = (...args: any[]) => void; 10 | 11 | export interface Store { 12 | action(action: Action): BoundAction; 13 | setState(update: Pick, overwrite?: boolean, action?: Action): void; 14 | subscribe(f: Listener): Unsubscribe; 15 | unsubscribe(f: Listener): void; 16 | getState(): K; 17 | } 18 | 19 | export default function createStore(state?: K): Store; 20 | 21 | export type ActionFn = (state: K, ...args: any[]) => Promise> | Partial | void; 22 | 23 | export interface ActionMap { 24 | [actionName: string]: ActionFn; 25 | } 26 | 27 | export type ActionCreator = (store: Store) => ActionMap; 28 | 29 | export type StateMapper = (state: K, props: T) => I; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unistore", 3 | "version": "3.5.2", 4 | "description": "Dead simple centralized state container (store) with preact and react bindings.", 5 | "source": "src/index.js", 6 | "module": "dist/unistore.es.js", 7 | "main": "dist/unistore.js", 8 | "umd:main": "dist/unistore.umd.js", 9 | "typings": "index.d.ts", 10 | "scripts": { 11 | "build": "npm-run-all --silent -p build:main build:integrations build:combined -s size docs", 12 | "build:main": "microbundle", 13 | "build:integrations": "microbundle src/integrations/*.js -o x.js -f cjs --external react,preact", 14 | "build:combined": "microbundle src/combined/*.js -o full/x.js --external react,preact", 15 | "size": "strip-json-comments --no-whitespace dist/unistore.js | gzip-size && bundlesize", 16 | "docs": "documentation readme src/index.js src/integrations/preact.js -q --section API && npm run -s fixreadme", 17 | "fixreadme": "node -e 'var fs=require(\"fs\");fs.writeFileSync(\"README.md\", fs.readFileSync(\"README.md\", \"utf8\").replace(/^- /gm, \"- \"))'", 18 | "test": "eslint src && npm run build && jest", 19 | "prepare": "npm t", 20 | "release": "npm t && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish" 21 | }, 22 | "eslintConfig": { 23 | "extends": "eslint-config-developit", 24 | "rules": { 25 | "prefer-rest-params": 0 26 | } 27 | }, 28 | "bundlesize": [ 29 | { 30 | "path": "full/preact.js", 31 | "maxSize": "760b" 32 | }, 33 | { 34 | "path": "dist/unistore.js", 35 | "maxSize": "400b" 36 | }, 37 | { 38 | "path": "preact.js", 39 | "maxSize": "600b" 40 | } 41 | ], 42 | "babel": { 43 | "presets": [ 44 | [ 45 | "env", 46 | { 47 | "targets": { 48 | "node": "current" 49 | } 50 | } 51 | ] 52 | ], 53 | "plugins": [ 54 | [ 55 | "transform-react-jsx", 56 | { 57 | "pragma": "h" 58 | } 59 | ] 60 | ] 61 | }, 62 | "jest": { 63 | "testURL": "http://localhost" 64 | }, 65 | "files": [ 66 | "src", 67 | "dist", 68 | "full", 69 | "preact.js", 70 | "preact.js.map", 71 | "react.js", 72 | "react.js.map", 73 | "index.d.ts", 74 | "preact.d.ts", 75 | "react.d.ts", 76 | "devtools.js", 77 | "devtools.d.ts" 78 | ], 79 | "keywords": [ 80 | "preact", 81 | "component", 82 | "state machine", 83 | "redux" 84 | ], 85 | "repository": "developit/unistore", 86 | "author": "Jason Miller ", 87 | "license": "MIT", 88 | "devDependencies": { 89 | "babel-jest": "^24.3.1", 90 | "babel-plugin-transform-react-jsx": "^6.24.1", 91 | "babel-preset-env": "^1.6.1", 92 | "bundlesize": "^0.17.1", 93 | "documentation": "^4.0.0", 94 | "enzyme": "^3.9.0", 95 | "enzyme-adapter-react-16": "^1.10.0", 96 | "eslint": "^4.16.0", 97 | "eslint-config-developit": "^1.1.1", 98 | "gzip-size-cli": "^2.1.0", 99 | "jest": "^24.3.1", 100 | "microbundle": "^0.11.0", 101 | "npm-run-all": "^4.1.2", 102 | "preact": "^10.0.0", 103 | "raf": "^3.4.0", 104 | "react": "^16.8.4", 105 | "react-dom": "^16.8.4", 106 | "strip-json-comments-cli": "^1.0.1" 107 | }, 108 | "peerDependenciesMeta": { 109 | "preact": { 110 | "optional": true 111 | }, 112 | "react": { 113 | "optional": true 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /preact.d.ts: -------------------------------------------------------------------------------- 1 | // T - Wrapped component props 2 | // S - Wrapped component state 3 | // K - Store state 4 | // I - Injected props to wrapped component 5 | 6 | declare module 'unistore/preact' { 7 | import * as Preact from 'preact'; 8 | import { ActionCreator, StateMapper, Store } from 'unistore'; 9 | 10 | export function connect( 11 | mapStateToProps: string | Array | StateMapper, 12 | actions?: ActionCreator | object 13 | ): ( 14 | Child: Preact.ComponentConstructor | Preact.AnyComponent 15 | ) => Preact.ComponentConstructor; 16 | 17 | export interface ProviderProps { 18 | store: Store; 19 | } 20 | 21 | export class Provider extends Preact.Component> { 22 | render(props: ProviderProps): Preact.JSX.Element; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /react.d.ts: -------------------------------------------------------------------------------- 1 | // T - Wrapped component props 2 | // S - Wrapped component state 3 | // K - Store state 4 | // I - Injected props to wrapped component 5 | 6 | declare module 'unistore/react' { 7 | import * as React from 'react'; 8 | import { ActionCreator, StateMapper, Store } from 'unistore'; 9 | 10 | export function connect( 11 | mapStateToProps: string | Array | StateMapper, 12 | actions?: ActionCreator | object 13 | ): ( 14 | Child: ((props: T & I) => React.ReactNode) | React.ComponentClass | React.FC 15 | ) => React.ComponentClass | React.FC; 16 | 17 | export interface ProviderProps { 18 | store: Store; 19 | } 20 | 21 | export class Provider extends React.Component, {}> { 22 | render(): React.ReactNode; 23 | } 24 | 25 | interface ComponentConstructor

{ 26 | new (props: P, context?: any): React.Component; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/combined/preact.js: -------------------------------------------------------------------------------- 1 | export { default as createStore } from '../index'; 2 | export { Provider, connect } from '../integrations/preact'; -------------------------------------------------------------------------------- /src/combined/react.js: -------------------------------------------------------------------------------- 1 | export { default as createStore } from '../index'; 2 | export { Provider, connect } from '../integrations/react'; -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { assign } from './util'; 2 | 3 | /** 4 | * Creates a new store, which is a tiny evented state container. 5 | * @name createStore 6 | * @param {Object} [state={}] Optional initial state 7 | * @returns {store} 8 | * @example 9 | * let store = createStore(); 10 | * store.subscribe( state => console.log(state) ); 11 | * store.setState({ a: 'b' }); // logs { a: 'b' } 12 | * store.setState({ c: 'd' }); // logs { a: 'b', c: 'd' } 13 | */ 14 | export default function createStore(state) { 15 | let listeners = []; 16 | state = state || {}; 17 | 18 | function unsubscribe(listener) { 19 | let out = []; 20 | for (let i=0; i stateUpdate` 49 | * @returns {Function} boundAction() 50 | */ 51 | action(action) { 52 | function apply(result) { 53 | setState(result, false, action); 54 | } 55 | 56 | // Note: perf tests verifying this implementation: https://esbench.com/bench/5a295e6299634800a0349500 57 | return function() { 58 | let args = [state]; 59 | for (let i=0; i { unsubscribe(listener); }; 83 | }, 84 | 85 | /** 86 | * Remove a previously-registered listener function. 87 | * @param {Function} listener The callback previously passed to `subscribe()` that should be removed. 88 | * @function 89 | */ 90 | unsubscribe, 91 | 92 | /** 93 | * Retrieve the current state object. 94 | * @returns {Object} state 95 | */ 96 | getState() { 97 | return state; 98 | } 99 | }; 100 | } 101 | -------------------------------------------------------------------------------- /src/integrations/preact.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import { assign, mapActions, select } from '../util'; 3 | 4 | /** 5 | * Wire a component up to the store. Passes state as props, re-renders on change. 6 | * @param {Function|Array|String} mapStateToProps A function mapping of store state to prop values, or an array/CSV of properties to map. 7 | * @param {Function|Object} [actions] Action functions (pure state mappings), or a factory returning them. Every action function gets current state as the first parameter and any other params next 8 | * @returns {Component} ConnectedComponent 9 | * @example 10 | * const Foo = connect('foo,bar')( ({ foo, bar }) =>

) 11 | * @example 12 | * const actions = { someAction } 13 | * const Foo = connect('foo,bar', actions)( ({ foo, bar, someAction }) =>
) 14 | * @example 15 | * @connect( state => ({ foo: state.foo, bar: state.bar }) ) 16 | * export class Foo { render({ foo, bar }) { } } 17 | */ 18 | export function connect(mapStateToProps, actions) { 19 | if (typeof mapStateToProps!='function') { 20 | mapStateToProps = select(mapStateToProps || {}); 21 | } 22 | return Child => { 23 | function Wrapper(props, context) { 24 | const store = context.store; 25 | let state = mapStateToProps(store ? store.getState() : {}, props); 26 | const boundActions = actions ? mapActions(actions, store) : { store }; 27 | let update = () => { 28 | let mapped = mapStateToProps(store ? store.getState() : {}, props); 29 | for (let i in mapped) if (mapped[i]!==state[i]) { 30 | state = mapped; 31 | return this.setState({}); 32 | } 33 | for (let i in state) if (!(i in mapped)) { 34 | state = mapped; 35 | return this.setState({}); 36 | } 37 | }; 38 | this.componentWillReceiveProps = p => { 39 | props = p; 40 | update(); 41 | }; 42 | this.componentDidMount = () => { 43 | store.subscribe(update); 44 | }; 45 | this.componentWillUnmount = () => { 46 | store.unsubscribe(update); 47 | }; 48 | this.render = props => h(Child, assign(assign(assign({}, boundActions), props), state)); 49 | } 50 | return (Wrapper.prototype = new Component()).constructor = Wrapper; 51 | }; 52 | } 53 | 54 | 55 | /** 56 | * Provider exposes a store (passed as `props.store`) into context. 57 | * 58 | * Generally, an entire application is wrapped in a single `` at the root. 59 | * @class 60 | * @extends Component 61 | * @param {Object} props 62 | * @param {Store} props.store A {Store} instance to expose via context. 63 | */ 64 | export function Provider(props) { 65 | this.getChildContext = () => ({ store: props.store }); 66 | } 67 | Provider.prototype.render = props => props.children && props.children[0] || props.children; 68 | -------------------------------------------------------------------------------- /src/integrations/react.js: -------------------------------------------------------------------------------- 1 | import { createElement, Children, Component } from 'react'; 2 | import { assign, mapActions, select } from '../util'; 3 | 4 | const CONTEXT_TYPES = { 5 | store: () => {} 6 | }; 7 | 8 | /** Wire a component up to the store. Passes state as props, re-renders on change. 9 | * @param {Function|Array|String} mapStateToProps A function mapping of store state to prop values, or an array/CSV of properties to map. 10 | * @param {Function|Object} [actions] Action functions (pure state mappings), or a factory returning them. Every action function gets current state as the first parameter and any other params next 11 | * @returns {Component} ConnectedComponent 12 | * @example 13 | * const Foo = connect('foo,bar')( ({ foo, bar }) =>
) 14 | * @example 15 | * const actions = { someAction } 16 | * const Foo = connect('foo,bar', actions)( ({ foo, bar, someAction }) =>
) 17 | * @example 18 | * @connect( state => ({ foo: state.foo, bar: state.bar }) ) 19 | * export class Foo { render({ foo, bar }) { } } 20 | */ 21 | export function connect(mapStateToProps, actions) { 22 | if (typeof mapStateToProps!=='function') { 23 | mapStateToProps = select(mapStateToProps || []); 24 | } 25 | return Child => { 26 | function Wrapper(props, context) { 27 | Component.call(this, props, context); 28 | const store = context.store; 29 | let state = mapStateToProps(store ? store.getState() : {}, props); 30 | const boundActions = actions ? mapActions(actions, store) : { store }; 31 | let update = () => { 32 | let mapped = mapStateToProps(store ? store.getState() : {}, props); 33 | for (let i in mapped) if (mapped[i]!==state[i]) { 34 | state = mapped; 35 | return this.forceUpdate(); 36 | } 37 | for (let i in state) if (!(i in mapped)) { 38 | state = mapped; 39 | return this.forceUpdate(); 40 | } 41 | }; 42 | this.UNSAFE_componentWillReceiveProps = p => { 43 | props = p; 44 | update(); 45 | }; 46 | this.componentDidMount = () => { 47 | store.subscribe(update); 48 | }; 49 | this.componentWillUnmount = () => { 50 | store.unsubscribe(update); 51 | }; 52 | this.render = () => createElement(Child, assign(assign(assign({}, boundActions), this.props), state)); 53 | } 54 | Wrapper.contextTypes = CONTEXT_TYPES; 55 | return (Wrapper.prototype = Object.create(Component.prototype)).constructor = Wrapper; 56 | }; 57 | } 58 | 59 | 60 | /** Provider exposes a store (passed as `props.store`) into context. 61 | * 62 | * Generally, an entire application is wrapped in a single `` at the root. 63 | * @class 64 | * @extends Component 65 | * @param {Object} props 66 | * @param {Store} props.store A {Store} instance to expose via context. 67 | */ 68 | export class Provider extends Component { 69 | getChildContext() { 70 | return { store: this.props.store }; 71 | } 72 | render() { 73 | return Children.only(this.props.children); 74 | } 75 | } 76 | Provider.childContextTypes = CONTEXT_TYPES; 77 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | // Bind an object/factory of actions to the store and wrap them. 2 | export function mapActions(actions, store) { 3 | if (typeof actions==='function') actions = actions(store); 4 | let mapped = {}; 5 | for (let i in actions) { 6 | mapped[i] = store.action(actions[i]); 7 | } 8 | return mapped; 9 | } 10 | 11 | 12 | // select('foo,bar') creates a function of the form: ({ foo, bar }) => ({ foo, bar }) 13 | export function select(properties) { 14 | if (typeof properties==='string') properties = properties.split(/\s*,\s*/); 15 | return state => { 16 | let selected = {}; 17 | for (let i=0; i2;)P.push(arguments[i]);t&&null!=t.children&&(P.length||P.push(t.children),delete t.children);while(P.length)if((o=P.pop())&&void 0!==o.pop)for(i=o.length;i--;)P.push(o[i]);else"boolean"==typeof o&&(o=null),(r="function"!=typeof e)&&(null==o?o="":"number"==typeof o?o+="":"string"!=typeof o&&(r=!1)),r&&n?l[l.length-1]+=o:l===W?l=[o]:l.push(o),n=r;var a=new T;return a.nodeName=e,a.children=l,a.attributes=null==t?void 0:t,a.key=null==t?void 0:t.key,void 0!==M.vnode&&M.vnode(a),a}function t(e,t){for(var n in t)e[n]=t[n];return e}function n(e,t){null!=e&&("function"==typeof e?e(t):e.current=t)}function o(n,o){return e(n.nodeName,t(t({},n.attributes),o),arguments.length>2?[].slice.call(arguments,2):n.children)}function r(e){!e.__d&&(e.__d=!0)&&1==V.push(e)&&(M.debounceRendering||D)(i)}function i(){var e;while(e=V.pop())e.__d&&x(e)}function l(e,t,n){return"string"==typeof t||"number"==typeof t?void 0!==e.splitText:"string"==typeof t.nodeName?!e._componentConstructor&&a(e,t.nodeName):n||e._componentConstructor===t.nodeName}function a(e,t){return e.__n===t||e.nodeName.toLowerCase()===t.toLowerCase()}function u(e){var n=t({},e.attributes);n.children=e.children;var o=e.nodeName.defaultProps;if(void 0!==o)for(var r in o)void 0===n[r]&&(n[r]=o[r]);return n}function c(e,t){var n=t?document.createElementNS("http://www.w3.org/2000/svg",e):document.createElement(e);return n.__n=e,n}function p(e){var t=e.parentNode;t&&t.removeChild(e)}function s(e,t,o,r,i){if("className"===t&&(t="class"),"key"===t);else if("ref"===t)n(o,null),n(r,e);else if("class"!==t||i)if("style"===t){if(r&&"string"!=typeof r&&"string"!=typeof o||(e.style.cssText=r||""),r&&"object"==typeof r){if("string"!=typeof o)for(var l in o)l in r||(e.style[l]="");for(var l in r)e.style[l]="number"==typeof r[l]&&!1===E.test(l)?r[l]+"px":r[l]}}else if("dangerouslySetInnerHTML"===t)r&&(e.innerHTML=r.__html||"");else if("o"==t[0]&&"n"==t[1]){var a=t!==(t=t.replace(/Capture$/,""));t=t.toLowerCase().substring(2),r?o||e.addEventListener(t,_,a):e.removeEventListener(t,_,a),(e.__l||(e.__l={}))[t]=r}else if("list"!==t&&"type"!==t&&!i&&t in e){try{e[t]=null==r?"":r}catch(e){}null!=r&&!1!==r||"spellcheck"==t||e.removeAttribute(t)}else{var u=i&&t!==(t=t.replace(/^xlink:?/,""));null==r||!1===r?u?e.removeAttributeNS("http://www.w3.org/1999/xlink",t.toLowerCase()):e.removeAttribute(t):"function"!=typeof r&&(u?e.setAttributeNS("http://www.w3.org/1999/xlink",t.toLowerCase(),r):e.setAttribute(t,r))}else e.className=r||""}function _(e){return this.__l[e.type](M.event&&M.event(e)||e)}function f(){var e;while(e=A.shift())M.afterMount&&M.afterMount(e),e.componentDidMount&&e.componentDidMount()}function d(e,t,n,o,r,i){H++||(R=null!=r&&void 0!==r.ownerSVGElement,B=null!=e&&!("__preactattr_"in e));var l=h(e,t,n,o,i);return r&&l.parentNode!==r&&r.appendChild(l),--H||(B=!1,i||f()),l}function h(e,t,n,o,r){var i=e,l=R;if(null!=t&&"boolean"!=typeof t||(t=""),"string"==typeof t||"number"==typeof t)return e&&void 0!==e.splitText&&e.parentNode&&(!e._component||r)?e.nodeValue!=t&&(e.nodeValue=t):(i=document.createTextNode(t),e&&(e.parentNode&&e.parentNode.replaceChild(i,e),v(e,!0))),i.__preactattr_=!0,i;var u=t.nodeName;if("function"==typeof u)return N(e,t,n,o);if(R="svg"===u||"foreignObject"!==u&&R,u+="",(!e||!a(e,u))&&(i=c(u,R),e)){while(e.firstChild)i.appendChild(e.firstChild);e.parentNode&&e.parentNode.replaceChild(i,e),v(e,!0)}var p=i.firstChild,s=i.__preactattr_,_=t.children;if(null==s){s=i.__preactattr_={};for(var f=i.attributes,d=f.length;d--;)s[f[d].name]=f[d].value}return!B&&_&&1===_.length&&"string"==typeof _[0]&&null!=p&&void 0!==p.splitText&&null==p.nextSibling?p.nodeValue!=_[0]&&(p.nodeValue=_[0]):(_&&_.length||null!=p)&&m(i,_,n,o,B||null!=s.dangerouslySetInnerHTML),y(i,t.attributes,s),R=l,i}function m(e,t,n,o,r){var i,a,u,c,s,_=e.childNodes,f=[],d={},m=0,b=0,y=_.length,g=0,w=t?t.length:0;if(0!==y)for(var C=0;C { 6 | describe('unistore', () => { 7 | it('should export only a single function as default', () => { 8 | expect(createStore).toBeInstanceOf(Function); 9 | }); 10 | }); 11 | 12 | describe('unistore/preact', () => { 13 | it('should export connect', () => { 14 | expect(preact).toHaveProperty('connect', expect.any(Function)); 15 | }); 16 | it('should export Provider', () => { 17 | expect(preact).toHaveProperty('Provider', expect.any(Function)); 18 | }); 19 | it('should no export anything else', () => { 20 | expect(preact).toEqual({ 21 | connect: preact.connect, 22 | Provider: preact.Provider 23 | }); 24 | }); 25 | }); 26 | 27 | describe('smoke test (preact)', () => { 28 | it('should render', done => { 29 | const { Provider, connect } = preact; 30 | const actions = ({ getState, setState }) => ({ 31 | incrementTwice(state) { 32 | setState({ count: state.count + 1 }); 33 | return new Promise(r => 34 | setTimeout(() => { 35 | r({ count: getState().count + 1 }); 36 | }, 20) 37 | ); 38 | } 39 | }); 40 | const App = connect('count', actions)(({ count, incrementTwice }) => ( 41 | 42 | )); 43 | const store = createStore({ count: 0 }); 44 | let root = document.createElement('div'); 45 | render( 46 | 47 | 48 | , 49 | root 50 | ); 51 | expect(store.getState()).toEqual({ count: 0 }); 52 | root.firstElementChild.click(); 53 | expect(store.getState()).toEqual({ count: 1 }); 54 | setTimeout(() => { 55 | expect(store.getState()).toEqual({ count: 2 }); 56 | expect(root.textContent).toBe('count: 2'); 57 | done(); 58 | }, 30); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /test/preact/preact-8.test.js: -------------------------------------------------------------------------------- 1 | // This runs the Preact tests against a copy of Preact 8.4.2. 2 | jest.isolateModules(() => { 3 | const preact = require('../fixtures/preact-8.min.js'); 4 | jest.setMock('preact', preact); 5 | 6 | // Patch Preact 8's render to work like Preact 10: 7 | let render = preact.render; 8 | preact.render = (vnode, parent) => parent._root = render(vnode, parent, parent._root); 9 | 10 | global.IS_PREACT_8 = true; 11 | require('./preact.test'); 12 | delete global.IS_PREACT_8; 13 | }); 14 | -------------------------------------------------------------------------------- /test/preact/preact.test.js: -------------------------------------------------------------------------------- 1 | import { h, render } from 'preact'; 2 | import createStore from '../../src'; 3 | import { Provider, connect } from '../../src/integrations/preact'; 4 | 5 | const sleep = ms => new Promise( r => setTimeout(r, ms) ); 6 | 7 | const NO_CHILDREN = global.IS_PREACT_8 ? expect.anything() : undefined; 8 | 9 | describe(`integrations/preact${global.IS_PREACT_8 ? '-8' : ''}`, () => { 10 | describe('', () => { 11 | afterEach(() => { 12 | render(null, document.body); 13 | }); 14 | 15 | it('should provide props into context', () => { 16 | const Child = jest.fn(); 17 | 18 | render( 19 | 20 | 21 | , 22 | document.body 23 | ); 24 | expect(Child).toHaveBeenCalledWith(expect.anything(), { store: 'a' }); 25 | 26 | render(null, document.body); 27 | 28 | let store = { name: 'obj' }; 29 | render( 30 | 31 | 32 | , 33 | document.body 34 | ); 35 | expect(Child).toHaveBeenCalledWith(expect.anything(), { store }); 36 | }); 37 | }); 38 | 39 | describe('connect()', () => { 40 | afterEach(() => { 41 | render(null, document.body); 42 | }); 43 | 44 | it('should pass mapped state as props', () => { 45 | let state = { a: 'b' }; 46 | const store = { subscribe: jest.fn(), unsubscribe: jest.fn(), getState: () => state }; 47 | const Child = jest.fn(); 48 | const ConnectedChild = connect(Object)(Child); 49 | render( 50 | 51 | 52 | , 53 | document.body 54 | ); 55 | expect(Child).toHaveBeenCalledWith( 56 | { a: 'b', store, children: NO_CHILDREN }, 57 | expect.anything() 58 | ); 59 | expect(store.subscribe).toBeCalled(); 60 | }); 61 | 62 | it('should transform string selector', () => { 63 | let state = { a: 'b', b: 'c', c: 'd' }; 64 | const store = { subscribe: jest.fn(), unsubscribe: jest.fn(), getState: () => state }; 65 | const Child = jest.fn(); 66 | const ConnectedChild = connect('a, b')(Child); 67 | render( 68 | 69 | 70 | , 71 | document.body 72 | ); 73 | expect(Child).toHaveBeenCalledWith( 74 | { a: 'b', b: 'c', store, children: NO_CHILDREN }, 75 | expect.anything() 76 | ); 77 | expect(store.subscribe).toBeCalled(); 78 | }); 79 | 80 | it('should subscribe to store on mount', async () => { 81 | const store = { subscribe: jest.fn(), unsubscribe: jest.fn(), getState: () => ({}) }; 82 | jest.spyOn(store, 'subscribe'); 83 | const ConnectedChild = connect(Object)(() => null); 84 | 85 | render( 86 | 87 | 88 | , 89 | document.body 90 | ); 91 | 92 | expect(store.subscribe).toBeCalledWith(expect.any(Function)); 93 | }); 94 | 95 | it('should unsubscribe from store when unmounted', async () => { 96 | const store = createStore(); 97 | jest.spyOn(store, 'unsubscribe'); 98 | const ConnectedChild = connect(Object)(() => null); 99 | render( 100 | 101 | 102 | , 103 | document.body 104 | ); 105 | await sleep(1); 106 | render(null, document.body); 107 | expect(store.unsubscribe).toBeCalled(); 108 | }); 109 | 110 | it('should subscribe to store', async () => { 111 | const store = createStore(); 112 | const Child = jest.fn(); 113 | jest.spyOn(store, 'subscribe'); 114 | jest.spyOn(store, 'unsubscribe'); 115 | const ConnectedChild = connect(Object)(Child); 116 | 117 | render( 118 | 119 | 120 | , 121 | document.body 122 | ); 123 | 124 | expect(store.subscribe).toBeCalledWith(expect.any(Function)); 125 | expect(Child).toHaveBeenCalledWith( 126 | { store, children: NO_CHILDREN }, 127 | expect.anything() 128 | ); 129 | 130 | Child.mockClear(); 131 | 132 | store.setState({ a: 'b' }); 133 | await sleep(1); 134 | expect(Child).toHaveBeenCalledWith( 135 | { a: 'b', store, children: NO_CHILDREN }, 136 | expect.anything() 137 | ); 138 | 139 | render(null, document.body); 140 | expect(store.unsubscribe).toBeCalled(); 141 | 142 | Child.mockClear(); 143 | 144 | store.setState({ c: 'd' }); 145 | await sleep(1); 146 | expect(Child).not.toHaveBeenCalled(); 147 | }); 148 | 149 | it('should run mapStateToProps and update when outer props change', async () => { 150 | let state = {}; 151 | const store = { subscribe: jest.fn(), unsubscribe: () => {}, getState: () => state }; 152 | const Child = jest.fn().mockName('').mockReturnValue(42); 153 | let mappings = 0; 154 | 155 | // Jest mock return values are broken :( 156 | const mapStateToProps = jest.fn((state, props) => ({ 157 | mappings: ++mappings, 158 | ...props 159 | })); 160 | 161 | const ConnectedChild = connect(mapStateToProps)(Child); 162 | render( 163 | 164 | 165 | , 166 | document.body 167 | ); 168 | 169 | expect(mapStateToProps).toHaveBeenCalledTimes(1); 170 | expect(mapStateToProps).toHaveBeenCalledWith({}, { children: NO_CHILDREN }); 171 | // first render calls mapStateToProps 172 | expect(Child).toHaveBeenCalledWith( 173 | { mappings: 1, store, children: NO_CHILDREN }, 174 | expect.anything() 175 | ); 176 | 177 | mapStateToProps.mockClear(); 178 | Child.mockClear(); 179 | 180 | render( 181 | 182 | 183 | , 184 | document.body 185 | ); 186 | 187 | expect(mapStateToProps).toHaveBeenCalledTimes(1); 188 | expect(mapStateToProps).toHaveBeenCalledWith({ }, { a: 'b', children: NO_CHILDREN }); 189 | // outer props were changed 190 | expect(Child).toHaveBeenCalledWith( 191 | { mappings: 2, a: 'b', store, children: NO_CHILDREN }, 192 | expect.anything() 193 | ); 194 | 195 | mapStateToProps.mockClear(); 196 | Child.mockClear(); 197 | 198 | render( 199 | 200 | 201 | , 202 | document.body 203 | ); 204 | 205 | expect(mapStateToProps).toHaveBeenCalledTimes(1); 206 | expect(mapStateToProps).toHaveBeenCalledWith({ }, { a: 'b', children: NO_CHILDREN }); 207 | 208 | // re-rendered, but outer props were not changed 209 | expect(Child).toHaveBeenCalledWith( 210 | { mappings: 3, a: 'b', store, children: NO_CHILDREN }, 211 | expect.anything() 212 | ); 213 | }); 214 | }); 215 | }); 216 | -------------------------------------------------------------------------------- /test/react/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", 5 | { 6 | "targets": { 7 | "node": true 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": [["transform-react-jsx"]] 13 | } 14 | -------------------------------------------------------------------------------- /test/react/builds.test.js: -------------------------------------------------------------------------------- 1 | import 'raf/polyfill'; 2 | import React from 'react'; 3 | 4 | import { mount, configure } from 'enzyme'; 5 | import Adapter from 'enzyme-adapter-react-16'; 6 | configure({ adapter: new Adapter() }); 7 | 8 | import createStore from '../..'; 9 | import react from '../../react'; 10 | 11 | describe('build: default', () => { 12 | describe('unistore', () => { 13 | it('should export only a single function as default', () => { 14 | expect(createStore).toBeInstanceOf(Function); 15 | }); 16 | }); 17 | 18 | describe('unistore/react', () => { 19 | it('should export connect', () => { 20 | expect(react).toHaveProperty('connect', expect.any(Function)); 21 | }); 22 | it('should export Provider', () => { 23 | expect(react).toHaveProperty('Provider', expect.any(Function)); 24 | }); 25 | it('should no export anything else', () => { 26 | expect(react).toEqual({ 27 | connect: react.connect, 28 | Provider: react.Provider 29 | }); 30 | }); 31 | }); 32 | 33 | describe('smoke test (react)', () => { 34 | it('should render', done => { 35 | const { Provider, connect } = react; 36 | const actions = ({ getState, setState }) => ({ 37 | incrementTwice(state) { 38 | setState({ count: state.count + 1 }); 39 | return new Promise(r => 40 | setTimeout(() => { 41 | r({ count: getState().count + 1 }); 42 | }, 20) 43 | ); 44 | } 45 | }); 46 | const App = connect('count', actions)(({ count, incrementTwice }) => ( 47 | 50 | )); 51 | const store = createStore({ count: 0 }); 52 | const provider = ( 53 | 54 | 55 | 56 | ); 57 | const mountedProvider = mount(provider); 58 | expect(store.getState()).toEqual({ count: 0 }); 59 | const button = mountedProvider.find('#some_button').simulate('click'); 60 | expect(store.getState()).toEqual({ count: 1 }); 61 | setTimeout(() => { 62 | expect(store.getState()).toEqual({ count: 2 }); 63 | expect(button.text()).toBe('count: 2'); 64 | done(); 65 | }, 30); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /test/react/react.test.js: -------------------------------------------------------------------------------- 1 | import 'raf/polyfill'; 2 | import React, { Component } from 'react'; 3 | import TestUtils from 'react-dom/test-utils'; 4 | import PropTypes from 'prop-types'; 5 | 6 | import createStore from '../../src'; 7 | import { Provider, connect } from '../../src/integrations/react'; 8 | 9 | import { mount, configure } from 'enzyme'; 10 | import Adapter from 'enzyme-adapter-react-16'; 11 | configure({ adapter: new Adapter() }); 12 | 13 | const sleep = ms => new Promise(r => setTimeout(r, ms)); 14 | 15 | describe('integrations/react', () => { 16 | const createChild = (storeKey = 'store') => { 17 | class Child extends Component { 18 | render() { 19 | return
; 20 | } 21 | } 22 | 23 | Child.contextTypes = { 24 | [storeKey]: PropTypes.object.isRequired 25 | }; 26 | 27 | return Child; 28 | }; 29 | const Child = createChild(); 30 | 31 | it('should provide props into context', () => { 32 | const store = createStore(() => ({})); 33 | 34 | const spy = jest.spyOn(console, 'error'); 35 | const tree = TestUtils.renderIntoDocument( 36 | 37 | 38 | 39 | ); 40 | expect(spy).not.toHaveBeenCalled(); 41 | 42 | const child = TestUtils.findRenderedComponentWithType(tree, Child); 43 | expect(child.context.store).toBe(store); 44 | }); 45 | 46 | it('should pass mapped state as props', () => { 47 | let state = { a: 'b' }; 48 | const store = { subscribe: jest.fn(), getState: () => state }; 49 | const ConnectedChild = connect(Object)(Child); 50 | 51 | const mountedProvider = mount( 52 | 53 | 54 | 55 | ); 56 | 57 | const child = mountedProvider.find(Child).first(); 58 | expect(child.props()).toEqual({ 59 | a: 'b', 60 | store 61 | }); 62 | expect(store.subscribe).toBeCalled(); 63 | }); 64 | 65 | it('should transform string selector', () => { 66 | let state = { a: 'b', b: 'c', c: 'd' }; 67 | const store = { subscribe: jest.fn(), getState: () => state }; 68 | const ConnectedChild = connect('a, b')(Child); 69 | const mountedProvider = mount( 70 | 71 | 72 | 73 | ); 74 | 75 | const child = mountedProvider.find(Child).first(); 76 | expect(child.props()).toEqual({ 77 | a: 'b', 78 | b: 'c', 79 | store 80 | }); 81 | expect(store.subscribe).toBeCalled(); 82 | }); 83 | 84 | it('should subscribe to store', async () => { 85 | const store = createStore(); 86 | jest.spyOn(store, 'subscribe'); 87 | jest.spyOn(store, 'unsubscribe'); 88 | 89 | const ConnectedChild = connect(Object)(Child); 90 | 91 | expect(store.subscribe).not.toHaveBeenCalled(); 92 | const mountedProvider = mount( 93 | 94 | 95 | 96 | ); 97 | 98 | expect(store.subscribe).toBeCalledWith(expect.any(Function)); 99 | 100 | let child = mountedProvider 101 | .find('Child') 102 | .first() 103 | .instance(); 104 | expect(child.props).toEqual({ store }); 105 | 106 | store.setState({ a: 'b' }); 107 | await sleep(1); 108 | 109 | child = mountedProvider 110 | .find('Child') 111 | .first() 112 | .instance(); 113 | expect(child.props).toEqual({ a: 'b', store }); 114 | 115 | expect(store.unsubscribe).not.toHaveBeenCalled(); 116 | mountedProvider.unmount(); 117 | expect(store.unsubscribe).toBeCalled(); 118 | }); 119 | 120 | it('should run mapStateToProps and update when outer props change', async () => { 121 | let state = {}; 122 | const store = { subscribe: jest.fn(), getState: () => state }; 123 | const Child = jest.fn(() => null).mockName(''); 124 | let mappings = 0; 125 | 126 | // Jest mock return values are broken :( 127 | const mapStateToProps = jest.fn((state, props) => ({ mappings: ++mappings, ...props })); 128 | 129 | let root; 130 | class Outer extends Component { 131 | constructor() { 132 | super(); 133 | this.state = {}; 134 | root = this; 135 | root.setProps = props => this.setState({ props }); 136 | } 137 | render() { 138 | root = this; 139 | return ( 140 | 141 | 142 | 143 | ); 144 | } 145 | } 146 | 147 | const ConnectedChild = connect(mapStateToProps)(Child); 148 | const mountedProvider = mount(); 149 | 150 | expect(mapStateToProps).toHaveBeenCalledTimes(1); 151 | expect(mapStateToProps).toHaveBeenCalledWith({}, { }); 152 | // first render calls mapStateToProps 153 | expect(Child).toHaveBeenCalledWith( 154 | { mappings: 1, store }, 155 | expect.anything() 156 | ); 157 | 158 | mapStateToProps.mockClear(); 159 | Child.mockClear(); 160 | 161 | // root.setState({ a: 'b' }); 162 | mountedProvider.setProps({ a: 'b' }); 163 | 164 | // await sleep(100); 165 | 166 | expect(mapStateToProps).toHaveBeenCalledTimes(1); 167 | expect(mapStateToProps).toHaveBeenCalledWith({}, { a: 'b' }); 168 | // outer props were changed 169 | expect(Child).toHaveBeenCalledWith( 170 | { mappings: 2, a: 'b', store }, 171 | expect.anything() 172 | ); 173 | 174 | mapStateToProps.mockClear(); 175 | Child.mockClear(); 176 | 177 | mountedProvider.setProps({ }); 178 | 179 | await sleep(1); 180 | 181 | // re-rendered, but outer props were not changed 182 | expect(Child).toHaveBeenCalledWith( 183 | { mappings: 3, a: 'b', store }, 184 | expect.anything() 185 | ); 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /test/unistore.test.js: -------------------------------------------------------------------------------- 1 | import createStore from '../src'; 2 | 3 | describe('createStore()', () => { 4 | it('should be instantiable', () => { 5 | let store = createStore(); 6 | expect(store).toMatchObject({ 7 | setState: expect.any(Function), 8 | getState: expect.any(Function), 9 | subscribe: expect.any(Function), 10 | unsubscribe: expect.any(Function) 11 | }); 12 | }); 13 | 14 | it('should update state in-place', () => { 15 | let store = createStore(); 16 | expect(store.getState()).toMatchObject({}); 17 | store.setState({ a: 'b' }); 18 | expect(store.getState()).toMatchObject({ a: 'b' }); 19 | store.setState({ c: 'd' }); 20 | expect(store.getState()).toMatchObject({ a: 'b', c: 'd' }); 21 | store.setState({ a: 'x' }); 22 | expect(store.getState()).toMatchObject({ a: 'x', c: 'd' }); 23 | store.setState({ c: null }); 24 | expect(store.getState()).toMatchObject({ a: 'x', c: null }); 25 | store.setState({ c: undefined }); 26 | expect(store.getState()).toMatchObject({ a: 'x', c: undefined }); 27 | }); 28 | 29 | it('should invoke subscriptions', () => { 30 | let store = createStore(); 31 | 32 | let sub1 = jest.fn(); 33 | let sub2 = jest.fn(); 34 | let action; 35 | 36 | let rval = store.subscribe(sub1); 37 | expect(rval).toBeInstanceOf(Function); 38 | 39 | store.setState({ a: 'b' }); 40 | expect(sub1).toBeCalledWith(store.getState(), action); 41 | 42 | store.subscribe(sub2); 43 | store.setState({ c: 'd' }); 44 | 45 | expect(sub1).toHaveBeenCalledTimes(2); 46 | expect(sub1).toHaveBeenLastCalledWith(store.getState(), action); 47 | expect(sub2).toBeCalledWith(store.getState(), action); 48 | }); 49 | 50 | it('should unsubscribe', () => { 51 | let store = createStore(); 52 | 53 | let sub1 = jest.fn(); 54 | let sub2 = jest.fn(); 55 | let sub3 = jest.fn(); 56 | 57 | store.subscribe(sub1); 58 | store.subscribe(sub2); 59 | let unsub3 = store.subscribe(sub3); 60 | 61 | store.setState({ a: 'b' }); 62 | expect(sub1).toBeCalled(); 63 | expect(sub2).toBeCalled(); 64 | expect(sub3).toBeCalled(); 65 | 66 | sub1.mockClear(); 67 | sub2.mockClear(); 68 | sub3.mockClear(); 69 | 70 | store.unsubscribe(sub2); 71 | 72 | store.setState({ c: 'd' }); 73 | expect(sub1).toBeCalled(); 74 | expect(sub2).not.toBeCalled(); 75 | expect(sub3).toBeCalled(); 76 | 77 | sub1.mockClear(); 78 | sub2.mockClear(); 79 | sub3.mockClear(); 80 | 81 | store.unsubscribe(sub1); 82 | 83 | store.setState({ e: 'f' }); 84 | expect(sub1).not.toBeCalled(); 85 | expect(sub2).not.toBeCalled(); 86 | expect(sub3).toBeCalled(); 87 | 88 | sub3.mockClear(); 89 | 90 | unsub3(); 91 | 92 | store.setState({ g: 'h' }); 93 | expect(sub1).not.toBeCalled(); 94 | expect(sub2).not.toBeCalled(); 95 | expect(sub3).not.toBeCalled(); 96 | }); 97 | }); 98 | --------------------------------------------------------------------------------