diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 3a2697d..0000000 --- a/.editorconfig +++ /dev/null @@ -1,17 +0,0 @@ -root = true - -[*] -indent_style = tab -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true - -[{package.json,.*rc,*.yml}] -indent_style = space -indent_size = 2 - -[*.md] -trim_trailing_whitespace = false -indent_style = space -indent_size = 2 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 96ffa51..0f72fcb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,5 @@ node_modules npm-debug.log -/dist -/full -package-lock.json -/preact.js -/preact.js.map -/react.js -/react.js.map +dist +README.md +package-lock.json \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5ae4894..0000000 --- a/.travis.yml +++ /dev/null @@ -1,3 +0,0 @@ -language: node_js -node_js: - - 10 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index ea83e0b..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,46 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -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. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment include: - -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting - -## Our Responsibilities - -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. - -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. - -## Scope - -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. - -## Enforcement - -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. - -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. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] - -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ diff --git a/README.md b/README.md index 3d82d21..10cbd88 100644 --- a/README.md +++ b/README.md @@ -1,301 +1,3 @@ -

- unistore -
- npm travis -

-# unistore -> A tiny 350b centralized state container with component bindings for [Preact] & [React]. - -- **Small** footprint complements Preact nicely _(unistore + unistore/preact is ~650b)_ -- **Familiar** names and ideas from Redux-like libraries -- **Useful** data selectors to extract properties from state -- **Portable** actions can be moved into a common place and imported -- **Functional** actions are just reducers -- **NEW**: seamlessly run Unistore in a worker via [Stockroom](https://github.com/developit/stockroom) - -## Table of Contents - -- [Install](#install) -- [Usage](#usage) -- [Examples](#examples) -- [API](#api) -- [License](#license) - -## Install - -This project uses [node](http://nodejs.org) and [npm](https://npmjs.com). Go check them out if you don't have them locally installed. - -```sh -npm install --save unistore -``` - -Then with a module bundler like [webpack](https://webpack.js.org) or [rollup](http://rollupjs.org), use as you would anything else: - -```js -// The store: -import createStore from 'unistore' - -// Preact integration -import { Provider, connect } from 'unistore/preact' - -// React integration -import { Provider, connect } from 'unistore/react' -``` - -Alternatively, you can import the "full" build for each, which includes both `createStore` and the integration for your library of choice: - -```js -import { createStore, Provider, connect } from 'unistore/full/preact' -``` - -The [UMD](https://github.com/umdjs/umd) build is also available on [unpkg](https://unpkg.com): - -```html - - - - - - -``` - -You can find the library on `window.unistore`. - -### Usage - -```js -import createStore from 'unistore' -import { Provider, connect } from 'unistore/preact' - -let store = createStore({ count: 0, stuff: [] }) - -let actions = { - // Actions can just return a state update: - increment(state) { - // The returned object will be merged into the current state - return { count: state.count+1 } - }, - - // The above example as an Arrow Function: - increment2: ({ count }) => ({ count: count+1 }), - - // Actions receive current state as first parameter and any other params next - // See the "Increment by 10"-button below - incrementBy: ({ count }, incrementAmount) => { - return { count: count+incrementAmount } - }, -} - -// If actions is a function, it gets passed the store: -let actionFunctions = store => ({ - // Async actions can be pure async/promise functions: - async getStuff(state) { - const res = await fetch('/foo.json') - return { stuff: await res.json() } - }, - - // ... or just actions that call store.setState() later: - clearOutStuff(state) { - setTimeout(() => { - store.setState({ stuff: [] }) // clear 'stuff' after 1 second - }, 1000) - } - - // Remember that the state passed to the action function could be stale after - // doing async work, so use getState() instead: - async incrementAfterStuff(state) { - const res = await fetch('foo.json') - const resJson = await res.json() - // the variable 'state' above could now be old, - // better get a new one from the store - const upToDateState = store.getState() - - return { - stuff: resJson, - count: upToDateState.count + resJson.length, - } - } -}) - -// Connecting a react/preact component to get current state and to bind actions -const App1 = connect('count', actions)( - ({ count, increment, incrementBy }) => ( -
-

Count: {count}

- - -
- ) -) - -// First argument to connect can also be a string, array or function while -// second argument can be an object or a function. Here we pass an array and -// a function. -const App2 = connect(['count', 'stuff'], actionFunctions)( - ({ count, stuff, getStuff, clearOutStuff, incrementAfterStuff }) => ( -
-

Count: {count}

-

Stuff: -

-

- - - -
- ) -) - -export const getApp1 = () => ( - - - -) - -export const getApp2 = () => ( - - - -) -``` - -### Debug - -Make sure to have [Redux devtools extension](https://github.com/zalmoxisus/redux-devtools-extension) previously installed. - -```js -import createStore from 'unistore' -import devtools from 'unistore/devtools' - -let initialState = { count: 0 }; -let store = process.env.NODE_ENV === 'production' ? createStore(initialState) : devtools(createStore(initialState)); - -// ... -``` - -### Examples - -[README Example on CodeSandbox](https://codesandbox.io/s/l7y7w5qkz9) - -### API - - - -#### createStore - -Creates a new store, which is a tiny evented state container. - -**Parameters** - -- `state` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** Optional initial state (optional, default `{}`) - -**Examples** - -```javascript -let store = createStore(); -store.subscribe( state => console.log(state) ); -store.setState({ a: 'b' }); // logs { a: 'b' } -store.setState({ c: 'd' }); // logs { a: 'b', c: 'd' } -``` - -Returns **[store](#store)** - -#### store - -An observable state container, returned from [createStore](#createstore) - -##### action - -Create a bound copy of the given action function. -The bound returned function invokes action() and persists the result back to the store. -If the return value of `action` is a Promise, the resolved value will be used as state. - -**Parameters** - -- `action` **[Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** An action of the form `action(state, ...args) -> stateUpdate` - -Returns **[Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** boundAction() - -##### setState - -Apply a partial state object to the current state, invoking registered listeners. - -**Parameters** - -- `update` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** An object with properties to be merged into state -- `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`) - -##### subscribe - -Register a listener function to be called whenever state is changed. Returns an `unsubscribe()` function. - -**Parameters** - -- `listener` **[Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** A function to call when state changes. Gets passed the new state. - -Returns **[Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** unsubscribe() - -##### unsubscribe - -Remove a previously-registered listener function. - -**Parameters** - -- `listener` **[Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** The callback previously passed to `subscribe()` that should be removed. - -##### getState - -Retrieve the current state object. - -Returns **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** state - -#### connect - -Wire a component up to the store. Passes state as props, re-renders on change. - -**Parameters** - -- `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. -- `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 - -**Examples** - -```javascript -const Foo = connect('foo,bar')( ({ foo, bar }) =>
) -``` - -```javascript -const actions = { someAction } -const Foo = connect('foo,bar', actions)( ({ foo, bar, someAction }) =>
) -``` - -Returns **Component** ConnectedComponent - -#### Provider - -**Extends Component** - -Provider exposes a store (passed as `props.store`) into context. - -Generally, an entire application is wrapped in a single `` at the root. - -**Parameters** - -- `props` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** - - `props.store` **Store** A {Store} instance to expose via context. - -### Reporting Issues - -Found a problem? Want a new feature? First of all, see if your issue or idea has [already been reported](../../issues). -If not, just open a [new clear and descriptive issue](../../issues/new). - -### License - -[MIT License](https://oss.ninja/mit/developit) © [Jason Miller](https://jasonformat.com) - -[preact]: https://github.com/developit/preact - -[react]: https://github.com/facebook/react +[![Greenkeeper badge](https://badges.greenkeeper.io/developit/unistore.svg)](https://greenkeeper.io/) \ No newline at end of file diff --git a/devtools.d.ts b/devtools.d.ts deleted file mode 100644 index 41678cf..0000000 --- a/devtools.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Store } from "unistore"; - -export default function unistoreDevTools(store: Store): Store; diff --git a/devtools.js b/devtools.js deleted file mode 100644 index 6c6b2f1..0000000 --- a/devtools.js +++ /dev/null @@ -1,33 +0,0 @@ -module.exports = function unistoreDevTools(store) { - var extension = window.__REDUX_DEVTOOLS_EXTENSION__ || window.top.__REDUX_DEVTOOLS_EXTENSION__; - var ignoreState = false; - - if (!extension) { - console.warn('Please install/enable Redux devtools extension'); - store.devtools = null; - - return store; - } - - if (!store.devtools) { - store.devtools = extension.connect(); - store.devtools.subscribe(function (message) { - if (message.type === 'DISPATCH' && message.state) { - ignoreState = (message.payload.type === 'JUMP_TO_ACTION' || message.payload.type === 'JUMP_TO_STATE'); - store.setState(JSON.parse(message.state), true); - } - }); - store.devtools.init(store.getState()); - store.subscribe(function (state, action) { - var actionName = action && action.name || 'setState'; - - if (!ignoreState) { - store.devtools.send(actionName, state); - } else { - ignoreState = false; - } - }); - } - - return store; -} diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index 98f9353..0000000 --- a/index.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -// T - Wrapped component props -// S - Wrapped component state -// K - Store state -// I - Injected props to wrapped component - -export type Listener = (state: K, action?: Action) => void; -export type Unsubscribe = () => void; -export type Action = (state: K, ...args: any[]) => void; -export type BoundAction = (...args: any[]) => void; - -export interface Store { - action(action: Action): BoundAction; - setState(update: Pick, overwrite?: boolean, action?: Action): void; - subscribe(f: Listener): Unsubscribe; - unsubscribe(f: Listener): void; - getState(): K; -} - -export default function createStore(state?: K): Store; - -export type ActionFn = (state: K, ...args: any[]) => Promise> | Partial | void; - -export interface ActionMap { - [actionName: string]: ActionFn; -} - -export type ActionCreator = (store: Store) => ActionMap; - -export type StateMapper = (state: K, props: T) => I; diff --git a/package.json b/package.json index 67ec7ca..42e280e 100644 --- a/package.json +++ b/package.json @@ -1,54 +1,31 @@ { "name": "unistore", - "version": "3.5.2", - "description": "Dead simple centralized state container (store) with preact and react bindings.", - "source": "src/index.js", + "version": "2.0.0", + "description": "Dead simple centralized state container (store) with preact bindings.", + "source": "unistore.js", "module": "dist/unistore.es.js", "main": "dist/unistore.js", "umd:main": "dist/unistore.umd.js", - "typings": "index.d.ts", "scripts": { - "build": "npm-run-all --silent -p build:main build:integrations build:combined -s size docs", - "build:main": "microbundle", - "build:integrations": "microbundle src/integrations/*.js -o x.js -f cjs --external react,preact", - "build:combined": "microbundle src/combined/*.js -o full/x.js --external react,preact", - "size": "strip-json-comments --no-whitespace dist/unistore.js | gzip-size && bundlesize", - "docs": "documentation readme src/index.js src/integrations/preact.js -q --section API && npm run -s fixreadme", - "fixreadme": "node -e 'var fs=require(\"fs\");fs.writeFileSync(\"README.md\", fs.readFileSync(\"README.md\", \"utf8\").replace(/^- /gm, \"- \"))'", - "test": "eslint src && npm run build && jest", - "prepare": "npm t", - "release": "npm t && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish" + "build": "npm run transpile && npm run size", + "transpile": "rollup -c --environment FORMAT:umd && rollup -c --environment FORMAT:cjs && rollup -c --environment FORMAT:es", + "size": "strip-json-comments --no-whitespace dist/unistore.js | gzip-size", + "test": "eslint unistore.js && jest", + "prepublish": "npm run build && cp \"*unistore.md\" README.md" }, "eslintConfig": { - "extends": "eslint-config-developit", + "extends": "eslint-config-synacor", + "globals": { + "jest": 1 + }, "rules": { + "guard-for-in": 0, "prefer-rest-params": 0 } }, - "bundlesize": [ - { - "path": "full/preact.js", - "maxSize": "760b" - }, - { - "path": "dist/unistore.js", - "maxSize": "400b" - }, - { - "path": "preact.js", - "maxSize": "600b" - } - ], "babel": { "presets": [ - [ - "env", - { - "targets": { - "node": "current" - } - } - ] + "es2015" ], "plugins": [ [ @@ -59,22 +36,9 @@ ] ] }, - "jest": { - "testURL": "http://localhost" - }, "files": [ - "src", - "dist", - "full", - "preact.js", - "preact.js.map", - "react.js", - "react.js.map", - "index.d.ts", - "preact.d.ts", - "react.d.ts", - "devtools.js", - "devtools.d.ts" + "unistore.js", + "dist" ], "keywords": [ "preact", @@ -86,31 +50,22 @@ "author": "Jason Miller ", "license": "MIT", "devDependencies": { - "babel-jest": "^24.3.1", + "babel-jest": "^21.2.0", "babel-plugin-transform-react-jsx": "^6.24.1", - "babel-preset-env": "^1.6.1", - "bundlesize": "^0.17.1", - "documentation": "^4.0.0", - "enzyme": "^3.9.0", - "enzyme-adapter-react-16": "^1.10.0", - "eslint": "^4.16.0", - "eslint-config-developit": "^1.1.1", - "gzip-size-cli": "^2.1.0", - "jest": "^24.3.1", - "microbundle": "^0.11.0", - "npm-run-all": "^4.1.2", - "preact": "^10.0.0", - "raf": "^3.4.0", - "react": "^16.8.4", - "react-dom": "^16.8.4", - "strip-json-comments-cli": "^1.0.1" + "babel-preset-es2015": "^6.24.1", + "eslint": "^4.6.1", + "eslint-config-synacor": "^2.0.2", + "gzip-size": "^4.1.0", + "jest": "^21.2.1", + "preact": "^8.2.6", + "rollup": "^0.52.1", + "rollup-plugin-buble": "^0.18.0", + "rollup-plugin-post-replace": "^1.0.0", + "rollup-plugin-uglify": "^2.0.1", + "strip-json-comments-cli": "^1.0.1", + "uglify-js": "^3.2.1" }, - "peerDependenciesMeta": { - "preact": { - "optional": true - }, - "react": { - "optional": true - } + "peerDependencies": { + "preact": "*" } } diff --git a/preact.d.ts b/preact.d.ts deleted file mode 100644 index 6fc65f0..0000000 --- a/preact.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -// T - Wrapped component props -// S - Wrapped component state -// K - Store state -// I - Injected props to wrapped component - -declare module 'unistore/preact' { - import * as Preact from 'preact'; - import { ActionCreator, StateMapper, Store } from 'unistore'; - - export function connect( - mapStateToProps: string | Array | StateMapper, - actions?: ActionCreator | object - ): ( - Child: Preact.ComponentConstructor | Preact.AnyComponent - ) => Preact.ComponentConstructor; - - export interface ProviderProps { - store: Store; - } - - export class Provider extends Preact.Component> { - render(props: ProviderProps): Preact.JSX.Element; - } -} diff --git a/react.d.ts b/react.d.ts deleted file mode 100644 index 1c95355..0000000 --- a/react.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -// T - Wrapped component props -// S - Wrapped component state -// K - Store state -// I - Injected props to wrapped component - -declare module 'unistore/react' { - import * as React from 'react'; - import { ActionCreator, StateMapper, Store } from 'unistore'; - - export function connect( - mapStateToProps: string | Array | StateMapper, - actions?: ActionCreator | object - ): ( - Child: ((props: T & I) => React.ReactNode) | React.ComponentClass | React.FC - ) => React.ComponentClass | React.FC; - - export interface ProviderProps { - store: Store; - } - - export class Provider extends React.Component, {}> { - render(): React.ReactNode; - } - - interface ComponentConstructor

{ - new (props: P, context?: any): React.Component; - } -} diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..b47eb06 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,44 @@ +import fs from 'fs'; +import buble from 'rollup-plugin-buble'; +import uglify from 'rollup-plugin-uglify'; +import replace from 'rollup-plugin-post-replace'; + +let pkg = JSON.parse(fs.readFileSync('./package.json')); + +let format = process.env.FORMAT; + +export default { + strict: false, + sourcemap: true, + exports: 'default', + input: pkg.source, + output: { + format, + name: pkg.amdName || pkg.name, + file: (format==='es' && pkg.module) || (format==='umd' && pkg['umd:main']) || pkg.main + }, + external: ['preact'], + globals: { + preact: 'preact' + }, + plugins: [ + buble({ + objectAssign: 'assign', + jsx: 'h' + }), + format==='cjs' && replace({ + 'module.exports = index;': '', + 'var index =': 'module.exports =' + }), + format==='umd' && replace({ + 'return index;': '', + 'var index =': 'return' + }), + format!=='es' && uglify({ + output: { comments: false }, + mangle: { + toplevel: format==='cjs' + } + }) + ] +}; diff --git a/src/combined/preact.js b/src/combined/preact.js deleted file mode 100644 index 67e8924..0000000 --- a/src/combined/preact.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default as createStore } from '../index'; -export { Provider, connect } from '../integrations/preact'; \ No newline at end of file diff --git a/src/combined/react.js b/src/combined/react.js deleted file mode 100644 index c5e4bdc..0000000 --- a/src/combined/react.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default as createStore } from '../index'; -export { Provider, connect } from '../integrations/react'; \ No newline at end of file diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 4df2006..0000000 --- a/src/index.js +++ /dev/null @@ -1,100 +0,0 @@ -import { assign } from './util'; - -/** - * Creates a new store, which is a tiny evented state container. - * @name createStore - * @param {Object} [state={}] Optional initial state - * @returns {store} - * @example - * let store = createStore(); - * store.subscribe( state => console.log(state) ); - * store.setState({ a: 'b' }); // logs { a: 'b' } - * store.setState({ c: 'd' }); // logs { a: 'b', c: 'd' } - */ -export default function createStore(state) { - let listeners = []; - state = state || {}; - - function unsubscribe(listener) { - let out = []; - for (let i=0; i stateUpdate` - * @returns {Function} boundAction() - */ - action(action) { - function apply(result) { - setState(result, false, action); - } - - // Note: perf tests verifying this implementation: https://esbench.com/bench/5a295e6299634800a0349500 - return function() { - let args = [state]; - for (let i=0; i { unsubscribe(listener); }; - }, - - /** - * Remove a previously-registered listener function. - * @param {Function} listener The callback previously passed to `subscribe()` that should be removed. - * @function - */ - unsubscribe, - - /** - * Retrieve the current state object. - * @returns {Object} state - */ - getState() { - return state; - } - }; -} diff --git a/src/integrations/preact.js b/src/integrations/preact.js deleted file mode 100644 index 81c09e3..0000000 --- a/src/integrations/preact.js +++ /dev/null @@ -1,67 +0,0 @@ -import { h, Component } from 'preact'; -import { assign, mapActions, select } from '../util'; - -/** - * Wire a component up to the store. Passes state as props, re-renders on change. - * @param {Function|Array|String} mapStateToProps A function mapping of store state to prop values, or an array/CSV of properties to map. - * @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 - * @returns {Component} ConnectedComponent - * @example - * const Foo = connect('foo,bar')( ({ foo, bar }) =>

) - * @example - * const actions = { someAction } - * const Foo = connect('foo,bar', actions)( ({ foo, bar, someAction }) =>
) - * @example - * @connect( state => ({ foo: state.foo, bar: state.bar }) ) - * export class Foo { render({ foo, bar }) { } } - */ -export function connect(mapStateToProps, actions) { - if (typeof mapStateToProps!='function') { - mapStateToProps = select(mapStateToProps || {}); - } - return Child => { - function Wrapper(props, context) { - const store = context.store; - let state = mapStateToProps(store ? store.getState() : {}, props); - const boundActions = actions ? mapActions(actions, store) : { store }; - let update = () => { - let mapped = mapStateToProps(store ? store.getState() : {}, props); - for (let i in mapped) if (mapped[i]!==state[i]) { - state = mapped; - return this.setState({}); - } - for (let i in state) if (!(i in mapped)) { - state = mapped; - return this.setState({}); - } - }; - this.componentWillReceiveProps = p => { - props = p; - update(); - }; - this.componentDidMount = () => { - store.subscribe(update); - }; - this.componentWillUnmount = () => { - store.unsubscribe(update); - }; - this.render = props => h(Child, assign(assign(assign({}, boundActions), props), state)); - } - return (Wrapper.prototype = new Component()).constructor = Wrapper; - }; -} - - -/** - * Provider exposes a store (passed as `props.store`) into context. - * - * Generally, an entire application is wrapped in a single `` at the root. - * @class - * @extends Component - * @param {Object} props - * @param {Store} props.store A {Store} instance to expose via context. - */ -export function Provider(props) { - this.getChildContext = () => ({ store: props.store }); -} -Provider.prototype.render = props => props.children && props.children[0] || props.children; diff --git a/src/integrations/react.js b/src/integrations/react.js deleted file mode 100644 index 672b3db..0000000 --- a/src/integrations/react.js +++ /dev/null @@ -1,76 +0,0 @@ -import { createElement, Children, Component } from 'react'; -import { assign, mapActions, select } from '../util'; - -const CONTEXT_TYPES = { - store: () => {} -}; - -/** Wire a component up to the store. Passes state as props, re-renders on change. - * @param {Function|Array|String} mapStateToProps A function mapping of store state to prop values, or an array/CSV of properties to map. - * @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 - * @returns {Component} ConnectedComponent - * @example - * const Foo = connect('foo,bar')( ({ foo, bar }) =>
) - * @example - * const actions = { someAction } - * const Foo = connect('foo,bar', actions)( ({ foo, bar, someAction }) =>
) - * @example - * @connect( state => ({ foo: state.foo, bar: state.bar }) ) - * export class Foo { render({ foo, bar }) { } } - */ -export function connect(mapStateToProps, actions) { - if (typeof mapStateToProps!=='function') { - mapStateToProps = select(mapStateToProps || []); - } - return Child => { - function Wrapper(props, context) { - Component.call(this, props, context); - const store = context.store; - let state = mapStateToProps(store ? store.getState() : {}, props); - const boundActions = actions ? mapActions(actions, store) : { store }; - let update = () => { - let mapped = mapStateToProps(store ? store.getState() : {}, props); - for (let i in mapped) if (mapped[i]!==state[i]) { - state = mapped; - return this.forceUpdate(); - } - for (let i in state) if (!(i in mapped)) { - state = mapped; - return this.forceUpdate(); - } - }; - this.UNSAFE_componentWillReceiveProps = p => { - props = p; - update(); - }; - this.componentDidMount = () => { - store.subscribe(update); - }; - this.componentWillUnmount = () => { - store.unsubscribe(update); - }; - this.render = () => createElement(Child, assign(assign(assign({}, boundActions), this.props), state)); - } - Wrapper.contextTypes = CONTEXT_TYPES; - return (Wrapper.prototype = Object.create(Component.prototype)).constructor = Wrapper; - }; -} - - -/** Provider exposes a store (passed as `props.store`) into context. - * - * Generally, an entire application is wrapped in a single `` at the root. - * @class - * @extends Component - * @param {Object} props - * @param {Store} props.store A {Store} instance to expose via context. - */ -export class Provider extends Component { - getChildContext() { - return { store: this.props.store }; - } - render() { - return Children.only(this.props.children); - } -} -Provider.childContextTypes = CONTEXT_TYPES; diff --git a/src/util.js b/src/util.js deleted file mode 100644 index 7050423..0000000 --- a/src/util.js +++ /dev/null @@ -1,29 +0,0 @@ -// Bind an object/factory of actions to the store and wrap them. -export function mapActions(actions, store) { - if (typeof actions==='function') actions = actions(store); - let mapped = {}; - for (let i in actions) { - mapped[i] = store.action(actions[i]); - } - return mapped; -} - - -// select('foo,bar') creates a function of the form: ({ foo, bar }) => ({ foo, bar }) -export function select(properties) { - if (typeof properties==='string') properties = properties.split(/\s*,\s*/); - return state => { - let selected = {}; - 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 { - describe('unistore', () => { - it('should export only a single function as default', () => { - expect(createStore).toBeInstanceOf(Function); - }); - }); - - describe('unistore/preact', () => { - it('should export connect', () => { - expect(preact).toHaveProperty('connect', expect.any(Function)); - }); - it('should export Provider', () => { - expect(preact).toHaveProperty('Provider', expect.any(Function)); - }); - it('should no export anything else', () => { - expect(preact).toEqual({ - connect: preact.connect, - Provider: preact.Provider - }); - }); - }); - - describe('smoke test (preact)', () => { - it('should render', done => { - const { Provider, connect } = preact; - const actions = ({ getState, setState }) => ({ - incrementTwice(state) { - setState({ count: state.count + 1 }); - return new Promise(r => - setTimeout(() => { - r({ count: getState().count + 1 }); - }, 20) - ); - } - }); - const App = connect('count', actions)(({ count, incrementTwice }) => ( - - )); - const store = createStore({ count: 0 }); - let root = document.createElement('div'); - render( - - - , - root - ); - expect(store.getState()).toEqual({ count: 0 }); - root.firstElementChild.click(); - expect(store.getState()).toEqual({ count: 1 }); - setTimeout(() => { - expect(store.getState()).toEqual({ count: 2 }); - expect(root.textContent).toBe('count: 2'); - done(); - }, 30); - }); - }); -}); diff --git a/test/preact/preact-8.test.js b/test/preact/preact-8.test.js deleted file mode 100644 index 654f8ae..0000000 --- a/test/preact/preact-8.test.js +++ /dev/null @@ -1,13 +0,0 @@ -// This runs the Preact tests against a copy of Preact 8.4.2. -jest.isolateModules(() => { - const preact = require('../fixtures/preact-8.min.js'); - jest.setMock('preact', preact); - - // Patch Preact 8's render to work like Preact 10: - let render = preact.render; - preact.render = (vnode, parent) => parent._root = render(vnode, parent, parent._root); - - global.IS_PREACT_8 = true; - require('./preact.test'); - delete global.IS_PREACT_8; -}); diff --git a/test/preact/preact.test.js b/test/preact/preact.test.js deleted file mode 100644 index a0c1ce9..0000000 --- a/test/preact/preact.test.js +++ /dev/null @@ -1,215 +0,0 @@ -import { h, render } from 'preact'; -import createStore from '../../src'; -import { Provider, connect } from '../../src/integrations/preact'; - -const sleep = ms => new Promise( r => setTimeout(r, ms) ); - -const NO_CHILDREN = global.IS_PREACT_8 ? expect.anything() : undefined; - -describe(`integrations/preact${global.IS_PREACT_8 ? '-8' : ''}`, () => { - describe('', () => { - afterEach(() => { - render(null, document.body); - }); - - it('should provide props into context', () => { - const Child = jest.fn(); - - render( - - - , - document.body - ); - expect(Child).toHaveBeenCalledWith(expect.anything(), { store: 'a' }); - - render(null, document.body); - - let store = { name: 'obj' }; - render( - - - , - document.body - ); - expect(Child).toHaveBeenCalledWith(expect.anything(), { store }); - }); - }); - - describe('connect()', () => { - afterEach(() => { - render(null, document.body); - }); - - it('should pass mapped state as props', () => { - let state = { a: 'b' }; - const store = { subscribe: jest.fn(), unsubscribe: jest.fn(), getState: () => state }; - const Child = jest.fn(); - const ConnectedChild = connect(Object)(Child); - render( - - - , - document.body - ); - expect(Child).toHaveBeenCalledWith( - { a: 'b', store, children: NO_CHILDREN }, - expect.anything() - ); - expect(store.subscribe).toBeCalled(); - }); - - it('should transform string selector', () => { - let state = { a: 'b', b: 'c', c: 'd' }; - const store = { subscribe: jest.fn(), unsubscribe: jest.fn(), getState: () => state }; - const Child = jest.fn(); - const ConnectedChild = connect('a, b')(Child); - render( - - - , - document.body - ); - expect(Child).toHaveBeenCalledWith( - { a: 'b', b: 'c', store, children: NO_CHILDREN }, - expect.anything() - ); - expect(store.subscribe).toBeCalled(); - }); - - it('should subscribe to store on mount', async () => { - const store = { subscribe: jest.fn(), unsubscribe: jest.fn(), getState: () => ({}) }; - jest.spyOn(store, 'subscribe'); - const ConnectedChild = connect(Object)(() => null); - - render( - - - , - document.body - ); - - expect(store.subscribe).toBeCalledWith(expect.any(Function)); - }); - - it('should unsubscribe from store when unmounted', async () => { - const store = createStore(); - jest.spyOn(store, 'unsubscribe'); - const ConnectedChild = connect(Object)(() => null); - render( - - - , - document.body - ); - await sleep(1); - render(null, document.body); - expect(store.unsubscribe).toBeCalled(); - }); - - it('should subscribe to store', async () => { - const store = createStore(); - const Child = jest.fn(); - jest.spyOn(store, 'subscribe'); - jest.spyOn(store, 'unsubscribe'); - const ConnectedChild = connect(Object)(Child); - - render( - - - , - document.body - ); - - expect(store.subscribe).toBeCalledWith(expect.any(Function)); - expect(Child).toHaveBeenCalledWith( - { store, children: NO_CHILDREN }, - expect.anything() - ); - - Child.mockClear(); - - store.setState({ a: 'b' }); - await sleep(1); - expect(Child).toHaveBeenCalledWith( - { a: 'b', store, children: NO_CHILDREN }, - expect.anything() - ); - - render(null, document.body); - expect(store.unsubscribe).toBeCalled(); - - Child.mockClear(); - - store.setState({ c: 'd' }); - await sleep(1); - expect(Child).not.toHaveBeenCalled(); - }); - - it('should run mapStateToProps and update when outer props change', async () => { - let state = {}; - const store = { subscribe: jest.fn(), unsubscribe: () => {}, getState: () => state }; - const Child = jest.fn().mockName('').mockReturnValue(42); - let mappings = 0; - - // Jest mock return values are broken :( - const mapStateToProps = jest.fn((state, props) => ({ - mappings: ++mappings, - ...props - })); - - const ConnectedChild = connect(mapStateToProps)(Child); - render( - - - , - document.body - ); - - expect(mapStateToProps).toHaveBeenCalledTimes(1); - expect(mapStateToProps).toHaveBeenCalledWith({}, { children: NO_CHILDREN }); - // first render calls mapStateToProps - expect(Child).toHaveBeenCalledWith( - { mappings: 1, store, children: NO_CHILDREN }, - expect.anything() - ); - - mapStateToProps.mockClear(); - Child.mockClear(); - - render( - - - , - document.body - ); - - expect(mapStateToProps).toHaveBeenCalledTimes(1); - expect(mapStateToProps).toHaveBeenCalledWith({ }, { a: 'b', children: NO_CHILDREN }); - // outer props were changed - expect(Child).toHaveBeenCalledWith( - { mappings: 2, a: 'b', store, children: NO_CHILDREN }, - expect.anything() - ); - - mapStateToProps.mockClear(); - Child.mockClear(); - - render( - - - , - document.body - ); - - expect(mapStateToProps).toHaveBeenCalledTimes(1); - expect(mapStateToProps).toHaveBeenCalledWith({ }, { a: 'b', children: NO_CHILDREN }); - - // re-rendered, but outer props were not changed - expect(Child).toHaveBeenCalledWith( - { mappings: 3, a: 'b', store, children: NO_CHILDREN }, - expect.anything() - ); - }); - }); -}); diff --git a/test/react/.babelrc b/test/react/.babelrc deleted file mode 100644 index aebf8ad..0000000 --- a/test/react/.babelrc +++ /dev/null @@ -1,13 +0,0 @@ -{ - "presets": [ - [ - "env", - { - "targets": { - "node": true - } - } - ] - ], - "plugins": [["transform-react-jsx"]] -} diff --git a/test/react/builds.test.js b/test/react/builds.test.js deleted file mode 100644 index 099bd68..0000000 --- a/test/react/builds.test.js +++ /dev/null @@ -1,68 +0,0 @@ -import 'raf/polyfill'; -import React from 'react'; - -import { mount, configure } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -configure({ adapter: new Adapter() }); - -import createStore from '../..'; -import react from '../../react'; - -describe('build: default', () => { - describe('unistore', () => { - it('should export only a single function as default', () => { - expect(createStore).toBeInstanceOf(Function); - }); - }); - - describe('unistore/react', () => { - it('should export connect', () => { - expect(react).toHaveProperty('connect', expect.any(Function)); - }); - it('should export Provider', () => { - expect(react).toHaveProperty('Provider', expect.any(Function)); - }); - it('should no export anything else', () => { - expect(react).toEqual({ - connect: react.connect, - Provider: react.Provider - }); - }); - }); - - describe('smoke test (react)', () => { - it('should render', done => { - const { Provider, connect } = react; - const actions = ({ getState, setState }) => ({ - incrementTwice(state) { - setState({ count: state.count + 1 }); - return new Promise(r => - setTimeout(() => { - r({ count: getState().count + 1 }); - }, 20) - ); - } - }); - const App = connect('count', actions)(({ count, incrementTwice }) => ( - - )); - const store = createStore({ count: 0 }); - const provider = ( - - - - ); - const mountedProvider = mount(provider); - expect(store.getState()).toEqual({ count: 0 }); - const button = mountedProvider.find('#some_button').simulate('click'); - expect(store.getState()).toEqual({ count: 1 }); - setTimeout(() => { - expect(store.getState()).toEqual({ count: 2 }); - expect(button.text()).toBe('count: 2'); - done(); - }, 30); - }); - }); -}); diff --git a/test/react/react.test.js b/test/react/react.test.js deleted file mode 100644 index 00ca647..0000000 --- a/test/react/react.test.js +++ /dev/null @@ -1,187 +0,0 @@ -import 'raf/polyfill'; -import React, { Component } from 'react'; -import TestUtils from 'react-dom/test-utils'; -import PropTypes from 'prop-types'; - -import createStore from '../../src'; -import { Provider, connect } from '../../src/integrations/react'; - -import { mount, configure } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -configure({ adapter: new Adapter() }); - -const sleep = ms => new Promise(r => setTimeout(r, ms)); - -describe('integrations/react', () => { - const createChild = (storeKey = 'store') => { - class Child extends Component { - render() { - return
; - } - } - - Child.contextTypes = { - [storeKey]: PropTypes.object.isRequired - }; - - return Child; - }; - const Child = createChild(); - - it('should provide props into context', () => { - const store = createStore(() => ({})); - - const spy = jest.spyOn(console, 'error'); - const tree = TestUtils.renderIntoDocument( - - - - ); - expect(spy).not.toHaveBeenCalled(); - - const child = TestUtils.findRenderedComponentWithType(tree, Child); - expect(child.context.store).toBe(store); - }); - - it('should pass mapped state as props', () => { - let state = { a: 'b' }; - const store = { subscribe: jest.fn(), getState: () => state }; - const ConnectedChild = connect(Object)(Child); - - const mountedProvider = mount( - - - - ); - - const child = mountedProvider.find(Child).first(); - expect(child.props()).toEqual({ - a: 'b', - store - }); - expect(store.subscribe).toBeCalled(); - }); - - it('should transform string selector', () => { - let state = { a: 'b', b: 'c', c: 'd' }; - const store = { subscribe: jest.fn(), getState: () => state }; - const ConnectedChild = connect('a, b')(Child); - const mountedProvider = mount( - - - - ); - - const child = mountedProvider.find(Child).first(); - expect(child.props()).toEqual({ - a: 'b', - b: 'c', - store - }); - expect(store.subscribe).toBeCalled(); - }); - - it('should subscribe to store', async () => { - const store = createStore(); - jest.spyOn(store, 'subscribe'); - jest.spyOn(store, 'unsubscribe'); - - const ConnectedChild = connect(Object)(Child); - - expect(store.subscribe).not.toHaveBeenCalled(); - const mountedProvider = mount( - - - - ); - - expect(store.subscribe).toBeCalledWith(expect.any(Function)); - - let child = mountedProvider - .find('Child') - .first() - .instance(); - expect(child.props).toEqual({ store }); - - store.setState({ a: 'b' }); - await sleep(1); - - child = mountedProvider - .find('Child') - .first() - .instance(); - expect(child.props).toEqual({ a: 'b', store }); - - expect(store.unsubscribe).not.toHaveBeenCalled(); - mountedProvider.unmount(); - expect(store.unsubscribe).toBeCalled(); - }); - - it('should run mapStateToProps and update when outer props change', async () => { - let state = {}; - const store = { subscribe: jest.fn(), getState: () => state }; - const Child = jest.fn(() => null).mockName(''); - let mappings = 0; - - // Jest mock return values are broken :( - const mapStateToProps = jest.fn((state, props) => ({ mappings: ++mappings, ...props })); - - let root; - class Outer extends Component { - constructor() { - super(); - this.state = {}; - root = this; - root.setProps = props => this.setState({ props }); - } - render() { - root = this; - return ( - - - - ); - } - } - - const ConnectedChild = connect(mapStateToProps)(Child); - const mountedProvider = mount(); - - expect(mapStateToProps).toHaveBeenCalledTimes(1); - expect(mapStateToProps).toHaveBeenCalledWith({}, { }); - // first render calls mapStateToProps - expect(Child).toHaveBeenCalledWith( - { mappings: 1, store }, - expect.anything() - ); - - mapStateToProps.mockClear(); - Child.mockClear(); - - // root.setState({ a: 'b' }); - mountedProvider.setProps({ a: 'b' }); - - // await sleep(100); - - expect(mapStateToProps).toHaveBeenCalledTimes(1); - expect(mapStateToProps).toHaveBeenCalledWith({}, { a: 'b' }); - // outer props were changed - expect(Child).toHaveBeenCalledWith( - { mappings: 2, a: 'b', store }, - expect.anything() - ); - - mapStateToProps.mockClear(); - Child.mockClear(); - - mountedProvider.setProps({ }); - - await sleep(1); - - // re-rendered, but outer props were not changed - expect(Child).toHaveBeenCalledWith( - { mappings: 3, a: 'b', store }, - expect.anything() - ); - }); -}); diff --git a/test/unistore.test.js b/test/unistore.test.js deleted file mode 100644 index 7bc3a40..0000000 --- a/test/unistore.test.js +++ /dev/null @@ -1,97 +0,0 @@ -import createStore from '../src'; - -describe('createStore()', () => { - it('should be instantiable', () => { - let store = createStore(); - expect(store).toMatchObject({ - setState: expect.any(Function), - getState: expect.any(Function), - subscribe: expect.any(Function), - unsubscribe: expect.any(Function) - }); - }); - - it('should update state in-place', () => { - let store = createStore(); - expect(store.getState()).toMatchObject({}); - store.setState({ a: 'b' }); - expect(store.getState()).toMatchObject({ a: 'b' }); - store.setState({ c: 'd' }); - expect(store.getState()).toMatchObject({ a: 'b', c: 'd' }); - store.setState({ a: 'x' }); - expect(store.getState()).toMatchObject({ a: 'x', c: 'd' }); - store.setState({ c: null }); - expect(store.getState()).toMatchObject({ a: 'x', c: null }); - store.setState({ c: undefined }); - expect(store.getState()).toMatchObject({ a: 'x', c: undefined }); - }); - - it('should invoke subscriptions', () => { - let store = createStore(); - - let sub1 = jest.fn(); - let sub2 = jest.fn(); - let action; - - let rval = store.subscribe(sub1); - expect(rval).toBeInstanceOf(Function); - - store.setState({ a: 'b' }); - expect(sub1).toBeCalledWith(store.getState(), action); - - store.subscribe(sub2); - store.setState({ c: 'd' }); - - expect(sub1).toHaveBeenCalledTimes(2); - expect(sub1).toHaveBeenLastCalledWith(store.getState(), action); - expect(sub2).toBeCalledWith(store.getState(), action); - }); - - it('should unsubscribe', () => { - let store = createStore(); - - let sub1 = jest.fn(); - let sub2 = jest.fn(); - let sub3 = jest.fn(); - - store.subscribe(sub1); - store.subscribe(sub2); - let unsub3 = store.subscribe(sub3); - - store.setState({ a: 'b' }); - expect(sub1).toBeCalled(); - expect(sub2).toBeCalled(); - expect(sub3).toBeCalled(); - - sub1.mockClear(); - sub2.mockClear(); - sub3.mockClear(); - - store.unsubscribe(sub2); - - store.setState({ c: 'd' }); - expect(sub1).toBeCalled(); - expect(sub2).not.toBeCalled(); - expect(sub3).toBeCalled(); - - sub1.mockClear(); - sub2.mockClear(); - sub3.mockClear(); - - store.unsubscribe(sub1); - - store.setState({ e: 'f' }); - expect(sub1).not.toBeCalled(); - expect(sub2).not.toBeCalled(); - expect(sub3).toBeCalled(); - - sub3.mockClear(); - - unsub3(); - - store.setState({ g: 'h' }); - expect(sub1).not.toBeCalled(); - expect(sub2).not.toBeCalled(); - expect(sub3).not.toBeCalled(); - }); -}); diff --git a/unistore.js b/unistore.js new file mode 100644 index 0000000..af58e82 --- /dev/null +++ b/unistore.js @@ -0,0 +1,115 @@ +import { h, Component } from 'preact'; + + +/** Creates a new store, which is a tiny evented state container. + * @example + * let store = createStore(); + * store.subscribe( state => console.log(state) ); + * store.setState({ a: 'b' }); // logs { a: 'b' } + * store.setState({ c: 'd' }); // logs { c: 'd' } + */ +export function createStore(state={}) { + let listeners = []; + + return { + setState(update) { + state = { ...state, ...update }; + listeners.forEach( f => { f(state); }); + }, + subscribe(f) { + listeners.push(f); + }, + unsubscribe(f) { + let i = listeners.indexOf(f); + listeners.splice(i, !!~i); + }, + getState() { + return state; + } + }; +} + + +/** Provides its props into the tree as context. + * @example + * let store = createStore(); + * + */ +export class Provider extends Component { + getChildContext() { + let context = { ...this.props }; + delete context.children; + return context; + } + render({ children }) { + return children[0]; + } +} + + +/** Wire a component up to the store. Passes state as props, re-renders on change. + * @param {Function|Array|String} mapStateToProps A function (or any `select()` argument) mapping of store state to prop values. + * @example + * const Foo = connect('foo,bar')( ({ foo, bar }) =>
) + * @example + * @connect( state => ({ foo: state.foo, bar: state.bar }) ) + * export class Foo { render({ foo, bar }) { } } + */ +export function connect(mapToProps, actions) { + if (typeof mapToProps!=='function') mapToProps = select(mapToProps); + return Child => ( + class Wrapper extends Component { + constructor(props, { store }) { + super(); + this.state = mapToProps(store ? store.getState() : {}, props); + this.actions = actions ? actions(store) : { store }; + this.update = () => { + let mapped = mapToProps(store ? store.getState() : {}, this.props); + if (!shallowEqual(mapped, this.state)) { + this.setState(mapped); + } + }; + } + componentDidMount() { + this.context.store.subscribe(this.update); + } + componentWillUnmount() { + this.context.store.unsubscribe(this.update); + } + render(props, state) { + return ; + } + } + ); +} + + +/** select('foo,bar') creates a function of the form: ({ foo, bar }) => ({ foo, bar }) */ +export function select(properties) { + if (typeof properties==='string') properties = properties.split(','); + return state => { + let selected = {}; + for (let i=0; i new Promise( r => setTimeout(r, ms) ); + +describe('createStore()', () => { + it('should be instantiable', () => { + let store = createStore(); + expect(store).toMatchObject({ + setState: expect.any(Function), + getState: expect.any(Function), + subscribe: expect.any(Function), + unsubscribe: expect.any(Function) + }); + }); + + it('should update state in-place', () => { + let store = createStore(); + expect(store.getState()).toMatchObject({}); + store.setState({ a: 'b' }); + expect(store.getState()).toMatchObject({ a: 'b' }); + store.setState({ c: 'd' }); + expect(store.getState()).toMatchObject({ a: 'b', c: 'd' }); + store.setState({ a: 'x' }); + expect(store.getState()).toMatchObject({ a: 'x', c: 'd' }); + store.setState({ c: null }); + expect(store.getState()).toMatchObject({ a: 'x', c: null }); + store.setState({ c: undefined }); + expect(store.getState()).toMatchObject({ a: 'x', c: undefined }); + }); + + it('should invoke subscriptions', () => { + let store = createStore(); + + let sub1 = jest.fn(); + let sub2 = jest.fn(); + + let rval = store.subscribe(sub1); + expect(rval).toBe(undefined); + + store.setState({ a: 'b' }); + expect(sub1).toBeCalledWith(store.getState()); + + store.subscribe(sub2); + store.setState({ c: 'd' }); + + expect(sub1).toHaveBeenCalledTimes(2); + expect(sub1).toHaveBeenLastCalledWith(store.getState()); + expect(sub2).toBeCalledWith(store.getState()); + }); + + it('should unsubscribe', () => { + let store = createStore(); + + let sub1 = jest.fn(); + let sub2 = jest.fn(); + + store.subscribe(sub1); + store.subscribe(sub2); + + store.setState({ a: 'b' }); + expect(sub1).toBeCalled(); + expect(sub2).toBeCalled(); + + sub1.mockReset(); + sub2.mockReset(); + + store.unsubscribe(sub2); + + store.setState({ c: 'd' }); + expect(sub1).toBeCalled(); + expect(sub2).not.toBeCalled(); + + sub1.mockReset(); + sub2.mockReset(); + + store.unsubscribe(sub1); + + store.setState({ e: 'f' }); + expect(sub1).not.toBeCalled(); + expect(sub2).not.toBeCalled(); + }); +}); + +describe('', () => { + it('should provide props into context', () => { + const Child = jest.fn(); + let obj = { name: 'obj' }; + render(, document.body); + expect(Child).toHaveBeenCalledWith(expect.anything(), { a: 'a', obj }); + }); +}); + +describe('connect()', () => { + it('should pass mapped state as props', () => { + let state = { a: 'b' }; + const store = { subscribe: jest.fn(), getState: () => state }; + const Child = jest.fn(); + const ConnectedChild = connect(Object)(Child); + render(, document.body); + expect(Child).toHaveBeenCalledWith({ a: 'b', store, children: expect.anything() }, expect.anything()); + expect(store.subscribe).toBeCalled(); + }); + + it('should subscribe to store', async () => { + const store = createStore(); + const Child = jest.fn(); + jest.spyOn(store, 'subscribe'); + jest.spyOn(store, 'unsubscribe'); + const ConnectedChild = connect(Object)(Child); + + let root = render(, document.body); + + expect(store.subscribe).toBeCalledWith(expect.any(Function)); + expect(Child).toHaveBeenCalledWith({ store, children: expect.anything() }, expect.anything()); + + Child.mockReset(); + + store.setState({ a: 'b' }); + await sleep(1); + expect(Child).toHaveBeenCalledWith({ a: 'b', store, children: expect.anything() }, expect.anything()); + + render(null, document.body, root); + expect(store.unsubscribe).toBeCalled(); + + Child.mockReset(); + + store.setState({ c: 'd' }); + await sleep(1); + expect(Child).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file