├── .babelrc
├── .editorconfig
├── .eslintrc
├── .gitignore
├── .npmignore
├── LICENSE
├── README.md
├── dist
├── SocketProvider.js
├── connect.js
└── index.js
├── package.json
├── src
├── SocketProvider.js
├── connect.js
└── index.js
└── test
├── components
├── SocketProvider.spec.js
└── connect.spec.js
├── setup.js
└── utils.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["react", "es2015", "stage-1"]
3 | }
4 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | trim_trailing_whitespace = true
6 | end_of_line = lf
7 | insert_final_newline = true
8 |
9 | # 2 space indentation
10 | [*.{js,jsx,html}]
11 | indent_style = space
12 | indent_size = 2
13 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": "airbnb",
4 | "rules": {
5 | "no-restricted-syntax": [0],
6 | "comma-dangle": [2, "never"],
7 | "new-cap": [0],
8 | "no-unused-vars": [2, {
9 | "vars": "all",
10 | "argsIgnorePattern": "^_",
11 | "varsIgnorePattern": "^_"
12 | }]
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.swp
2 | *~
3 | *.iml
4 | .*.haste_cache.*
5 | .DS_Store
6 | .idea
7 | npm-debug.log
8 | node_modules
9 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | *.swp
2 | *~
3 | *.iml
4 | .*.haste_cache.*
5 | .DS_Store
6 | .idea
7 | npm-debug.log
8 | lib
9 | .babelrc
10 | .editorconfig
11 | .eslintrc
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Daniel Farrell
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # redux-channels
2 |
3 | A provider and connect for getting websocket channels(ie, Socket.io/Phoenix/ActionCable) working with Redux.
4 |
5 | This library was heavily inspired by and borrows code from [react-redux](https://github.com/reactjs/react-redux/) and [react-apollo](https://github.com/apollostack/react-apollo).
6 |
7 | ## Install
8 |
9 | `npm install --save redux-channels`
10 |
11 | ## Demo
12 |
13 | There is a demo app over at [presentation_app](https://github.com/danielfarrell/presentation_app).
14 |
15 | ## Usage
16 |
17 | ### Phoenix channels
18 |
19 | Phoenix channels are my primary target with the library.
20 |
21 | ```
22 | import React from 'react';
23 | import { render } from 'react-dom';
24 | import { Socket } from 'phoenix';
25 | import { SocketProvider } from 'redux-channels';
26 |
27 | import configureStore from './store/configureStore';
28 | import App from './containers/App';
29 |
30 | const store = configureStore();
31 |
32 | render(
33 |
34 |
35 | ,
36 | chatDOM
37 | );
38 | ```
39 |
40 | ```
41 | import { connect } from 'redux-channels';
42 |
43 | class App extends React.Component {
44 | static propTypes = {
45 | messages: PropTypes.object,
46 | actions: PropTypes.object,
47 | someChannel: PropTypes.object
48 | };
49 |
50 | constructor(props) {
51 | super(props);
52 | const { actions, someChannel } = props;
53 | this.someChannel = someChannel;
54 | // Setup all your channel listening here
55 | }
56 | }
57 |
58 | const mapStateToProps = (state) => ({
59 | messages: state.messages
60 | });
61 | const mapDispatchToProps = (dispatch) => ({
62 | action: bindActionCreators(action, dispatch)
63 | });
64 | const mapSocketToProps = ({ socket, state }) => ({
65 | someChannel: socket.channel('some', { username: state.auth })
66 | });
67 | export default connect({
68 | mapStateToProps,
69 | mapDispatchToProps,
70 | mapSocketToProps
71 | })(App);
72 | ```
73 |
74 | ### socket.io
75 |
76 | Use [namespaces](http://socket.io/docs/rooms-and-namespaces/) in socket.io to have different channels and follow the general pattern above.
77 |
--------------------------------------------------------------------------------
/dist/SocketProvider.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | Object.defineProperty(exports, "__esModule", {
4 | value: true
5 | });
6 |
7 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
8 |
9 | var _react = require('react');
10 |
11 | var _react2 = _interopRequireDefault(_react);
12 |
13 | var _reactRedux = require('react-redux');
14 |
15 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
16 |
17 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
18 |
19 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; }
20 |
21 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }
22 |
23 | var Component = _react2.default.Component;
24 | var PropTypes = _react2.default.PropTypes;
25 |
26 |
27 | var storeShape = PropTypes.shape({
28 | subscribe: PropTypes.func.isRequired,
29 | dispatch: PropTypes.func.isRequired,
30 | getState: PropTypes.func.isRequired
31 | });
32 |
33 | var SocketProvider = function (_Component) {
34 | _inherits(SocketProvider, _Component);
35 |
36 | function SocketProvider(props, context) {
37 | _classCallCheck(this, SocketProvider);
38 |
39 | var _this = _possibleConstructorReturn(this, Object.getPrototypeOf(SocketProvider).call(this, props, context));
40 |
41 | _this.store = props.store;
42 | _this.socket = props.socket;
43 | return _this;
44 | }
45 |
46 | _createClass(SocketProvider, [{
47 | key: 'getChildContext',
48 | value: function getChildContext() {
49 | return {
50 | store: this.store,
51 | socket: this.socket
52 | };
53 | }
54 | }, {
55 | key: 'render',
56 | value: function render() {
57 | var children = this.props.children;
58 |
59 | return _react2.default.createElement(
60 | _reactRedux.Provider,
61 | { store: this.store },
62 | children
63 | );
64 | }
65 | }]);
66 |
67 | return SocketProvider;
68 | }(Component);
69 |
70 | exports.default = SocketProvider;
71 |
72 |
73 | SocketProvider.propTypes = {
74 | store: storeShape,
75 | socket: PropTypes.object,
76 | children: PropTypes.element
77 | };
78 |
79 | SocketProvider.childContextTypes = {
80 | store: storeShape,
81 | socket: PropTypes.object
82 | };
--------------------------------------------------------------------------------
/dist/connect.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | Object.defineProperty(exports, "__esModule", {
4 | value: true
5 | });
6 |
7 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
8 |
9 | exports.default = connect;
10 |
11 | var _react = require('react');
12 |
13 | var _lodash = require('lodash.isobject');
14 |
15 | var _lodash2 = _interopRequireDefault(_lodash);
16 |
17 | var _lodash3 = require('lodash.isequal');
18 |
19 | var _lodash4 = _interopRequireDefault(_lodash3);
20 |
21 | var _invariant = require('invariant');
22 |
23 | var _invariant2 = _interopRequireDefault(_invariant);
24 |
25 | var _objectAssign = require('object-assign');
26 |
27 | var _objectAssign2 = _interopRequireDefault(_objectAssign);
28 |
29 | var _reactRedux = require('react-redux');
30 |
31 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
32 |
33 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
34 |
35 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; }
36 |
37 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }
38 |
39 | var defaultMapSocketToProps = function defaultMapSocketToProps() {
40 | return {};
41 | };
42 |
43 | function getDisplayName(WrappedComponent) {
44 | return WrappedComponent.displayName || WrappedComponent.name || 'Component';
45 | }
46 |
47 | // Helps track hot reloading.
48 | var nextVersion = 0;
49 |
50 | function connect() {
51 | var opts = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0];
52 | var mapSocketToProps = opts.mapSocketToProps;
53 |
54 |
55 | mapSocketToProps = mapSocketToProps || defaultMapSocketToProps;
56 |
57 | // Helps track hot reloading.
58 | var version = nextVersion++;
59 |
60 | return function wrapWithChannelComponent(WrappedComponent) {
61 | var channelConnectDisplayName = 'ChannelConnect(' + getDisplayName(WrappedComponent) + ')';
62 |
63 | var ChannelConnect = function (_Component) {
64 | _inherits(ChannelConnect, _Component);
65 |
66 | function ChannelConnect(props, context) {
67 | _classCallCheck(this, ChannelConnect);
68 |
69 | var _this = _possibleConstructorReturn(this, Object.getPrototypeOf(ChannelConnect).call(this, props, context));
70 |
71 | _this.version = version;
72 | _this.store = props.store || context.store;
73 | _this.socket = props.socket || context.socket;
74 |
75 | (0, _invariant2.default)(!!_this.socket, 'Could not find "socket" in either the context or ' + ('props of "' + channelConnectDisplayName + '". ') + 'Either wrap the root component in a , ' + ('or explicitly pass "socket" as a prop to "' + channelConnectDisplayName + '".'));
76 |
77 | _this.channels = {};
78 | return _this;
79 | }
80 |
81 | _createClass(ChannelConnect, [{
82 | key: 'componentWillMount',
83 | value: function componentWillMount() {
84 | var props = this.props;
85 |
86 | this.connectToAllChannels(props);
87 | }
88 | }, {
89 | key: 'componentWillReceiveProps',
90 | value: function componentWillReceiveProps(nextProps) {
91 | // we got new props, we need to unsubscribe and rebuild all handles
92 | // with the new data
93 | if (!(0, _lodash4.default)(this.props, nextProps)) {
94 | this.haveOwnPropsChanged = true;
95 | this.connectToAllChannels(nextProps);
96 | }
97 | }
98 | }, {
99 | key: 'shouldComponentUpdate',
100 | value: function shouldComponentUpdate(nextProps, _nextState) {
101 | return this.haveOwnPropsChanged;
102 | }
103 | }, {
104 | key: 'componentWillUnmount',
105 | value: function componentWillUnmount() {
106 | this.disconnectAllChannels();
107 | }
108 | }, {
109 | key: 'connectToAllChannels',
110 | value: function connectToAllChannels(props) {
111 | var socket = this.socket;
112 | var store = this.store;
113 |
114 |
115 | var channelHandles = mapSocketToProps({
116 | socket: socket,
117 | ownProps: props,
118 | state: store.getState()
119 | });
120 |
121 | var oldChannels = (0, _objectAssign2.default)({}, this.previousChannels);
122 | this.previousChannels = (0, _objectAssign2.default)({}, channelHandles);
123 |
124 | // don't re run channels if nothing has changed
125 | if ((0, _lodash4.default)(oldChannels, channelHandles)) {
126 | return;
127 | } else if (oldChannels) {
128 | // unsubscribe from previous channels
129 | this.disconnectAllChannels();
130 | }
131 |
132 | if ((0, _lodash2.default)(channelHandles) && Object.keys(channelHandles).length) {
133 | this.channelHandles = channelHandles;
134 | }
135 | }
136 | }, {
137 | key: 'disconnectAllChannels',
138 | value: function disconnectAllChannels() {
139 | if (this.channelHandles) {
140 | for (var key in this.channelHandles) {
141 | if (!this.channelHandles.hasOwnProperty(key)) {
142 | continue;
143 | }
144 | if (this.channelHandles[key].hasOwnProperty('leave')) {
145 | this.channelHandles[key].leave(); // socket.io and Phoenix
146 | } else if (this.channelHandles[key].hasOwnProperty('unsubscribe')) {
147 | this.channelHandles[key].unsubscribe(); // Rails ActionCable
148 | }
149 | }
150 | }
151 | }
152 | }, {
153 | key: 'render',
154 | value: function render() {
155 | var haveOwnPropsChanged = this.haveOwnPropsChanged;
156 | var renderedElement = this.renderedElement;
157 | var props = this.props;
158 |
159 |
160 | this.haveOwnPropsChanged = false;
161 |
162 | var channelsProps = this.channelHandles;
163 |
164 | var mergedPropsAndChannels = (0, _objectAssign2.default)({}, props, channelsProps);
165 |
166 | if (!haveOwnPropsChanged && renderedElement) {
167 | return renderedElement;
168 | }
169 |
170 | this.renderedElement = (0, _react.createElement)(WrappedComponent, mergedPropsAndChannels);
171 | return this.renderedElement;
172 | }
173 | }]);
174 |
175 | return ChannelConnect;
176 | }(_react.Component);
177 |
178 | ChannelConnect.displayName = channelConnectDisplayName;
179 | ChannelConnect.WrappedComponent = WrappedComponent;
180 | ChannelConnect.contextTypes = {
181 | store: _react.PropTypes.object.isRequired,
182 | socket: _react.PropTypes.object.isRequired
183 | };
184 | ChannelConnect.propTypes = {
185 | store: _react.PropTypes.object,
186 | socket: _react.PropTypes.object
187 | };
188 |
189 | // apply react-redux args from original args
190 | var mapStateToProps = opts.mapStateToProps;
191 | var mapDispatchToProps = opts.mapDispatchToProps;
192 | var mergeProps = opts.mergeProps;
193 | var options = opts.options;
194 |
195 | return (0, _reactRedux.connect)(mapStateToProps, mapDispatchToProps, mergeProps, options)(ChannelConnect);
196 | };
197 | }
--------------------------------------------------------------------------------
/dist/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | Object.defineProperty(exports, "__esModule", {
4 | value: true
5 | });
6 | exports.connect = exports.SocketProvider = undefined;
7 |
8 | var _SocketProvider2 = require('./SocketProvider');
9 |
10 | var _SocketProvider3 = _interopRequireDefault(_SocketProvider2);
11 |
12 | var _connect2 = require('./connect');
13 |
14 | var _connect3 = _interopRequireDefault(_connect2);
15 |
16 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
17 |
18 | exports.SocketProvider = _SocketProvider3.default;
19 | exports.connect = _connect3.default;
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-channels",
3 | "version": "0.1.0",
4 | "description": "Redux integration for websocket channels",
5 | "main": "./dist/index.js",
6 | "scripts": {
7 | "test": "mocha --compilers js:babel-register --recursive --require ./test/setup.js",
8 | "lint": "eslint --ext .js ./src",
9 | "clean": "rm -rf ./dist",
10 | "compile": "npm run clean && babel ./src --out-dir ./dist",
11 | "build": "npm run compile"
12 | },
13 | "author": "Daniel Farrell ",
14 | "license": "MIT",
15 | "peerDependencies": {
16 | "react": "^15.1.0",
17 | "redux": "^3.5.2"
18 | },
19 | "devDependencies": {
20 | "babel-cli": "^6.10.1",
21 | "babel-core": "^6.9.1",
22 | "babel-eslint": "^6.0.4",
23 | "babel-preset-es2015": "^6.9.0",
24 | "babel-preset-react": "^6.5.0",
25 | "babel-preset-stage-1": "^6.5.0",
26 | "eslint": "^2.13.0",
27 | "eslint-config-airbnb": "^9.0.1",
28 | "eslint-plugin-import": "^1.8.1",
29 | "eslint-plugin-jsx-a11y": "^1.5.3",
30 | "eslint-plugin-react": "^5.2.2",
31 | "expect": "^1.20.1",
32 | "jsdom": "^9.2.1",
33 | "mocha": "^2.5.3",
34 | "react-addons-test-utils": "^15.1.0",
35 | "react-dom": "^15.1.0"
36 | },
37 | "dependencies": {
38 | "invariant": "^2.2.1",
39 | "lodash.isequal": "^4.2.0",
40 | "lodash.isobject": "^3.0.2",
41 | "object-assign": "^4.1.0",
42 | "react-redux": "^4.4.5"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/SocketProvider.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Provider } from 'react-redux';
3 | const { Component, PropTypes } = React;
4 |
5 | const storeShape = PropTypes.shape({
6 | subscribe: PropTypes.func.isRequired,
7 | dispatch: PropTypes.func.isRequired,
8 | getState: PropTypes.func.isRequired
9 | });
10 |
11 | export default class SocketProvider extends Component {
12 | constructor(props, context) {
13 | super(props, context);
14 | this.store = props.store;
15 | this.socket = props.socket;
16 | }
17 |
18 | getChildContext() {
19 | return {
20 | store: this.store,
21 | socket: this.socket
22 | };
23 | }
24 |
25 | render() {
26 | const { children } = this.props;
27 | return (
28 |
29 | {children}
30 |
31 | );
32 | }
33 | }
34 |
35 | SocketProvider.propTypes = {
36 | store: storeShape,
37 | socket: PropTypes.object,
38 | children: PropTypes.element
39 | };
40 |
41 | SocketProvider.childContextTypes = {
42 | store: storeShape,
43 | socket: PropTypes.object
44 | };
45 |
--------------------------------------------------------------------------------
/src/connect.js:
--------------------------------------------------------------------------------
1 | import { Component, createElement, PropTypes } from 'react';
2 | import isObject from 'lodash.isobject';
3 | import isEqual from 'lodash.isequal';
4 | import invariant from 'invariant';
5 | import assign from 'object-assign';
6 | import { connect as ReactReduxConnect } from 'react-redux';
7 |
8 | const defaultMapSocketToProps = () => ({});
9 |
10 | function getDisplayName(WrappedComponent) {
11 | return WrappedComponent.displayName || WrappedComponent.name || 'Component';
12 | }
13 |
14 | // Helps track hot reloading.
15 | let nextVersion = 0;
16 |
17 | export default function connect(opts = {}) {
18 | let { mapSocketToProps } = opts;
19 |
20 | mapSocketToProps = mapSocketToProps || defaultMapSocketToProps;
21 |
22 | // Helps track hot reloading.
23 | const version = nextVersion++;
24 |
25 | return function wrapWithChannelComponent(WrappedComponent) {
26 | const channelConnectDisplayName = `ChannelConnect(${getDisplayName(WrappedComponent)})`;
27 |
28 | class ChannelConnect extends Component {
29 | constructor(props, context) {
30 | super(props, context);
31 | this.version = version;
32 | this.store = props.store || context.store;
33 | this.socket = props.socket || context.socket;
34 |
35 | invariant(!!this.socket,
36 | 'Could not find "socket" in either the context or ' +
37 | `props of "${channelConnectDisplayName}". ` +
38 | 'Either wrap the root component in a , ' +
39 | `or explicitly pass "socket" as a prop to "${channelConnectDisplayName}".`
40 | );
41 |
42 | this.channels = {};
43 | }
44 |
45 | componentWillMount() {
46 | const { props } = this;
47 | this.connectToAllChannels(props);
48 | }
49 |
50 | componentWillReceiveProps(nextProps) {
51 | // we got new props, we need to unsubscribe and rebuild all handles
52 | // with the new data
53 | if (!isEqual(this.props, nextProps)) {
54 | this.haveOwnPropsChanged = true;
55 | this.connectToAllChannels(nextProps);
56 | }
57 | }
58 |
59 | shouldComponentUpdate(nextProps, _nextState) {
60 | return this.haveOwnPropsChanged;
61 | }
62 |
63 | componentWillUnmount() {
64 | this.disconnectAllChannels();
65 | }
66 |
67 | connectToAllChannels(props) {
68 | const { socket, store } = this;
69 |
70 | const channelHandles = mapSocketToProps({
71 | socket,
72 | ownProps: props,
73 | state: store.getState()
74 | });
75 |
76 | const oldChannels = assign({}, this.previousChannels);
77 | this.previousChannels = assign({}, channelHandles);
78 |
79 | // don't re run channels if nothing has changed
80 | if (isEqual(oldChannels, channelHandles)) {
81 | return;
82 | } else if (oldChannels) {
83 | // unsubscribe from previous channels
84 | this.disconnectAllChannels();
85 | }
86 |
87 | if (isObject(channelHandles) && Object.keys(channelHandles).length) {
88 | this.channelHandles = channelHandles;
89 | }
90 | }
91 |
92 | disconnectAllChannels() {
93 | if (this.channelHandles) {
94 | for (const key in this.channelHandles) {
95 | if (!this.channelHandles.hasOwnProperty(key)) {
96 | continue;
97 | }
98 | if (this.channelHandles[key].hasOwnProperty('leave')) {
99 | this.channelHandles[key].leave(); // socket.io and Phoenix
100 | } else if (this.channelHandles[key].hasOwnProperty('unsubscribe')) {
101 | this.channelHandles[key].unsubscribe(); // Rails ActionCable
102 | }
103 | }
104 | }
105 | }
106 |
107 | render() {
108 | const {
109 | haveOwnPropsChanged,
110 | renderedElement,
111 | props
112 | } = this;
113 |
114 | this.haveOwnPropsChanged = false;
115 |
116 | const channelsProps = this.channelHandles;
117 |
118 | const mergedPropsAndChannels = assign({}, props, channelsProps);
119 |
120 | if (
121 | !haveOwnPropsChanged &&
122 | renderedElement
123 | ) {
124 | return renderedElement;
125 | }
126 |
127 | this.renderedElement = createElement(WrappedComponent, mergedPropsAndChannels);
128 | return this.renderedElement;
129 | }
130 |
131 | }
132 |
133 | ChannelConnect.displayName = channelConnectDisplayName;
134 | ChannelConnect.WrappedComponent = WrappedComponent;
135 | ChannelConnect.contextTypes = {
136 | store: PropTypes.object.isRequired,
137 | socket: PropTypes.object.isRequired
138 | };
139 | ChannelConnect.propTypes = {
140 | store: PropTypes.object,
141 | socket: PropTypes.object
142 | };
143 |
144 | // apply react-redux args from original args
145 | const { mapStateToProps, mapDispatchToProps, mergeProps, options } = opts;
146 | return ReactReduxConnect(
147 | mapStateToProps,
148 | mapDispatchToProps,
149 | mergeProps,
150 | options
151 | )(ChannelConnect);
152 | };
153 | }
154 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export SocketProvider from './SocketProvider';
2 | export connect from './connect';
3 |
--------------------------------------------------------------------------------
/test/components/SocketProvider.spec.js:
--------------------------------------------------------------------------------
1 | /* global describe it */
2 | import expect from 'expect';
3 | import React, { PropTypes, Component } from 'react';
4 | import TestUtils from 'react-addons-test-utils';
5 | import { createStore } from 'redux';
6 | import { SocketProvider } from '../../src/index';
7 |
8 | describe('React', () => {
9 | describe('SocketProvider', () => {
10 | class Child extends Component {
11 | render() {
12 | return ;
13 | }
14 | }
15 |
16 | Child.contextTypes = {
17 | store: PropTypes.object.isRequired,
18 | socket: PropTypes.object.isRequired
19 | };
20 |
21 | it('should enforce a single child', () => {
22 | const store = createStore(() => ({}));
23 | const socket = {};
24 |
25 | // Ignore propTypes warnings
26 | const propTypes = SocketProvider.propTypes;
27 | SocketProvider.propTypes = {};
28 |
29 | try {
30 | expect(() => TestUtils.renderIntoDocument(
31 |
32 |
33 |
34 | )).toNotThrow();
35 |
36 | expect(() => TestUtils.renderIntoDocument(
37 |
38 | )).toThrow(/exactly one child/);
39 |
40 | expect(() => TestUtils.renderIntoDocument(
41 |
42 |
43 |
44 |
45 | )).toThrow(/exactly one child/);
46 | } finally {
47 | SocketProvider.propTypes = propTypes;
48 | }
49 | });
50 |
51 | it('should add the store and socket to the child context', () => {
52 | const store = createStore(() => ({}));
53 | const socket = {};
54 |
55 | const spy = expect.spyOn(console, 'error');
56 | const tree = TestUtils.renderIntoDocument(
57 |
58 |
59 |
60 | );
61 | spy.destroy();
62 | expect(spy.calls.length).toBe(0);
63 |
64 | const child = TestUtils.findRenderedComponentWithType(tree, Child);
65 | expect(child.context.store).toBe(store);
66 | expect(child.context.socket).toBe(socket);
67 | });
68 | });
69 | });
70 |
--------------------------------------------------------------------------------
/test/components/connect.spec.js:
--------------------------------------------------------------------------------
1 | /* global describe it */
2 | import expect from 'expect';
3 | import React, { createClass, Children, PropTypes, Component } from 'react';
4 | import TestUtils from 'react-addons-test-utils';
5 | import { createStore } from 'redux';
6 | import { createSocket } from '../utils';
7 | import { connect } from '../../src/index';
8 |
9 | describe('React', () => {
10 | describe('connect', () => {
11 | class Passthrough extends Component {
12 | render() {
13 | return ;
14 | }
15 | }
16 |
17 | class ProviderMock extends Component {
18 | getChildContext() {
19 | return {
20 | store: this.props.store,
21 | socket: this.props.socket
22 | };
23 | }
24 |
25 | render() {
26 | return Children.only(this.props.children);
27 | }
28 | }
29 |
30 | ProviderMock.childContextTypes = {
31 | store: PropTypes.object,
32 | socket: PropTypes.object
33 | };
34 |
35 | function stringBuilder(prev = '', action) {
36 | return action.type === 'APPEND'
37 | ? prev + action.body
38 | : prev;
39 | }
40 |
41 | it('should pass store through in the context', () => {
42 | const store = createStore(() => ({}));
43 | const socket = createSocket();
44 |
45 | class Container extends Component {
46 | render() {
47 | return ;
48 | }
49 | }
50 | Container = connect()(Container);
51 |
52 | const tree = TestUtils.renderIntoDocument(
53 |
54 |
55 |
56 | );
57 |
58 | const container = TestUtils.findRenderedComponentWithType(tree, Container);
59 | expect(container.context.store).toBe(store);
60 | });
61 |
62 | it('should pass state and props to the given component', () => {
63 | const store = createStore(() => ({
64 | foo: 'bar',
65 | baz: 42,
66 | hello: 'world'
67 | }));
68 | const socket = createSocket();
69 |
70 | class Container extends Component {
71 | render() {
72 | return ;
73 | }
74 | }
75 | Container = connect({ mapStateToProps: ({ foo, baz }) => ({ foo, baz }) })(Container);
76 |
77 | const container = TestUtils.renderIntoDocument(
78 |
79 |
80 |
81 | );
82 | const stub = TestUtils.findRenderedComponentWithType(container, Passthrough);
83 | expect(stub.props.pass).toEqual('through');
84 | expect(stub.props.foo).toEqual('bar');
85 | expect(stub.props.baz).toEqual(42);
86 | expect(stub.props.hello).toEqual(undefined);
87 | expect(() =>
88 | TestUtils.findRenderedComponentWithType(container, Container)
89 | ).toNotThrow();
90 | });
91 |
92 | it('should handle dispatches before componentDidMount', () => {
93 | const store = createStore(stringBuilder);
94 | const socket = createSocket();
95 |
96 | class Container extends Component {
97 | componentWillMount() {
98 | store.dispatch({ type: 'APPEND', body: 'a' });
99 | }
100 |
101 | render() {
102 | return ;
103 | }
104 | }
105 | Container = connect({ mapStateToProps: state => ({ string: state }) })(Container);
106 |
107 | const tree = TestUtils.renderIntoDocument(
108 |
109 |
110 |
111 | );
112 |
113 | const stub = TestUtils.findRenderedComponentWithType(tree, Passthrough);
114 | expect(stub.props.string).toBe('a');
115 | });
116 |
117 | it('should handle additional prop changes in addition to slice', () => {
118 | const store = createStore(() => ({
119 | foo: 'bar'
120 | }));
121 | const socket = createSocket();
122 |
123 | class ConnectContainer extends Component {
124 | render() {
125 | return (
126 |
127 | );
128 | }
129 | }
130 | ConnectContainer = connect({ mapStateToProps: state => state })(ConnectContainer);
131 |
132 | class Container extends Component {
133 | constructor() {
134 | super();
135 | this.state = {
136 | bar: {
137 | baz: ''
138 | }
139 | };
140 | }
141 |
142 | componentDidMount() {
143 | this.setState({
144 | bar: Object.assign({}, this.state.bar, { baz: 'through' })
145 | });
146 | }
147 |
148 | render() {
149 | return (
150 |
151 |
152 |
153 | );
154 | }
155 | }
156 |
157 | const container = TestUtils.renderIntoDocument();
158 | const stub = TestUtils.findRenderedComponentWithType(container, Passthrough);
159 | expect(stub.props.foo).toEqual('bar');
160 | expect(stub.props.pass).toEqual('through');
161 | });
162 |
163 | it('should merge actionProps into WrappedComponent', () => {
164 | const store = createStore(() => ({
165 | foo: 'bar'
166 | }));
167 | const socket = createSocket();
168 |
169 | class Container extends Component {
170 | render() {
171 | return ;
172 | }
173 | }
174 | Container = connect({
175 | mapStateToProps: state => state,
176 | mapDispatchToProps: dispatch => ({ dispatch })
177 | })(Container);
178 |
179 | const container = TestUtils.renderIntoDocument(
180 |
181 |
182 |
183 | );
184 | const stub = TestUtils.findRenderedComponentWithType(container, Passthrough);
185 | expect(stub.props.dispatch).toEqual(store.dispatch);
186 | expect(stub.props.foo).toEqual('bar');
187 | expect(() =>
188 | TestUtils.findRenderedComponentWithType(container, Container)
189 | ).toNotThrow();
190 | const decorated = TestUtils.findRenderedComponentWithType(container, Container);
191 | expect(decorated.isSubscribed()).toBe(true);
192 | });
193 |
194 | it('should recalculate the state and rebind the actions on hot update', () => {
195 | const store = createStore(() => {});
196 | const socket = createSocket();
197 |
198 | class ContainerBefore extends Component {
199 | render() {
200 | return (
201 |
202 | );
203 | }
204 | }
205 | ContainerBefore = connect({
206 | mapDispatchToProps: () => ({ scooby: 'doo' })
207 | })(ContainerBefore);
208 |
209 | class ContainerAfter extends Component {
210 | render() {
211 | return (
212 |
213 | );
214 | }
215 | }
216 | ContainerAfter = connect({
217 | mapStateToProps: () => ({ foo: 'baz' }),
218 | mapDispatchToProps: () => ({ scooby: 'foo' })
219 | })(ContainerAfter);
220 |
221 | class ContainerNext extends Component {
222 | render() {
223 | return (
224 |
225 | );
226 | }
227 | }
228 | ContainerNext = connect({
229 | mapStateToProps: () => ({ foo: 'bar' }),
230 | mapDispatchToProps: () => ({ scooby: 'boo' })
231 | })(ContainerNext);
232 |
233 | let container;
234 | TestUtils.renderIntoDocument(
235 |
236 | container = instance} />
237 |
238 | );
239 | const stub = TestUtils.findRenderedComponentWithType(container, Passthrough);
240 | expect(stub.props.foo).toEqual(undefined);
241 | expect(stub.props.scooby).toEqual('doo');
242 |
243 | function imitateHotReloading(TargetClass, SourceClass) {
244 | // Crude imitation of hot reloading that does the job
245 | Object.getOwnPropertyNames(SourceClass.prototype).filter(key =>
246 | typeof SourceClass.prototype[key] === 'function'
247 | ).forEach(key => {
248 | if (key !== 'render' && key !== 'constructor') {
249 | TargetClass.prototype[key] = SourceClass.prototype[key];
250 | }
251 | });
252 |
253 | container.forceUpdate();
254 | }
255 |
256 | imitateHotReloading(ContainerBefore, ContainerAfter);
257 | expect(stub.props.foo).toEqual('baz');
258 | expect(stub.props.scooby).toEqual('foo');
259 |
260 | imitateHotReloading(ContainerBefore, ContainerNext);
261 | expect(stub.props.foo).toEqual('bar');
262 | expect(stub.props.scooby).toEqual('boo');
263 | });
264 |
265 | it('should set the displayName correctly', () => {
266 | expect(connect(state => state)(
267 | class Foo extends Component {
268 | render() {
269 | return ;
270 | }
271 | }
272 | ).displayName).toBe('Connect(ChannelConnect(Foo))');
273 |
274 | expect(connect(state => state)(
275 | createClass({
276 | displayName: 'Bar',
277 | render() {
278 | return ;
279 | }
280 | })
281 | ).displayName).toBe('Connect(ChannelConnect(Bar))');
282 |
283 | expect(connect(state => state)(
284 | createClass({
285 | render() {
286 | return ;
287 | }
288 | })
289 | ).displayName).toBe('Connect(ChannelConnect(Component))');
290 | });
291 |
292 | it('should expose the wrapped component as WrappedComponent', () => {
293 | class Container extends Component {
294 | render() {
295 | return ;
296 | }
297 | }
298 |
299 | const decorator = connect(state => state);
300 | const decorated = decorator(Container);
301 |
302 | expect(decorated.WrappedComponent).toBe(Container);
303 | });
304 |
305 | it('should use the store from the props instead of from the context if present', () => {
306 | class Container extends Component {
307 | render() {
308 | return ;
309 | }
310 | }
311 |
312 | let actualState;
313 |
314 | const expectedState = { foos: {} };
315 | const decorator = connect({
316 | mapStateToProps: state => {
317 | actualState = state;
318 | return {};
319 | }
320 | });
321 | const Decorated = decorator(Container);
322 | const mockStore = {
323 | dispatch: () => {},
324 | subscribe: () => {},
325 | getState: () => expectedState
326 | };
327 | const socket = createSocket();
328 |
329 | TestUtils.renderIntoDocument();
330 |
331 | expect(actualState).toEqual(expectedState);
332 | });
333 |
334 | it('should throw an error if the store is not in the props or context', () => {
335 | class Container extends Component {
336 | render() {
337 | return ;
338 | }
339 | }
340 |
341 | const decorator = connect(() => {});
342 | const Decorated = decorator(Container);
343 |
344 | expect(() =>
345 | TestUtils.renderIntoDocument()
346 | ).toThrow(
347 | /Could not find "store"/
348 | );
349 | });
350 |
351 | it('should use the socket from the props instead of from the context if present', () => {
352 | class Container extends Component {
353 | render() {
354 | return ;
355 | }
356 | }
357 |
358 | let actualSocket;
359 |
360 | const expectedSocket = { test: true };
361 | const decorator = connect({
362 | mapSocketToProps: ({ socket }) => {
363 | actualSocket = socket;
364 | return {};
365 | }
366 | });
367 | const Decorated = decorator(Container);
368 | const store = createStore(() => {});
369 | const mockSocket = { test: true };
370 |
371 | TestUtils.renderIntoDocument();
372 |
373 | expect(actualSocket).toEqual(expectedSocket);
374 | });
375 |
376 | it('should throw an error if the socket is not in the props or context', () => {
377 | class Container extends Component {
378 | render() {
379 | return ;
380 | }
381 | }
382 | const store = createStore(() => {});
383 |
384 | const decorator = connect(() => {});
385 |
386 | const Decorated = decorator(
387 |
388 | );
389 |
390 | expect(() => {
391 | TestUtils.renderIntoDocument(
392 |
393 |
394 |
395 | );
396 | }).toThrow(
397 | /Could not find "socket"/
398 | );
399 | });
400 |
401 | it('should not call update if mergeProps return value has not changed', () => {
402 | let mapStateCalls = 0;
403 | let renderCalls = 0;
404 | const store = createStore(stringBuilder);
405 | const socket = createSocket();
406 |
407 | class Container extends Component {
408 | render() {
409 | renderCalls++;
410 | return ;
411 | }
412 | }
413 | Container = connect({
414 | mapStateToProps: () => ({ a: ++mapStateCalls }),
415 | mapDispatchToProps: null,
416 | mergeProps: () => ({ changed: false })
417 | })(Container);
418 |
419 | TestUtils.renderIntoDocument(
420 |
421 |
422 |
423 | );
424 |
425 | expect(renderCalls).toBe(1);
426 | expect(mapStateCalls).toBe(1);
427 |
428 | store.dispatch({ type: 'APPEND', body: 'a' });
429 |
430 | expect(mapStateCalls).toBe(2);
431 | expect(renderCalls).toBe(1);
432 | });
433 | });
434 | });
435 |
--------------------------------------------------------------------------------
/test/setup.js:
--------------------------------------------------------------------------------
1 | import { jsdom } from 'jsdom';
2 |
3 | global.document = jsdom('');
4 | global.window = document.defaultView;
5 | global.navigator = global.window.navigator;
6 | global.WebSocket = () => {};
7 |
--------------------------------------------------------------------------------
/test/utils.js:
--------------------------------------------------------------------------------
1 | export function createSocket() {
2 | return new WebSocket('http://www.example.com/socketserver');
3 | }
4 |
--------------------------------------------------------------------------------