├── .flowconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── declarations └── jasmine.js ├── karma.conf.js ├── package.json ├── scripts └── publish-to-npm.sh ├── src ├── _.js ├── create-fluce.js ├── fluce-component.js ├── index.js ├── reduce.js └── types.js └── test ├── _.spec.js ├── create-fluce.spec.js ├── fixtures.js ├── fluce-component.spec.js ├── helpers.js └── reduce.spec.js /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/enhanced-resolve/test/fixtures/node_modules/invalidPackageJson 3 | .*/phantomjs/node_modules/npmconf/test/fixtures/package.json 4 | .*/npm-pkg 5 | 6 | [include] 7 | 8 | [libs] 9 | declarations/ 10 | 11 | [options] 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | npm-pkg 30 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | before_script: 4 | - export DISPLAY=:99.0 5 | - sh -e /etc/init.d/xvfb start 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Roman Pominov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Project status 2 | 3 | It's unfinished and frozen for now because of existence of a better alternative — [Redux](https://github.com/gaearon/redux) 4 | 5 | # Fluce 6 | 7 | [![Dependency Status](https://david-dm.org/rpominov/fluce.svg)](https://david-dm.org/rpominov/fluce) 8 | [![devDependency Status](https://david-dm.org/rpominov/fluce/dev-status.svg)](https://david-dm.org/rpominov/fluce#info=devDependencies) 9 | [![Build Status](https://travis-ci.org/rpominov/fluce.svg)](https://travis-ci.org/rpominov/fluce) 10 | [![Join the chat at https://gitter.im/rpominov/fluce](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/rpominov/fluce?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 11 | 12 | Well, Flux again ... 13 | 14 | - lib agnostic, but with helpers for React 15 | - forces to use immutable data structures in stores, or just never mutate 16 | - forces to use pure functions in stores 17 | - stores are just reducers of actions to their state 18 | - server-side ready 19 | - without singletons 20 | 21 | The name is combined from "flux" and "reduce". 22 | 23 | 24 | ## Installation 25 | 26 | ``` 27 | $ npm install fluce 28 | ``` 29 | 30 | 31 | ## Store 32 | 33 | Store in Fluce is just an object with the following shape: 34 | 35 | ```js 36 | { 37 | initial: Function, 38 | reducers: { 39 | foo: Function, 40 | bar: Function, 41 | ... 42 | } 43 | } 44 | ``` 45 | 46 | Where `initial()` returns an initial _state_, and each of `reducers` is an action 47 | handler called with the current _state_ and the action's _payload_ as arguments 48 | returning a new _state_. Each reducer must be a pure function, that never 49 | mutate current state, but returns a new one instead. A reducer's name 50 | (e.g. `foo` above) is the action type that the reducer want to handle. 51 | 52 | ```js 53 | let myStore = { 54 | initial() { 55 | return myInitialState; 56 | }, 57 | reducers: { 58 | actionType1(currentStoreState, actionPayload) { 59 | return computeNewState(currentStoreState, actionPayload); 60 | }, 61 | actionType2(currentStoreState, actionPayload) { 62 | /* ... */ 63 | } 64 | } 65 | }; 66 | ``` 67 | 68 | 69 | ## Fluce instance 70 | 71 | You start use Fluce by creating an instance of it. Normally you want 72 | only one instance in the browser, but may want to create an instance 73 | for each request on the server. 74 | 75 | ```js 76 | let createFluce = require('fluce/create-fluce'); 77 | 78 | let fluce = createFluce(); 79 | ``` 80 | 81 | When an instance is created, you can add stores to it: 82 | 83 | ```js 84 | fluce.addStore('storeName1', myStore1); 85 | fluce.addStore('storeName2', myStore2); 86 | ``` 87 | 88 | After this is done, you can access each store's current state as `fluce.stores.storeName`, 89 | and dispatch actions with `fluce.dispatch('actionType', payload)`. 90 | Also you can subscribe to changes of stores' states: 91 | 92 | ```js 93 | let unsubscribe = fluce.subscribe(['storeName1', 'storeName2'], (updatedStoresNames) => { 94 | // you can read new state directly from `fluce.stores.storeName` 95 | }); 96 | 97 | // later... 98 | unsubscribe(); 99 | ``` 100 | 101 | Callback is called when some of specified stores change their state (via a reducer). 102 | If two or more stores change in response to a single action, the callback will 103 | be called only once. Also if a reducer returns same state, the store will be 104 | considered not changed. 105 | 106 | 107 | ## Example 108 | 109 | ```js 110 | let createFluce = require('fluce/create-fluce'); 111 | 112 | 113 | // Setup 114 | 115 | let fluce = createFluce(); 116 | 117 | fluce.addStore('counter', { 118 | initial() { 119 | return 0; 120 | }, 121 | reducers: { 122 | counterAdd(cur, x) { 123 | return cur + x; 124 | }, 125 | counterSubtract(cur, x) { 126 | return cur - x; 127 | } 128 | } 129 | }); 130 | 131 | fluce.addStore('counterInverted', { 132 | initial() { 133 | return 0; 134 | }, 135 | reducers: { 136 | counterAdd(cur, x) { 137 | return cur - x; 138 | }, 139 | counterSubtract(cur, x) { 140 | return cur + x; 141 | } 142 | } 143 | }); 144 | 145 | 146 | // Usage 147 | 148 | console.log(fluce.stores.counter); // => 0 149 | console.log(fluce.stores.counterInverted); // => 0 150 | 151 | fluce.actions.dispatch('counterAdd', 10); 152 | 153 | console.log(fluce.stores.counter); // => 10 154 | console.log(fluce.stores.counterInverted); // => -10 155 | 156 | fluce.subscribe(['counter', 'counterInverted'], (updated) => { 157 | console.log('following stores have updated:', updated); 158 | }); 159 | 160 | fluce.actions.dispatch('counterSubtract', 5); 161 | // => following stores have updated: ['counter', 'counterInverted'] 162 | ``` 163 | 164 | 165 | # In progress 166 | 167 | The features below aren't done yet. 168 | 169 | ## <Fluce /> React component 170 | 171 | `` is an helper component, you can use to subscribe to stores and 172 | fire actions from a component that know nothing about Fluce. It outputs nothing 173 | but it's child component to the result DOM. It can have only one child, and 174 | renders it with a bit of a magic (adds more props to it). 175 | 176 | `` accepts following props: 177 | 178 | - `fluce` — the Fluce instance to use, the property is optional if there is another `` up the tree with this property specified. 179 | - `stores` — object of the shape `{foo: 'storeName', bar: 'anotherStoreName', ...}`, containing name of stores from which you want to read. 180 | Current state of each of these stores will be always available on the child component's props (`foo` and `bar` are the props names). 181 | - `actionCreators` — object of shape `{foo: (fluce, arg1, agr2) => {...}, ...}`, containing action creators that will be available as props on the child component. You will be able to call them like this `this.props.foo(arg1, agr2)` (without providing the first argument `fluce` — an instance of Fluce). 182 | - `render` — a custom render function you can provide, that will be used instead of simply render child with additional props. 183 | 184 | 185 | ```js 186 | let Fluce = require('fluce/fluce-component'); 187 | 188 | class Counter extends React.Component { 189 | render() { 190 | 191 | // Also `this.props.fluce` will be available here, 192 | // but you shouldn't need it in most cases. 193 | 194 | return
195 | 196 | {this.props.counter} 197 | 198 |
; 199 | } 200 | } 201 | 202 | function increment(fluce) { 203 | fluce.dispatch('counterAdd', 1); 204 | } 205 | 206 | function decrement(fluce) { 207 | fluce.dispatch('counterSubtract', 1); 208 | } 209 | 210 | class App extends React.Component { 211 | constructor() { 212 | render() { 213 | return
214 | ... 215 | 219 | 220 | 221 | ... 222 |
; 223 | } 224 | } 225 | } 226 | 227 | React.render(, document.getElementById('root')); 228 | ``` 229 | 230 | And here is an example with custom render function: 231 | 232 | ```js 233 | { 237 | 238 | return ; 243 | 244 | }} 245 | /> 246 | 247 | ``` 248 | 249 | Internally we use [context](https://facebook.github.io/react/blog/2014/03/28/the-road-to-1.0.html#context) 250 | to pass `fluce` instance through components tree, and 251 | [`React.addons.cloneWithProps`](https://facebook.github.io/react/docs/clone-with-props.html) 252 | to add props to child component. 253 | 254 | 255 | ## Optimistic dispatch 256 | 257 | Thanks to pure action handlers we can support 258 | optimistic dispatch of actions. An optimistic dispatch can be canceled, 259 | in this case we simply roll back to the state before that action, 260 | and replay all actions except the canceled one. 261 | 262 | ```js 263 | fluce.addActionCreator('fooAdd', (fluce) => { 264 | return (foo) => { 265 | let action = fluce.optimisticallyDispatch('fooAdd', foo); 266 | addFooOnServer(foo) 267 | .then( 268 | // To confirm an optimistic dispatch is as important as to cancel, 269 | // because before it confirmed we have to collect 270 | // all actions (with payloads) that comes after the action in question. 271 | () => action.confirm(), 272 | () => action.cancel() 273 | ); 274 | }; 275 | }); 276 | ``` 277 | -------------------------------------------------------------------------------- /declarations/jasmine.js: -------------------------------------------------------------------------------- 1 | declare var describe: (text: string, callback: Function) => void 2 | declare var it: (text: string, callback: Function) => void 3 | declare var expect: (value: any) => { 4 | toBe: Function, 5 | toEqual: Function, 6 | not: { 7 | toBe: Function, 8 | toEqual: Function 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | config.set({ 3 | basePath: '', 4 | frameworks: ['jasmine'], 5 | files: [ 6 | 'test/**/*.spec.js' 7 | ], 8 | exclude: [], 9 | preprocessors: { 10 | 'test/**/*.spec.js': ['webpack'] 11 | }, 12 | webpack: { 13 | module: { 14 | loaders: [ 15 | {test: /\.js$/, loaders: ['babel'], exclude: /node_modules/} 16 | ] 17 | } 18 | }, 19 | reporters: ['progress'], 20 | port: 9876, 21 | colors: true, 22 | logLevel: config.LOG_INFO, 23 | autoWatch: true, 24 | browsers: ['Chrome'], 25 | singleRun: false 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fluce", 3 | "version": "0.1.0", 4 | "description": "Flux with immutable data and pure action handlers", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "karma start --browsers Firefox --single-run" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/pozadi/fluce.git" 12 | }, 13 | "keywords": [ 14 | "flux", 15 | "react" 16 | ], 17 | "author": "Roman Pominov (http://pozadi.github.io/)", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/pozadi/fluce/issues" 21 | }, 22 | "homepage": "https://github.com/pozadi/fluce#readme", 23 | "devDependencies": { 24 | "babel": "^5.4.7", 25 | "babel-core": "^5.4.7", 26 | "babel-loader": "^5.1.3", 27 | "jasmine-core": "^2.3.4", 28 | "karma": "^0.12.32", 29 | "karma-chrome-launcher": "^0.1.12", 30 | "karma-cli": "0.0.4", 31 | "karma-firefox-launcher": "^0.1.6", 32 | "karma-jasmine": "^0.3.5", 33 | "karma-phantomjs-launcher": "^0.2.0", 34 | "karma-webpack": "^1.5.1", 35 | "node-libs-browser": "^0.5.2", 36 | "phantomjs": "^1.9.17", 37 | "react": "^0.13.3", 38 | "webpack": "^1.9.9" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /scripts/publish-to-npm.sh: -------------------------------------------------------------------------------- 1 | rm -r ./npm-pkg && \ 2 | ./node_modules/.bin/babel src --out-dir npm-pkg && \ 3 | cp package.json ./npm-pkg && \ 4 | cp README.md ./npm-pkg && \ 5 | cd ./npm-pkg && \ 6 | npm publish 7 | -------------------------------------------------------------------------------- /src/_.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import type {map} from './types' 4 | 5 | export function shallowEq(a: map, b: map): boolean { 6 | var keysA = Object.keys(a) 7 | var keysB = Object.keys(b) 8 | var i 9 | var key 10 | if (keysA.length !== keysB.length) { 11 | return false 12 | } 13 | for (i = 0; i < keysA.length; i++) { 14 | key = keysA[i] 15 | if (keysB.indexOf(key) === -1 || a[key] !== b[key]) { 16 | return false 17 | } 18 | } 19 | return true 20 | } 21 | 22 | export function shallowPropsDiff(a: map, b: map): Array { 23 | var keysA = Object.keys(a) 24 | var keysB = Object.keys(b) 25 | var diff = [] 26 | var i 27 | var key 28 | for (i = 0; i < keysA.length; i++) { 29 | key = keysA[i] 30 | if (keysB.indexOf(key) === -1 || a[key] !== b[key]) { 31 | diff.push(key) 32 | } 33 | } 34 | for (i = 0; i < keysB.length; i++) { 35 | key = keysB[i] 36 | if (keysA.indexOf(key) === -1) { 37 | diff.push(key) 38 | } 39 | } 40 | return diff 41 | } 42 | 43 | export function assoc(key: string, value: any, source: map): map { 44 | var keys = Object.keys(source) 45 | var result = Object.create(null) 46 | var i 47 | for (i = 0; i < keys.length; i++) { 48 | result[keys[i]] = source[keys[i]] 49 | } 50 | result[key] = value 51 | return result 52 | } 53 | 54 | export function hasIntersection(smaller: Array, bigger: Array): boolean { 55 | var i 56 | for (i = 0; i < smaller.length; i++) { 57 | if (bigger.indexOf(smaller[i]) !== -1) { 58 | return true 59 | } 60 | } 61 | return false 62 | } 63 | 64 | export function pick(keys: Array, source: map): map { 65 | var result = Object.create(null) 66 | var i 67 | for (i = 0; i < keys.length; i++) { 68 | result[keys[i]] = source[keys[i]] 69 | } 70 | return result 71 | } 72 | 73 | export function eqArrays(a: Array, b: Array): boolean { 74 | var i 75 | if (a.length !== b.length) { 76 | return false 77 | } 78 | for (i = 0; i < a.length; i++) { 79 | if (a[i] !== b[i]) { 80 | return false 81 | } 82 | } 83 | return true 84 | } 85 | -------------------------------------------------------------------------------- /src/create-fluce.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import {shallowPropsDiff, assoc, hasIntersection, shallowEq} from './_' 4 | import {reduceAllStores} from './reduce' 5 | import type {ReplaceStateMiddleware, FluceInstance} from './types' 6 | 7 | 8 | export default function(): FluceInstance { 9 | 10 | var stores = Object.create(null) 11 | var listeners = [] 12 | 13 | function addStore(name, store) { 14 | replaceState(assoc(name, store.initial(), fluce.stores)) 15 | stores[name] = store 16 | } 17 | 18 | function dispatch(type, payload) { 19 | replaceState(reduceAllStores(stores, {type, payload}, fluce.stores)) 20 | } 21 | 22 | function subscribe(stores, callback) { 23 | var listener = {stores, callback} 24 | listeners = listeners.concat([listener]) 25 | return () => { 26 | listeners = listeners.filter(s => s !== listener) 27 | } 28 | } 29 | 30 | function replaceState(newState) { 31 | if (!shallowEq(fluce.stores, newState)) { 32 | var updatedStores = shallowPropsDiff(fluce.stores, newState) 33 | fluce.stores = newState 34 | notify(updatedStores) 35 | } 36 | } 37 | 38 | function notify(updatedStores) { 39 | listeners.forEach(listener => { 40 | if (hasIntersection(updatedStores, listener.stores)) { 41 | listener.callback(updatedStores) 42 | } 43 | }) 44 | } 45 | 46 | var fluce = { 47 | stores: Object.create(null), 48 | addStore, 49 | dispatch, 50 | subscribe, 51 | 52 | // for testing 53 | _countListeners() { 54 | return listeners.length 55 | } 56 | } 57 | 58 | return fluce 59 | } 60 | -------------------------------------------------------------------------------- /src/fluce-component.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | 4 | import React from 'react/addons' 5 | import {FluceInstance} from './types' 6 | import {pick, eqArrays} from './_' 7 | 8 | 9 | class Fluce extends React.Component { 10 | 11 | _unsubscribe: ?Function; 12 | 13 | constructor(props: {}) { 14 | super(props) 15 | this.state = { 16 | partialStoresState: Object.create(null) 17 | } 18 | } 19 | 20 | componentWillMount() { 21 | if (!this.getFluce()) { 22 | throw new Error('Could not find `fluce` on `this.props` or `this.context` of ') 23 | } 24 | this.subscribe(this.props.stores || []) 25 | this.updateLocalState(this.props.stores || []) 26 | } 27 | 28 | componentWillUnmount() { 29 | this.unsubscribe() 30 | } 31 | 32 | componentWillReceiveProps(nextProps: {stores: ?Array}) { 33 | var curStores = this.props.stores || [] 34 | var nextStores = nextProps.stores || [] 35 | 36 | if (!eqArrays(curStores, nextStores)) { 37 | this.unsubscribe() 38 | this.subscribe(nextStores) 39 | this.updateLocalState(nextStores) 40 | } 41 | } 42 | 43 | subscribe(stores: Array) { 44 | if (stores.length > 0) { 45 | this._unsubscribe = this.getFluce().subscribe(stores, () => this.updateLocalState(stores)) 46 | } 47 | } 48 | 49 | unsubscribe() { 50 | if (this._unsubscribe) { 51 | this._unsubscribe() 52 | this._unsubscribe = undefined 53 | } 54 | } 55 | 56 | updateLocalState(stores: Array) { 57 | var fluce = this.getFluce() 58 | this.setState({ 59 | partialStoresState: pick(stores, fluce.stores) 60 | }) 61 | } 62 | 63 | render(): ReactElement { 64 | return this.wrapChild(React.Children.only(this.props.children)) 65 | } 66 | 67 | wrapChild(child: ReactElement): ReactElement { 68 | // `React.cloneElement` doesn't preserve `context`. 69 | // See https://github.com/facebook/react/issues/4008 70 | return React.addons.cloneWithProps(child, this.getChildProps()) 71 | } 72 | 73 | getFluce(): FluceInstance { 74 | return this.props.fluce || this.context.fluce 75 | } 76 | 77 | getChildProps(): {} { 78 | return { 79 | fluce: this.getFluce(), 80 | stores: this.state.partialStoresState 81 | } 82 | } 83 | 84 | getChildContext(): {} { 85 | return { 86 | fluce: this.getFluce() 87 | } 88 | } 89 | 90 | } 91 | 92 | Fluce.propsTypes = { 93 | fluce: React.PropTypes.object, 94 | stores: React.PropTypes.array 95 | } 96 | 97 | Fluce.contextTypes = { 98 | fluce: React.PropTypes.object 99 | } 100 | 101 | Fluce.childContextTypes = { 102 | fluce: React.PropTypes.object 103 | } 104 | 105 | 106 | 107 | export default Fluce 108 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Dummy file for npm, doesn't export anything. 2 | // One supposed to import concrete modules: 3 | // 4 | // const createFluce = require('fluce/create-fluce') 5 | -------------------------------------------------------------------------------- /src/reduce.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import type {Store, Action, Stores, StoreStates} from './types' 4 | 5 | 6 | var noopReducer = (state, payload) => state 7 | 8 | export function reduceStore(store: Store, action: Action, state: any): any { 9 | var reducer = store.reducers[action.type] || noopReducer 10 | return reducer(state, action.payload) 11 | } 12 | 13 | export function reduceAllStores(stores: Stores, action: Action, curState: StoreStates): StoreStates { 14 | var storeNames = Object.keys(stores) 15 | var newState = {} 16 | storeNames.forEach(storeName => { 17 | newState[storeName] = reduceStore(stores[storeName], action, curState[storeName]) 18 | }) 19 | return newState 20 | } 21 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | export type map = {[key: string]: any} 4 | 5 | export type ReplaceState = (newState: map) => void 6 | export type ReplaceStateMiddleware = (replace: ReplaceState) => ReplaceState 7 | 8 | export type Reducer = (state: any, payload: any) => any 9 | export type Store = {initial: () => any, reducers: {[key: string]: Reducer}} 10 | 11 | export type Action = {type: string; payload: any} 12 | export type Stores = {[key: string]: Store} 13 | export type StoreStates = {[key: string]: any} 14 | 15 | export type FluceInstance = { 16 | stores: {[key: string]: any}, 17 | addStore(name: string, store: Store): void, 18 | dispatch(type: string, payload: any): void, 19 | subscribe(stores: Array, callback: (updatedStores: Array) => void): () => void, 20 | _countListeners(): number 21 | } 22 | -------------------------------------------------------------------------------- /test/_.spec.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import * as _ from '../src/_' 4 | import {removeProto} from './helpers' 5 | 6 | 7 | describe('shallowEq', () => { 8 | it('should work', () => { 9 | expect(_.shallowEq({}, {})).toBe(true) 10 | expect(_.shallowEq({a: 1}, {})).toBe(false) 11 | expect(_.shallowEq({}, {a: 1})).toBe(false) 12 | expect(_.shallowEq({a: 2}, {a: 1})).toBe(false) 13 | expect(_.shallowEq({a: 2}, {a: 2})).toBe(true) 14 | expect(_.shallowEq({a: 2, b: 1}, {a: 2})).toBe(false) 15 | expect(_.shallowEq({a: 2}, {a: 2, b: 1})).toBe(false) 16 | expect(_.shallowEq({a: 2, b: 2}, {a: 2, b: 1})).toBe(false) 17 | expect(_.shallowEq({a: 2, b: 2}, {a: 2, b: 2})).toBe(true) 18 | }) 19 | }) 20 | 21 | describe('shallowPropsDiff', () => { 22 | it('should work', () => { 23 | expect(_.shallowPropsDiff({}, {})).toEqual([]) 24 | expect(_.shallowPropsDiff({a: 1}, {})).toEqual(['a']) 25 | expect(_.shallowPropsDiff({}, {a: 1})).toEqual(['a']) 26 | expect(_.shallowPropsDiff({a: 2}, {a: 1})).toEqual(['a']) 27 | expect(_.shallowPropsDiff({a: 2}, {a: 2})).toEqual([]) 28 | expect(_.shallowPropsDiff({a: 2, b: 1}, {a: 2})).toEqual(['b']) 29 | expect(_.shallowPropsDiff({a: 2}, {a: 2, b: 1})).toEqual(['b']) 30 | expect(_.shallowPropsDiff({a: 2, b: 2}, {a: 2, b: 1})).toEqual(['b']) 31 | expect(_.shallowPropsDiff({a: 2, b: 2}, {a: 1, b: 1})).toEqual(['a', 'b']) 32 | expect(_.shallowPropsDiff({a: 2, b: 2}, {a: 2, b: 2})).toEqual([]) 33 | }) 34 | }) 35 | 36 | describe('assoc', () => { 37 | it('should not mutate', () => { 38 | var map = {} 39 | expect(_.assoc('a', 1, {})).not.toBe(map) 40 | }) 41 | it('result\'s prototype should be null', () => { 42 | expect(_.assoc('a', 1, {}).toString).toBe(undefined) 43 | }) 44 | it('should work', () => { 45 | expect(_.assoc('a', 1, {})).toEqual(removeProto({a: 1})) 46 | expect(_.assoc('a', 1, {a: 0})).toEqual(removeProto({a: 1})) 47 | expect(_.assoc('a', 1, {b: 0})).toEqual(removeProto({a: 1, b: 0})) 48 | }) 49 | }) 50 | 51 | describe('hasIntersection', () => { 52 | it('should work', () => { 53 | expect(_.hasIntersection([], [])).toBe(false) 54 | expect(_.hasIntersection([1], [])).toBe(false) 55 | expect(_.hasIntersection([], [1])).toBe(false) 56 | expect(_.hasIntersection([1], [1])).toBe(true) 57 | expect(_.hasIntersection([1], [2])).toBe(false) 58 | expect(_.hasIntersection([1], [2, 1])).toBe(true) 59 | expect(_.hasIntersection([2, 1], [1])).toBe(true) 60 | }) 61 | }) 62 | 63 | describe('pick', () => { 64 | it('should work', () => { 65 | expect(_.pick(['a', 'b', 'c'], {a: 1, b: 2, d: 3, e: 4})).toEqual(removeProto({a: 1, b: 2, c: undefined})) 66 | }) 67 | }) 68 | 69 | describe('eqArrays', () => { 70 | it('should work', () => { 71 | expect(_.eqArrays([], [])).toBe(true) 72 | expect(_.eqArrays([1], [])).toBe(false) 73 | expect(_.eqArrays([], [1])).toBe(false) 74 | expect(_.eqArrays([1], [1])).toBe(true) 75 | expect(_.eqArrays([1, 2], [1])).toBe(false) 76 | expect(_.eqArrays([1], [1, 2])).toBe(false) 77 | expect(_.eqArrays([1, 2], [1, 2])).toBe(true) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /test/create-fluce.spec.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import createFluce from '../src/create-fluce' 4 | import {storeCounter, storeCounter2} from './fixtures' 5 | 6 | describe('createFluce', () => { 7 | 8 | it('should return empty instance', () => { 9 | var fluce = createFluce() 10 | expect(fluce.stores).toEqual(Object.create(null)) 11 | }) 12 | 13 | describe('.addStore', () => { 14 | it('should add initial state to the .stores', () => { 15 | var fluce = createFluce() 16 | fluce.addStore('counter', storeCounter) 17 | expect(fluce.stores.counter).toBe(storeCounter.initial()) 18 | }) 19 | }) 20 | 21 | describe('.dispatch', () => { 22 | it('should update stores state', () => { 23 | var fluce = createFluce() 24 | fluce.addStore('counter', storeCounter) 25 | fluce.addStore('counter2', storeCounter2) 26 | fluce.dispatch('add', 1) 27 | expect(fluce.stores.counter).toBe(1) 28 | expect(fluce.stores.counter2).toBe(-1) 29 | }) 30 | }) 31 | 32 | describe('.subscribe', () => { 33 | it('listeners should be notified', () => { 34 | var log = [] 35 | var fluce = createFluce() 36 | fluce.addStore('counter', storeCounter) 37 | fluce.addStore('counter2', storeCounter2) 38 | fluce.addStore('counter3', storeCounter) 39 | fluce.subscribe(['counter', 'counter2'], (updated) => {log.push(updated)}) 40 | fluce.dispatch('add', 1) 41 | expect(log).toEqual([['counter', 'counter2', 'counter3']]) 42 | fluce.dispatch('subtract', 1) 43 | expect(log).toEqual([['counter', 'counter2', 'counter3'], ['counter', 'counter3']]) 44 | fluce.dispatch('foo', 1) 45 | expect(log).toEqual([['counter', 'counter2', 'counter3'], ['counter', 'counter3']]) 46 | }) 47 | it('listeners shouldn\'t be notified if state doesn\'t change', () => { 48 | var log = [] 49 | var fluce = createFluce() 50 | fluce.addStore('counter', storeCounter) 51 | fluce.subscribe(['counter'], (updated) => {log.push(updated)}) 52 | fluce.dispatch('add', 0) 53 | expect(log).toEqual([]) 54 | }) 55 | }) 56 | 57 | }) 58 | -------------------------------------------------------------------------------- /test/fixtures.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | 4 | 5 | // Stores 6 | 7 | export var storeCounter = { 8 | initial(): number {return 0}, 9 | reducers: { 10 | add(cur: number, x: number): number {return cur + x}, 11 | subtract(cur: number, x: number): number {return cur - x} 12 | } 13 | } 14 | 15 | export var storeCounter2 = { 16 | initial(): number {return 0}, 17 | reducers: { 18 | add(cur: number, x: number): number {return cur - x}, 19 | multiply(cur: number, x: number): number {return cur * x} 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/fluce-component.spec.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import React from 'react' 4 | import {addons} from 'react/addons' 5 | var {TestUtils} = addons 6 | var {createRenderer} = TestUtils 7 | 8 | import Fluce from '../src/fluce-component' 9 | import createFluce from '../src/create-fluce' 10 | 11 | import {withDOM, cleanHtml, renderToHtml, removeProto} from './helpers' 12 | import {storeCounter, storeCounter2} from './fixtures' 13 | 14 | 15 | 16 | describe('', () => { 17 | 18 | 19 | it('Should throw when rendered with many children', () => { 20 | var renderer = createRenderer() 21 | var err: any 22 | try { 23 | renderer.render(
) 24 | } catch (e) { 25 | err = e 26 | } 27 | expect(err.message).toBe('Invariant Violation: onlyChild must be passed a children with exactly one child.') 28 | renderer.unmount() 29 | }) 30 | 31 | 32 | it('Should add `fluce` and `stores` to the child\'s props', () => { 33 | var fluce = createFluce() 34 | 35 | var renderer = createRenderer() 36 | renderer.render(
) 37 | var result = renderer.getRenderOutput() 38 | renderer.unmount() 39 | 40 | expect(result.type).toBe('div') 41 | expect(result.props).toEqual({foo: 'bar', fluce, stores: Object.create(null)}) 42 | }) 43 | 44 | 45 | describe('Should transfer Fluce instance using context', () => { 46 | 47 | var fluce = createFluce() 48 | fluce.addStore('test', { 49 | initial() { 50 | return '123' 51 | }, 52 | reducers: {} 53 | }) 54 | 55 | class Test extends React.Component { 56 | render() { 57 | return
{this.props.fluce.stores.test}
58 | } 59 | } 60 | 61 | // Here `fluce` also passed through props, so I'm not sure what works 62 | it('... without wrapper', () => { 63 | expect(renderToHtml( 64 | 65 | 66 | 67 | 68 | 69 | )).toBe('
123
') 70 | }) 71 | it('... all in wrapper', () => { 72 | class Wrap extends React.Component { 73 | render() { 74 | return 75 | 76 | 77 | 78 | 79 | } 80 | } 81 | expect(renderToHtml()).toBe('
123
') 82 | }) 83 | 84 | // This doesn't work in React 0.13, but will on 0.14 85 | // 86 | // it('... without wrapper, one layer deeper', () => { 87 | // expect(renderToHtml( 88 | // 89 | //
90 | // 91 | // 92 | // 93 | //
94 | //
95 | // )).toBe('
123
') 96 | // }) 97 | // it('... all in wrapper, one layer deeper', () => { 98 | // class Wrap extends React.Component { 99 | // render() { 100 | // return 101 | //
102 | // 103 | // 104 | // 105 | //
106 | //
107 | // } 108 | // } 109 | // expect(renderToHtml()).toBe('
123
') 110 | // }) 111 | 112 | it('... in a wrapper', () => { 113 | class Wrap extends React.Component { 114 | render() { 115 | return 116 | } 117 | } 118 | expect(renderToHtml( 119 | 120 | 121 | 122 | )).toBe('
123
') 123 | }) 124 | 125 | it('... in a wrapper, one layer deeper', () => { 126 | class Wrap extends React.Component { 127 | render() { 128 | return
129 | } 130 | } 131 | expect(renderToHtml( 132 | 133 | 134 | 135 | )).toBe('
123
') 136 | }) 137 | 138 | }) 139 | 140 | 141 | 142 | 143 | describe('should pass stores to props', () => { 144 | 145 | var fluce = createFluce() 146 | fluce.addStore('counter', storeCounter) 147 | fluce.addStore('counter2', storeCounter2) 148 | fluce.addStore('counter3', storeCounter) 149 | fluce.addStore('counter4', storeCounter2) 150 | 151 | it('should pass initial state', () => { 152 | var renderer = createRenderer() 153 | renderer.render(
) 154 | expect(renderer.getRenderOutput().props.stores).toEqual(removeProto({counter: 0, counter2: 0})) 155 | renderer.unmount() 156 | }) 157 | 158 | it('should pass updated state', () => { 159 | var renderer = createRenderer() 160 | renderer.render(
) 161 | fluce.dispatch('add', 10) 162 | expect(renderer.getRenderOutput().props.stores).toEqual(removeProto({counter: 10, counter2: -10})) 163 | fluce.dispatch('add', -10) 164 | renderer.unmount() 165 | }) 166 | 167 | it('should unsubscribe', () => { 168 | var renderer = createRenderer() 169 | var countBefore = fluce._countListeners() 170 | renderer.render(
) 171 | var countAfter = fluce._countListeners() 172 | renderer.unmount() 173 | var countAfter2 = fluce._countListeners() 174 | expect([countBefore, countAfter, countAfter2]).toEqual([0, 1, 0]) 175 | }) 176 | 177 | it('should respect change of `stores` prop', () => { 178 | var renderer = createRenderer() 179 | renderer.render(
) 180 | renderer.render(
) 181 | expect(renderer.getRenderOutput().props.stores).toEqual(removeProto({counter: 0})) 182 | renderer.unmount() 183 | }) 184 | 185 | it('should respect change of `stores` prop (should re-subscribe)', () => { 186 | var renderer = createRenderer() 187 | renderer.render(
) 188 | renderer.render(
) 189 | fluce.dispatch('add', 10) 190 | expect(renderer.getRenderOutput().props.stores).toEqual(removeProto({counter: 10, counter2: -10})) 191 | fluce.dispatch('add', -10) 192 | renderer.unmount() 193 | expect(fluce._countListeners()).toBe(0) 194 | }) 195 | 196 | it('should respect change of `stores` prop (real DOM)', () => { 197 | class PrintStores extends React.Component { 198 | render() { 199 | return
{JSON.stringify(this.props.stores)}
200 | } 201 | } 202 | withDOM(el => { 203 | React.render(, el) 204 | expect(cleanHtml(el.innerHTML)).toBe('
{"counter":0,"counter2":0}
') 205 | React.render(, el) 206 | expect(cleanHtml(el.innerHTML)).toBe('
{"counter":0}
') 207 | }) 208 | }) 209 | 210 | }) 211 | 212 | 213 | 214 | 215 | 216 | }) 217 | -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import React from 'react' 4 | 5 | 6 | export function withDOM(cb: (el: Element) => T): T { 7 | var div = document.createElement('div') 8 | document.body.appendChild(div) 9 | var result = cb(div) 10 | document.body.removeChild(div) 11 | return result 12 | } 13 | 14 | export function cleanHtml(html: string): string { 15 | return html.replace(/ data\-reactid=".*?"/g, '') 16 | } 17 | 18 | export function renderToHtml(tree: ReactElement): string { 19 | return withDOM(el => { 20 | React.render(tree, el) 21 | return cleanHtml(el.innerHTML) 22 | }) 23 | } 24 | 25 | 26 | type map = {[key: string]: any} 27 | 28 | // Converts Object({...}) to null({...}) 29 | export function removeProto(source: map): map { 30 | var result = Object.create(null) 31 | var keys = Object.keys(source) 32 | var i 33 | for (i = 0; i < keys.length; i++) { 34 | result[keys[i]] = source[keys[i]] 35 | } 36 | return result 37 | } 38 | -------------------------------------------------------------------------------- /test/reduce.spec.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import {reduceStore, reduceAllStores} from '../src/reduce' 4 | import {storeCounter, storeCounter2} from './fixtures' 5 | 6 | 7 | 8 | var actionAdd5 = {type: 'add', payload: 5} 9 | var actionSubtract7 = {type: 'subtract', payload: 7} 10 | var actionMult2 = {type: 'multiply', payload: 2} 11 | 12 | 13 | 14 | describe('reduceStore', () => { 15 | 16 | it('should return new state, if the store has the action handler', () => { 17 | expect(reduceStore(storeCounter, actionAdd5, 5)).toBe(10) 18 | }) 19 | 20 | it('should return same state, if the store doesn\'t have the action handler', () => { 21 | var state = {} 22 | expect(reduceStore(storeCounter, actionMult2, state)).toBe(state) 23 | }) 24 | 25 | }) 26 | 27 | 28 | describe('reduceAllStores', () => { 29 | 30 | var counterStores = { 31 | counter: storeCounter, 32 | counter2: storeCounter2 33 | } 34 | 35 | it('update all stores', () => { 36 | var states = { 37 | counter: 0, 38 | counter2: 5 39 | } 40 | expect(reduceAllStores(counterStores, actionAdd5, states)).toEqual({counter: 5, counter2: 0}) 41 | }) 42 | 43 | it('update some stores', () => { 44 | var states = { 45 | counter: {test: 'test'}, 46 | counter2: 5 47 | } 48 | expect(reduceAllStores(counterStores, actionMult2, states)).toEqual({counter: {test: 'test'}, counter2: 10}) 49 | }) 50 | 51 | 52 | }) 53 | --------------------------------------------------------------------------------