├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── index.d.ts ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── connect.js ├── index.js ├── state.js └── utils.js ├── test-ts ├── index.tsx └── tsconfig.json └── test ├── .eslintrc ├── connect ├── bad-usage.js ├── batch-force-update.js ├── classic-component.js ├── component.js ├── create-react-class.js ├── partial-connect.js ├── pure-component.js ├── set-on-component-did-mount.js └── stateless-component.js ├── helpers └── setup-test-env.js └── utils ├── can-use-dom-client.js ├── can-use-dom-server.js ├── component.js ├── instance.js └── pure-component.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-react", 4 | "@babel/preset-env" 5 | ], 6 | "plugins": [ 7 | ["@babel/plugin-proposal-decorators", { "legacy": true }], 8 | ["@babel/plugin-proposal-class-properties", { "loose" : true }] 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "airbnb", 4 | "env": { 5 | "browser": true, 6 | "node": true 7 | }, 8 | "plugins": [ 9 | "react", 10 | "jsx-a11y", 11 | "import" 12 | ], 13 | "parser": "babel-eslint", 14 | "rules": { 15 | "arrow-parens": ["error", "as-needed", { "requireForBlockBody": false }], 16 | "consistent-return": ["off"], 17 | "prefer-rest-params": ["off"], 18 | "no-else-return": ["off"], 19 | "no-param-reassign": ["off"], 20 | "no-restricted-syntax": ["off"], 21 | "no-underscore-dangle": ["off", { "allowAfterThis": true, "allowAfterSuper": true }], 22 | "no-plusplus": ["off"], 23 | "react/jsx-filename-extension": ["error", { "extensions": [".js", ".jsx"] }], 24 | "react/prefer-stateless-function": ["off"], 25 | "class-methods-use-this": ["off"], 26 | "react/no-multi-comp": ["off"] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # WebStorm 40 | .idea 41 | 42 | # Build files 43 | dist 44 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Project 2 | test 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # node-waf configuration 27 | .lock-wscript 28 | 29 | # Compiled binary addons (http://nodejs.org/api/addons.html) 30 | build/Release 31 | 32 | # Dependency directories 33 | node_modules 34 | jspm_packages 35 | 36 | # Optional npm cache directory 37 | .npm 38 | 39 | # Optional REPL history 40 | .node_repl_history 41 | 42 | # WebStorm 43 | .idea 44 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | - "8" 5 | env: 6 | - CXX=g++-4.8 7 | before_install: 8 | npm install -g npm@latest 9 | addons: 10 | apt: 11 | sources: 12 | - ubuntu-toolchain-r-test 13 | packages: 14 | - g++-4.8 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [v1.0.0](https://github.com/nofluxjs/noflux-state/tree/v1.0.0) (2018-08-17) 4 | 5 | - v1 released [\#28](https://github.com/nofluxjs/noflux-react/pull/28) ([malash](https://github.com/malash)) 6 | 7 | - Remove deprecated API [\#26](https://github.com/nofluxjs/noflux-react/pull/26) ([malash](https://github.com/malash)) 8 | 9 | - Rewrite connect with ES6 class [\#25](https://github.com/nofluxjs/noflux-react/pull/25) ([malash](https://github.com/malash)) 10 | 11 | - Use constructor for init in connect component [\#24](https://github.com/nofluxjs/noflux-react/pull/24) ([malash](https://github.com/malash)) 12 | 13 | ## [v0.8.1](https://github.com/nofluxjs/noflux-react/tree/v0.8.1) (2018-04-12) 14 | 15 | - Support React new lifecycle methods [\#24](https://github.com/nofluxjs/noflux-react/pull/24) ([malash](https://github.com/malash)) 16 | 17 | ## [v0.8.0](https://github.com/nofluxjs/noflux-react/tree/v0.8.0) (2017-12-27) 18 | 19 | - Upgrade @noflux/state@0.7.0 20 | 21 | ## [v0.7.0](https://github.com/nofluxjs/noflux-react/tree/v0.7.0) (2017-12-25) 22 | 23 | - Reduce component type check [\#23](https://github.com/nofluxjs/noflux-react/pull/23) ([malash](https://github.com/malash)) 24 | - Update dependencies [\#22](https://github.com/nofluxjs/noflux-react/pull/22) ([malash](https://github.com/malash)) 25 | - Upgrade enzyme@3 [\#21](https://github.com/nofluxjs/noflux-react/pull/21) ([malash](https://github.com/malash)) 26 | 27 | ## [v0.6.1](https://github.com/nofluxjs/noflux-react/tree/v0.6.1) (2017-10-10) 28 | - Add `index.d.ts` file. [\#20](https://github.com/nofluxjs/noflux-react/pull/20) ([malash](https://github.com/malash)) 29 | - Batch forceUpdate [\#19](https://github.com/nofluxjs/noflux-react/pull/19) ([malash](https://github.com/malash)) 30 | - Update dependencies [\#18](https://github.com/nofluxjs/noflux-react/pull/18) ([malash](https://github.com/malash)) 31 | 32 | ## [v0.6.0](https://github.com/nofluxjs/noflux-react/tree/v0.6.0) (2017-09-06) 33 | 34 | - Fix memory leak on server-side rendering [\#15](https://github.com/nofluxjs/noflux-react/issues/15) by adding canUseDOM for preventing memory leak from server-side rendering [\#17](https://github.com/nofluxjs/noflux-react/pull/17) ([malash](https://github.com/malash)) 35 | - External package in es/cjs build & add rollup-plugin-filesize [\#14](https://github.com/nofluxjs/noflux-react/pull/14) ([malash](https://github.com/malash)) 36 | 37 | ## [v0.5.2](https://github.com/nofluxjs/noflux-react/tree/v0.5.2) (2017-08-18) 38 | - Replace `NODE_ENV` in noflux-state.umd.js [\#13](https://github.com/nofluxjs/noflux-react/pull/13) ([malash](https://github.com/malash)) 39 | 40 | ## [v0.5.0](https://github.com/nofluxjs/noflux-react/tree/v0.5.0) (2017-08-10) 41 | - Use rollup for bundle build [\#12](https://github.com/nofluxjs/noflux-react/pull/12) ([malash](https://github.com/malash)) 42 | 43 | ## [v0.4.4](https://github.com/nofluxjs/noflux-react/tree/v0.4.4) (2017-07-13) 44 | 45 | - Upgrade @noflux/state@0.5.2 46 | 47 | ## [v0.4.3](https://github.com/nofluxjs/noflux-react/tree/v0.4.3) (2017-06-10) 48 | 49 | - Support Node.js 8 & NPM 5 [\#10](https://github.com/nofluxjs/noflux-react/pull/10) ([malash](https://github.com/malash)) 50 | 51 | ## [v0.4.2](https://github.com/nofluxjs/noflux-react/tree/v0.4.2) (2017-06-01) 52 | - Fix bug `state.set` on `componentDidMount` does not call forceUpdate [\#11](https://github.com/nofluxjs/noflux-react/pull/11) ([malash](https://github.com/malash)) 53 | 54 | ## [v0.4.1](https://github.com/nofluxjs/noflux-react/tree/v0.4.1) (2017-05-16) 55 | 56 | - Fix `@connect` validate throw error 57 | 58 | ## [v0.4.0](https://github.com/nofluxjs/noflux-react/tree/v0.4.0) (2017-05-15) 59 | - Refact partial connect for a better paradigm [\#9](https://github.com/nofluxjs/noflux-react/pull/9) ([malash](https://github.com/malash)) 60 | 61 | ## [v0.3.0](https://github.com/nofluxjs/noflux-react/tree/v0.3.0) (2017-05-07) 62 | - Add `state.load` API [\#8](https://github.com/nofluxjs/noflux-react/pull/8) ([malash](https://github.com/malash)) 63 | 64 | ## [v0.2.0](https://github.com/nofluxjs/noflux-react/tree/v0.2.0) (2017-04-23) 65 | - New connect without rxjs [\#7](https://github.com/nofluxjs/noflux-react/pull/7) ([sartrey](https://github.com/sartrey)) 66 | - Remove semver for less code size [\#6](https://github.com/nofluxjs/noflux-react/pull/6) ([malash](https://github.com/malash)) 67 | 68 | ## [v0.1.0](https://github.com/nofluxjs/noflux-react/tree/v0.1.0) (2017-04-12) 69 | - Refactor [\#5](https://github.com/nofluxjs/noflux-react/pull/5) ([malash](https://github.com/malash)) 70 | - Less code in pure.js [\#2](https://github.com/nofluxjs/noflux-react/pull/2) ([malash](https://github.com/malash)) 71 | - Start noflux@next project [\#1](https://github.com/nofluxjs/noflux-react/pull/1) ([malash](https://github.com/malash)) 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Malash , ssnau 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 | # @noflux/react 2 | 3 | Official React bindings for [Noflux](https://github.com/nofluxjs/noflux). 4 | 5 | | Package | Version | Dependencies | DevDependencies | Build | 6 | |--------|-------|------------|----------|----------| 7 | | `@noflux/react` | [![npm (scoped)](https://img.shields.io/npm/v/@noflux/react.svg?maxAge=86400)](https://www.npmjs.com/package/@noflux/react) | [![Dependency Status](https://david-dm.org/nofluxjs/noflux-react.svg)](https://david-dm.org/nofluxjs/noflux-react) | [![devDependency Status](https://david-dm.org/nofluxjs/noflux-react/dev-status.svg)](https://david-dm.org/nofluxjs/noflux-react?type=dev) | [![Build Status](https://travis-ci.org/nofluxjs/noflux-react.svg?branch=next)](https://travis-ci.org/nofluxjs/noflux-react) | 8 | 9 | ## Usage 10 | 11 | All Noflux documentations can be found [here](https://github.com/nofluxjs/noflux). 12 | 13 | You can read the online version at [noflux.js.org](https://noflux.js.org/). 14 | 15 | ## Contribution 16 | 17 | For development: 18 | 19 | ```bash 20 | npm install 21 | npm run dev 22 | ``` 23 | 24 | For publish: 25 | 26 | ```bash 27 | npm publish 28 | ``` 29 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { State } from '@noflux/state'; 2 | import { ComponentClass, ClassicComponentClass, StatelessComponent } from 'react'; 3 | 4 | export const state: State; 5 | 6 | export function connect>(target: T): T; 7 | export function connect>(target: T): T; 8 | export function connect

(target: StatelessComponent

): ComponentClass

; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@noflux/react", 3 | "version": "1.0.0", 4 | "description": "Official React bindings for noflux", 5 | "main": "dist/noflux-react.cjs.js", 6 | "module": "dist/noflux-react.es.js", 7 | "unpkg": "dist/noflux-react.umd.js", 8 | "types": "index.d.ts", 9 | "scripts": { 10 | "prepublishOnly": "npm run build", 11 | "lint": "eslint src/ test/", 12 | "lint:watch": "esw src/ test/ -w", 13 | "coverage": "npm run ava:coverage", 14 | "ava": "ava --verbose", 15 | "ava:watch": "ava --watch --verbose", 16 | "ava:coverage": "nyc ava --verbose", 17 | "ts": "tsc -p test-ts/", 18 | "ts:watch": "tsc -p test-ts/ --watch", 19 | "dev": "concurrently -p \"[{name}]\" -n \"lint,ava\" \"npm run lint:watch\" \"npm run ava:watch\"", 20 | "test": "npm run lint && npm run coverage && npm run ts", 21 | "clean": "rimraf dist", 22 | "build": "npm run clean && npm run build:cjs && npm run build:es && npm run build:umd && npm run build:umd-min", 23 | "build:cjs": "cross-env TARGET=cjs rollup -c", 24 | "build:es": "cross-env TARGET=es rollup -c", 25 | "build:umd": "cross-env TARGET=umd rollup -c", 26 | "build:umd-min": "cross-env TARGET=umd NODE_ENV=production rollup -c" 27 | }, 28 | "ava": { 29 | "files": [ 30 | "test/**/*.js" 31 | ], 32 | "sources": [ 33 | "**/*.{js,jsx}", 34 | "!dist/**/*" 35 | ], 36 | "babel": { 37 | "extensions": [ 38 | "js" 39 | ] 40 | }, 41 | "require": [ 42 | "@babel/register" 43 | ] 44 | }, 45 | "repository": { 46 | "type": "git", 47 | "url": "git+https://github.com/nofluxjs/noflux-react.git" 48 | }, 49 | "keywords": [ 50 | "noflux", 51 | "react", 52 | "redux", 53 | "flux", 54 | "state" 55 | ], 56 | "contributors": [ 57 | { 58 | "name": "ssnau", 59 | "email": "korige@gmail.com", 60 | "url": "http://liuxijin.com/" 61 | }, 62 | { 63 | "name": "Malash", 64 | "email": "i@malash.me", 65 | "url": "https://malash.me/" 66 | }, 67 | { 68 | "name": "sartrey", 69 | "email": "sartrey@163.com", 70 | "url": "https://sartrey.cn/" 71 | } 72 | ], 73 | "license": "MIT", 74 | "bugs": { 75 | "url": "https://github.com/nofluxjs/noflux-react/issues" 76 | }, 77 | "homepage": "https://github.com/nofluxjs/noflux-react#readme", 78 | "dependencies": { 79 | "@noflux/state": "^1.0.1" 80 | }, 81 | "devDependencies": { 82 | "@babel/cli": "^7.0.0", 83 | "@babel/core": "^7.0.1", 84 | "@babel/plugin-proposal-class-properties": "^7.0.0", 85 | "@babel/plugin-proposal-decorators": "^7.0.0", 86 | "@babel/preset-env": "^7.0.0", 87 | "@babel/preset-react": "^7.0.0", 88 | "@babel/register": "^7.0.0", 89 | "@types/create-react-class": "^15.6.1", 90 | "@types/prop-types": "^15.5.2", 91 | "@types/react": "^16.3.14", 92 | "@types/react-dom": "^16.0.5", 93 | "ava": "^1.0.0-beta.8", 94 | "babel-eslint": "^9.0.0", 95 | "concurrently": "^4.0.1", 96 | "create-react-class": "^15.6.3", 97 | "cross-env": "^5.2.0", 98 | "enzyme": "^3.1.0", 99 | "enzyme-adapter-react-16": "^1.0.2", 100 | "eslint": "^5.5.0", 101 | "eslint-config-airbnb": "^17.1.0", 102 | "eslint-config-airbnb-base": "^13.1.0", 103 | "eslint-plugin-import": "^2.14.0", 104 | "eslint-plugin-jsx-a11y": "^6.0.2", 105 | "eslint-plugin-react": "^7.8.2", 106 | "eslint-watch": "^4.0.2", 107 | "jsdom": "^12.0.0", 108 | "nyc": "^13.0.1", 109 | "prop-types": "^15.6.1", 110 | "react": "^16.2.0", 111 | "react-dom": "^16.2.0", 112 | "rimraf": "^2.6.1", 113 | "rollup": "^0.65.2", 114 | "rollup-plugin-babel": "^4.0.3", 115 | "rollup-plugin-commonjs": "^9.1.6", 116 | "rollup-plugin-filesize": "^4.0.1", 117 | "rollup-plugin-node-resolve": "^3.4.0", 118 | "rollup-plugin-replace": "^2.0.0", 119 | "rollup-plugin-uglify": "^5.0.2", 120 | "typescript": "^3.0.3" 121 | }, 122 | "peerDependencies": { 123 | "react": ">=15.3.0" 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import babel from 'rollup-plugin-babel'; 4 | import replace from 'rollup-plugin-replace'; 5 | import { uglify } from 'rollup-plugin-uglify'; 6 | import filesize from 'rollup-plugin-filesize'; 7 | import { version, dependencies, devDependencies, peerDependencies } from './package.json'; 8 | 9 | const target = process.env.TARGET || 'es'; 10 | const env = process.env.NODE_ENV || 'development'; 11 | const isProd = env === 'production'; 12 | const banner = `/* 13 | * @license 14 | * @noflux/react v${version} 15 | * (c) 2017-${new Date().getFullYear()} Malash 16 | * Released under the MIT License. 17 | */`; 18 | 19 | const config = { 20 | input: 'src/index.js', 21 | external: Object.keys(Object.assign({}, dependencies, devDependencies, peerDependencies)), 22 | plugins: [ 23 | babel({ 24 | babelrc: true, 25 | exclude: 'node_modules/**', 26 | externalHelpers: true, 27 | }), 28 | commonjs(), 29 | filesize(), 30 | ], 31 | output: { 32 | banner, 33 | name: 'NofluxReact', 34 | file: `dist/noflux-react.${target}.${isProd ? 'min.js' : 'js'}`, 35 | format: target, 36 | globals: { 37 | react: 'React', 38 | }, 39 | }, 40 | }; 41 | 42 | if (target === 'umd') { 43 | config.external = Object.keys(Object.assign({}, peerDependencies)); 44 | config.plugins = [].concat( 45 | [resolve()], 46 | config.plugins, 47 | [replace({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) })], 48 | ); 49 | } 50 | 51 | if (isProd) { 52 | config.plugins = [].concat( 53 | config.plugins, 54 | [uglify({ 55 | output: { 56 | comments: (node, comment) => comment.type === 'comment2' && /@preserve|@license|@cc_on/i.test(comment.value), 57 | }, 58 | })], 59 | ); 60 | } 61 | 62 | export default config; 63 | -------------------------------------------------------------------------------- /src/connect.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import state from './state'; 3 | import { 4 | __DEV__, 5 | timer, 6 | isReactComponent, 7 | isReactComponentInstance, 8 | getComponentName, 9 | canUseDOM, 10 | SYMBOL_NOFLUX, 11 | } from './utils'; 12 | 13 | const connectComponent = Target => { 14 | if (Target[SYMBOL_NOFLUX]) { 15 | throw new SyntaxError(`You should not use @connect for component ${getComponentName(Target)} more than once.`); 16 | } 17 | Target[SYMBOL_NOFLUX] = {}; 18 | 19 | // skip event listening for server-side rendering 20 | if (!canUseDOM) { 21 | return Target; 22 | } 23 | 24 | class ConnectedComponent extends Target { 25 | static displayName = `Connect(${getComponentName(Target)})`; 26 | 27 | constructor(props) { 28 | super(props); 29 | 30 | // init 31 | this[SYMBOL_NOFLUX] = { 32 | getPaths: {}, 33 | onSetDisposers: [], 34 | mounted: false, 35 | isForcingUpdate: false, 36 | }; 37 | const __noflux = this[SYMBOL_NOFLUX]; 38 | 39 | const onSet = () => { 40 | // skip re-render after unmounting component 41 | // TODO: test this guard 42 | if (!__noflux.mounted) return; 43 | 44 | // skip duplicate forceUpdate calling 45 | if (__noflux.isForcingUpdate) return; 46 | __noflux.isForcingUpdate = true; 47 | 48 | const startTime = timer.now(); 49 | this.forceUpdate(() => { 50 | __noflux.isForcingUpdate = false; 51 | 52 | const endTime = timer.now(); 53 | const cost = endTime - startTime; 54 | if (__DEV__) { 55 | // eslint-disable-next-line no-console 56 | console.log(`[noflux] ${getComponentName(Target)} rendering time ${cost.toFixed(3)} ms`); 57 | } 58 | }); 59 | }; 60 | __noflux.onGetDisposer = state.on('get', ({ path }) => { 61 | if (__noflux.isRendering && !__noflux.getPaths[path]) { 62 | __noflux.getPaths[path] = true; 63 | // register cursor on set handler 64 | __noflux.onSetDisposers.push(state.cursor(path).on('set', onSet)); 65 | } 66 | }); 67 | } 68 | 69 | componentDidMount() { 70 | // set component mounted flag 71 | this[SYMBOL_NOFLUX].mounted = true; 72 | 73 | // call origin componentDidMount 74 | if (super.componentDidMount) { 75 | super.componentDidMount(); 76 | } 77 | } 78 | 79 | componentWillUnmount() { 80 | const __noflux = this[SYMBOL_NOFLUX]; 81 | // dispose cursor on set listeners 82 | __noflux.onSetDisposers.forEach(disposer => disposer()); 83 | 84 | // dispose get listener 85 | __noflux.onGetDisposer(); 86 | 87 | // reset component mounted flag 88 | __noflux.mounted = false; 89 | 90 | // call origin componentWillUnmount 91 | if (super.componentWillUnmount) { 92 | super.componentWillUnmount.call(this); 93 | } 94 | } 95 | 96 | render() { 97 | if (!super.render) { 98 | throw new Error(`No render method found on the returned component instance of ${getComponentName(Target)}, you may have forgotten to define render.`); 99 | } 100 | 101 | const __noflux = this[SYMBOL_NOFLUX]; 102 | __noflux.isRendering = true; 103 | const vdom = super.render(); 104 | __noflux.isRendering = false; 105 | return vdom; 106 | } 107 | } 108 | return ConnectedComponent; 109 | }; 110 | 111 | const connect = (target, prop, descriptor) => { 112 | if (!target) { 113 | throw new TypeError('@connect() is invalid, do you mean @connect ?'); 114 | } 115 | if (isReactComponentInstance(target) && prop && descriptor) { 116 | throw new SyntaxError('@connect should not be used for component method.'); 117 | } 118 | if (!isReactComponent(target)) { 119 | if (typeof target !== 'function') { 120 | throw new TypeError('@connect should be used for React component'); 121 | } 122 | class ConnectedComponent extends Component { 123 | static displayName = `Connect(${getComponentName(target)})`; 124 | 125 | static contextTypes = target.contextTypes; 126 | 127 | static propTypes = target.propTypes; 128 | 129 | static defaultProps = target.defaultProps; 130 | 131 | render() { 132 | return target.call(this, this.props, this.context); 133 | } 134 | } 135 | return connectComponent(ConnectedComponent); 136 | } 137 | return connectComponent(target); 138 | }; 139 | 140 | export default connect; 141 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import state from './state'; 2 | import connect from './connect'; 3 | 4 | export { 5 | state, 6 | connect, 7 | }; 8 | -------------------------------------------------------------------------------- /src/state.js: -------------------------------------------------------------------------------- 1 | import { State } from '@noflux/state'; 2 | 3 | const state = new State(); 4 | 5 | export default state; 6 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const __DEV__ = process.env.NODE_ENV !== 'production'; 2 | 3 | /* global performance */ 4 | export const timer = ( 5 | typeof performance !== 'undefined' 6 | && performance 7 | && performance.now 8 | ? performance : Date 9 | ); 10 | 11 | export const isReactComponent = Component => Boolean(Component 12 | && Component.prototype 13 | && typeof Component.prototype.render === 'function'); 14 | 15 | export const isReactComponentInstance = instance => Boolean(instance 16 | && typeof instance.render === 'function'); 17 | 18 | export const getComponentName = Component => { 19 | const constructor = Component.prototype && Component.prototype.constructor; 20 | return ( 21 | Component.displayName 22 | || (constructor && constructor.displayName) 23 | || Component.name 24 | || (constructor && constructor.name) 25 | || 'Component' 26 | ); 27 | }; 28 | 29 | // detect if the component is rendering from the client or the server 30 | // copy from fbjs/lib/ExecutionEnvironment 31 | // https://github.com/facebook/fbjs/blob/38bf26f4e6ea64d7ff68393919fb5e98f5ceac3b/packages/fbjs/src/core/ExecutionEnvironment.js#L12-L16 32 | export const canUseDOM = !!( 33 | typeof window !== 'undefined' 34 | && window.document 35 | && window.document.createElement 36 | ); 37 | 38 | const hasSymbol = typeof Symbol === 'function' && Symbol.for; 39 | 40 | export const SYMBOL_NOFLUX = hasSymbol ? Symbol.for('noflux') : '__noflux'; 41 | -------------------------------------------------------------------------------- /test-ts/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import * as createReactClass from 'create-react-class'; 4 | import * as PropTypes from 'prop-types'; 5 | import { connect } from '..'; 6 | 7 | // Component 8 | 9 | @connect 10 | class AppComponent extends React.Component<{ pizza: number }, {}> { 11 | render() { 12 | return

{this.props.pizza}
; 13 | } 14 | } 15 | 16 | class AppComponentFunc extends React.Component<{ pizza: number }, {}> { 17 | render() { 18 | return
{this.props.pizza}
; 19 | } 20 | } 21 | const ConnectAppComponentFunc = connect(AppComponentFunc); 22 | 23 | // Pure Component 24 | 25 | @connect 26 | class AppPureComponent extends React.PureComponent<{ pizza: number }, {}> { 27 | render() { 28 | return
{this.props.pizza}
; 29 | } 30 | } 31 | 32 | 33 | class AppPureComponentFunc extends React.PureComponent<{ pizza: number }, {}> { 34 | render() { 35 | return
{this.props.pizza}
; 36 | } 37 | } 38 | const ConnectAppPureComponentFunc = connect(AppPureComponentFunc); 39 | 40 | // Stateless Component 41 | 42 | const AppStateless = (props: { pizza: number }) => { 43 | return
{props.pizza}
; 44 | }; 45 | const ConnectAppStateless = connect(AppStateless); 46 | 47 | // Classic Component 48 | 49 | const AppClassic = createReactClass({ 50 | getDefaultProps() { 51 | return { pizza: 0 }; 52 | }, 53 | propTypes: { 54 | pizza: PropTypes.number, 55 | }, 56 | render() { 57 | return
{this.props.pizza}
; 58 | }, 59 | }); 60 | 61 | const ConnectAppClassic = connect(AppClassic); 62 | 63 | const App = () => ( 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | ) 75 | -------------------------------------------------------------------------------- /test-ts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.7.5", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "strict": true, 6 | "experimentalDecorators": true, 7 | "jsx": "react", 8 | "noEmit": true, 9 | "rootDir": "../../", 10 | "module": "commonjs", 11 | "lib": ["es5", "dom"] 12 | }, 13 | "exclude": [ 14 | 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "import/first": ["off"], 4 | "import/no-dynamic-require": ["off"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/connect/bad-usage.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import '../helpers/setup-test-env'; 3 | import React, { Component } from 'react'; 4 | import PropTypes from 'prop-types'; 5 | import { mount } from 'enzyme'; 6 | import { connect } from '../../src'; 7 | 8 | test('can not use @connect for component method', t => { 9 | const error = t.throws(() => { 10 | class App extends Component { 11 | @connect 12 | render() { 13 | const { text } = this.props; 14 | return ( 15 |

16 | {text} 17 |

18 | ); 19 | } 20 | } 21 | App.propTypes = { 22 | text: PropTypes.string.isRequired, 23 | }; 24 | mount(); 25 | }); 26 | t.is(error.message, '@connect should not be used for component method.'); 27 | }); 28 | 29 | test('can not use @connect()', t => { 30 | const error = t.throws(() => connect()(() => null)); 31 | t.is(error.message, '@connect() is invalid, do you mean @connect ?'); 32 | }); 33 | 34 | test('can not use @connect for non-component value', t => { 35 | const errors = [ 36 | t.throws(() => connect({})), 37 | t.throws(() => connect([])), 38 | t.throws(() => connect(1)), 39 | t.throws(() => connect('a')), 40 | ]; 41 | for (const error of errors) { 42 | t.is(error.message, '@connect should be used for React component'); 43 | } 44 | }); 45 | -------------------------------------------------------------------------------- /test/connect/batch-force-update.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import '../helpers/setup-test-env'; 3 | import React, { Component } from 'react'; 4 | import { mount } from 'enzyme'; 5 | import { connect, state } from '../../src'; 6 | 7 | test('should batch forceUpdate', t => { 8 | state.set({ name: 'Ssnau' }); 9 | 10 | let forceUpdateCallTimes = 0; 11 | let renderCallTimes = 0; 12 | 13 | @connect 14 | class App extends Component { 15 | onClick() { 16 | for (let i = 0; i < 10; i++) { 17 | state.set('name', `Malash${i}`); 18 | } 19 | } 20 | 21 | forceUpdate(...args) { 22 | super.forceUpdate(...args); 23 | forceUpdateCallTimes++; 24 | } 25 | 26 | render() { 27 | renderCallTimes++; 28 | return ( 29 | 32 | ); 33 | } 34 | } 35 | 36 | const wrapper = mount(); 37 | wrapper.find('button').simulate('click'); 38 | 39 | t.is(forceUpdateCallTimes, 1); 40 | t.is(renderCallTimes, 2); 41 | t.is(wrapper.find('button').text(), 'Malash9'); 42 | }); 43 | -------------------------------------------------------------------------------- /test/connect/classic-component.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import '../helpers/setup-test-env'; 3 | import React from 'react'; 4 | import createReactClass from 'create-react-class'; 5 | import { mount } from 'enzyme'; 6 | import { connect, state } from '../../src'; 7 | 8 | test('make the classic component fluxify', t => { 9 | state.set({ name: 'Ssnau' }); 10 | 11 | let renderCallTimes = 0; 12 | // eslint-disable-next-line react/prefer-es6-class 13 | const App = connect(createReactClass({ 14 | render() { 15 | renderCallTimes++; 16 | return ( 17 |

18 | {state.get('name')} 19 |

20 | ); 21 | }, 22 | })); 23 | 24 | const wrapper = mount(); 25 | t.is(wrapper.find('h1').props().id, 'Ssnau'); 26 | t.is(wrapper.find('h1').text(), 'Ssnau'); 27 | t.is(renderCallTimes, 1); 28 | 29 | state.set({ name: 'Malash' }); 30 | 31 | t.is(wrapper.find('h1').instance().getAttribute('id'), 'Malash'); 32 | t.is(wrapper.find('h1').text(), 'Malash'); 33 | t.is(renderCallTimes, 2); 34 | }); 35 | -------------------------------------------------------------------------------- /test/connect/component.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import '../helpers/setup-test-env'; 3 | import React, { Component } from 'react'; 4 | import { mount } from 'enzyme'; 5 | import { connect, state } from '../../src'; 6 | 7 | test('make the component fluxify', t => { 8 | state.set({ name: 'Ssnau' }); 9 | 10 | let renderCallTimes = 0; 11 | @connect 12 | class App extends Component { 13 | render() { 14 | renderCallTimes++; 15 | return ( 16 |

17 | {state.get('name')} 18 |

19 | ); 20 | } 21 | } 22 | const wrapper = mount(); 23 | t.is(wrapper.find('h1').props().id, 'Ssnau'); 24 | t.is(wrapper.find('h1').text(), 'Ssnau'); 25 | t.is(renderCallTimes, 1); 26 | 27 | state.set({ name: 'Malash' }); 28 | 29 | t.is(wrapper.find('h1').instance().getAttribute('id'), 'Malash'); 30 | t.is(wrapper.find('h1').text(), 'Malash'); 31 | t.is(renderCallTimes, 2); 32 | }); 33 | -------------------------------------------------------------------------------- /test/connect/create-react-class.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prefer-es6-class */ 2 | import test from 'ava'; 3 | import '../helpers/setup-test-env'; 4 | import React from 'react'; 5 | import createReactClass from 'create-react-class'; 6 | import { mount } from 'enzyme'; 7 | import { connect, state } from '../../src'; 8 | 9 | test('works with create-react-class', t => { 10 | state.set({ name: 'Ssnau' }); 11 | 12 | let renderCallTimes = 0; 13 | const App = connect(createReactClass({ 14 | render() { 15 | renderCallTimes++; 16 | return ( 17 |

18 | {state.get('name')} 19 |

20 | ); 21 | }, 22 | })); 23 | const wrapper = mount(); 24 | t.is(wrapper.find('h1').props().id, 'Ssnau'); 25 | t.is(wrapper.find('h1').text(), 'Ssnau'); 26 | t.is(renderCallTimes, 1); 27 | 28 | state.set({ name: 'Malash' }); 29 | 30 | t.is(wrapper.find('h1').instance().getAttribute('id'), 'Malash'); 31 | t.is(wrapper.find('h1').text(), 'Malash'); 32 | t.is(renderCallTimes, 2); 33 | }); 34 | -------------------------------------------------------------------------------- /test/connect/partial-connect.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import '../helpers/setup-test-env'; 3 | import React, { Component } from 'react'; 4 | import { mount } from 'enzyme'; 5 | import { connect, state } from '../../src'; 6 | 7 | test('partial connect', t => { 8 | state.set({ 9 | profile: { 10 | name: 'ssnau', 11 | }, 12 | project: { 13 | name: 'noflux', 14 | }, 15 | }); 16 | 17 | let profileRenderCallTimes = 0; 18 | @connect 19 | class ProfileContainer extends Component { 20 | render() { 21 | profileRenderCallTimes++; 22 | return ( 23 |
24 | Profile name is 25 | {' '} 26 | {state.get('profile.name')} 27 |
28 | ); 29 | } 30 | } 31 | 32 | let projectRenderCallTimes = 0; 33 | @connect 34 | class ProjectContainer extends Component { 35 | render() { 36 | projectRenderCallTimes++; 37 | return ( 38 |
39 | Project name is 40 | {' '} 41 | {state.get('project.name')} 42 |
43 | ); 44 | } 45 | } 46 | 47 | let combineRenderCallTimes = 0; 48 | @connect 49 | class CombineContainer extends Component { 50 | render() { 51 | combineRenderCallTimes++; 52 | return ( 53 |
54 | Profile name is 55 | {' '} 56 | {state.get('profile.name')} 57 | Project name is 58 | {' '} 59 | {state.get('project.name')} 60 |
61 | ); 62 | } 63 | } 64 | 65 | class App extends Component { 66 | render() { 67 | return ( 68 |
69 | 70 | 71 | 72 |
73 | ); 74 | } 75 | } 76 | 77 | mount(); 78 | t.is(profileRenderCallTimes, 1); 79 | t.is(projectRenderCallTimes, 1); 80 | t.is(combineRenderCallTimes, 1); 81 | 82 | state.set('profile.name', 'malash'); 83 | t.is(profileRenderCallTimes, 2); 84 | t.is(projectRenderCallTimes, 1); 85 | t.is(combineRenderCallTimes, 2); 86 | 87 | state.set('project.name', '@noflux/react'); 88 | t.is(profileRenderCallTimes, 2); 89 | t.is(projectRenderCallTimes, 2); 90 | t.is(combineRenderCallTimes, 3); 91 | 92 | state.set('project.repo', 'https://github.com/nofluxjs/react.git'); 93 | t.is(profileRenderCallTimes, 2); 94 | t.is(projectRenderCallTimes, 2); 95 | t.is(combineRenderCallTimes, 3); 96 | 97 | state.set('other', 'data'); 98 | t.is(profileRenderCallTimes, 2); 99 | t.is(projectRenderCallTimes, 2); 100 | t.is(combineRenderCallTimes, 3); 101 | }); 102 | -------------------------------------------------------------------------------- /test/connect/pure-component.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import '../helpers/setup-test-env'; 3 | import React, { PureComponent } from 'react'; 4 | import { mount } from 'enzyme'; 5 | import { connect, state } from '../../src'; 6 | 7 | test('make the pure component fluxify', t => { 8 | state.set({ name: 'Ssnau' }); 9 | 10 | let renderCallTimes = 0; 11 | @connect 12 | class App extends PureComponent { 13 | render() { 14 | renderCallTimes++; 15 | return ( 16 |

17 | {state.get('name')} 18 |

19 | ); 20 | } 21 | } 22 | const wrapper = mount(); 23 | t.is(wrapper.find('h1').props().id, 'Ssnau'); 24 | t.is(wrapper.find('h1').text(), 'Ssnau'); 25 | t.is(renderCallTimes, 1); 26 | 27 | state.set({ name: 'Malash' }); 28 | 29 | t.is(wrapper.find('h1').instance().getAttribute('id'), 'Malash'); 30 | t.is(wrapper.find('h1').text(), 'Malash'); 31 | t.is(renderCallTimes, 2); 32 | }); 33 | -------------------------------------------------------------------------------- /test/connect/set-on-component-did-mount.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import '../helpers/setup-test-env'; 3 | import React, { Component } from 'react'; 4 | import { mount } from 'enzyme'; 5 | import { connect, state } from '../../src'; 6 | 7 | test('set on componentDidMount should re-render', t => { 8 | state.set({ name: 'Ssnau' }); 9 | 10 | let renderCallTimes = 0; 11 | @connect 12 | class App extends Component { 13 | componentDidMount() { 14 | state.set('name', 'Malash'); 15 | } 16 | 17 | render() { 18 | renderCallTimes++; 19 | return ( 20 |

21 | {state.get('name')} 22 |

23 | ); 24 | } 25 | } 26 | const wrapper = mount(); 27 | t.is(wrapper.find('h1').props().id, 'Malash'); 28 | t.is(wrapper.find('h1').text(), 'Malash'); 29 | t.is(renderCallTimes, 2); 30 | }); 31 | -------------------------------------------------------------------------------- /test/connect/stateless-component.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import '../helpers/setup-test-env'; 3 | import React from 'react'; 4 | import { mount } from 'enzyme'; 5 | import { connect, state } from '../../src'; 6 | 7 | test('make the stateless component fluxify', t => { 8 | state.set({ name: 'Ssnau' }); 9 | 10 | let renderCallTimes = 0; 11 | 12 | const App = connect(() => { 13 | renderCallTimes++; 14 | return ( 15 |

16 | {state.get('name')} 17 |

18 | ); 19 | }); 20 | const wrapper = mount(); 21 | t.is(wrapper.find('h1').props().id, 'Ssnau'); 22 | t.is(wrapper.find('h1').text(), 'Ssnau'); 23 | t.is(renderCallTimes, 1); 24 | 25 | state.set({ name: 'Malash' }); 26 | 27 | t.is(wrapper.find('h1').instance().getAttribute('id'), 'Malash'); 28 | t.is(wrapper.find('h1').text(), 'Malash'); 29 | t.is(renderCallTimes, 2); 30 | }); 31 | -------------------------------------------------------------------------------- /test/helpers/setup-test-env.js: -------------------------------------------------------------------------------- 1 | import { JSDOM } from 'jsdom'; 2 | import { configure } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | 5 | configure({ adapter: new Adapter() }); 6 | 7 | const jsdom = new JSDOM(''); 8 | const { window } = jsdom; 9 | 10 | const copyProps = (src, target) => { 11 | const props = Object.getOwnPropertyNames(src) 12 | .filter(prop => typeof target[prop] === 'undefined') 13 | .reduce((result, prop) => ({ 14 | ...result, 15 | [prop]: Object.getOwnPropertyDescriptor(src, prop), 16 | }), {}); 17 | Object.defineProperties(target, props); 18 | }; 19 | 20 | global.window = window; 21 | global.document = window.document; 22 | global.navigator = { 23 | userAgent: 'node.js', 24 | }; 25 | copyProps(window, global); 26 | 27 | // requestAnimationFrame polyfill 28 | global.requestAnimationFrame = callback => { 29 | setTimeout(callback, 0); 30 | }; 31 | -------------------------------------------------------------------------------- /test/utils/can-use-dom-client.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import '../helpers/setup-test-env'; 3 | import { canUseDOM } from '../../src/utils'; 4 | 5 | test('can use DOM from client or jsdom', t => { 6 | t.is(canUseDOM, true); 7 | }); 8 | -------------------------------------------------------------------------------- /test/utils/can-use-dom-server.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { canUseDOM } from '../../src/utils'; 3 | 4 | test('can not use DOM from server', t => { 5 | t.is(canUseDOM, false); 6 | }); 7 | -------------------------------------------------------------------------------- /test/utils/component.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import React, { Component } from 'react'; 3 | import { isReactComponent, getComponentName } from '../../src/utils'; 4 | 5 | test('check component', t => { 6 | class App extends Component { 7 | render() { 8 | return ( 9 |

10 | hello, world 11 |

12 | ); 13 | } 14 | } 15 | t.truthy(isReactComponent(App)); 16 | t.is(getComponentName(App), 'App'); 17 | }); 18 | -------------------------------------------------------------------------------- /test/utils/instance.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import React, { Component } from 'react'; 3 | import { 4 | isReactComponent, 5 | isReactComponentInstance, 6 | } from '../../src/utils'; 7 | 8 | test('check component instance', t => { 9 | class App extends Component { 10 | render() { 11 | return ( 12 |

13 | hello, world 14 |

15 | ); 16 | } 17 | } 18 | const instance = new App(); 19 | t.falsy(isReactComponent(instance)); 20 | t.truthy(isReactComponentInstance(instance)); 21 | }); 22 | -------------------------------------------------------------------------------- /test/utils/pure-component.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import React, { PureComponent } from 'react'; 3 | import { isReactComponent, getComponentName } from '../../src/utils'; 4 | 5 | test('check pure component', t => { 6 | class App extends PureComponent { 7 | render() { 8 | return ( 9 |

10 | hello, world 11 |

12 | ); 13 | } 14 | } 15 | t.truthy(isReactComponent(App)); 16 | t.is(getComponentName(App), 'App'); 17 | }); 18 | --------------------------------------------------------------------------------