diff --git a/.travis.yml b/.travis.yml index 98d32a7..5ae4894 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,3 @@ language: node_js node_js: - - stable \ No newline at end of file + - 10 diff --git a/README.md b/README.md index 1ddf7f5..3d82d21 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ # unistore -> A tiny ~650b centralized state container with component bindings for [Preact] & [React]. +> A tiny 350b centralized state container with component bindings for [Preact] & [React]. -- **Small** footprint complements Preact nicely +- **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 @@ -69,51 +69,95 @@ You can find the library on `window.unistore`. import createStore from 'unistore' import { Provider, connect } from 'unistore/preact' -let store = createStore({ count: 0 }) +let store = createStore({ count: 0, stuff: [] }) -// If actions is a function, it gets passed the store: -let actions = store => ({ +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 - //check this function as + + + ) +) + +// 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 default () => ( +export const getApp1 = () => ( + + + +) + +export const getApp2 = () => ( - + ) ``` diff --git a/index.d.ts b/index.d.ts index 7402c2a..98f9353 100644 --- a/index.d.ts +++ b/index.d.ts @@ -6,7 +6,7 @@ export type Listener = (state: K, action?: Action) => void; export type Unsubscribe = () => void; export type Action = (state: K, ...args: any[]) => void; -export type BoundAction = () => void; +export type BoundAction = (...args: any[]) => void; export interface Store { action(action: Action): BoundAction; @@ -18,7 +18,7 @@ export interface Store { export default function createStore(state?: K): Store; -export type ActionFn = (state: K) => object; +export type ActionFn = (state: K, ...args: any[]) => Promise> | Partial | void; export interface ActionMap { [actionName: string]: ActionFn; diff --git a/package.json b/package.json index 5260a7b..67ec7ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "unistore", - "version": "3.1.1", + "version": "3.5.2", "description": "Dead simple centralized state container (store) with preact and react bindings.", "source": "src/index.js", "module": "dist/unistore.es.js", @@ -10,8 +10,8 @@ "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 --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, \"- \"))'", @@ -28,7 +28,7 @@ "bundlesize": [ { "path": "full/preact.js", - "maxSize": "750b" + "maxSize": "760b" }, { "path": "dist/unistore.js", @@ -45,7 +45,7 @@ "env", { "targets": { - "node": true + "node": "current" } } ] @@ -73,7 +73,8 @@ "index.d.ts", "preact.d.ts", "react.d.ts", - "devtools.js" + "devtools.js", + "devtools.d.ts" ], "keywords": [ "preact", @@ -85,27 +86,31 @@ "author": "Jason Miller ", "license": "MIT", "devDependencies": { - "babel-jest": "^21.2.0", + "babel-jest": "^24.3.1", "babel-plugin-transform-react-jsx": "^6.24.1", "babel-preset-env": "^1.6.1", - "bundlesize": "^0.15.3", + "bundlesize": "^0.17.1", "documentation": "^4.0.0", - "enzyme": "^3.2.0", - "enzyme-adapter-react-16": "^1.1.1", + "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": "^23.6.0", - "microbundle": "^0.4.3", + "jest": "^24.3.1", + "microbundle": "^0.11.0", "npm-run-all": "^4.1.2", - "preact": "^8.3.1", + "preact": "^10.0.0", "raf": "^3.4.0", - "react": "^16.2.0", - "react-dom": "^16.2.0", + "react": "^16.8.4", + "react-dom": "^16.8.4", "strip-json-comments-cli": "^1.0.1" }, - "peerDependencies": { - "preact": "*", - "react": "*" + "peerDependenciesMeta": { + "preact": { + "optional": true + }, + "react": { + "optional": true + } } } diff --git a/preact.d.ts b/preact.d.ts index bc8fcf6..6fc65f0 100644 --- a/preact.d.ts +++ b/preact.d.ts @@ -3,21 +3,22 @@ // K - Store state // I - Injected props to wrapped component -declare module "unistore/preact" { - import * as Preact from "preact"; - import { ActionCreator, StateMapper, Store } from "unistore"; +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; - + ): ( + Child: Preact.ComponentConstructor | Preact.AnyComponent + ) => Preact.ComponentConstructor; export interface ProviderProps { store: Store; } export class Provider extends Preact.Component> { - render(props: ProviderProps): JSX.Element; + render(props: ProviderProps): Preact.JSX.Element; } } diff --git a/react.d.ts b/react.d.ts index b935f1a..1c95355 100644 --- a/react.d.ts +++ b/react.d.ts @@ -3,14 +3,16 @@ // K - Store state // I - Injected props to wrapped component -declare module "unistore/react" { - import * as React from "react"; - import { ActionCreator, StateMapper, Store } from "unistore"; +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.ComponentClass; + ): ( + Child: ((props: T & I) => React.ReactNode) | React.ComponentClass | React.FC + ) => React.ComponentClass | React.FC; export interface ProviderProps { store: Store; @@ -21,6 +23,6 @@ declare module "unistore/react" { } interface ComponentConstructor

{ - new(props: P, context?: any): React.Component; + new (props: P, context?: any): React.Component; } } diff --git a/src/integrations/preact.js b/src/integrations/preact.js index 9300c4a..81c09e3 100644 --- a/src/integrations/preact.js +++ b/src/integrations/preact.js @@ -16,25 +16,29 @@ import { assign, mapActions, select } from '../util'; * export class Foo { render({ foo, bar }) { } } */ export function connect(mapStateToProps, actions) { - if (typeof mapStateToProps!=='function') { - mapStateToProps = select(mapStateToProps || []); + if (typeof mapStateToProps!='function') { + mapStateToProps = select(mapStateToProps || {}); } return Child => { - function Wrapper(props, { store }) { + function Wrapper(props, context) { + const store = context.store; let state = mapStateToProps(store ? store.getState() : {}, props); - let boundActions = actions ? mapActions(actions, store) : { store }; + const boundActions = actions ? mapActions(actions, store) : { store }; let update = () => { - let mapped = mapStateToProps(store ? store.getState() : {}, this.props); + let mapped = mapStateToProps(store ? store.getState() : {}, props); for (let i in mapped) if (mapped[i]!==state[i]) { state = mapped; - return this.setState(); + return this.setState({}); } for (let i in state) if (!(i in mapped)) { state = mapped; - return this.setState(); + return this.setState({}); } }; - this.componentWillReceiveProps = update; + this.componentWillReceiveProps = p => { + props = p; + update(); + }; this.componentDidMount = () => { store.subscribe(update); }; @@ -60,4 +64,4 @@ export function connect(mapStateToProps, actions) { export function Provider(props) { this.getChildContext = () => ({ store: props.store }); } -Provider.prototype.render = props => props.children[0]; +Provider.prototype.render = props => props.children && props.children[0] || props.children; diff --git a/src/integrations/react.js b/src/integrations/react.js index 1c35ef2..672b3db 100644 --- a/src/integrations/react.js +++ b/src/integrations/react.js @@ -25,11 +25,11 @@ export function connect(mapStateToProps, actions) { return Child => { function Wrapper(props, context) { Component.call(this, props, context); - let { store } = context; + const store = context.store; let state = mapStateToProps(store ? store.getState() : {}, props); - let boundActions = actions ? mapActions(actions, store) : { store }; + const boundActions = actions ? mapActions(actions, store) : { store }; let update = () => { - let mapped = mapStateToProps(store ? store.getState() : {}, this.props); + let mapped = mapStateToProps(store ? store.getState() : {}, props); for (let i in mapped) if (mapped[i]!==state[i]) { state = mapped; return this.forceUpdate(); @@ -39,7 +39,10 @@ export function connect(mapStateToProps, actions) { return this.forceUpdate(); } }; - this.componentWillReceiveProps = update; + this.UNSAFE_componentWillReceiveProps = p => { + props = p; + update(); + }; this.componentDidMount = () => { store.subscribe(update); }; diff --git a/test/fixtures/preact-8.min.js b/test/fixtures/preact-8.min.js new file mode 100644 index 0000000..1b32b1b --- /dev/null +++ b/test/fixtures/preact-8.min.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +!function(){"use strict";function e(e,t){var n,o,r,i,l=W;for(i=arguments.length;i-- >2;)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 { + 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 new file mode 100644 index 0000000..a0c1ce9 --- /dev/null +++ b/test/preact/preact.test.js @@ -0,0 +1,215 @@ +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/preact/unistore.test.js b/test/preact/unistore.test.js deleted file mode 100644 index d7169f0..0000000 --- a/test/preact/unistore.test.js +++ /dev/null @@ -1,270 +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) ); - -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(); - }); -}); - -describe('', () => { - it('should provide props into context', () => { - const Child = jest.fn(); - - render( - - - , - document.body - ); - expect(Child).toHaveBeenCalledWith(expect.anything(), { store: 'a' }); - - let store = { name: 'obj' }; - render( - - - , - document.body - ); - expect(Child).toHaveBeenCalledWith(expect.anything(), { store }); - }); -}); - -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 transform string selector', () => { - let state = { a: 'b', b: 'c', c: 'd' }; - const store = { subscribe: 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: 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.mockClear(); - - 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.mockClear(); - - store.setState({ c: 'd' }); - await sleep(1); - expect(Child).not.toHaveBeenCalled(); - }); - - it('should run mapStateToProps and update when outer props change', () => { - let state = {}; - const store = { subscribe: jest.fn(), getState: () => state }; - const Child = jest.fn().mockName(''); - let mappings = 0; - - // Jest mock return values are broken :( - const mapStateToProps = jest.fn((state, props) => ({ mappings: ++mappings, ...props })); - - // const mapper = jest.fn(); - // const mapStateToProps = (state, props) => { - // mapper(state, props); - // mappings++; - // return { mappings, ...props }; - // }; - - const ConnectedChild = connect(mapStateToProps)(Child); - let root = render( - - - , - document.body - ); - - expect(mapStateToProps).toHaveBeenCalledTimes(1); - expect(mapStateToProps).toHaveBeenCalledWith({}, { children: expect.anything() }); - // first render calls mapStateToProps - expect(Child).toHaveBeenCalledWith( - { mappings: 1, store, children: expect.anything() }, - expect.anything() - ); - - mapStateToProps.mockClear(); - Child.mockClear(); - - render( - - - , - document.body, - root - ); - - expect(mapStateToProps).toHaveBeenCalledTimes(1); - expect(mapStateToProps).toHaveBeenCalledWith({}, { children: expect.anything() }); - // outer props were changed - expect(Child).toHaveBeenCalledWith( - { mappings: 2, a: 'b', store, children: expect.anything() }, - expect.anything() - ); - - mapStateToProps.mockClear(); - Child.mockClear(); - - render( - - - , - document.body, - root - ); - - // re-rendered, but outer props were not changed - expect(Child).toHaveBeenCalledWith( - { mappings: 3, a: 'b', store, children: expect.anything() }, - expect.anything() - ); - }); -}); diff --git a/test/react/unistore.test.js b/test/react/react.test.js similarity index 64% rename from test/react/unistore.test.js rename to test/react/react.test.js index d8756e6..00ca647 100644 --- a/test/react/unistore.test.js +++ b/test/react/react.test.js @@ -12,83 +12,7 @@ configure({ adapter: new Adapter() }); const sleep = ms => 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).toBeInstanceOf(Function); - store.setState({ a: 'b' }); - expect(sub1).toHaveBeenCalledTimes(1); - expect(sub1).toHaveBeenCalledWith(store.getState(), undefined); - store.subscribe(sub2); - store.setState({ c: 'd' }); - expect(sub1).toHaveBeenCalledTimes(2); - expect(sub1).toHaveBeenLastCalledWith(store.getState(), undefined); - expect(sub2).toBeCalledWith(store.getState(), undefined); - }); - 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(); - }); -}); - -describe('', () => { +describe('integrations/react', () => { const createChild = (storeKey = 'store') => { class Child extends Component { render() { @@ -240,7 +164,7 @@ describe('', () => { // await sleep(100); expect(mapStateToProps).toHaveBeenCalledTimes(1); - expect(mapStateToProps).toHaveBeenCalledWith({}, {}); + expect(mapStateToProps).toHaveBeenCalledWith({}, { a: 'b' }); // outer props were changed expect(Child).toHaveBeenCalledWith( { mappings: 2, a: 'b', store }, diff --git a/test/unistore.test.js b/test/unistore.test.js new file mode 100644 index 0000000..7bc3a40 --- /dev/null +++ b/test/unistore.test.js @@ -0,0 +1,97 @@ +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(); + }); +});