From 8cc44669814549ec76bacc4abb6e337bb0cd4666 Mon Sep 17 00:00:00 2001 From: Jason Miller Date: Wed, 6 Dec 2017 13:15:15 -0500 Subject: [PATCH 1/3] Initial commit! --- .gitignore | 5 ++ package.json | 71 +++++++++++++++++++++++++ rollup.config.js | 44 ++++++++++++++++ unistore.js | 115 +++++++++++++++++++++++++++++++++++++++++ unistore.test.js | 132 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 367 insertions(+) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 rollup.config.js create mode 100644 unistore.js create mode 100644 unistore.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f72fcb --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +npm-debug.log +dist +README.md +package-lock.json \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..9779be4 --- /dev/null +++ b/package.json @@ -0,0 +1,71 @@ +{ + "name": "unistore", + "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", + "scripts": { + "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-synacor", + "globals": { + "jest": 1 + }, + "rules": { + "guard-for-in": 0, + "prefer-rest-params": 0 + } + }, + "babel": { + "presets": [ + "es2015" + ], + "plugins": [ + [ + "transform-react-jsx", + { + "pragma": "h" + } + ] + ] + }, + "files": [ + "unistore.js", + "dist" + ], + "keywords": [ + "preact", + "component", + "state machine", + "redux" + ], + "repository": "developit/unistore", + "author": "Jason Miller ", + "license": "MIT", + "devDependencies": { + "babel-jest": "^21.2.0", + "babel-plugin-transform-react-jsx": "^6.24.1", + "babel-preset-es2015": "^6.24.1", + "eslint": "^4.6.1", + "eslint-config-synacor": "^2.0.2", + "gzip-size": "^3.0.0", + "jest": "^21.2.1", + "preact": "^8.2.6", + "rollup": "^0.49.2", + "rollup-plugin-buble": "^0.15.0", + "rollup-plugin-post-replace": "^1.0.0", + "rollup-plugin-uglify": "^2.0.1", + "strip-json-comments-cli": "^1.0.1", + "uglify-js": "^2.8.29" + }, + "peerDependencies": { + "preact": "*" + } +} 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/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 From cc5a69add1b60c80c6e918dfd3417a4eef384b57 Mon Sep 17 00:00:00 2001 From: "greenkeeper[bot]" Date: Wed, 6 Dec 2017 18:15:57 +0000 Subject: [PATCH 2/3] chore(package): update dependencies --- package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 9779be4..42e280e 100644 --- a/package.json +++ b/package.json @@ -55,15 +55,15 @@ "babel-preset-es2015": "^6.24.1", "eslint": "^4.6.1", "eslint-config-synacor": "^2.0.2", - "gzip-size": "^3.0.0", + "gzip-size": "^4.1.0", "jest": "^21.2.1", "preact": "^8.2.6", - "rollup": "^0.49.2", - "rollup-plugin-buble": "^0.15.0", + "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": "^2.8.29" + "uglify-js": "^3.2.1" }, "peerDependencies": { "preact": "*" From 8021b1a9bab855a82a377eb91834708b2bcbada4 Mon Sep 17 00:00:00 2001 From: "greenkeeper[bot]" Date: Wed, 6 Dec 2017 18:16:00 +0000 Subject: [PATCH 3/3] docs(readme): add Greenkeeper badge --- README.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..10cbd88 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ + + +[![Greenkeeper badge](https://badges.greenkeeper.io/developit/unistore.svg)](https://greenkeeper.io/) \ No newline at end of file