diff --git a/README.md b/README.md index 64d822b..6f0dcd4 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,45 @@ This package provides easy to use mixins/utils for both fluxible stores and reac $ npm install --save fluxible-immutable-utils ``` +## `createImmutableContainer` + +This method creates an immutable higher order component. + +```js + +var MyComponent = React.createClass({ + displayName: 'MyComponent', + + ... +}); + +var createImmutableContainer = require('fluxible-immutable-utils').createImmutableContainer; + +// Wraps your component in an immutable container. +// Prevents renders when props are the same +module.exports = createImmutableContainer(MyComponent); + +// Wraps your component in an immutable container that listens to stores +// and pass its state down as props +module.exports = createImmutableContainer(MyComponent, { + stores: [SomeStore], + getStateFromStores: { + SomeStore: function (store) { + return { + someState: store.state; + } + } + } +}); +``` + ## `ComponentMixin` 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). This mixin has several purposes: - Checks that the objects in state/props of each component are an Immutable Map. - Implements a default shouldComponentUpdate method. -- Provides a convenience method for dealing with state changes/component +- Provides a convenience method for dealing with state changes/component initialization. ### Immutalizing State @@ -76,14 +108,14 @@ module.exports = React.createClass({ var myObject = { foo: 'bar' }; - ``` #### Configuring the Mixin -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. +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. **Example** @@ -104,7 +136,7 @@ module.exports = React.createClass({ state: { anotherKey: 'SKIP_SHOULD_UPDATE' // don't check anotherKey for immutablility in props, AND don't check its value is shouldComponentUpdate } - + } }, @@ -112,7 +144,7 @@ module.exports = React.createClass({ }); ``` -If you want to just pass around a common config, then use: +If you want to just pass around a common config, then use: ```jsx var ImmutableMixin = require('fluxible-immutable-utils').createComponentMixin(myConfig); ``` @@ -122,19 +154,19 @@ Where myConfig has the same structure as the statics above. A helper method similar to `React.createClass` but for creating immutable [stores](https://facebook.github.io/flux/docs/overview.html#stores). Internally it wraps a call to [`'fluxible/utils/createStore`](https://github.com/yahoo/fluxible/blob/v0.2.9/utils/createStore.js). -The main use case for this method is to reduce boilerplate when implementing immutable [`fluxible`](fluxible.io) stores. +The main use case for this method is to reduce boilerplate when implementing immutable [`fluxible`](fluxible.io) stores. The helper adds a new property and method to the created store * `_state` {[Map](http://facebook.github.io/immutable-js/docs/#/Map)} - The root `Immutable` where all data in the store will be saved. -* `setState(newState, [event], [payload])` {Function} - This method replaces `this._state` with `newState` (unless they were the same) and then calls `this.emit(event, payload)`. +* `setState(newState, [event], [payload])` {Function} - This method replaces `this._state` with `newState` (unless they were the same) and then calls `this.emit(event, payload)`. * If `event` is *falsy* it will call `this.emitChange(payload)` * The method also ensures that `_state` remains immutable by auto-converting `newState` to an immutable object. and creates defaults for the following [fluxible store](http://fluxible.io/api/stores.html) methods * [`initialize()`](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) -* [`rehydrate(state)`](http://fluxible.io/api/stores.html#rehydrate-state-) - The default implementation hydrates `_state` +* [`rehydrate(state)`](http://fluxible.io/api/stores.html#rehydrate-state-) - The default implementation hydrates `_state` * [`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`) diff --git a/internals/utils.js b/internals/utils.js index 3dbdd60..0b48c58 100644 --- a/internals/utils.js +++ b/internals/utils.js @@ -5,14 +5,38 @@ */ 'use strict'; -module.exports = { - merge: function merge(dest, src) { - dest || (dest = {}); +var Immutable = require('immutable'); +var isImmutable = Immutable.Iterable.isIterable; +var isReactElement = require('react/addons').isValidElement; + +function isNonImmutable(item) { + return ( + item + && typeof item === 'object' + && !isReactElement(item) + && !isImmutable(item) + ); +} + +function warnNonImmutable(component, prop) { + console.warn('Component ' + + '"' + component.constructor.displayName + '"' + + ' received non-immutable object for ' + + '"' + prop + '"'); +} - src && typeof src === 'object' && Object.keys(src).forEach(function mergeCb(prop) { - dest[prop] = src[prop]; - }); +function merge(dest, src) { + dest || (dest = {}); - return dest; - } + src && typeof src === 'object' && Object.keys(src).forEach(function mergeCb(prop) { + dest[prop] = src[prop]; + }); + + return dest; +} + +module.exports = { + merge: merge, + isNonImmutable: isNonImmutable, + warnNonImmutable: warnNonImmutable }; diff --git a/lib/createImmutableContainer.js b/lib/createImmutableContainer.js new file mode 100644 index 0000000..93d63fb --- /dev/null +++ b/lib/createImmutableContainer.js @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2015, Yahoo Inc. All rights reserved. + * Copyrights licensed under the New BSD License. + * See the accompanying LICENSE file for terms. + */ + +'use strict'; + +var React = require('react'); +var connectToStores = require('fluxible/addons/connectToStores'); +var utils = require('../internals/utils'); +var shallowEqual = require('react/lib/shallowEqual'); + +function getIgnoredProps(ignore) { + if (!Array.isArray(ignore)) { + return ignore || {}; + } + + var ignoredProps = {}; + + ignore.forEach(function (prop) { + ignoredProps[prop] = true; + }); + + return ignoredProps; +} + +module.exports = function createImmutableContainer(Component, options) { + options = options || {}; + var ignore = options.ignore; + var ignoreWarnings = options.ignoreWarnings; + var getStateFromStores = options.getStateFromStores; + var stores = options.stores || Object.keys(getStateFromStores || {}); + var componentName = Component.displayName || Component.name; + + var ImmutableComponent = React.createClass({ + displayName: componentName + ':Immutable', + + getDefaultProps: function () { + return {}; + }, + + checkImmutable: function (prop) { + if ( + !this._ignoredProps[prop] && + utils.isNonImmutable(this.props[prop]) + ) { + utils.warnNonImmutable(this, prop); + } + }, + + checkAllImmutable: function () { + if (!ignoreWarnings) { + Object.keys(this.props).forEach(this.checkImmutable); + } + }, + + componentWillMount: function () { + this._ignoredProps = getIgnoredProps(ignore); + this.checkAllImmutable(); + }, + + componentWillUpdate: function () { + this.checkAllImmutable(); + }, + + shouldComponentUpdate: function (nextProps) { + return !shallowEqual(this.props, nextProps); + }, + + render: function () { + return React.createElement(Component, this.props); + } + }); + + return stores.length ? + connectToStores(ImmutableComponent, stores, getStateFromStores) : + ImmutableComponent; +}; diff --git a/lib/createImmutableMixin.js b/lib/createImmutableMixin.js index 66d8479..5e9db7f 100644 --- a/lib/createImmutableMixin.js +++ b/lib/createImmutableMixin.js @@ -5,12 +5,8 @@ */ 'use strict'; -var Immutable = require('immutable'); -var isImmutable = Immutable.Iterable.isIterable; -var isReactElement = require('react/addons').isValidElement; -var GET_STATE_FUNCTION = 'getStateOnChange'; var utils = require('../internals/utils'); - +var GET_STATE_FUNCTION = 'getStateOnChange'; var IGNORE_IMMUTABLE_CHECK = 'ignoreImmutableCheck'; var IGNORE_EQUALITY_CHECK_FLAG = 'SKIP_SHOULD_UPDATE'; @@ -23,11 +19,7 @@ var IGNORE_EQUALITY_CHECK_FLAG = 'SKIP_SHOULD_UPDATE'; * @return {Boolean} True if non-immutable object, else false. */ function checkNonImmutableObject(key, item, component) { - if (item - && typeof item === 'object' - && !isReactElement(item) - && !isImmutable(item) - ) { + if (utils.isNonImmutable(item)) { console.warn('WARN: component: ' + component.constructor.displayName + ' received non-immutable object: ' + key); } diff --git a/package.json b/package.json index ba57516..6170f91 100644 --- a/package.json +++ b/package.json @@ -14,22 +14,22 @@ "devtest": "mocha tests --recursive --reporter spec", "test": "npm run lint && npm run cover" }, + "peerDependencies": { + "react": "^0.13" + }, "dependencies": { "immutable": "^3.3.0", "fluxible": "^0.4.0" }, - "peerDependencies": { - "react": "<=0.13.x" - }, "devDependencies": { "chai": "^2.0.0", "coveralls": "^2.11.1", "eslint": "^0.17.0", "istanbul": "^0.3.2", - "jsx-test": "^0.4.2", + "jsx-test": "^0.6", "mocha": "^2.0", "pre-commit": "^1.0", - "react": "<=0.13.x", + "react": "^0.13", "sinon": "^1.14.1" }, "pre-commit": [ diff --git a/tests/lib/createImmutableContainer.js b/tests/lib/createImmutableContainer.js new file mode 100644 index 0000000..9f22a11 --- /dev/null +++ b/tests/lib/createImmutableContainer.js @@ -0,0 +1,149 @@ +/*globals describe, it, beforeEach, afterEach */ + +'use strict'; + +var jsx = require('jsx-test'); +var expect = require('chai').expect; +var Immutable = require('immutable'); +var React = require('react'); +var sinon = require('sinon'); +var createImmutableContainer = require('../../lib/createImmutableContainer'); +var createStore = require('fluxible/addons/createStore'); + +describe('createImmutableMixin', function () { + var DummyComponent = React.createClass({ + displayName: 'Dummy', + + render: function () { + return React.createElement('div', this.props); + } + }); + + var DummyStore = createStore({ + storeName: 'DummyStore' + }); + + beforeEach(function () { + sinon.spy(console, 'warn'); + }); + + afterEach(function () { + console.warn.restore(); + }); + + it('wraps the component without the store connector', function () { + var Component = createImmutableContainer(DummyComponent); + + expect(Component.displayName).to.equal('Dummy:Immutable'); + }); + + it('wraps the component with the store connector', function () { + var Component = createImmutableContainer(DummyComponent, { + stores: [DummyStore], + getStateFromStores: { + DummyStore: function (store) { } + } + }); + + expect(Component.displayName).to.equal('Dummy:ImmutableStoreConnector'); + }); + + describe('#componentWillMount', function () { + var Component = createImmutableContainer(DummyComponent, { + ignore: ['data-items'] + }); + + it('raise warnings if non immutable props are passed', function () { + jsx.renderComponent(Component, {stuff: [1, 2, 3]}); + + expect( + console.warn.calledWith('Component "Dummy:Immutable" received non-immutable object for "stuff"') + ).to.equal(true); + }); + + it('bypasses certain fields if they are ignored', function () { + jsx.renderComponent(Component, {'data-items': [1, 2, 3]}); + expect(console.warn.callCount).to.equal(0); + }); + + it('raises a warning for each non-imutable object', function () { + jsx.renderComponent(Component, { + items: [1, 2, 3], + stuff: {}, + map: Immutable.Map(), + number: 1, + name: 'something' + }); + expect(console.warn.callCount).to.equal(2); + }); + + it('should never warn if ignoreAllWarnings is true', function () { + var Component2 = createImmutableContainer(DummyComponent, { + ignoreWarnings: true + }); + + jsx.renderComponent(Component2, { + items: [1, 2, 3], + nonImmutable: {} + }); + + expect(console.warn.callCount).to.equal(0); + }); + }); + + describe('#componentWillUpdate', function () { + var Component = createImmutableContainer(DummyComponent); + var component = jsx.renderComponent(Component, { + items: [1, 2, 3], + stuff: {}, + map: Immutable.Map(), + number: 1, + name: 'something' + }); + + it('raises a warning for each non-imutable object', function () { + component.componentWillUpdate(); + expect(console.warn.callCount).to.equal(2); + }); + }); + + describe('#shouldComponentUpdate', function () { + var someMap = Immutable.Map(); + var Component = createImmutableContainer(DummyComponent); + + beforeEach(function () { + this.component = jsx.renderComponent(Component, { + name: 'Bilbo', + map: someMap + }); + }); + + it('should return false if props are equal', function () { + expect(this.component.shouldComponentUpdate({ + name: 'Bilbo', + map: someMap + })).to.equal(false); + }); + + it('should return true if any prop changes', function () { + expect(this.component.shouldComponentUpdate({ + name: 'Frodo', + map: someMap + })).to.equal(true); + }); + + it('should return true if any prop is removed', function () { + expect(this.component.shouldComponentUpdate({ + name: 'Bilbo' + })).to.equal(true); + }); + + it('should return true if a new prop is passed', function () { + expect(this.component.shouldComponentUpdate({ + name: 'Bilbo', + map: someMap, + n: 1 + })).to.equal(true); + }); + }); +});