Skip to content

Commit cab9e0e

Browse files
committed
Initial commit!
0 parents  commit cab9e0e

File tree

5 files changed

+367
-0
lines changed

5 files changed

+367
-0
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules
2+
npm-debug.log
3+
dist
4+
README.md
5+
package-lock.json

package.json

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{
2+
"name": "unistore",
3+
"version": "2.0.0",
4+
"description": "Dead simple centralized state container (store) with preact bindings.",
5+
"source": "unistore.js",
6+
"module": "dist/unistore.es.js",
7+
"main": "dist/unistore.js",
8+
"umd:main": "dist/unistore.umd.js",
9+
"scripts": {
10+
"build": "npm run transpile && npm run size",
11+
"transpile": "rollup -c --environment FORMAT:umd && rollup -c --environment FORMAT:cjs && rollup -c --environment FORMAT:es",
12+
"size": "strip-json-comments --no-whitespace dist/unistore.js | gzip-size",
13+
"test": "eslint unistore.js && jest",
14+
"prepublish": "npm run build && cp \"*unistore.md\" README.md"
15+
},
16+
"eslintConfig": {
17+
"extends": "eslint-config-synacor",
18+
"globals": {
19+
"jest": 1
20+
},
21+
"rules": {
22+
"guard-for-in": 0,
23+
"prefer-rest-params": 0
24+
}
25+
},
26+
"babel": {
27+
"presets": [
28+
"es2015"
29+
],
30+
"plugins": [
31+
[
32+
"transform-react-jsx",
33+
{
34+
"pragma": "h"
35+
}
36+
]
37+
]
38+
},
39+
"files": [
40+
"unistore.js",
41+
"dist"
42+
],
43+
"keywords": [
44+
"preact",
45+
"component",
46+
"state machine",
47+
"redux"
48+
],
49+
"repository": "developit/unistore",
50+
"author": "Jason Miller <jason@developit.ca>",
51+
"license": "MIT",
52+
"devDependencies": {
53+
"babel-jest": "^21.2.0",
54+
"babel-plugin-transform-react-jsx": "^6.24.1",
55+
"babel-preset-es2015": "^6.24.1",
56+
"eslint": "^4.6.1",
57+
"eslint-config-synacor": "^2.0.2",
58+
"gzip-size": "^3.0.0",
59+
"jest": "^21.2.1",
60+
"preact": "^8.2.6",
61+
"rollup": "^0.49.2",
62+
"rollup-plugin-buble": "^0.15.0",
63+
"rollup-plugin-post-replace": "^1.0.0",
64+
"rollup-plugin-uglify": "^2.0.1",
65+
"strip-json-comments-cli": "^1.0.1",
66+
"uglify-js": "^2.8.29"
67+
},
68+
"peerDependencies": {
69+
"preact": "*"
70+
}
71+
}

rollup.config.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import fs from 'fs';
2+
import buble from 'rollup-plugin-buble';
3+
import uglify from 'rollup-plugin-uglify';
4+
import replace from 'rollup-plugin-post-replace';
5+
6+
let pkg = JSON.parse(fs.readFileSync('./package.json'));
7+
8+
let format = process.env.FORMAT;
9+
10+
export default {
11+
strict: false,
12+
sourcemap: true,
13+
exports: 'default',
14+
input: pkg.source,
15+
output: {
16+
format,
17+
name: pkg.amdName || pkg.name,
18+
file: (format==='es' && pkg.module) || (format==='umd' && pkg['umd:main']) || pkg.main
19+
},
20+
external: ['preact'],
21+
globals: {
22+
preact: 'preact'
23+
},
24+
plugins: [
25+
buble({
26+
objectAssign: 'assign',
27+
jsx: 'h'
28+
}),
29+
format==='cjs' && replace({
30+
'module.exports = index;': '',
31+
'var index =': 'module.exports ='
32+
}),
33+
format==='umd' && replace({
34+
'return index;': '',
35+
'var index =': 'return'
36+
}),
37+
format!=='es' && uglify({
38+
output: { comments: false },
39+
mangle: {
40+
toplevel: format==='cjs'
41+
}
42+
})
43+
]
44+
};

unistore.js

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { h, Component } from 'preact';
2+
3+
4+
/** Creates a new store, which is a tiny evented state container.
5+
* @example
6+
* let store = createStore();
7+
* store.subscribe( state => console.log(state) );
8+
* store.setState({ a: 'b' }); // logs { a: 'b' }
9+
* store.setState({ c: 'd' }); // logs { c: 'd' }
10+
*/
11+
export function createStore(state={}) {
12+
let listeners = [];
13+
14+
return {
15+
setState(update) {
16+
state = { ...state, ...update };
17+
listeners.forEach( f => { f(state); });
18+
},
19+
subscribe(f) {
20+
listeners.push(f);
21+
},
22+
unsubscribe(f) {
23+
let i = listeners.indexOf(f);
24+
listeners.splice(i, !!~i);
25+
},
26+
getState() {
27+
return state;
28+
}
29+
};
30+
}
31+
32+
33+
/** Provides its props into the tree as context.
34+
* @example
35+
* let store = createStore();
36+
* <Provider store={store}><App /></Provider>
37+
*/
38+
export class Provider extends Component {
39+
getChildContext() {
40+
let context = { ...this.props };
41+
delete context.children;
42+
return context;
43+
}
44+
render({ children }) {
45+
return children[0];
46+
}
47+
}
48+
49+
50+
/** Wire a component up to the store. Passes state as props, re-renders on change.
51+
* @param {Function|Array|String} mapStateToProps A function (or any `select()` argument) mapping of store state to prop values.
52+
* @example
53+
* const Foo = connect('foo,bar')( ({ foo, bar }) => <div /> )
54+
* @example
55+
* @connect( state => ({ foo: state.foo, bar: state.bar }) )
56+
* export class Foo { render({ foo, bar }) { } }
57+
*/
58+
export function connect(mapToProps, actions) {
59+
if (typeof mapToProps!=='function') mapToProps = select(mapToProps);
60+
return Child => (
61+
class Wrapper extends Component {
62+
constructor(props, { store }) {
63+
super();
64+
this.state = mapToProps(store ? store.getState() : {}, props);
65+
this.actions = actions ? actions(store) : { store };
66+
this.update = () => {
67+
let mapped = mapToProps(store ? store.getState() : {}, this.props);
68+
if (!shallowEqual(mapped, this.state)) {
69+
this.setState(mapped);
70+
}
71+
};
72+
}
73+
componentDidMount() {
74+
this.context.store.subscribe(this.update);
75+
}
76+
componentWillUnmount() {
77+
this.context.store.unsubscribe(this.update);
78+
}
79+
render(props, state) {
80+
return <Child {...this.actions} {...props} {...state} />;
81+
}
82+
}
83+
);
84+
}
85+
86+
87+
/** select('foo,bar') creates a function of the form: ({ foo, bar }) => ({ foo, bar }) */
88+
export function select(properties) {
89+
if (typeof properties==='string') properties = properties.split(',');
90+
return state => {
91+
let selected = {};
92+
for (let i=0; i<properties.length; i++) {
93+
selected[properties[i]] = state[properties[i]];
94+
}
95+
return selected;
96+
};
97+
}
98+
99+
100+
// eslint-disable-next-line
101+
function assign(obj) {
102+
for (let i=1; i<arguments.length; i++) {
103+
let props = arguments[i];
104+
for (let j in props) obj[j] = props[j];
105+
}
106+
return obj;
107+
}
108+
109+
110+
/** Returns a boolean indicating if all keys and values match between two objects. */
111+
function shallowEqual(a, b) {
112+
for (let i in a) if (a[i]!==b[i]) return false;
113+
for (let i in b) if (!(i in a)) return false;
114+
return true;
115+
}

unistore.test.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { h, render } from 'preact';
2+
import { createStore, Provider, connect } from './unistore';
3+
4+
const sleep = ms => new Promise( r => setTimeout(r, ms) );
5+
6+
describe('createStore()', () => {
7+
it('should be instantiable', () => {
8+
let store = createStore();
9+
expect(store).toMatchObject({
10+
setState: expect.any(Function),
11+
getState: expect.any(Function),
12+
subscribe: expect.any(Function),
13+
unsubscribe: expect.any(Function)
14+
});
15+
});
16+
17+
it('should update state in-place', () => {
18+
let store = createStore();
19+
expect(store.getState()).toMatchObject({});
20+
store.setState({ a: 'b' });
21+
expect(store.getState()).toMatchObject({ a: 'b' });
22+
store.setState({ c: 'd' });
23+
expect(store.getState()).toMatchObject({ a: 'b', c: 'd' });
24+
store.setState({ a: 'x' });
25+
expect(store.getState()).toMatchObject({ a: 'x', c: 'd' });
26+
store.setState({ c: null });
27+
expect(store.getState()).toMatchObject({ a: 'x', c: null });
28+
store.setState({ c: undefined });
29+
expect(store.getState()).toMatchObject({ a: 'x', c: undefined });
30+
});
31+
32+
it('should invoke subscriptions', () => {
33+
let store = createStore();
34+
35+
let sub1 = jest.fn();
36+
let sub2 = jest.fn();
37+
38+
let rval = store.subscribe(sub1);
39+
expect(rval).toBe(undefined);
40+
41+
store.setState({ a: 'b' });
42+
expect(sub1).toBeCalledWith(store.getState());
43+
44+
store.subscribe(sub2);
45+
store.setState({ c: 'd' });
46+
47+
expect(sub1).toHaveBeenCalledTimes(2);
48+
expect(sub1).toHaveBeenLastCalledWith(store.getState());
49+
expect(sub2).toBeCalledWith(store.getState());
50+
});
51+
52+
it('should unsubscribe', () => {
53+
let store = createStore();
54+
55+
let sub1 = jest.fn();
56+
let sub2 = jest.fn();
57+
58+
store.subscribe(sub1);
59+
store.subscribe(sub2);
60+
61+
store.setState({ a: 'b' });
62+
expect(sub1).toBeCalled();
63+
expect(sub2).toBeCalled();
64+
65+
sub1.mockReset();
66+
sub2.mockReset();
67+
68+
store.unsubscribe(sub2);
69+
70+
store.setState({ c: 'd' });
71+
expect(sub1).toBeCalled();
72+
expect(sub2).not.toBeCalled();
73+
74+
sub1.mockReset();
75+
sub2.mockReset();
76+
77+
store.unsubscribe(sub1);
78+
79+
store.setState({ e: 'f' });
80+
expect(sub1).not.toBeCalled();
81+
expect(sub2).not.toBeCalled();
82+
});
83+
});
84+
85+
describe('<Provider>', () => {
86+
it('should provide props into context', () => {
87+
const Child = jest.fn();
88+
let obj = { name: 'obj' };
89+
render(<Provider a="a" obj={obj}><Child /></Provider>, document.body);
90+
expect(Child).toHaveBeenCalledWith(expect.anything(), { a: 'a', obj });
91+
});
92+
});
93+
94+
describe('connect()', () => {
95+
it('should pass mapped state as props', () => {
96+
let state = { a: 'b' };
97+
const store = { subscribe: jest.fn(), getState: () => state };
98+
const Child = jest.fn();
99+
const ConnectedChild = connect(Object)(Child);
100+
render(<Provider store={store}><ConnectedChild /></Provider>, document.body);
101+
expect(Child).toHaveBeenCalledWith({ a: 'b', store, children: expect.anything() }, expect.anything());
102+
expect(store.subscribe).toBeCalled();
103+
});
104+
105+
it('should subscribe to store', async () => {
106+
const store = createStore();
107+
const Child = jest.fn();
108+
jest.spyOn(store, 'subscribe');
109+
jest.spyOn(store, 'unsubscribe');
110+
const ConnectedChild = connect(Object)(Child);
111+
112+
let root = render(<Provider store={store}><ConnectedChild /></Provider>, document.body);
113+
114+
expect(store.subscribe).toBeCalledWith(expect.any(Function));
115+
expect(Child).toHaveBeenCalledWith({ store, children: expect.anything() }, expect.anything());
116+
117+
Child.mockReset();
118+
119+
store.setState({ a: 'b' });
120+
await sleep(1);
121+
expect(Child).toHaveBeenCalledWith({ a: 'b', store, children: expect.anything() }, expect.anything());
122+
123+
render(null, document.body, root);
124+
expect(store.unsubscribe).toBeCalled();
125+
126+
Child.mockReset();
127+
128+
store.setState({ c: 'd' });
129+
await sleep(1);
130+
expect(Child).not.toHaveBeenCalled();
131+
});
132+
});

0 commit comments

Comments
 (0)