├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── index.js ├── internals └── utils.js ├── lib ├── ImmutableStore.js ├── createImmutableContainer.js ├── createImmutableMixin.js ├── createImmutableStore.js └── index.js ├── mixins ├── ImmutableMixin.js └── index.js ├── package.json └── tests ├── lib ├── ImmutableStore.js ├── createImmutableContainer.js ├── createImmutableMixin.js └── createImmutableStore.js └── mixins └── ImmutableMixin.js /.eslintignore: -------------------------------------------------------------------------------- 1 | example.js 2 | build/** 3 | artifacts/** 4 | lib/ImmutableStore.js -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "browser": true 5 | }, 6 | "rules": { 7 | "array-bracket-spacing": [2, "never"], 8 | "computed-property-spacing": [2, "never"], 9 | "consistent-this": [2, "self"], 10 | "guard-for-in": 2, 11 | "handle-callback-err": 2, 12 | "indent" : 2, 13 | "keyword-spacing": 2, 14 | "new-cap": 0, 15 | "no-catch-shadow": 2, 16 | "no-else-return": 2, 17 | "no-floating-decimal": 2, 18 | "no-inline-comments": 1, 19 | "no-lonely-if": 2, 20 | "no-multiple-empty-lines": 2, 21 | "no-nested-ternary": 2, 22 | "no-self-compare": 2, 23 | "no-sync": 0, 24 | "no-throw-literal": 2, 25 | "no-underscore-dangle" : 0, 26 | "no-unused-expressions" : 1, 27 | "no-unused-vars" : [ 2, { "args" : "none" } ], 28 | "no-void": 2, 29 | "no-warning-comments": 1, 30 | "object-curly-spacing": [2, "never"], 31 | "padded-blocks" : [ 2, "never"], 32 | "quotes" : [ 2,"single"], 33 | "radix": 2, 34 | "space-before-function-paren" : [ 2, {"anonymous": "always", "named": "never"}], 35 | "valid-jsdoc": [2], 36 | "vars-on-top": 0, 37 | "wrap-iife" : 2, 38 | "wrap-regex": 2 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.tgz 3 | .nyc_output 4 | artifacts 5 | node_modules -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.tgz 3 | .eslintignore 4 | .eslintrc 5 | .npmignore 6 | .nyc_output 7 | .travis.yml 8 | artifacts 9 | tests 10 | artifacts 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "8" 5 | - "6" 6 | - "4" 7 | after_success: 8 | - "cat artifacts/lcov.info | ./node_modules/coveralls/bin/coveralls.js" 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Yahoo Inc. All rights reserved. 2 | 3 | Redistribution and use of this software in source and binary forms, 4 | with or without modification, are permitted provided that the following 5 | conditions are met: 6 | 7 | * Redistributions of source code must retain the above 8 | copyright notice, this list of conditions and the 9 | following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the 13 | following disclaimer in the documentation and/or other 14 | materials provided with the distribution. 15 | 16 | * Neither the name of Yahoo Inc. nor the names of its 17 | contributors may be used to endorse or promote products 18 | derived from this software without specific prior 19 | written permission of Yahoo Inc. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 22 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 23 | TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 24 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 25 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 26 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 27 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 28 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 29 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 30 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 31 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fluxible-immutable-utils 2 | 3 | [![npm version](https://badge.fury.io/js/fluxible-immutable-utils.svg)](http://badge.fury.io/js/fluxible-immutable-utils) 4 | [![Build Status](https://travis-ci.org/yahoo/fluxible-immutable-utils.svg?branch=master)](https://travis-ci.org/yahoo/fluxible-immutable-utils) 5 | [![Dependency Status](https://david-dm.org/yahoo/fluxible-immutable-utils.svg)](https://david-dm.org/yahoo/fluxible-immutable-utils) 6 | [![devDependency Status](https://david-dm.org/yahoo/fluxible-immutable-utils/dev-status.svg)](https://david-dm.org/yahoo/fluxible-immutable-utils#info=devDependencies) 7 | [![Coverage Status](https://coveralls.io/repos/yahoo/fluxible-immutable-utils/badge.svg)](https://coveralls.io/r/yahoo/fluxible-immutable-utils) 8 | 9 | This package provides easy to use mixins/utils for both fluxible stores and react components. 10 | 11 | ```bash 12 | $ npm install --save fluxible-immutable-utils 13 | ``` 14 | 15 | ## `createImmutableContainer` 16 | 17 | This method creates an immutable higher order component. 18 | 19 | ```js 20 | 21 | var MyComponent = createReactClass({ 22 | displayName: 'MyComponent', 23 | 24 | ... 25 | }); 26 | 27 | var createImmutableContainer = require('fluxible-immutable-utils').createImmutableContainer; 28 | 29 | // Wraps your component in an immutable container. 30 | // Prevents renders when props are the same 31 | module.exports = createImmutableContainer(MyComponent); 32 | 33 | // Wraps your component in an immutable container that listens to stores 34 | // and pass its state down as props 35 | module.exports = createImmutableContainer(MyComponent, { 36 | stores: [SomeStore], 37 | getStateFromStores: { 38 | SomeStore: function (store) { 39 | return { 40 | someState: store.state; 41 | } 42 | } 43 | } 44 | }); 45 | ``` 46 | 47 | ## `ComponentMixin` 48 | A mixin that provides convenience methods for using Immutable.js inside of react components. Note that this mixin uses the initializeComponent method for setup, and any components that use this mixin should define a 'getStateOnChange' function for generating component state (see below). 49 | 50 | This mixin has several purposes: 51 | - Checks that the objects in state/props of each component are an Immutable Map. 52 | - Implements a default shouldComponentUpdate method. 53 | - Provides a convenience method for dealing with state changes/component 54 | initialization. 55 | 56 | ### Immutalizing State 57 | The mixin uses the initalizeState method to set up all default functions, and checks for a method named 'getStateOnChange' in order to get the initial state object. If used with fluxible's FluxibleMixin, getStateOnChange will also be called whenever a store is updated (if onChange is not defined). This allows a reduction in boilerplate by not having to define separate functions for app initialization/store updates (since components should handle state the same in either case). 58 | 59 | The mixin expects props/state to remain immutable throughout a component's lifecycle and only shallowly examines the props object when checking for data equality. Thus it is HIGHLY recommended to pass in immutable objects as props/state to a component using this mixin (the mixin will warn when not doing so). You may configure which objects to check by setting the ignoreImmutableObjects static property (example below). 60 | 61 | ### shouldComponentUpdate 62 | The immutable mixin implements a version of shouldComponentUpdate to prevent needless re-rendering of components when the props/state haven't changed (by checking if the new props/state have been changed from the old props/state). If a component provides its own shouldComponentUpdate method, then the default implementation will not be used. 63 | 64 | ### getStateOnChange 65 | Since ImmutableMixin must use the initializeComponent method for setting up default methods, it cannot be used by the components. Instead, ImmutableMixin will call the 'getStateOnChange' method in getInitialState. This method will also be called if used with the FluxibleMixin on store changes (again, only if onChange is not defined) which helps to reduce boilerplate within components. 66 | 67 | ### API 68 | 69 | #### shouldUpdate (nextProps, nextState) 70 | 71 | Utility method that is set as the `shouldComponentUpdate` method in a component unless it is already defined. Checks whether the props/state of the component has changed so that we know whether to render or not. 72 | 73 | 1. {Object} The next props object 74 | 2. {Object} The next state object 75 | 76 | #### defaultOnChange () 77 | 78 | Utility method that is set as the `onChange` method. A default onChange function that sets the the components state from the getStateOnChange method. This is only set if a component does not implement its own onChange function. Typically used with the fluxibleMixin. 79 | 80 | #### getInitialState () 81 | 82 | Called internally by React. Sets up a few of the immutable methods and then returns the state of the component, after ensuring it is immutable. If getStateOnChange() is not defined, then just sets the state to null. 83 | 84 | **Example** 85 | 86 | ```jsx 87 | // MyReactComponent.jsx 88 | 89 | var ImmutableMixin = require('fluxible-immutable-utils').ComponentMixin; 90 | 91 | module.exports = createReactClass({ 92 | displayName: 'MyReactComponent', 93 | mixins: [ImmutableMixin], 94 | getStateOnChange: function () { 95 | if (!this.state) { 96 | //initialize here if needed 97 | } 98 | return { 99 | someStateProperty: 'foo' 100 | }; 101 | }, 102 | 103 | render: function () { 104 | return My React Component; 105 | } 106 | }); 107 | 108 | var myObject = { 109 | foo: 'bar' 110 | }; 111 | 115 | ``` 116 | 117 | #### Configuring the Mixin 118 | If you are using third party libraries/have a special case where you don't want the mixin to consider some of the keys of props/state, you have two options. First, you can set the ignoreImmutableCheck object to skip the check for immutability for any keys you decide. Second, if you want the mixin to also ignore a key when checking for data equality in props/state, you can set the key value to the flag `SKIP_SHOULD_UPDATE`. You must set these values inside a component's `statics` field (or in a config, see below), and they must be set seperately for props/state. You can also turn off all warnings by settings the ignoreAllWarnings flag. 119 | 120 | **Example** 121 | 122 | ```jsx 123 | // MyReactComponent.jsx 124 | 125 | var ImmutableMixin = require('fluxible-immutable-utils').ComponentMixin; 126 | 127 | module.exports = createReactClass({ 128 | displayName: 'MyReactComponent', 129 | mixins: [ImmutableMixin], 130 | statics: { 131 | ignoreAllWarnings: (process.env.NODE_ENV !== 'dev') // turn off all warnings when not in dev mode 132 | ignoreImmutableCheck: { 133 | props: { 134 | someKey: true // don't check someKey for immutablility in props 135 | }, 136 | state: { 137 | anotherKey: 'SKIP_SHOULD_UPDATE' // don't check anotherKey for immutablility in props, AND don't check its value is shouldComponentUpdate 138 | } 139 | 140 | } 141 | }, 142 | 143 | ...rest of component follows... 144 | }); 145 | ``` 146 | 147 | If you want to just pass around a common config, then use: 148 | ```jsx 149 | var ImmutableMixin = require('fluxible-immutable-utils').createComponentMixin(myConfig); 150 | ``` 151 | Where myConfig has the same structure as the statics above. 152 | 153 | ## `ImmutableStore` 154 | 155 | A class to inherit similar to the fluxible addon `BaseStore`. Internally it inherits [`'fluxible/addons/BaseStore`](https://github.com/yahoo/fluxible/blob/master/docs/api/addons/BaseStore.md). 156 | 157 | The main use case for this method is to reduce boilerplate when implementing immutable [`fluxible`](fluxible.io) stores. 158 | 159 | The helper adds a new property and some helper methods to the created store 160 | * `_state` {[Map](http://facebook.github.io/immutable-js/docs/#/Map)} - The root `Immutable` where all data in the store will be saved. 161 | 162 | * `setState(newState, [event], [payload])` {Function} - This method replaces `this._state` with `newState` (unless they were the same) and then calls `this.emit(event, payload)`. 163 | * If `event` is *falsy* it will call `this.emitChange(payload)` 164 | * The method also ensures that `_state` remains immutable by auto-converting `newState` to an immutable object. 165 | 166 | * `mergeState(newState, [event], [payload])` {Function} - This method does a shallow merge with `this._state` and then calls `this.emit(event, payload)`. 167 | * If `event` is *falsy* it will call `this.emitChange(payload)` 168 | * The method also ensures that `_state` remains immutable by auto-converting `newState` to an immutable object. 169 | 170 | * `getState()` {Function} - This method returns the `this._state`. 171 | 172 | * `get(key)` {Function} - Get a value by key from the store. 173 | 174 | and creates defaults for the following [fluxible store](http://fluxible.io/api/stores.html) methods 175 | * [`constructor()`](http://fluxible.io/api/stores.html#constructor) - The default implementations creates a `_state` property on the store and initializes it to [`Immutable.Map`](http://facebook.github.io/immutable-js/docs/#/Map) 176 | 177 | * [`rehydrate(state)`](http://fluxible.io/api/stores.html#rehydrate-state-) - The default implementation hydrates `_state` 178 | 179 | * [`dehydrate()`](http://fluxible.io/api/stores.html#dehydrate-) - The default implementation simply returns `_state` which is `Immutable` (due to all `Immutable` objects implementing a [`toJSON`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#toJSON_behavior) function, `_state` can be directly passed to `JSON.stringify`) 180 | 181 | **Note** that all defaults can still be overwritten when creating the store. 182 | 183 | **Note 2** Avoid returning a `_state.toJS()` from a store when using it with the createImmutableContainer since an ImmutableContainer expects and uses the Immutable data to do comparisons when deciding to re-render. 184 | 185 | ### Example Usage 186 | 187 | ```js 188 | // FooStore.js 189 | 190 | import {ImmutableStore} from 'fluxible-immutable-utils'; 191 | 192 | class FooStore extends ImmutableStore { 193 | // public accessors 194 | getBar: function (id) { 195 | return this._state.get('bar'); 196 | } 197 | 198 | // private mutators, these should only be called via dispatch 199 | _onNewFoo(data) { 200 | // data = { foo: 'Hello', bar: 'World' } 201 | this.setState(data); 202 | } 203 | 204 | _onNewBar(bar) { 205 | // This will just update bar and leave foo with the same state 206 | this.mergeState({ bar: bar }); 207 | } 208 | } 209 | 210 | FooStore.storeName = 'FooStore'; 211 | 212 | FooStore.handlers = { 213 | NEW_FOO: '_onNewFooBar', 214 | NEW_FOOS: '_onNewBar' 215 | }; 216 | 217 | export default FooStore; 218 | ``` -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ComponentMixin: require('./mixins/ImmutableMixin'), 3 | createImmutableMixin: require('./lib/createImmutableMixin'), 4 | createImmutableContainer: require('./lib/createImmutableContainer'), 5 | createImmutableStore: require('./lib/createImmutableStore'), 6 | ImmutableStore: require('./lib/ImmutableStore') 7 | }; 8 | -------------------------------------------------------------------------------- /internals/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2015, Yahoo Inc. All rights reserved. 3 | * Copyrights licensed under the New BSD License. 4 | * See the accompanying LICENSE file for terms. 5 | */ 6 | 'use strict'; 7 | 8 | var Immutable = require('immutable'); 9 | var isImmutable = Immutable.Iterable.isIterable; 10 | var isReactElement = require('react').isValidElement; 11 | 12 | function isNonImmutable(item) { 13 | return ( 14 | item 15 | && typeof item === 'object' 16 | && !isReactElement(item) 17 | && !isImmutable(item) 18 | ); 19 | } 20 | 21 | function warnNonImmutable(component, prop) { 22 | console.warn('Component ' + 23 | '"' + component.constructor.displayName + '"' + 24 | ' received non-immutable object for ' + 25 | '"' + prop + '"'); 26 | } 27 | 28 | function merge(dest, src) { 29 | if (!dest) { 30 | dest = {}; 31 | } 32 | 33 | if (typeof src === 'object') { 34 | Object.keys(src).forEach(function mergeCb(prop) { 35 | dest[prop] = src[prop]; 36 | }); 37 | } 38 | 39 | return dest; 40 | } 41 | 42 | module.exports = { 43 | merge: merge, 44 | isNonImmutable: isNonImmutable, 45 | warnNonImmutable: warnNonImmutable 46 | }; 47 | -------------------------------------------------------------------------------- /lib/ImmutableStore.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2015, Yahoo Inc. All rights reserved. 3 | * Copyrights licensed under the New BSD License. 4 | * See the accompanying LICENSE file for terms. 5 | */ 6 | 7 | var BaseStore = require('fluxible/addons/BaseStore'); 8 | var Immutable = require('immutable'); 9 | var inherits = require('inherits'); 10 | 11 | function ImmutableStore(dispatcher) { 12 | this._state = Immutable.Map(); 13 | BaseStore.call(this, dispatcher); 14 | }; 15 | 16 | inherits(ImmutableStore, BaseStore); 17 | 18 | ImmutableStore.prototype.dehydrate = function() { 19 | return this._state; 20 | } 21 | 22 | ImmutableStore.prototype.get = function(key) { 23 | return Array.isArray(key) ? this._state.getIn(key) : this._state.get(key); 24 | } 25 | 26 | ImmutableStore.prototype.getState = function() { 27 | return this.dehydrate(); 28 | } 29 | 30 | ImmutableStore.prototype.mergeState = function(stateFragment, event, payload) { 31 | return this.setState( 32 | this._state.merge(stateFragment), 33 | event, 34 | payload 35 | ); 36 | } 37 | 38 | ImmutableStore.prototype.rehydrate = function(state) { 39 | this._state = Immutable.fromJS(state); 40 | } 41 | 42 | ImmutableStore.prototype.setState = function(newState, event, payload) { 43 | newState = Immutable.fromJS(newState); 44 | 45 | if (this._state.equals(newState)) { 46 | return false; 47 | } 48 | 49 | this._state = newState; 50 | 51 | if (event){ 52 | this.emit(event, payload); 53 | } 54 | else { 55 | this.emitChange(payload); 56 | } 57 | 58 | return true; 59 | } 60 | 61 | module.exports = ImmutableStore; 62 | -------------------------------------------------------------------------------- /lib/createImmutableContainer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2015, Yahoo Inc. All rights reserved. 3 | * Copyrights licensed under the New BSD License. 4 | * See the accompanying LICENSE file for terms. 5 | */ 6 | 7 | 'use strict'; 8 | 9 | var React = require('react'); 10 | var connectToStores = require('fluxible-addons-react/connectToStores'); 11 | var createReactClass = require('create-react-class'); 12 | var shallowequal = require('shallowequal'); 13 | var utils = require('../internals/utils'); 14 | 15 | function getIgnoredProps(ignore) { 16 | if (!Array.isArray(ignore)) { 17 | return ignore || {}; 18 | } 19 | 20 | var ignoredProps = {}; 21 | 22 | ignore.forEach(function (prop) { 23 | ignoredProps[prop] = true; 24 | }); 25 | 26 | return ignoredProps; 27 | } 28 | 29 | module.exports = function createImmutableContainer(Component, options) { 30 | options = options || {}; 31 | var ignore = options.ignore; 32 | var ignoreWarnings = options.ignoreWarnings; 33 | var getStateFromStores = options.getStateFromStores; 34 | var stores = options.stores || Object.keys(getStateFromStores || {}); 35 | var componentName = Component.displayName || Component.name; 36 | 37 | var ImmutableComponent = createReactClass({ 38 | displayName: componentName + ':Immutable', 39 | 40 | getDefaultProps: function () { 41 | return {}; 42 | }, 43 | 44 | checkImmutable: function (prop) { 45 | if ( 46 | !this._ignoredProps[prop] && 47 | utils.isNonImmutable(this.props[prop]) 48 | ) { 49 | utils.warnNonImmutable(this, prop); 50 | } 51 | }, 52 | 53 | checkAllImmutable: function () { 54 | if (!ignoreWarnings) { 55 | Object.keys(this.props).forEach(this.checkImmutable); 56 | } 57 | }, 58 | 59 | componentWillMount: function () { 60 | this._ignoredProps = getIgnoredProps(ignore); 61 | this.checkAllImmutable(); 62 | }, 63 | 64 | componentWillUpdate: function () { 65 | this.checkAllImmutable(); 66 | }, 67 | 68 | shouldComponentUpdate: function (nextProps, nextState) { 69 | // Backward compatibility for components with no state 70 | nextState = nextState || null; 71 | return !shallowequal(this.props, nextProps) || !shallowequal(this.state, nextState); 72 | }, 73 | 74 | render: function () { 75 | return React.createElement(Component, this.props); 76 | } 77 | }); 78 | 79 | return stores.length ? 80 | connectToStores(ImmutableComponent, stores, getStateFromStores) : 81 | ImmutableComponent; 82 | }; 83 | -------------------------------------------------------------------------------- /lib/createImmutableMixin.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2015, Yahoo Inc. All rights reserved. 3 | * Copyrights licensed under the New BSD License. 4 | * See the accompanying LICENSE file for terms. 5 | */ 6 | 'use strict'; 7 | 8 | var utils = require('../internals/utils'); 9 | var GET_STATE_FUNCTION = 'getStateOnChange'; 10 | var IGNORE_IMMUTABLE_CHECK = 'ignoreImmutableCheck'; 11 | var IGNORE_EQUALITY_CHECK_FLAG = 'SKIP_SHOULD_UPDATE'; 12 | 13 | /** 14 | * Make sure an item is not a non immutable object. The only exceptions are valid 15 | * react elements and objects specifically set to be ignored. 16 | * @param {String} key The item to test's key 17 | * @param {Any} item The item to test 18 | * @param {Object} component The component 19 | * @return {Boolean} True if non-immutable object, else false. 20 | */ 21 | function checkNonImmutableObject(key, item, component) { 22 | if (utils.isNonImmutable(item)) { 23 | console.warn('WARN: component: ' + component.constructor.displayName 24 | + ' received non-immutable object: ' + key); 25 | console.trace(); 26 | } 27 | } 28 | 29 | /** 30 | * Check that all an objects fields are either primitives or immutable objects. 31 | * @param {Object} object The object to check 32 | * @param {Object} component The component being checked 33 | * @param {Object} ignoreImmutableCheckKeys Objects to skip a check for 34 | * @return {Undefined} none 35 | */ 36 | function checkObjectProperties(object, component, ignoreImmutableCheckKeys) { 37 | if (component.ignoreAllWarnings || !object || typeof object !== 'object') { 38 | return; 39 | } 40 | var ignoreImmutableCheck; 41 | Object.keys(object).forEach(function objectIterator(key) { 42 | ignoreImmutableCheck = ignoreImmutableCheckKeys[key]; 43 | if (!ignoreImmutableCheck) { 44 | checkNonImmutableObject(key, object[key], component); 45 | } 46 | }); 47 | } 48 | 49 | /** 50 | * Tests whether the two objects are shallowly equivalent using the Immutable.is method. 51 | * If we pass in two objects that have properties that are vanilla (not Immutable) objects, 52 | * this method will always return false, therefore make sure you are passing in immutable 53 | * objects to your props. 54 | * @param {Object} item1 The first object to compare 55 | * @param {Object} item2 The second object to compare 56 | * @param {Object} component The component being examined 57 | * @param {Object} ignoreImmutableCheckKeys Objects to skip when checking if immutable 58 | * @return {Boolean} True if the objects are shallowly equavalent, else false. 59 | */ 60 | function shallowEqualsImmutable(item1, item2, component, ignoreImmutableCheckKeys) { 61 | if (item1 === item2) { 62 | return true; 63 | } 64 | if (!item1 || !item2) { 65 | return false; 66 | } 67 | 68 | var i; 69 | var key; 70 | var ignoreImmutableCheck; 71 | var ignoreAllWarnings = component.ignoreAllWarnings; 72 | var item1Keys = Object.keys(item1); 73 | var item2Keys = Object.keys(item2); 74 | var item1Prop; 75 | var item2Prop; 76 | 77 | // Different key set length, no need to proceed..check it here because we still 78 | // want to check all of item2's objects to see if any are non-immutable. 79 | if (item1Keys.length !== item2Keys.length) { 80 | return false; 81 | } 82 | // check item2keys so that we can also check for any non-immutable objects 83 | for (i = 0; i < item2Keys.length; i++) { 84 | key = item2Keys[i]; 85 | ignoreImmutableCheck = ignoreImmutableCheckKeys[key]; 86 | item2Prop = item2[key]; 87 | item1Prop = item1[key]; 88 | 89 | if (!ignoreAllWarnings && !ignoreImmutableCheck) { 90 | checkNonImmutableObject(key, item2Prop, component); 91 | } 92 | if (ignoreImmutableCheck !== IGNORE_EQUALITY_CHECK_FLAG 93 | && (!item1.hasOwnProperty(key) || item1Prop !== item2Prop)) { 94 | return false; 95 | } 96 | } 97 | 98 | return true; 99 | } 100 | 101 | /** 102 | * Merge either an already defined object or a constructor object over the default 103 | * values. This function is used to create the ignoreImmutableCheck object 104 | * which has a 'props' and 'state' key. 105 | * @param {Object} defaultObject And object with default values. 106 | * @param {Object} config A config used by the mixin. 107 | * @param {String} objectName The name/key of the object we are creating. 108 | * @param {Object} component The component this object is being attached to. 109 | * @return {Object} The newly created object. 110 | */ 111 | function mergeDefaultValues(defaultObject, config, objectName, component) { 112 | var objectToCreate = component[objectName] 113 | || component.constructor[objectName] 114 | || config[objectName] 115 | || {}; 116 | // merge any custom configs over defaults 117 | objectToCreate.props = utils.merge(defaultObject.props, objectToCreate.props); 118 | objectToCreate.state = utils.merge(defaultObject.state, objectToCreate.state); 119 | 120 | // assign any values that are for both props and state. 121 | Object.keys(objectToCreate).forEach(function keyIterator(key) { 122 | if (key !== 'props' && key !== 'state') { 123 | var val = objectToCreate[key]; 124 | objectToCreate.props[key] = val; 125 | objectToCreate.state[key] = val; 126 | } 127 | }); 128 | return objectToCreate; 129 | } 130 | 131 | var defaults = { 132 | /** 133 | * Get default ignoreImmutableCheck objects. This is not a hardcoded object 134 | * since it might be mutable 135 | * @return {Object} by default avoid props.children 136 | */ 137 | getIgnoreImmutableCheck: function () { 138 | return { 139 | props: { 140 | // Always ignore children props since it's special 141 | children: true 142 | } 143 | }; 144 | }, 145 | 146 | /** 147 | * Used as the default shouldComponentUpdate function. Checks whether the props/state of the 148 | * component has actually changed so that we know whether or not to run the render() method. 149 | * Since all state/props are immutable, we can use a simple reference check in the majority of cases. 150 | * @method shouldUpdate 151 | * @param {Object} nextProps The new props object for the component. 152 | * @param {Object} nextState The new state object for the component. 153 | * @return {Boolean} True if the component should run render(), else false. 154 | */ 155 | shouldComponentUpdate: function shouldComponentUpdate(nextProps, nextState) { 156 | var ignoreImmutableCheck = this.ignoreImmutableCheck; 157 | var propsEqual = shallowEqualsImmutable(this.props, nextProps, this, ignoreImmutableCheck.props); 158 | var stateEqual = shallowEqualsImmutable(this.state, nextState, this, ignoreImmutableCheck.state); 159 | return !(stateEqual && propsEqual); 160 | }, 161 | 162 | /** 163 | * A default onChange function that sets the the components state from the getStateOnChange 164 | * method. This is only set if a component does not implement its own onChange function. 165 | * @method defaultOnChange 166 | * @return {undefined} Does not return anything 167 | */ 168 | onChange: function onChange() { 169 | if (this[GET_STATE_FUNCTION]) { 170 | this.setState(this[GET_STATE_FUNCTION].apply(this, arguments)); 171 | } 172 | } 173 | }; 174 | 175 | /** 176 | * React mixin for making components state/props immutable using the immutable.js library. This 177 | * mixin ensures that the state and props of the component always contain immutable objects, allowing 178 | * us to implement a fast version of shouldComponentUpdate (reducing render calls). The mixin 179 | * expects that the component uses the method 'getStateOnChange' instead of 'getInitialState' 180 | * in order to ensure that state is an immutable object. Additionally, this component overrides 181 | * the 'setState' method so that it works correctly with an immutable state. 182 | * @class ImmutableMixin 183 | */ 184 | 185 | /** 186 | * A constructor function for the ComponentMixin that takes an optional config. 187 | * The config can specify values for 'ignoreImmutableCheck' and 'ignoreAllWarnings'. 188 | * @param {Object} config An optional config used by the mixin. 189 | * @return {Object} The mixin object 190 | */ 191 | module.exports = function (config) { 192 | config = config || {}; 193 | return { 194 | componentWillMount: function () { 195 | this.ignoreImmutableCheck = mergeDefaultValues( 196 | defaults.getIgnoreImmutableCheck(), config, IGNORE_IMMUTABLE_CHECK, this); 197 | this.ignoreAllWarnings = this.ignoreAllWarnings 198 | || this.constructor.ignoreAllWarnings 199 | || config.ignoreAllWarnings 200 | || false; 201 | 202 | // Set default methods if the there is no override 203 | this.onChange = this.onChange || defaults.onChange.bind(this); 204 | this.shouldComponentUpdate = this.shouldComponentUpdate || defaults.shouldComponentUpdate.bind(this); 205 | // Checks the props and state to raise warnings 206 | checkObjectProperties(this.props, this, this.ignoreImmutableCheck.props); 207 | checkObjectProperties(this.state, this, this.ignoreImmutableCheck.state); 208 | }, 209 | 210 | getInitialState: function () { 211 | return this[GET_STATE_FUNCTION] ? this[GET_STATE_FUNCTION]() : {}; 212 | } 213 | }; 214 | }; 215 | -------------------------------------------------------------------------------- /lib/createImmutableStore.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2015, Yahoo Inc. All rights reserved. 3 | * Copyrights licensed under the New BSD License. 4 | * See the accompanying LICENSE file for terms. 5 | */ 6 | 'use strict'; 7 | 8 | var Immutable = require('immutable'); 9 | var utils = require('../internals/utils'); 10 | var createStore = require('fluxible/addons/createStore'); 11 | 12 | function initialize() { 13 | this._state = Immutable.Map(); 14 | } 15 | 16 | function rehydrate(state) { 17 | this._state = Immutable.fromJS(state); 18 | } 19 | 20 | function dehydrate() { 21 | return this._state; 22 | } 23 | 24 | function setState(newState, event, payload) { 25 | newState = Immutable.fromJS(newState); 26 | 27 | if (this._state.equals(newState)) { 28 | return false; 29 | } 30 | 31 | this._state = newState; 32 | 33 | if (event) { 34 | this.emit(event, payload); 35 | } else { 36 | this.emitChange(payload); 37 | } 38 | 39 | return true; 40 | } 41 | 42 | function mergeState(stateFragment, event, payload) { 43 | return this.setState( 44 | this._state.merge(stateFragment), 45 | event, 46 | payload 47 | ); 48 | } 49 | 50 | function get(key, defaultValue) { 51 | return Array.isArray(key) ? 52 | this._state.getIn(key, defaultValue) : 53 | this._state.get(key, defaultValue); 54 | } 55 | 56 | /** 57 | * Helper for creating an immutable store class 58 | * 59 | * @method createStore 60 | * @param {Object} spec of the created Store class 61 | * @param {String} spec.storeName The name of the store 62 | * @param {Object} spec.handlers Hash of action name to method name of action handlers 63 | * @param {Function} [spec.initialize] Function called during construction for setting the default `_state` (optional). 64 | * @return {Store} Store class 65 | **/ 66 | module.exports = function createImmutableStore(spec) { 67 | return createStore(utils.merge({ 68 | initialize: initialize, 69 | rehydrate: rehydrate, 70 | dehydrate: dehydrate, 71 | setState: setState, 72 | mergeState: mergeState, 73 | get: get 74 | }, spec)); 75 | }; 76 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | createImmutableMixin: require('./createImmutableMixin'), 3 | createImmutableStore: require('./createImmutableStore') 4 | }; 5 | -------------------------------------------------------------------------------- /mixins/ImmutableMixin.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../lib/createImmutableMixin')(); 2 | -------------------------------------------------------------------------------- /mixins/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ComponentMixin: require('./ImmutableMixin') 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fluxible-immutable-utils", 3 | "author": "dhood@yahoo-inc.com", 4 | "description": "A collection of utilities that provide convenience methods for using Immutable.js with a fluxible based application", 5 | "version": "0.5.10", 6 | "main": "index.js", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/yahoo/fluxible-immutable-utils" 10 | }, 11 | "scripts": { 12 | "lint": "eslint .", 13 | "pretest": "npm run lint", 14 | "test": "jenkins-mocha tests --recursive" 15 | }, 16 | "peerDependencies": { 17 | "react": "^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0", 18 | "react-dom": "^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" 19 | }, 20 | "dependencies": { 21 | "create-react-class": "^15.5.0", 22 | "fluxible": "^1.4.0", 23 | "fluxible-addons-react": "^0.2.12", 24 | "immutable": "^3.8.1", 25 | "inherits": "^2.0.3", 26 | "shallowequal": "^1.0.1" 27 | }, 28 | "devDependencies": { 29 | "chai": "^4.0.2", 30 | "coveralls": "^2.13.1", 31 | "eslint": "^4.0.0", 32 | "filter-invalid-dom-props": "^1.0.0", 33 | "jenkins-mocha": "^4.1.2", 34 | "jsx-test": "^2.0.1", 35 | "pre-commit": "^1.2.2", 36 | "react": "^17.0.0", 37 | "react-dom": "^17.0.0", 38 | "sinon": "^2.3.4" 39 | }, 40 | "pre-commit": [ 41 | "test" 42 | ], 43 | "contributors": [ 44 | "Mo Kouli ", 45 | "Marcelo Eden ", 46 | "Akshay Patel " 47 | ], 48 | "license": "BSD-3-Clause", 49 | "keywords": [ 50 | "yahoo", 51 | "immutable", 52 | "immutablejs", 53 | "immutable.js", 54 | "fluxible", 55 | "react", 56 | "flux" 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /tests/lib/ImmutableStore.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2015, Yahoo Inc. All rights reserved. 3 | * Copyrights licensed under the New BSD License. 4 | * See the accompanying LICENSE file for terms. 5 | */ 6 | /*globals describe,it,beforeEach*/ 7 | 'use strict'; 8 | 9 | var ImmutableStore = require('../../lib/ImmutableStore'); 10 | var expect = require('chai').expect; 11 | 12 | describe('ImmutableStore', function () { 13 | beforeEach(function () { 14 | this.store = new ImmutableStore(); 15 | }); 16 | 17 | describe('#constructor', function () { 18 | it('defines a immutable _state', function () { 19 | expect(this.store._state.toJS()).to.deep.equal({}); 20 | }); 21 | }); 22 | 23 | describe('#rehydrate', function () { 24 | it('sets the state', function () { 25 | var state = {list: [1, 2, 3], error: null}; 26 | this.store.rehydrate(state); 27 | 28 | expect(this.store._state.toJS()).to.deep.equal(state); 29 | }); 30 | }); 31 | 32 | var methods = ['dehydrate', 'getState']; 33 | methods.forEach(function (methodName) { 34 | describe('#' + methodName, function () { 35 | it('gets the initial state', function () { 36 | expect(this.store[methodName]()).to.deep.equal(this.store._state); 37 | expect(this.store[methodName]().toJS()).to.deep.equal({}); 38 | }); 39 | 40 | it('gets the rehydrated state', function () { 41 | var state = {list: [1, 2, 3], error: null}; 42 | this.store.rehydrate(state); 43 | 44 | expect(this.store[methodName]()).to.deep.equal(this.store._state); 45 | }); 46 | }); 47 | }); 48 | 49 | describe('#get', function () { 50 | beforeEach(function () { 51 | this.store.rehydrate({ 52 | weapons: ['sword', 'bow', 'shotgun'], 53 | spells: { 54 | 'Fire Ball': 3, 55 | 'Arcane Intelect': 1 56 | } 57 | }); 58 | }); 59 | 60 | it('gets a part of the state', function () { 61 | expect(this.store.get('weapons').toJS()).to.deep.equal([ 62 | 'sword', 'bow', 'shotgun' 63 | ]); 64 | }); 65 | 66 | it('gets a nested part of the state', function () { 67 | expect(this.store.get(['weapons', 1])).to.equal('bow'); 68 | expect(this.store.get(['spells', 'Fire Ball'])).to.equal(3); 69 | expect(this.store.get(['spells', 'Arcane Intelect'])).to.equal(1); 70 | }); 71 | }); 72 | 73 | describe('#setState', function () { 74 | it('updates the state and emits a "change" event', function (done) { 75 | this.store.on('change', function () { 76 | expect(this._state.toJS()).to.deep.equal({list: [1, 2, 3]}); 77 | done(); 78 | }.bind(this.store)); 79 | 80 | this.store.setState(this.store._state.set('list', [1, 2, 3])); 81 | }); 82 | 83 | it('updates the state and emit a custom event', function (done) { 84 | this.store.on('rename', function () { 85 | expect(this._state.toJS()).to.deep.equal({name: '_mo'}); 86 | done(); 87 | }.bind(this.store)); 88 | 89 | this.store.setState(this.store._state.set('name', '_mo'), 'rename'); 90 | }); 91 | 92 | it('updates the state and passes a custom payload', function (done) { 93 | var payload = {name: '_mo', type: 'AnyPlayload'}; 94 | 95 | this.store.on('rename', function (data) { 96 | expect(this._state.toJS()).to.deep.equal({name: '_mo'}); 97 | expect(data).to.deep.equal(payload); 98 | done(); 99 | }.bind(this.store)); 100 | 101 | this.store.setState(this.store._state.set('name', '_mo'), 'rename', payload); 102 | }); 103 | 104 | it('only emits the chage if there are changes', function (done) { 105 | var count = 0; 106 | 107 | this.store.emit = function () { 108 | count++; 109 | }; 110 | 111 | this.store.setState(this.store._state.mergeDeep({list: [1, 2, 3]})); 112 | this.store.setState(this.store._state.mergeDeep({list: [1, 2, 3]})); 113 | this.store.setState(this.store._state.mergeDeep({list: [1, 2, 3]})); 114 | 115 | expect(this.store._state.toJS()).to.deep.equal({list: [1, 2, 3]}); 116 | expect(count).to.equal(1); 117 | done(); 118 | }); 119 | }); 120 | 121 | describe('#mergeState', function () { 122 | beforeEach(function () { 123 | this.store.setState(this.store._state.set('list1', [1, 2, 3])); 124 | }); 125 | 126 | it('replaces the list with a new list', function (done) { 127 | this.store.on('change', function () { 128 | expect(this._state.toJS()).to.deep.equal({ 129 | list1: [4] 130 | }); 131 | done(); 132 | }.bind(this.store)); 133 | 134 | this.store.mergeState({'list1': [4]}); 135 | }); 136 | 137 | it('merges the state and emits a "change" event', function (done) { 138 | this.store.on('change', function () { 139 | expect(this._state.toJS()).to.deep.equal({ 140 | list1: [1, 2, 3], 141 | list2: [4, 5, 6] 142 | }); 143 | done(); 144 | }.bind(this.store)); 145 | 146 | this.store.mergeState({'list2': [4, 5, 6]}); 147 | }); 148 | 149 | it('merges the state and emit a custom event', function (done) { 150 | this.store.on('custom', function () { 151 | expect(this._state.toJS()).to.deep.equal({ 152 | list1: [1, 2, 3], 153 | list2: [4, 5, 6] 154 | }); 155 | done(); 156 | }.bind(this.store)); 157 | 158 | this.store.mergeState({'list2': [4, 5, 6]}, 'custom'); 159 | }); 160 | 161 | it('merges the state and passes a custom payload', function (done) { 162 | var payload = {name: '_mo', type: 'AnyPlayload'}; 163 | 164 | this.store.on('change', function (data) { 165 | expect(this._state.toJS()).to.deep.equal({ 166 | list1: [1, 2, 3], 167 | list2: [4, 5, 6] 168 | }); 169 | expect(data).to.deep.equal(payload); 170 | done(); 171 | }.bind(this.store)); 172 | 173 | this.store.mergeState({'list2': [4, 5, 6]}, 'change', payload); 174 | }); 175 | 176 | it('only emits the change if there the stateFragment is different', function (done) { 177 | var count = 0; 178 | 179 | this.store.emit = function () { 180 | count++; 181 | }; 182 | 183 | this.store.mergeState({list2: [4, 5, 6]}); 184 | this.store.mergeState({list2: [4, 5, 6]}); 185 | this.store.mergeState({list2: [4, 5, 6]}); 186 | 187 | expect(this.store._state.toJS()).to.deep.equal({ 188 | list1: [1, 2, 3], 189 | list2: [4, 5, 6] 190 | }); 191 | expect(count).to.equal(1); 192 | done(); 193 | }); 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /tests/lib/createImmutableContainer.js: -------------------------------------------------------------------------------- 1 | /*globals describe, it, beforeEach, afterEach */ 2 | 3 | 'use strict'; 4 | var Immutable = require('immutable'); 5 | var React = require('react'); 6 | var createImmutableContainer = require('../../lib/createImmutableContainer'); 7 | var createReactClass = require('create-react-class'); 8 | var createStore = require('fluxible/addons/createStore'); 9 | var expect = require('chai').expect; 10 | var filterInvalidDOMProps = require('filter-invalid-dom-props').default 11 | var jsx = require('jsx-test'); 12 | var sinon = require('sinon'); 13 | 14 | describe('createImmutableContainer', function () { 15 | var DummyComponent = createReactClass({ 16 | displayName: 'Dummy', 17 | 18 | render: function () { 19 | return React.createElement('div', filterInvalidDOMProps(this.props)); 20 | } 21 | }); 22 | 23 | var DummyStore = createStore({ 24 | storeName: 'DummyStore' 25 | }); 26 | 27 | beforeEach(function () { 28 | sinon.spy(console, 'warn'); 29 | }); 30 | 31 | afterEach(function () { 32 | console.warn.restore(); 33 | }); 34 | 35 | it('wraps the component without the store connector', function () { 36 | var Component = createImmutableContainer(DummyComponent); 37 | 38 | expect(Component.displayName).to.equal('Dummy:Immutable'); 39 | }); 40 | 41 | it('wraps the component with the store connector', function () { 42 | var Component = createImmutableContainer(DummyComponent, { 43 | stores: [DummyStore], 44 | getStateFromStores: { 45 | DummyStore: function () { } 46 | } 47 | }); 48 | 49 | expect(Component.displayName).to.equal('storeConnector(Dummy:Immutable)'); 50 | }); 51 | 52 | describe('#componentWillMount', function () { 53 | var Component; 54 | beforeEach(function () { 55 | Component = createImmutableContainer(DummyComponent, { 56 | ignore: ['data-items'] 57 | }); 58 | }); 59 | 60 | it('raise warnings if non immutable props are passed', function () { 61 | jsx.renderComponent(Component, {stuff: [1, 2, 3]}); 62 | 63 | expect( 64 | console.warn.calledWith('Component "Dummy:Immutable" received non-immutable object for "stuff"') 65 | ).to.equal(true); 66 | }); 67 | 68 | it('bypasses certain fields if they are ignored', function () { 69 | jsx.renderComponent(Component, {'data-items': [1, 2, 3]}); 70 | expect(console.warn.callCount).to.equal(0); 71 | }); 72 | 73 | it('raises a warning for each non-immutable object', function () { 74 | jsx.renderComponent(Component, { 75 | items: [1, 2, 3], 76 | stuff: {}, 77 | map: Immutable.Map(), 78 | number: 1, 79 | name: 'something' 80 | }); 81 | expect(console.warn.callCount).to.equal(2); 82 | }); 83 | 84 | it('should never warn if ignoreAllWarnings is true', function () { 85 | var Component2 = createImmutableContainer(DummyComponent, { 86 | ignoreWarnings: true 87 | }); 88 | 89 | jsx.renderComponent(Component2, { 90 | items: [1, 2, 3], 91 | nonImmutable: {} 92 | }); 93 | 94 | expect(console.warn.callCount).to.equal(0); 95 | }); 96 | }); 97 | 98 | describe('#componentWillUpdate', function () { 99 | var Component = createImmutableContainer(DummyComponent); 100 | var component = jsx.renderComponent(Component, { 101 | items: [1, 2, 3], 102 | stuff: {}, 103 | map: Immutable.Map(), 104 | number: 1, 105 | name: 'something' 106 | }); 107 | 108 | it('raises a warning for each non-imutable object', function () { 109 | component.componentWillUpdate(); 110 | expect(console.warn.callCount).to.equal(2); 111 | }); 112 | }); 113 | 114 | describe('#shouldComponentUpdate', function () { 115 | var someMap = Immutable.Map(); 116 | var Component = createImmutableContainer(DummyComponent); 117 | 118 | beforeEach(function () { 119 | this.component = jsx.renderComponent(Component, { 120 | name: 'Bilbo', 121 | map: someMap 122 | }); 123 | }); 124 | 125 | it('should return false if props are equal', function () { 126 | expect(this.component.shouldComponentUpdate({ 127 | name: 'Bilbo', 128 | map: someMap 129 | })).to.equal(false); 130 | }); 131 | 132 | it('should return true if any prop changes', function () { 133 | expect(this.component.shouldComponentUpdate({ 134 | name: 'Frodo', 135 | map: someMap 136 | })).to.equal(true); 137 | }); 138 | 139 | it('should return true if any prop is removed', function () { 140 | expect(this.component.shouldComponentUpdate({ 141 | name: 'Bilbo' 142 | })).to.equal(true); 143 | }); 144 | 145 | it('should return true if a new prop is passed', function () { 146 | expect(this.component.shouldComponentUpdate({ 147 | name: 'Bilbo', 148 | map: someMap, 149 | n: 1 150 | })).to.equal(true); 151 | }); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /tests/lib/createImmutableMixin.js: -------------------------------------------------------------------------------- 1 | /*globals describe,it,beforeEach,afterEach*/ 2 | 3 | 'use strict'; 4 | 5 | var createImmutableMixin = require('../../lib/createImmutableMixin'); 6 | var createReactClass = require('create-react-class'); 7 | var expect = require('chai').expect; 8 | var jsx = require('jsx-test'); 9 | var sinon = require('sinon'); 10 | 11 | describe('createImmutableMixin', function () { 12 | var config = { 13 | ignoreImmutableCheck: { 14 | props: { 15 | data: 'SKIP_SHOULD_UPDATE' 16 | }, 17 | state: { 18 | foo: true 19 | } 20 | } 21 | }; 22 | var CustomImmutableMixin = createImmutableMixin(config); 23 | 24 | beforeEach(function () { 25 | sinon.spy(console, 'warn'); 26 | }); 27 | 28 | afterEach(function () { 29 | console.warn.restore(); 30 | }); 31 | 32 | it('should apply configs if we use createComponentMixn', function () { 33 | var state = { 34 | foo: {}, 35 | baz: {} 36 | }; 37 | var Component = createReactClass({ 38 | displayName: 'MyComponent', 39 | mixins: [CustomImmutableMixin], 40 | render: function () { 41 | return null; 42 | }, 43 | getStateOnChange: function () { 44 | return state; 45 | } 46 | }); 47 | var props = { 48 | data: {} 49 | }; 50 | var component = jsx.renderComponent(Component, {data: false}); 51 | expect(component.shouldComponentUpdate(props, state)).to.equal(false); 52 | expect(console.warn.callCount).to.equal(1); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /tests/lib/createImmutableStore.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2015, Yahoo Inc. All rights reserved. 3 | * Copyrights licensed under the New BSD License. 4 | * See the accompanying LICENSE file for terms. 5 | */ 6 | /*globals describe,it,beforeEach*/ 7 | 'use strict'; 8 | 9 | var createImmutableStore = require('../../lib/createImmutableStore'); 10 | var expect = require('chai').expect; 11 | 12 | describe('createImmutableStore', function () { 13 | var Store = createImmutableStore({ 14 | storeName: 'ImmutableStore' 15 | }); 16 | 17 | beforeEach(function () { 18 | this.store = new Store(); 19 | }); 20 | 21 | describe('#initialize', function () { 22 | it('defines a immutable _state', function () { 23 | expect(this.store._state.toJS()).to.deep.equal({}); 24 | }); 25 | }); 26 | 27 | describe('#rehydrate', function () { 28 | it('sets the state', function () { 29 | var state = {list: [1, 2, 3], error: null}; 30 | this.store.rehydrate(state); 31 | 32 | expect(this.store._state.toJS()).to.deep.equal(state); 33 | }); 34 | }); 35 | 36 | describe('#dehydrate', function () { 37 | it('gets the initial state', function () { 38 | expect(this.store.dehydrate()).to.deep.equal(this.store._state); 39 | expect(this.store.dehydrate().toJS()).to.deep.equal({}); 40 | }); 41 | 42 | it('gets the rehydrated state', function () { 43 | var state = {list: [1, 2, 3], error: null}; 44 | this.store.rehydrate(state); 45 | 46 | expect(this.store.dehydrate()).to.deep.equal(this.store._state); 47 | }); 48 | }); 49 | 50 | describe('#get', function () { 51 | beforeEach(function () { 52 | this.store.rehydrate({ 53 | weapons: ['sword', 'bow', 'shotgun'], 54 | spells: { 55 | 'Fire Ball': 3, 56 | 'Arcane Intelect': 1 57 | } 58 | }); 59 | }); 60 | 61 | it('gets a part of the state', function () { 62 | expect(this.store.get('weapons').toJS()).to.deep.equal([ 63 | 'sword', 'bow', 'shotgun' 64 | ]); 65 | }); 66 | 67 | it('gets a nested part of the state', function () { 68 | expect(this.store.get(['weapons', 1])).to.equal('bow'); 69 | expect(this.store.get(['spells', 'Fire Ball'])).to.equal(3); 70 | expect(this.store.get(['spells', 'Arcane Intelect'])).to.equal(1); 71 | }); 72 | 73 | it('returns default value if part in state is not defined', function () { 74 | expect(this.store.get(['weapons', 3], 'knife')).to.equal('knife'); 75 | expect(this.store.get('casts', 'Magic')).to.equal('Magic'); 76 | }); 77 | }); 78 | 79 | describe('#setState', function () { 80 | it('updates the state and emits a "change" event', function (done) { 81 | this.store.on('change', function () { 82 | expect(this._state.toJS()).to.deep.equal({list: [1, 2, 3]}); 83 | done(); 84 | }.bind(this.store)); 85 | 86 | this.store.setState(this.store._state.set('list', [1, 2, 3])); 87 | }); 88 | 89 | it('updates the state and emit a custom event', function (done) { 90 | this.store.on('rename', function () { 91 | expect(this._state.toJS()).to.deep.equal({name: '_mo'}); 92 | done(); 93 | }.bind(this.store)); 94 | 95 | this.store.setState(this.store._state.set('name', '_mo'), 'rename'); 96 | }); 97 | 98 | it('updates the state and passes a custom payload', function (done) { 99 | var payload = {name: '_mo', type: 'AnyPlayload'}; 100 | 101 | this.store.on('rename', function (data) { 102 | expect(this._state.toJS()).to.deep.equal({name: '_mo'}); 103 | expect(data).to.deep.equal(payload); 104 | done(); 105 | }.bind(this.store)); 106 | 107 | this.store.setState(this.store._state.set('name', '_mo'), 'rename', payload); 108 | }); 109 | 110 | it('only emits the chage if there are changes', function (done) { 111 | var count = 0; 112 | 113 | this.store.emit = function () { 114 | count++; 115 | }; 116 | 117 | this.store.setState(this.store._state.mergeDeep({list: [1, 2, 3]})); 118 | this.store.setState(this.store._state.mergeDeep({list: [1, 2, 3]})); 119 | this.store.setState(this.store._state.mergeDeep({list: [1, 2, 3]})); 120 | 121 | expect(this.store._state.toJS()).to.deep.equal({list: [1, 2, 3]}); 122 | expect(count).to.equal(1); 123 | done(); 124 | }); 125 | }); 126 | 127 | describe('#mergeState', function () { 128 | beforeEach(function () { 129 | this.store.setState(this.store._state.set('list1', [1, 2, 3])); 130 | }); 131 | 132 | it('replaces the list with a new list', function (done) { 133 | this.store.on('change', function () { 134 | expect(this._state.toJS()).to.deep.equal({ 135 | list1: [4] 136 | }); 137 | done(); 138 | }.bind(this.store)); 139 | 140 | this.store.mergeState({'list1': [4]}); 141 | }); 142 | 143 | it('merges the state and emits a "change" event', function (done) { 144 | this.store.on('change', function () { 145 | expect(this._state.toJS()).to.deep.equal({ 146 | list1: [1, 2, 3], 147 | list2: [4, 5, 6] 148 | }); 149 | done(); 150 | }.bind(this.store)); 151 | 152 | this.store.mergeState({'list2': [4, 5, 6]}); 153 | }); 154 | 155 | it('merges the state and emit a custom event', function (done) { 156 | this.store.on('custom', function () { 157 | expect(this._state.toJS()).to.deep.equal({ 158 | list1: [1, 2, 3], 159 | list2: [4, 5, 6] 160 | }); 161 | done(); 162 | }.bind(this.store)); 163 | 164 | this.store.mergeState({'list2': [4, 5, 6]}, 'custom'); 165 | }); 166 | 167 | it('merges the state and passes a custom payload', function (done) { 168 | var payload = {name: '_mo', type: 'AnyPlayload'}; 169 | 170 | this.store.on('change', function (data) { 171 | expect(this._state.toJS()).to.deep.equal({ 172 | list1: [1, 2, 3], 173 | list2: [4, 5, 6] 174 | }); 175 | expect(data).to.deep.equal(payload); 176 | done(); 177 | }.bind(this.store)); 178 | 179 | this.store.mergeState({'list2': [4, 5, 6]}, 'change', payload); 180 | }); 181 | 182 | it('only emits the change if there the stateFragment is different', function (done) { 183 | var count = 0; 184 | 185 | this.store.emit = function () { 186 | count++; 187 | }; 188 | 189 | this.store.mergeState({list2: [4, 5, 6]}); 190 | this.store.mergeState({list2: [4, 5, 6]}); 191 | this.store.mergeState({list2: [4, 5, 6]}); 192 | 193 | expect(this.store._state.toJS()).to.deep.equal({ 194 | list1: [1, 2, 3], 195 | list2: [4, 5, 6] 196 | }); 197 | expect(count).to.equal(1); 198 | done(); 199 | }); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /tests/mixins/ImmutableMixin.js: -------------------------------------------------------------------------------- 1 | /* eslint no-warning-comments: off */ 2 | /*globals describe,it,beforeEach,afterEach*/ 3 | 4 | 'use strict'; 5 | 6 | var Immutable = require('immutable'); 7 | var ImmutableMixin = require('../../mixins/ImmutableMixin'); 8 | var React = require('react'); 9 | var createReactClass = require('create-react-class'); 10 | var expect = require('chai').expect; 11 | var jsx = require('jsx-test'); 12 | var sinon = require('sinon'); 13 | 14 | describe('ImmutableMixin', function () { 15 | describe('#objectsToIgnore', function () { 16 | beforeEach(function () { 17 | sinon.spy(console, 'warn'); 18 | }); 19 | 20 | afterEach(function () { 21 | console.warn.restore(); 22 | }); 23 | 24 | it('should bypass certain props fields if they are ignored', function () { 25 | var Component = createReactClass({ 26 | displayName: 'MyComponent', 27 | mixins: [ImmutableMixin], 28 | // TODO: simplify ignoreImmutableCheck 29 | ignoreImmutableCheck: { 30 | props: { 31 | data: true 32 | } 33 | }, 34 | render: function () { 35 | return null; 36 | } 37 | }); 38 | 39 | jsx.renderComponent(Component, {data: {list: [1, 2, 3]}}); 40 | expect(console.warn.callCount).to.equal(0); 41 | }); 42 | 43 | it('should bypass fields in both props and state if they at the top level', function () { 44 | var Component = createReactClass({ 45 | displayName: 'MyComponent', 46 | mixins: [ImmutableMixin], 47 | ignoreImmutableCheck: { 48 | data: true 49 | }, 50 | getStateOnChange: function () { 51 | return { 52 | data: true 53 | }; 54 | }, 55 | render: function () { 56 | return null; 57 | } 58 | }); 59 | 60 | var component = jsx.renderComponent(Component, {data: {}}); 61 | expect(console.warn.callCount).to.equal(0); 62 | expect(component.shouldComponentUpdate({}, {data: {}})).to.equal(true); 63 | expect(console.warn.callCount).to.equal(0); 64 | }); 65 | 66 | it('should ignore certain props/state fields if they are marked SKIP_SHOULD_UPDATE', function () { 67 | var Component = createReactClass({ 68 | displayName: 'MyComponent', 69 | mixins: [ImmutableMixin], 70 | ignoreImmutableCheck: { 71 | props: { 72 | data: 'SKIP_SHOULD_UPDATE' 73 | } 74 | }, 75 | render: function () { 76 | return null; 77 | } 78 | }); 79 | var props = { 80 | data: true 81 | }; 82 | var component = jsx.renderComponent(Component, {data: {}}); 83 | expect(component.shouldComponentUpdate(props, {})).to.equal(false); 84 | expect(console.warn.callCount).to.equal(0); 85 | }); 86 | 87 | it('should warn if next state is mutable', function (done) { 88 | var Component = createReactClass({ 89 | displayName: 'MyComponent', 90 | mixins: [ImmutableMixin], 91 | render: function () { 92 | return null; 93 | }, 94 | getStateOnChange: function () { 95 | return { 96 | testData: Immutable.Map() 97 | }; 98 | } 99 | }); 100 | 101 | var comp = jsx.renderComponent(Component, {}); 102 | comp.setState({testData: {list: [1, 2, 3]}}, function () { 103 | expect( 104 | console.warn.calledWith('WARN: component: MyComponent received non-immutable object: testData') 105 | ).to.equal(true); 106 | done(); 107 | }); 108 | }); 109 | 110 | it('should never warn if ignoreAllWarnings is true', function (done) { 111 | var Component = createReactClass({ 112 | displayName: 'MyComponent', 113 | mixins: [ImmutableMixin], 114 | statics: { 115 | ignoreAllWarnings: true 116 | }, 117 | render: function () { 118 | return null; 119 | } 120 | }); 121 | 122 | var comp = jsx.renderComponent(Component, {}); 123 | comp.setState({testData: {list: [1, 2, 3]}}, function () { 124 | expect(console.warn.callCount).to.equal(0); 125 | done(); 126 | }); 127 | }); 128 | 129 | it('should bypass certain state fields if are ignored', function (done) { 130 | var Component = createReactClass({ 131 | displayName: 'MyComponent', 132 | mixins: [ImmutableMixin], 133 | ignoreImmutableCheck: { 134 | state: { 135 | testData: true 136 | } 137 | }, 138 | render: function () { 139 | return null; 140 | } 141 | }); 142 | 143 | var comp = jsx.renderComponent(Component, {}); 144 | comp.setState({testData: {list: [1, 2, 3]}}, function () { 145 | expect(console.warn.callCount).to.equal(0); 146 | done(); 147 | }); 148 | }); 149 | 150 | it('should merge w/ default certain state fields if are ignored', function (done) { 151 | var Component = createReactClass({ 152 | displayName: 'MyComponent', 153 | mixins: [ImmutableMixin], 154 | ignoreImmutableCheck: { 155 | state: { 156 | testData: true 157 | } 158 | }, 159 | render: function () { 160 | return React.createElement('div', {}, this.props.children); 161 | } 162 | }); 163 | 164 | var comp = jsx.renderComponent(Component, {}, ['foo', 'bar']); 165 | comp.setState({testData: {list: [1, 2, 3]}}, function () { 166 | expect(console.warn.callCount).to.equal(0); 167 | done(); 168 | }); 169 | }); 170 | }); 171 | 172 | describe('#componentWillMount', function () { 173 | beforeEach(function () { 174 | sinon.spy(console, 'warn'); 175 | }); 176 | 177 | afterEach(function () { 178 | console.warn.restore(); 179 | }); 180 | 181 | it('should raise warnings if non immutable props are passed', function () { 182 | var Component = createReactClass({ 183 | displayName: 'MyComponent', 184 | mixins: [ImmutableMixin], 185 | render: function () { 186 | return null; 187 | } 188 | }); 189 | 190 | jsx.renderComponent(Component, {data: {list: [1, 2, 3]}}); 191 | expect( 192 | console.warn.calledWith('WARN: component: MyComponent received non-immutable object: data') 193 | ).to.equal(true); 194 | }); 195 | 196 | it('should not raise warnings for children', function () { 197 | var Component = createReactClass({ 198 | displayName: 'MyComponent', 199 | mixins: [ImmutableMixin], 200 | render: function () { 201 | return React.createElement('div', {}, this.props.children); 202 | } 203 | }); 204 | 205 | jsx.renderComponent(Component, {}, ['foo', 'bar']); 206 | expect(console.warn.callCount).to.equal(0); 207 | }); 208 | 209 | it('does not raise warning if props or state is null', function () { 210 | var Component = createReactClass({ 211 | displayName: 'MyComponent', 212 | mixins: [ImmutableMixin], 213 | getInitialState: function () { 214 | return null; 215 | }, 216 | getStateOnChange: function () { 217 | return null; 218 | }, 219 | render: function () { 220 | return null; 221 | } 222 | }); 223 | 224 | jsx.renderComponent(Component, null); 225 | expect(console.warn.callCount).to.equal(0); 226 | }); 227 | 228 | it('should raise warkings if there are non immutable states', function () { 229 | var Component = createReactClass({ 230 | displayName: 'YComponent', 231 | mixins: [ImmutableMixin], 232 | getInitialState: function () { 233 | return {list: [1, 2, 3]}; 234 | }, 235 | render: function () { 236 | return null; 237 | } 238 | }); 239 | 240 | jsx.renderComponent(Component, {}); 241 | expect( 242 | console.warn.calledWith('WARN: component: YComponent received non-immutable object: list') 243 | ).to.equal(true); 244 | }); 245 | }); 246 | 247 | describe('#getStateOnChange', function () { 248 | it('should merge the getInitialState with getStateOnChange', function () { 249 | var Component = createReactClass({ 250 | mixins: [ImmutableMixin], 251 | getStateOnChange: function () { 252 | return {foo: 'bar'}; 253 | }, 254 | getInitialState: function () { 255 | return {bar: 'foo'}; 256 | }, 257 | render: function () { 258 | return null; 259 | } 260 | }); 261 | 262 | var component = jsx.renderComponent(Component, {}); 263 | 264 | expect(component.state).to.deep.equal({ 265 | bar: 'foo', 266 | foo: 'bar' 267 | }); 268 | }); 269 | 270 | it('should call getStateOnChange from onChange correctly', function (done) { 271 | var Component = createReactClass({ 272 | mixins: [ImmutableMixin], 273 | getStateOnChange: function (foo) { 274 | if (foo) { 275 | done(); 276 | } 277 | return {}; 278 | }, 279 | render: function () { 280 | return null; 281 | } 282 | }); 283 | 284 | var component = jsx.renderComponent(Component, {}); 285 | var onChange = component.onChange; 286 | onChange(true); 287 | }); 288 | 289 | it('should call getStateOnChange to initialize state', function (done) { 290 | var Component = createReactClass({ 291 | mixins: [ImmutableMixin], 292 | getStateOnChange: function () { 293 | done(); 294 | return {}; 295 | }, 296 | render: function () { 297 | return null; 298 | } 299 | }); 300 | 301 | jsx.renderComponent(Component, {}); 302 | }); 303 | 304 | it('shouldn\'t crash if getStateOnChange is not there', function () { 305 | var Component = createReactClass({ 306 | mixins: [ImmutableMixin], 307 | render: function () { 308 | return null; 309 | } 310 | }); 311 | var component = jsx.renderComponent(Component, {}); 312 | 313 | // This shouldn't throw 314 | component.onChange({foo: 'bar'}); 315 | }); 316 | 317 | it('should pass onChange arguments to getStateOnChange', function () { 318 | var Component = createReactClass({ 319 | mixins: [ImmutableMixin], 320 | getStateOnChange: function (data) { 321 | return data || {}; 322 | }, 323 | render: function () { 324 | return null; 325 | } 326 | }); 327 | 328 | var component = jsx.renderComponent(Component, {}); 329 | component.onChange({foo: 'bar'}); 330 | expect(component.state).to.deep.equal({foo: 'bar'}); 331 | }); 332 | }); 333 | 334 | describe('#shouldComponentUpdate', function () { 335 | var component; 336 | var props; 337 | var state; 338 | 339 | beforeEach(function () { 340 | props = {foo: 'bar', list: Immutable.fromJS(['baz', 'foo'])}; 341 | state = {list: Immutable.fromJS(['baz', 'foo'])}; 342 | 343 | var Component = createReactClass({ 344 | mixins: [ImmutableMixin], 345 | getStateOnChange: function () { 346 | return state; 347 | }, 348 | render: function () { 349 | return null; 350 | } 351 | }); 352 | 353 | component = jsx.renderComponent(Component, props); 354 | }); 355 | 356 | function assertComponentUpdate(newProps, newState, expected) { 357 | expect( 358 | component.shouldComponentUpdate(newProps, newState) 359 | ).to.equal(expected); 360 | } 361 | 362 | it('should return false if props/state are equal', function () { 363 | assertComponentUpdate(props, state, false); 364 | assertComponentUpdate({foo: 'bar', list: props.list}, state, false); 365 | assertComponentUpdate(props, {list: state.list}, false); 366 | }); 367 | 368 | it('should return true if a current prop value is changed', function () { 369 | assertComponentUpdate({foo: 'fubar', list: props.list}, state, true); 370 | assertComponentUpdate({foo: 'bar', list: state.list}, state, true); 371 | assertComponentUpdate({}, state, true); 372 | }); 373 | 374 | it('should return true if a new prop value is added', function () { 375 | assertComponentUpdate(props, state, false); 376 | props.test = 'baz'; 377 | assertComponentUpdate(props, state, true); 378 | }); 379 | 380 | it('should return true if a new prop value is removed', function () { 381 | assertComponentUpdate(props, state, false); 382 | delete props.foo; 383 | assertComponentUpdate(props, state, true); 384 | }); 385 | 386 | it('should return true if state is changed', function () { 387 | assertComponentUpdate(props, {list: props.list}, true); 388 | assertComponentUpdate(props, {foo: 'bar', list: state.list}, true); 389 | }); 390 | 391 | it('should return true if state is null', function () { 392 | assertComponentUpdate(props, null, true); 393 | assertComponentUpdate(props, {foo: 'bar', list: state.list}, true); 394 | }); 395 | 396 | it('allows the shouldComponentUpdate to be overridden', function () { 397 | var Component = createReactClass({ 398 | mixins: [ImmutableMixin], 399 | shouldComponentUpdate: function () { 400 | return 'override'; 401 | }, 402 | render: function () { 403 | return null; 404 | } 405 | }); 406 | 407 | component = jsx.renderComponent(Component, props); 408 | assertComponentUpdate({}, {}, 'override'); 409 | }); 410 | }); 411 | }); 412 | --------------------------------------------------------------------------------