Skip to content

Commit

Permalink
Improved looping; safely mutate listeners queue; add a test for liste…
Browse files Browse the repository at this point in the history
…ners unsubscribing
  • Loading branch information
crazy4groovy committed Jan 27, 2018
1 parent e8e468c commit 4be9b4b
Show file tree
Hide file tree
Showing 9 changed files with 113 additions and 41 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ package-lock.json
/preact.js.map
/react.js
/react.js.map
.vscode/
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,10 @@ You can find the library on `window.unistore`.
import createStore from 'unistore'
import { Provider, connect } from 'unistore/preact'

let store = createStore({ count: 0 })
const store = createStore({ count: 0 })

// If actions is a function, it gets passed the store:
let actions = store => ({
const actions = store => ({
// Actions can just return a state update:
increment(state) {
return { count: state.count+1 }
Expand All @@ -90,7 +90,7 @@ let actions = store => ({

// Async actions can be pure async/promise functions:
async getStuff(state) {
let res = await fetch('/foo.json')
const res = await fetch('/foo.json')
return { stuff: await res.json() }
},

Expand Down Expand Up @@ -126,8 +126,8 @@ Make sure to have [Redux devtools extension](https://github.com/zalmoxisus/redux
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));
const initialState = { count: 0 };
const store = process.env.NODE_ENV === 'production' ? createStore(initialState) : devtools(createStore(initialState));

// ...
```
Expand Down
11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@
"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",
"build:combined": "microbundle src/combined/*.js -o full/x.js",
"build:integrations": "microbundle \"src/integrations/*.js\" -o x.js -f cjs",
"build:combined": "microbundle \"src/combined/*.js\" -o full/x.js",
"size": "strip-json-comments --no-whitespace dist/unistore.js | gzip-size && bundlesize",
"docs": "documentation readme unistore.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",
"test:jest": "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"
},
Expand All @@ -28,15 +29,15 @@
"bundlesize": [
{
"path": "full/preact.js",
"maxSize": "750b"
"maxSize": "727b"
},
{
"path": "dist/unistore.js",
"maxSize": "400b"
"maxSize": "348b"
},
{
"path": "preact.js",
"maxSize": "600b"
"maxSize": "523b"
}
],
"babel": {
Expand Down
28 changes: 10 additions & 18 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,18 @@ import { assign } from './util';
* store.setState({ c: 'd' }); // logs { a: 'b', c: 'd' }
*/
export default function createStore(state) {
let listeners = [];
const listeners = [];
state = state || {};

function unsubscribe(listener) {
let out = [];
for (let i=0; i<listeners.length; i++) {
if (listeners[i]===listener) {
listener = null;
}
else {
out.push(listeners[i]);
}
}
listeners = out;
const i = listeners.indexOf(listener);
~i && listeners.splice(i, 1);
}

function setState(update, overwrite, action) {
state = overwrite ? update : assign(assign({}, state), update);
let currentListeners = listeners;
for (let i=0; i<currentListeners.length; i++) currentListeners[i](state, action);
let i = listeners.length;
while (i-- > 0) listeners[i](state, action);
}

/** An observable state container, returned from {@link createStore}
Expand All @@ -46,15 +38,15 @@ export default function createStore(state) {
* @returns {Function} boundAction()
*/
action(action) {
function apply(result) {
setState(result, false, action);
function apply(update) {
setState(update, false, action);
}

// Note: perf tests verifying this implementation: https://esbench.com/bench/5a295e6299634800a0349500
return function() {
let args = [state];
for (let i=0; i<arguments.length; i++) args.push(arguments[i]);
let ret = action.apply(this, args);
const args = [state];
for (let i = 0; i<arguments.length; i++) args.push(arguments[i]);
const ret = action.apply(this, args);
if (ret!=null) {
if (ret.then) ret.then(apply);
else apply(ret);
Expand Down
4 changes: 2 additions & 2 deletions src/integrations/preact.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ export function connect(mapStateToProps, actions) {
return Child => {
function Wrapper(props, { store }) {
let state = mapStateToProps(store ? store.getState() : {}, props);
let boundActions = actions ? mapActions(actions, store) : { store };
let update = () => {
const boundActions = actions ? mapActions(actions, store) : { store };
const update = () => {
let mapped = mapStateToProps(store ? store.getState() : {}, this.props);
for (let i in mapped) if (mapped[i]!==state[i]) {
state = mapped;
Expand Down
6 changes: 3 additions & 3 deletions src/integrations/react.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ export function connect(mapStateToProps, actions) {
Component.call(this, props, context);
let { store } = context;
let state = mapStateToProps(store ? store.getState() : {}, props);
let boundActions = actions ? mapActions(actions, store) : { store };
let update = () => {
let mapped = mapStateToProps(store ? store.getState() : {}, this.props);
const boundActions = actions ? mapActions(actions, store) : { store };
const update = () => {
const mapped = mapStateToProps(store ? store.getState() : {}, this.props);
for (let i in mapped) if (mapped[i]!==state[i]) {
state = mapped;
return this.forceUpdate();
Expand Down
13 changes: 5 additions & 8 deletions src/util.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
// 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]);
}
const mapped = {};
for (let i in actions) mapped[i] = store.action(actions[i]);
return mapped;
}

Expand All @@ -13,10 +11,9 @@ export function mapActions(actions, store) {
export function select(properties) {
if (typeof properties==='string') properties = properties.split(/\s*,\s*/);
return state => {
let selected = {};
for (let i=0; i<properties.length; i++) {
selected[properties[i]] = state[properties[i]];
}
const selected = {};
let i = properties.length;
while (i-- > 0) selected[properties[i]] = state[properties[i]];
return selected;
};
}
Expand Down
46 changes: 46 additions & 0 deletions test/preact/unistore.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,52 @@ describe('createStore()', () => {
expect(sub2).toBeCalledWith(store.getState(), action);
});

it('should invoke all subscriptions at time of setState, no matter unsubscriptions', () => {
let store = createStore();

let called = [];
let unsub2;

let sub1 = jest.fn(() => {
called.push(1);
});
let sub2 = jest.fn(() => {
called.push(2);
unsub2(); // unsubscribe during a listener callback
});
let sub3 = jest.fn(() => {
called.push(3);
});

let unsub1 = store.subscribe(sub1);
unsub2 = store.subscribe(sub2);
let unsub3 = store.subscribe(sub3);

store.setState({ a: 'a' });

expect(sub1).toHaveBeenCalledTimes(1);
expect(sub2).toHaveBeenCalledTimes(1);
expect(sub3).toHaveBeenCalledTimes(1);
expect(called.sort()).toEqual([1, 2, 3]);

store.setState({ a: 'b' });

expect(sub1).toHaveBeenCalledTimes(2);
expect(sub2).toHaveBeenCalledTimes(1);
expect(sub3).toHaveBeenCalledTimes(2);
expect(called.sort()).toEqual([1, 1, 2, 3, 3]);

unsub1();
unsub3();

store.setState({ a: 'c' });

expect(sub1).toHaveBeenCalledTimes(2);
expect(sub2).toHaveBeenCalledTimes(1);
expect(sub3).toHaveBeenCalledTimes(2);
expect(called.sort()).toEqual([1, 1, 2, 3, 3]);
});

it('should unsubscribe', () => {
let store = createStore();

Expand Down
35 changes: 35 additions & 0 deletions test/react/unistore.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,41 @@ describe('createStore()', () => {
expect(sub1).toHaveBeenLastCalledWith(store.getState(), undefined);
expect(sub2).toBeCalledWith(store.getState(), undefined);
});
it('should invoke all subscriptions at time of setState, no matter unsubscriptions', () => {
let store = createStore();
let called = [];
let unsub2;
let sub1 = jest.fn(() => {
called.push(1);
});
let sub2 = jest.fn(() => {
called.push(2);
unsub2(); // unsubscribe during a listener callback
});
let sub3 = jest.fn(() => {
called.push(3);
});
let unsub1 = store.subscribe(sub1);
unsub2 = store.subscribe(sub2);
let unsub3 = store.subscribe(sub3);
store.setState({ a: 'a' });
expect(sub1).toHaveBeenCalledTimes(1);
expect(sub2).toHaveBeenCalledTimes(1);
expect(sub3).toHaveBeenCalledTimes(1);
expect(called.sort()).toEqual([1, 2, 3]);
store.setState({ a: 'b' });
expect(sub1).toHaveBeenCalledTimes(2);
expect(sub2).toHaveBeenCalledTimes(1);
expect(sub3).toHaveBeenCalledTimes(2);
expect(called.sort()).toEqual([1, 1, 2, 3, 3]);
unsub1();
unsub3();
store.setState({ a: 'c' });
expect(sub1).toHaveBeenCalledTimes(2);
expect(sub2).toHaveBeenCalledTimes(1);
expect(sub3).toHaveBeenCalledTimes(2);
expect(called.sort()).toEqual([1, 1, 2, 3, 3]);
});
it('should unsubscribe', () => {
let store = createStore();
let sub1 = jest.fn();
Expand Down

0 comments on commit 4be9b4b

Please sign in to comment.