├── .babelrc ├── .eslintrc ├── .gitignore ├── .travis.yml ├── Makefile ├── README.md ├── benchmark └── index.js ├── devtool.js ├── example ├── basic │ ├── README.md │ ├── favicon.ico │ ├── index.html │ ├── package.json │ └── src │ │ └── index.js └── pure-optimization │ ├── README.md │ ├── favicon.ico │ ├── index.html │ ├── package.json │ └── src │ └── index.js ├── flow-typed └── jest_v20.x.x.js ├── package.json ├── src ├── __tests__ │ ├── .eslintrc │ └── index-test.js ├── devtool.js ├── devtool.js.flow ├── index.js └── index.js.flow ├── test_flow ├── .flowconfig ├── index.js └── package.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["prometheusresearch"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "prometheusresearch", 3 | "globals": { 4 | "jest": true, 5 | "expect": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | examples/*/bundle 4 | npm-debug.log 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | scripts: 3 | - make test 4 | - make test-flow 5 | node_js: 6 | - 4 7 | - 5 8 | - 6 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DELETE_ON_ERROR: 2 | 3 | BIN = ./node_modules/.bin 4 | TESTS = $(shell find src -path '*/__tests__/*-test.js') 5 | FIXTURES = $(shell find src -path '*/__tests__/*-fixture/*.js') 6 | SRC = $(filter-out $(TESTS) $(FIXTURES), \ 7 | $(shell find src -name '*.js' -or -name '*.js.flow')) 8 | LIB = $(SRC:src/%=lib/%) 9 | 10 | build:: 11 | @$(MAKE) -j 8 $(LIB) 12 | 13 | benchmark: build 14 | @node ./benchmark/index.js 15 | 16 | lint:: 17 | @$(BIN)/eslint src 18 | 19 | check:: 20 | @$(BIN)/flow --show-all-errors src 21 | 22 | test:: 23 | @$(BIN)/jest 24 | 25 | test-flow:: 26 | @(cd test_flow/ && npm install && $(BIN)/flow check-contents < ./index.js) 27 | 28 | ci:: 29 | @$(BIN)/jest --watch 30 | 31 | doctoc: 32 | @$(BIN)/doctoc --title '**Table of Contents**' ./README.md 33 | 34 | version-major version-minor version-patch:: lint test build 35 | @npm version $(@:version-%=%) 36 | 37 | publish:: 38 | @npm publish 39 | @git push --tags origin HEAD:master 40 | 41 | clean:: 42 | @rm -rf lib 43 | 44 | lib/%.js: src/%.js 45 | @echo "Building $<" 46 | @mkdir -p $(@D) 47 | @$(BIN)/babel $(BABEL_OPTIONS) -o $@ $< 48 | 49 | lib/%.js.flow: src/%.js.flow 50 | @echo "Building $<" 51 | @mkdir -p $(@D) 52 | @cp $< $@ 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Derivable 2 | 3 | [![Travis build status](https://img.shields.io/travis/andreypopp/react-derivable/master.svg)](https://travis-ci.org/andreypopp/react-derivable) 4 | [![npm](https://img.shields.io/npm/v/react-derivable.svg)](https://www.npmjs.com/package/react-derivable) 5 | 6 | React Derivable allows to define [React][] components which re-render when reactive 7 | values (defined in terms of [derivable][]) used in `render()` change. 8 | 9 | 10 | 11 | **Table of Contents** 12 | 13 | - [Installation](#installation) 14 | - [Usage](#usage) 15 | - [API](#api) 16 | - [`reactive(Component)`](#reactivecomponent) 17 | - [`pure(Component)`](#purecomponent) 18 | - [`pure(Component).withEquality(eq)`](#purecomponentwithequalityeq) 19 | - [Guides](#guides) 20 | - [Local component state](#local-component-state) 21 | - [Flux/Redux-like unidirectional data flow](#fluxredux-like-unidirectional-data-flow) 22 | - [Binding to external state sources](#binding-to-external-state-sources) 23 | - [Lifting regular React components to work with derivable values](#lifting-regular-react-components-to-work-with-derivable-values) 24 | - [Examples](#examples) 25 | 26 | 27 | 28 | ## Installation 29 | 30 | Install from npm (`react` and `derivable` are peer dependencies and must be 31 | installed for an application too): 32 | 33 | ``` 34 | % npm install react 35 | % npm install derivable@1.0.0-beta10 36 | % npm install react-derivable 37 | ``` 38 | 39 | ## Usage 40 | 41 | Define your application state in terms of [derivable][]: 42 | 43 | ```js 44 | import {atom} from 'derivable' 45 | 46 | let message = atom('Hello, World!') 47 | ``` 48 | 49 | Define a React component which accepts and uses in render a reactive value 50 | `message`: 51 | 52 | ```js 53 | import React from 'react' 54 | 55 | let Hello = props => 56 |
{props.message.get()}
57 | ``` 58 | 59 | Now produce a new reactive component using higher-order `reactive` component 60 | 61 | ```js 62 | import reactive from 'react-derivable' 63 | 64 | let ReactiveHello = reactive(Hello) 65 | ``` 66 | 67 | Render `` into DOM and pass it a reactive `message` value: 68 | 69 | ```js 70 | import ReactDOM from 'react-dom' 71 | 72 | ReactDOM.render(, ...) 73 | ``` 74 | 75 | Each time reactive value updates - component gets rerendered: 76 | 77 | ```js 78 | message.set('Works!') 79 | ``` 80 | 81 | ## API 82 | 83 | ### `reactive(Component)` 84 | 85 | As shown in the usage section above `reactive(Component)` decorator produces a 86 | reactive component out of an original one. 87 | 88 | Reactive components re-render when one of the reactive values referenced from 89 | within `render()` change. 90 | 91 | ```js 92 | import React from 'react' 93 | import {reactive} from 'react-derivable' 94 | 95 | let ReactiveFunctional = reactive(props => 96 |
{props.message.get()}
) 97 | 98 | let ReactiveClassBased = reactive(class extends React.Component { 99 | 100 | render() { 101 | return
{this.props.message.get()}
102 | } 103 | }) 104 | ``` 105 | 106 | ### `pure(Component)` 107 | 108 | Makes component reactive and defines `shouldComponentUpdate` which compares 109 | `props` and `state` with respect to reactive values. 110 | 111 | That allows to get rid of unnecessary re-renders. 112 | 113 | ```js 114 | import React from 'react' 115 | import {pure} from 'react-derivable' 116 | 117 | let PureFunctional = pure(props => 118 |
{props.message.get()}
) 119 | 120 | let PureClassBased = pure(class extends React.Component { 121 | 122 | render() { 123 | return
{this.props.message.get()}
124 | } 125 | }) 126 | ``` 127 | 128 | ### `pure(Component).withEquality(eq)` 129 | 130 | Same as using `pure(Component)` but with a custom equality function which is 131 | used to compare props/state and reactive values. 132 | 133 | Useful when using with libraries like [Immutable.js][immutable] which provide 134 | its equality definition: 135 | 136 | ```js 137 | import * as Immutable from 'immutable' 138 | import {pure} from 'react-derivable' 139 | 140 | let Pure = pure(Component).withEquality(Immutable.is) 141 | ``` 142 | 143 | ## Guides 144 | 145 | ### Local component state 146 | 147 | React has its own facilities for managing local component state. In my mind it 148 | is much more convenient to have the same mechanism serve both local component 149 | state and global app state management needs. That way composing code which uses 150 | different state values and updates becomes much easier. Also refactorings which 151 | change from where state is originated from are frictionless with this approach. 152 | 153 | As any component produced with `reactive(Component)` reacts on changes to 154 | reactive values dereferenced in its `render()` method we can take advantage of 155 | this. 156 | 157 | Just store some atom on a component instance and use it to render UI and update 158 | its value when needed. 159 | 160 | That's all it takes to introduce local component state: 161 | 162 | ```js 163 | import {Component} from 'react' 164 | import {atom} from 'derivable' 165 | import {reactive} from 'react-derivable' 166 | 167 | class Counter extends Component { 168 | 169 | counter = atom(1) 170 | 171 | onClick = () => 172 | this.counter.swap(value => value + 1) 173 | 174 | render() { 175 | return ( 176 |
177 |
{this.counter.get()}
178 | 179 |
180 | ) 181 | } 182 | } 183 | 184 | Counter = reactive(Counter) 185 | ``` 186 | 187 | ### Flux/Redux-like unidirectional data flow 188 | 189 | Flux (or more Redux) like architecture can be implemented easily with reactive 190 | values. 191 | 192 | You would need to create a Flux architecture blueprint as a function which 193 | initialises an atom with some initial state and sets up action dispatching as a 194 | reducer (a-la Redux): 195 | 196 | ```js 197 | import {atom} from 'derivable' 198 | 199 | function createApp(transformWithAction, initialState = {}) { 200 | let state = atom(initialState) 201 | return { 202 | state: state.derive(state => state), 203 | dispatch(action) { 204 | let transform = transformWithAction[action.type] 205 | state.swap(state => transform(state, action)) 206 | } 207 | } 208 | } 209 | ``` 210 | 211 | Now we can use `createApp()` function to define an application in terms of 212 | initial state and actions which transform application state: 213 | 214 | ```js 215 | const CREATE_TODO = 'create-todo' 216 | 217 | let todoApp = createApp( 218 | { 219 | [CREATE_TODO](state, action) { 220 | let todoList = state.todoList.concat({text: action.text}) 221 | return {...state, todoList} 222 | } 223 | }, 224 | {todoList: []} 225 | ) 226 | 227 | function createTodo(text) { 228 | todoApp.dispatch({type: CREATE_TODO, text}) 229 | } 230 | ``` 231 | 232 | Now it is easy to render app state into UI and subscribe to state changes 233 | through the `reactive(Component)` decorator: 234 | 235 | ```js 236 | import React from 'react' 237 | import {reactive} from 'react-derivable' 238 | 239 | let App = reactive(() => 240 | 243 | ) 244 | ``` 245 | 246 | ### Binding to external state sources 247 | 248 | Sometimes state is originated not from application but from some external 249 | sources. One notorious example is routing where state is stored and partially 250 | controlled by a browser. 251 | 252 | It is still useful to have access to that state and do it using the homogenous 253 | API. 254 | 255 | Like we already discovered we can use derivable library to implement local 256 | component state and flux like state management easily. Let's see how we can use 257 | derivable to implement routing based on browser navigation state (HTML5 258 | pushState API). 259 | 260 | We'll be using the [history][] npm package which makes working with HTML5 API 261 | smooth and simple. 262 | 263 | First step is to make a history object which will hold the navigation state and 264 | some methods to influence those state: 265 | 266 | ```js 267 | import {createHistory as createBaseHistory} from 'history' 268 | import {atom} from 'derivable' 269 | 270 | function createHistory(options) { 271 | let history = createBaseHistory(options) 272 | let location = atom(history.getCurrentLocation()) 273 | history.listen(loc => location.set(loc)); 274 | history.location = location.derive(location => location) 275 | return history 276 | } 277 | 278 | let history = createHistory() 279 | ``` 280 | 281 | Now to build the router we just need to use `history.location` value in 282 | `render()`: 283 | 284 | ```js 285 | let Router = reactive(props => { 286 | let {pathname} = history.location.get() 287 | // Any complex pathname matching logic here, really. 288 | if (pathname === '/') { 289 | return 290 | } else if (pathname === '/about') { 291 | return 292 | } else { 293 | return 294 | } 295 | }) 296 | ``` 297 | 298 | Now to change location you would need another component which transforms 299 | location state: Link. Also it could track "active" state (if link's location is 300 | the current location): 301 | 302 | ```js 303 | let Link = reactive(props => { 304 | let {pathname} = history.location.get() 305 | let className = pathname == props.href ? 'active' : '' 306 | let onClick = e => { 307 | e.preventDefault() 308 | history.push(props.href) 309 | } 310 | return 311 | }) 312 | ``` 313 | 314 | ### Lifting regular React components to work with derivable values 315 | 316 | If you already have a React component which works with regular JS values but 317 | want it to work with derivable values you can use this little trick: 318 | 319 | ```js 320 | import {atom, unpack} from 'derivable' 321 | import {reactive} from 'react-derivable' 322 | 323 | class Hello extends React.Component { 324 | 325 | render() { 326 | return
{this.props.message}
327 | } 328 | } 329 | 330 | let ReactiveHello = reactive(props => 331 | ) 332 | 333 | 334 | ``` 335 | 336 | Also because you are passing values as plain props they are going to participate 337 | in React component lifecycle as usual (e.g. you can access prev values in 338 | `componentDidUpdate`): 339 | 340 | ```js 341 | class Hello extends React.Component { 342 | 343 | render() { 344 | return
{this.props.message}
345 | } 346 | 347 | componentDidUpdate(prevProps) { 348 | if (prevProps.message !== this.props.message) { 349 | // do something! 350 | } 351 | } 352 | } 353 | 354 | let ReactiveHello = reactive(props => 355 | ) 356 | ``` 357 | 358 | ## Examples 359 | 360 | See examples in [examples](./example) directory. 361 | 362 | [React]: https://reactjs.org 363 | [derivable]: https://github.com/ds300/derivablejs 364 | [immutable]: https://github.com/facebook/immutable-js 365 | [history]: https://github.com/ReactJSTraining/history§ 366 | -------------------------------------------------------------------------------- /benchmark/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let Benchmark = require('benchmark'); 4 | let React = require('react'); 5 | 6 | let {atom} = require('derivable'); 7 | let {reactive} = require('../'); 8 | 9 | let suite = new Benchmark.Suite(); 10 | 11 | let message = atom('ok'); 12 | let Component = reactive(() => React.createElement('div', null, message.get())); 13 | let component = new Component({}, {}); 14 | component.componentWillMount && component.componentWillMount(); 15 | 16 | suite 17 | .add('management overhead', function() { 18 | component.render(); 19 | component.componentWillUpdate && component.componentWillUpdate(); 20 | }) 21 | .on('cycle', function(event) { 22 | console.log(String(event.target)); // eslint-disable-line no-console 23 | }) 24 | .on('complete', function() { 25 | console.log('Fastest is ' + this.filter('fastest').map('name')); // eslint-disable-line no-console 26 | }) 27 | .run({'async': true}); 28 | -------------------------------------------------------------------------------- /devtool.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/devtool'); 2 | -------------------------------------------------------------------------------- /example/basic/README.md: -------------------------------------------------------------------------------- 1 | Run: 2 | 3 | ``` 4 | % npm start 5 | ``` 6 | -------------------------------------------------------------------------------- /example/basic/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreypopp/react-derivable/731dd3f78cd997577e26de36dfaa15c5bd6b8913/example/basic/favicon.ico -------------------------------------------------------------------------------- /example/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React App 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /example/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basic", 3 | "version": "0.0.1", 4 | "private": true, 5 | "devDependencies": { 6 | "react-scripts": "0.2.1" 7 | }, 8 | "dependencies": { 9 | "derivable": "^1.0.0-beta10", 10 | "react": "^15.2.1", 11 | "react-derivable": "../../", 12 | "react-dom": "^15.2.1" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "eject": "react-scripts eject" 18 | }, 19 | "eslintConfig": { 20 | "extends": "./node_modules/react-scripts/config/eslint.js" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /example/basic/src/index.js: -------------------------------------------------------------------------------- 1 | import {atom} from 'derivable'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import {reactive} from 'react-derivable'; 5 | 6 | /** 7 | * State. 8 | */ 9 | 10 | let counter = atom(0); 11 | 12 | /** 13 | * Actions which operate on state. 14 | */ 15 | 16 | let increase = () => 17 | counter.swap(value => value + 1); 18 | 19 | let decrease = () => 20 | counter.swap(value => value - 1); 21 | 22 | /** 23 | * Reactive component which reads from state and modifies it via actions. 24 | */ 25 | 26 | let App = reactive(props => 27 |
28 |
29 | Value: {counter.get()} 30 | 31 | 32 |
33 |
34 | ); 35 | 36 | /** 37 | * Render application into DOM. 38 | */ 39 | 40 | let render = () => 41 | ReactDOM.render( 42 | , 43 | document.getElementById('root') 44 | ); 45 | 46 | /** 47 | * This is not required to use React and Derivable! 48 | * 49 | * This helps webpack reload application when source code changes. 50 | */ 51 | if (module.hot) { 52 | module.hot.accept(); 53 | render(); 54 | } 55 | -------------------------------------------------------------------------------- /example/pure-optimization/README.md: -------------------------------------------------------------------------------- 1 | Run: 2 | 3 | ``` 4 | % npm start 5 | ``` 6 | -------------------------------------------------------------------------------- /example/pure-optimization/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreypopp/react-derivable/731dd3f78cd997577e26de36dfaa15c5bd6b8913/example/pure-optimization/favicon.ico -------------------------------------------------------------------------------- /example/pure-optimization/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React App 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /example/pure-optimization/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-derivable-pure-optimization", 3 | "version": "0.0.1", 4 | "private": true, 5 | "devDependencies": { 6 | "react-scripts": "0.2.1" 7 | }, 8 | "dependencies": { 9 | "derivable": "^1.0.0-beta10", 10 | "react": "^15.2.1", 11 | "react-derivable": "../../", 12 | "react-dom": "^15.2.1" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "eject": "react-scripts eject" 18 | }, 19 | "eslintConfig": { 20 | "extends": "./node_modules/react-scripts/config/eslint.js" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /example/pure-optimization/src/index.js: -------------------------------------------------------------------------------- 1 | import {atom} from 'derivable'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import {reactive, pure} from 'react-derivable'; 5 | 6 | /** 7 | * State. 8 | */ 9 | 10 | let state = atom({ 11 | a: 0, 12 | b: 0 13 | }); 14 | 15 | /** 16 | * State derivations. 17 | * 18 | * They allow to isolate scope of the state. So reactive components which 19 | * consume such derivations only react when those derivations are changed. 20 | */ 21 | 22 | let countA = state.derive(state => state.a); 23 | let countB = state.derive(state => state.b); 24 | 25 | /** 26 | * Actions which operate on state. 27 | */ 28 | 29 | let increaseA = () => 30 | state.swap(state => ({...state, a: state.a + 1})); 31 | 32 | let decreaseA = () => 33 | state.swap(state => ({...state, a: state.a - 1})); 34 | 35 | let increaseB = () => 36 | state.swap(state => ({...state, b: state.b + 1})); 37 | 38 | let decreaseB = () => 39 | state.swap(state => ({...state, b: state.b - 1})); 40 | 41 | /** 42 | * is a reactive component which distributes state among its children. 43 | */ 44 | 45 | let App = reactive(props => { 46 | console.log(`render: `); 47 | return ( 48 |
49 |
{JSON.stringify(state.get(), null, 2)}
50 | 56 | 62 |
63 | ); 64 | }); 65 | 66 | /** 67 | * is a pure reactive component. 68 | * 69 | * Pure means that its value depends on only reactive values dereferences in its 70 | * render method. Even if app re-renders it won't re-render unless `counter` 71 | * prop value changes. 72 | */ 73 | 74 | let Counter = pure(props => { 75 | console.log(`render: `); 76 | return ( 77 |
78 |
79 | Value: {props.counter.get()} 80 | 81 | 82 |
83 |
84 | ); 85 | }); 86 | 87 | /** 88 | * Render application into DOM. 89 | */ 90 | 91 | let render = () => 92 | ReactDOM.render( 93 | , 94 | document.getElementById('root') 95 | ); 96 | 97 | /** 98 | * This is not required to use React and Derivable! 99 | * 100 | * This helps webpack reload application when source code changes. 101 | */ 102 | if (module.hot) { 103 | module.hot.accept(); 104 | render(); 105 | } 106 | -------------------------------------------------------------------------------- /flow-typed/jest_v20.x.x.js: -------------------------------------------------------------------------------- 1 | type JestMockFn = { 2 | (...args: Array): any, 3 | /** 4 | * An object for introspecting mock calls 5 | */ 6 | mock: { 7 | /** 8 | * An array that represents all calls that have been made into this mock 9 | * function. Each call is represented by an array of arguments that were 10 | * passed during the call. 11 | */ 12 | calls: Array>, 13 | /** 14 | * An array that contains all the object instances that have been 15 | * instantiated from this mock function. 16 | */ 17 | instances: mixed 18 | }, 19 | /** 20 | * Resets all information stored in the mockFn.mock.calls and 21 | * mockFn.mock.instances arrays. Often this is useful when you want to clean 22 | * up a mock's usage data between two assertions. 23 | */ 24 | mockClear(): Function, 25 | /** 26 | * Resets all information stored in the mock. This is useful when you want to 27 | * completely restore a mock back to its initial state. 28 | */ 29 | mockReset(): Function, 30 | /** 31 | * Removes the mock and restores the initial implementation. This is useful 32 | * when you want to mock functions in certain test cases and restore the 33 | * original implementation in others. Beware that mockFn.mockRestore only 34 | * works when mock was created with jest.spyOn. Thus you have to take care of 35 | * restoration yourself when manually assigning jest.fn(). 36 | */ 37 | mockRestore(): Function, 38 | /** 39 | * Accepts a function that should be used as the implementation of the mock. 40 | * The mock itself will still record all calls that go into and instances 41 | * that come from itself -- the only difference is that the implementation 42 | * will also be executed when the mock is called. 43 | */ 44 | mockImplementation(fn: Function): JestMockFn, 45 | /** 46 | * Accepts a function that will be used as an implementation of the mock for 47 | * one call to the mocked function. Can be chained so that multiple function 48 | * calls produce different results. 49 | */ 50 | mockImplementationOnce(fn: Function): JestMockFn, 51 | /** 52 | * Just a simple sugar function for returning `this` 53 | */ 54 | mockReturnThis(): void, 55 | /** 56 | * Deprecated: use jest.fn(() => value) instead 57 | */ 58 | mockReturnValue(value: any): JestMockFn, 59 | /** 60 | * Sugar for only returning a value once inside your mock 61 | */ 62 | mockReturnValueOnce(value: any): JestMockFn 63 | }; 64 | 65 | type JestAsymmetricEqualityType = { 66 | /** 67 | * A custom Jasmine equality tester 68 | */ 69 | asymmetricMatch(value: mixed): boolean 70 | }; 71 | 72 | type JestCallsType = { 73 | allArgs(): mixed, 74 | all(): mixed, 75 | any(): boolean, 76 | count(): number, 77 | first(): mixed, 78 | mostRecent(): mixed, 79 | reset(): void 80 | }; 81 | 82 | type JestClockType = { 83 | install(): void, 84 | mockDate(date: Date): void, 85 | tick(milliseconds?: number): void, 86 | uninstall(): void 87 | }; 88 | 89 | type JestMatcherResult = { 90 | message?: string | (() => string), 91 | pass: boolean 92 | }; 93 | 94 | type JestMatcher = (actual: any, expected: any) => JestMatcherResult; 95 | 96 | type JestPromiseType = { 97 | /** 98 | * Use rejects to unwrap the reason of a rejected promise so any other 99 | * matcher can be chained. If the promise is fulfilled the assertion fails. 100 | */ 101 | rejects: JestExpectType, 102 | /** 103 | * Use resolves to unwrap the value of a fulfilled promise so any other 104 | * matcher can be chained. If the promise is rejected the assertion fails. 105 | */ 106 | resolves: JestExpectType 107 | }; 108 | 109 | /** 110 | * Plugin: jest-enzyme 111 | */ 112 | type EnzymeMatchersType = { 113 | toBeChecked(): void, 114 | toBeDisabled(): void, 115 | toBeEmpty(): void, 116 | toBePresent(): void, 117 | toContainReact(component: React$Element): void, 118 | toHaveClassName(className: string): void, 119 | toHaveHTML(html: string): void, 120 | toHaveProp(propKey: string, propValue?: any): void, 121 | toHaveRef(refName: string): void, 122 | toHaveState(stateKey: string, stateValue?: any): void, 123 | toHaveStyle(styleKey: string, styleValue?: any): void, 124 | toHaveTagName(tagName: string): void, 125 | toHaveText(text: string): void, 126 | toIncludeText(text: string): void, 127 | toHaveValue(value: any): void, 128 | toMatchSelector(selector: string): void, 129 | }; 130 | 131 | type JestExpectType = { 132 | not: JestExpectType & EnzymeMatchersType, 133 | /** 134 | * If you have a mock function, you can use .lastCalledWith to test what 135 | * arguments it was last called with. 136 | */ 137 | lastCalledWith(...args: Array): void, 138 | /** 139 | * toBe just checks that a value is what you expect. It uses === to check 140 | * strict equality. 141 | */ 142 | toBe(value: any): void, 143 | /** 144 | * Use .toHaveBeenCalled to ensure that a mock function got called. 145 | */ 146 | toBeCalled(): void, 147 | /** 148 | * Use .toBeCalledWith to ensure that a mock function was called with 149 | * specific arguments. 150 | */ 151 | toBeCalledWith(...args: Array): void, 152 | /** 153 | * Using exact equality with floating point numbers is a bad idea. Rounding 154 | * means that intuitive things fail. 155 | */ 156 | toBeCloseTo(num: number, delta: any): void, 157 | /** 158 | * Use .toBeDefined to check that a variable is not undefined. 159 | */ 160 | toBeDefined(): void, 161 | /** 162 | * Use .toBeFalsy when you don't care what a value is, you just want to 163 | * ensure a value is false in a boolean context. 164 | */ 165 | toBeFalsy(): void, 166 | /** 167 | * To compare floating point numbers, you can use toBeGreaterThan. 168 | */ 169 | toBeGreaterThan(number: number): void, 170 | /** 171 | * To compare floating point numbers, you can use toBeGreaterThanOrEqual. 172 | */ 173 | toBeGreaterThanOrEqual(number: number): void, 174 | /** 175 | * To compare floating point numbers, you can use toBeLessThan. 176 | */ 177 | toBeLessThan(number: number): void, 178 | /** 179 | * To compare floating point numbers, you can use toBeLessThanOrEqual. 180 | */ 181 | toBeLessThanOrEqual(number: number): void, 182 | /** 183 | * Use .toBeInstanceOf(Class) to check that an object is an instance of a 184 | * class. 185 | */ 186 | toBeInstanceOf(cls: Class<*>): void, 187 | /** 188 | * .toBeNull() is the same as .toBe(null) but the error messages are a bit 189 | * nicer. 190 | */ 191 | toBeNull(): void, 192 | /** 193 | * Use .toBeTruthy when you don't care what a value is, you just want to 194 | * ensure a value is true in a boolean context. 195 | */ 196 | toBeTruthy(): void, 197 | /** 198 | * Use .toBeUndefined to check that a variable is undefined. 199 | */ 200 | toBeUndefined(): void, 201 | /** 202 | * Use .toContain when you want to check that an item is in a list. For 203 | * testing the items in the list, this uses ===, a strict equality check. 204 | */ 205 | toContain(item: any): void, 206 | /** 207 | * Use .toContainEqual when you want to check that an item is in a list. For 208 | * testing the items in the list, this matcher recursively checks the 209 | * equality of all fields, rather than checking for object identity. 210 | */ 211 | toContainEqual(item: any): void, 212 | /** 213 | * Use .toEqual when you want to check that two objects have the same value. 214 | * This matcher recursively checks the equality of all fields, rather than 215 | * checking for object identity. 216 | */ 217 | toEqual(value: any): void, 218 | /** 219 | * Use .toHaveBeenCalled to ensure that a mock function got called. 220 | */ 221 | toHaveBeenCalled(): void, 222 | /** 223 | * Use .toHaveBeenCalledTimes to ensure that a mock function got called exact 224 | * number of times. 225 | */ 226 | toHaveBeenCalledTimes(number: number): void, 227 | /** 228 | * Use .toHaveBeenCalledWith to ensure that a mock function was called with 229 | * specific arguments. 230 | */ 231 | toHaveBeenCalledWith(...args: Array): void, 232 | /** 233 | * Use .toHaveBeenLastCalledWith to ensure that a mock function was last called 234 | * with specific arguments. 235 | */ 236 | toHaveBeenLastCalledWith(...args: Array): void, 237 | /** 238 | * Check that an object has a .length property and it is set to a certain 239 | * numeric value. 240 | */ 241 | toHaveLength(number: number): void, 242 | /** 243 | * 244 | */ 245 | toHaveProperty(propPath: string, value?: any): void, 246 | /** 247 | * Use .toMatch to check that a string matches a regular expression. 248 | */ 249 | toMatch(regexp: RegExp): void, 250 | /** 251 | * Use .toMatchObject to check that a javascript object matches a subset of the properties of an object. 252 | */ 253 | toMatchObject(object: Object): void, 254 | /** 255 | * This ensures that a React component matches the most recent snapshot. 256 | */ 257 | toMatchSnapshot(name?: string): void, 258 | /** 259 | * Use .toThrow to test that a function throws when it is called. 260 | * If you want to test that a specific error gets thrown, you can provide an 261 | * argument to toThrow. The argument can be a string for the error message, 262 | * a class for the error, or a regex that should match the error. 263 | * 264 | * Alias: .toThrowError 265 | */ 266 | toThrow(message?: string | Error | RegExp): void, 267 | toThrowError(message?: string | Error | RegExp): void, 268 | /** 269 | * Use .toThrowErrorMatchingSnapshot to test that a function throws a error 270 | * matching the most recent snapshot when it is called. 271 | */ 272 | toThrowErrorMatchingSnapshot(): void 273 | }; 274 | 275 | type JestObjectType = { 276 | /** 277 | * Disables automatic mocking in the module loader. 278 | * 279 | * After this method is called, all `require()`s will return the real 280 | * versions of each module (rather than a mocked version). 281 | */ 282 | disableAutomock(): JestObjectType, 283 | /** 284 | * An un-hoisted version of disableAutomock 285 | */ 286 | autoMockOff(): JestObjectType, 287 | /** 288 | * Enables automatic mocking in the module loader. 289 | */ 290 | enableAutomock(): JestObjectType, 291 | /** 292 | * An un-hoisted version of enableAutomock 293 | */ 294 | autoMockOn(): JestObjectType, 295 | /** 296 | * Clears the mock.calls and mock.instances properties of all mocks. 297 | * Equivalent to calling .mockClear() on every mocked function. 298 | */ 299 | clearAllMocks(): JestObjectType, 300 | /** 301 | * Resets the state of all mocks. Equivalent to calling .mockReset() on every 302 | * mocked function. 303 | */ 304 | resetAllMocks(): JestObjectType, 305 | /** 306 | * Removes any pending timers from the timer system. 307 | */ 308 | clearAllTimers(): void, 309 | /** 310 | * The same as `mock` but not moved to the top of the expectation by 311 | * babel-jest. 312 | */ 313 | doMock(moduleName: string, moduleFactory?: any): JestObjectType, 314 | /** 315 | * The same as `unmock` but not moved to the top of the expectation by 316 | * babel-jest. 317 | */ 318 | dontMock(moduleName: string): JestObjectType, 319 | /** 320 | * Returns a new, unused mock function. Optionally takes a mock 321 | * implementation. 322 | */ 323 | fn(implementation?: Function): JestMockFn, 324 | /** 325 | * Determines if the given function is a mocked function. 326 | */ 327 | isMockFunction(fn: Function): boolean, 328 | /** 329 | * Given the name of a module, use the automatic mocking system to generate a 330 | * mocked version of the module for you. 331 | */ 332 | genMockFromModule(moduleName: string): any, 333 | /** 334 | * Mocks a module with an auto-mocked version when it is being required. 335 | * 336 | * The second argument can be used to specify an explicit module factory that 337 | * is being run instead of using Jest's automocking feature. 338 | * 339 | * The third argument can be used to create virtual mocks -- mocks of modules 340 | * that don't exist anywhere in the system. 341 | */ 342 | mock( 343 | moduleName: string, 344 | moduleFactory?: any, 345 | options?: Object 346 | ): JestObjectType, 347 | /** 348 | * Resets the module registry - the cache of all required modules. This is 349 | * useful to isolate modules where local state might conflict between tests. 350 | */ 351 | resetModules(): JestObjectType, 352 | /** 353 | * Exhausts the micro-task queue (usually interfaced in node via 354 | * process.nextTick). 355 | */ 356 | runAllTicks(): void, 357 | /** 358 | * Exhausts the macro-task queue (i.e., all tasks queued by setTimeout(), 359 | * setInterval(), and setImmediate()). 360 | */ 361 | runAllTimers(): void, 362 | /** 363 | * Exhausts all tasks queued by setImmediate(). 364 | */ 365 | runAllImmediates(): void, 366 | /** 367 | * Executes only the macro task queue (i.e. all tasks queued by setTimeout() 368 | * or setInterval() and setImmediate()). 369 | */ 370 | runTimersToTime(msToRun: number): void, 371 | /** 372 | * Executes only the macro-tasks that are currently pending (i.e., only the 373 | * tasks that have been queued by setTimeout() or setInterval() up to this 374 | * point) 375 | */ 376 | runOnlyPendingTimers(): void, 377 | /** 378 | * Explicitly supplies the mock object that the module system should return 379 | * for the specified module. Note: It is recommended to use jest.mock() 380 | * instead. 381 | */ 382 | setMock(moduleName: string, moduleExports: any): JestObjectType, 383 | /** 384 | * Indicates that the module system should never return a mocked version of 385 | * the specified module from require() (e.g. that it should always return the 386 | * real module). 387 | */ 388 | unmock(moduleName: string): JestObjectType, 389 | /** 390 | * Instructs Jest to use fake versions of the standard timer functions 391 | * (setTimeout, setInterval, clearTimeout, clearInterval, nextTick, 392 | * setImmediate and clearImmediate). 393 | */ 394 | useFakeTimers(): JestObjectType, 395 | /** 396 | * Instructs Jest to use the real versions of the standard timer functions. 397 | */ 398 | useRealTimers(): JestObjectType, 399 | /** 400 | * Creates a mock function similar to jest.fn but also tracks calls to 401 | * object[methodName]. 402 | */ 403 | spyOn(object: Object, methodName: string): JestMockFn 404 | }; 405 | 406 | type JestSpyType = { 407 | calls: JestCallsType 408 | }; 409 | 410 | /** Runs this function after every test inside this context */ 411 | declare function afterEach(fn: Function): void; 412 | /** Runs this function before every test inside this context */ 413 | declare function beforeEach(fn: Function): void; 414 | /** Runs this function after all tests have finished inside this context */ 415 | declare function afterAll(fn: Function): void; 416 | /** Runs this function before any tests have started inside this context */ 417 | declare function beforeAll(fn: Function): void; 418 | /** A context for grouping tests together */ 419 | declare function describe(name: string, fn: Function): void; 420 | 421 | /** An individual test unit */ 422 | declare var it: { 423 | /** 424 | * An individual test unit 425 | * 426 | * @param {string} Name of Test 427 | * @param {Function} Test 428 | */ 429 | (name: string, fn?: Function): ?Promise, 430 | /** 431 | * Only run this test 432 | * 433 | * @param {string} Name of Test 434 | * @param {Function} Test 435 | */ 436 | only(name: string, fn?: Function): ?Promise, 437 | /** 438 | * Skip running this test 439 | * 440 | * @param {string} Name of Test 441 | * @param {Function} Test 442 | */ 443 | skip(name: string, fn?: Function): ?Promise, 444 | /** 445 | * Run the test concurrently 446 | * 447 | * @param {string} Name of Test 448 | * @param {Function} Test 449 | */ 450 | concurrent(name: string, fn?: Function): ?Promise 451 | }; 452 | declare function fit(name: string, fn: Function): ?Promise; 453 | /** An individual test unit */ 454 | declare var test: typeof it; 455 | /** A disabled group of tests */ 456 | declare var xdescribe: typeof describe; 457 | /** A focused group of tests */ 458 | declare var fdescribe: typeof describe; 459 | /** A disabled individual test */ 460 | declare var xit: typeof it; 461 | /** A disabled individual test */ 462 | declare var xtest: typeof it; 463 | 464 | /** The expect function is used every time you want to test a value */ 465 | declare var expect: { 466 | /** The object that you want to make assertions against */ 467 | (value: any): JestExpectType & JestPromiseType & EnzymeMatchersType, 468 | /** Add additional Jasmine matchers to Jest's roster */ 469 | extend(matchers: { [name: string]: JestMatcher }): void, 470 | /** Add a module that formats application-specific data structures. */ 471 | addSnapshotSerializer(serializer: (input: Object) => string): void, 472 | assertions(expectedAssertions: number): void, 473 | hasAssertions(): void, 474 | any(value: mixed): JestAsymmetricEqualityType, 475 | anything(): void, 476 | arrayContaining(value: Array): void, 477 | objectContaining(value: Object): void, 478 | /** Matches any received string that contains the exact expected string. */ 479 | stringContaining(value: string): void, 480 | stringMatching(value: string | RegExp): void 481 | }; 482 | 483 | // TODO handle return type 484 | // http://jasmine.github.io/2.4/introduction.html#section-Spies 485 | declare function spyOn(value: mixed, method: string): Object; 486 | 487 | /** Holds all functions related to manipulating test runner */ 488 | declare var jest: JestObjectType; 489 | 490 | /** 491 | * The global Jamine object, this is generally not exposed as the public API, 492 | * using features inside here could break in later versions of Jest. 493 | */ 494 | declare var jasmine: { 495 | DEFAULT_TIMEOUT_INTERVAL: number, 496 | any(value: mixed): JestAsymmetricEqualityType, 497 | anything(): void, 498 | arrayContaining(value: Array): void, 499 | clock(): JestClockType, 500 | createSpy(name: string): JestSpyType, 501 | createSpyObj( 502 | baseName: string, 503 | methodNames: Array 504 | ): { [methodName: string]: JestSpyType }, 505 | objectContaining(value: Object): void, 506 | stringMatching(value: string): void 507 | }; 508 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-derivable", 3 | "version": "0.4.0", 4 | "description": "Make React component react on changes in referened derivable values", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "lib" 8 | ], 9 | "scripts": { 10 | "test": "make lint test" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/andreypopp/react-derivable.git" 15 | }, 16 | "keywords": [ 17 | "react", 18 | "derivable" 19 | ], 20 | "author": "Andrey Popp <8mayday@gmail.com>", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/andreypopp/react-derivable/issues" 24 | }, 25 | "homepage": "https://github.com/andreypopp/react-derivable#readme", 26 | "peerDependencies": { 27 | "derivable": "2.0.0-beta.2", 28 | "react": ">= 0.14.0 < 16.0.0" 29 | }, 30 | "devDependencies": { 31 | "babel-cli": "^6.10.1", 32 | "babel-core": "^6.10.4", 33 | "babel-jest": "^20.0.3", 34 | "babel-preset-prometheusresearch": "^0.1.0", 35 | "benchmark": "^2.1.0", 36 | "create-react-app": "^0.2.0", 37 | "derivable": "2.0.0-beta.2", 38 | "doctoc": "^1.2.0", 39 | "eslint": "^3.0.1", 40 | "eslint-config-prometheusresearch": "^0.2.0", 41 | "eslint-plugin-react": "^5.2.2", 42 | "flow-bin": "^0.37.0", 43 | "immutable": "^3.8.1", 44 | "jest-cli": "^20.0.4", 45 | "prettier": "^1.5.3", 46 | "react": "^15.2.1", 47 | "react-addons-test-utils": "^15.4.1", 48 | "react-dom": "^15.2.1" 49 | }, 50 | "dependencies": { 51 | "invariant": "^2.2.1", 52 | "prop-types": "^15.5.10" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/__tests__/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "globals": { 6 | "assert": true, 7 | "sinon": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/__tests__/index-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @copyright 2016-present, Prometheus Research, LLC 3 | */ 4 | 5 | import React from 'react'; 6 | import ReactDOM from 'react-dom'; 7 | import PropTypes from 'prop-types'; 8 | import {atom} from 'derivable'; 9 | import * as Immutable from 'immutable'; 10 | 11 | import {shallowEqual, reactive, pure} from '../index'; 12 | 13 | function markup(element) { 14 | return element.innerHTML.replace(/ data\-reactroot="[^"]*"/g, ''); 15 | } 16 | 17 | describe('react-derivable', function() { 18 | describe('shallowEqual', function() { 19 | it('compares two object for eq', function() { 20 | expect(shallowEqual({}, {})).toBe(true); 21 | expect(shallowEqual({a: 1}, {a: 1})).toBe(true); 22 | expect(shallowEqual({a: 1}, {})).toBe(false); 23 | expect(shallowEqual({a: 1}, {a: 2})).toBe(false); 24 | expect(shallowEqual({a: {}}, {a: {}})).toBe(false); 25 | expect(shallowEqual(1, 2)).toBe(false); 26 | expect(shallowEqual(1, 1)).toBe(true); 27 | expect(shallowEqual(null, null)).toBe(true); 28 | expect(shallowEqual(undefined, undefined)).toBe(true); 29 | expect(shallowEqual('', '')).toBe(true); 30 | expect(shallowEqual(true, true)).toBe(true); 31 | expect(shallowEqual(false, false)).toBe(true); 32 | }); 33 | 34 | it('compares reactive values', function() { 35 | let a1 = atom(1); 36 | let a2 = atom(1); 37 | let onDerivableReplace = jest.fn(); 38 | expect(shallowEqual({a: a1}, {a: a2}, undefined, onDerivableReplace)).toBe(true); 39 | expect(onDerivableReplace.mock.calls.length).toBe(1); 40 | expect(onDerivableReplace.mock.calls[0]).toEqual([a1, a2]); 41 | a2.set(2); 42 | expect(shallowEqual({a: a1}, {a: a2}), undefined, onDerivableReplace).toBe(false); 43 | expect(onDerivableReplace.mock.calls.length).toBe(1); 44 | }); 45 | 46 | it('compares reactive values w/ diff eq', function() { 47 | let almostEq = (a, b) => Math.abs(a - b) < 5; 48 | let a1 = atom(1).withEquality(almostEq); 49 | let a2 = atom(1); 50 | let onDerivableReplace = jest.fn(); 51 | expect(shallowEqual({a: a1}, {a: a2}, undefined, onDerivableReplace)).toBe(false); 52 | expect(onDerivableReplace.mock.calls.length).toBe(0); 53 | }); 54 | 55 | it('compares reactive values w/ custom eq', function() { 56 | let almostEq = (a, b) => Math.abs(a - b) < 5; 57 | let a1 = atom(1).withEquality(almostEq); 58 | let a2 = atom(1).withEquality(almostEq); 59 | let onDerivableReplace = jest.fn(); 60 | expect(shallowEqual({a: a1}, {a: a2}, undefined, onDerivableReplace)).toBe(true); 61 | expect(onDerivableReplace.mock.calls.length).toBe(1); 62 | expect(onDerivableReplace.mock.calls[0]).toEqual([a1, a2]); 63 | a2.set(2); 64 | expect(shallowEqual({a: a1}, {a: a2}, undefined, onDerivableReplace)).toBe(true); 65 | expect(onDerivableReplace.mock.calls.length).toBe(2); 66 | expect(onDerivableReplace.mock.calls[1]).toEqual([a1, a2]); 67 | a2.set(20); 68 | expect(shallowEqual({a: a1}, {a: a2}, undefined, onDerivableReplace)).toBe(false); 69 | expect(onDerivableReplace.mock.calls.length).toBe(2); 70 | }); 71 | }); 72 | 73 | describe('decorators', function() { 74 | let renderCount; 75 | let root; 76 | 77 | class ClassBased extends React.Component { 78 | render() { 79 | let {title, message, x} = this.props; 80 | renderCount += 1; 81 | return ( 82 |
83 | {message.get()} 84 |
85 | ); 86 | } 87 | } 88 | 89 | function Functional({title, message, x}) { 90 | renderCount += 1; 91 | return ( 92 |
93 | {message.get()} 94 |
95 | ); 96 | } 97 | 98 | beforeEach(function() { 99 | renderCount = 0; 100 | root = document.createElement('div'); 101 | }); 102 | 103 | afterEach(function() { 104 | ReactDOM.unmountComponentAtNode(root); 105 | }); 106 | 107 | describe('common cases for reactive(Component) and pure(Component)', function() { 108 | [ 109 | reactive(ClassBased), 110 | reactive(Functional), 111 | pure(ClassBased), 112 | pure(Functional), 113 | ].forEach(function(Component) { 114 | describe(Component.displayName || Component.name, function() { 115 | it('renders', function() { 116 | let message = atom('World'); 117 | ReactDOM.render(, root); 118 | 119 | expect(renderCount).toBe(1); 120 | expect(markup(root)).toBe('
World
'); 121 | }); 122 | 123 | it('reacts', function() { 124 | let message = atom('World'); 125 | ReactDOM.render(, root); 126 | 127 | expect(renderCount).toBe(1); 128 | expect(markup(root)).toBe('
World
'); 129 | 130 | message.set('Andrey'); 131 | 132 | expect(renderCount).toBe(2); 133 | expect(markup(root)).toBe('
Andrey
'); 134 | }); 135 | 136 | it('ignore re-render if reactive value does not change', function() { 137 | let message = atom('World'); 138 | ReactDOM.render(, root); 139 | 140 | expect(renderCount).toBe(1); 141 | expect(markup(root)).toBe('
World
'); 142 | 143 | message.set('World'); 144 | 145 | expect(renderCount).toBe(1); 146 | expect(markup(root)).toBe('
World
'); 147 | }); 148 | 149 | it('re-renders on reactive prop change', function() { 150 | let message = atom('World'); 151 | ReactDOM.render(, root); 152 | 153 | expect(renderCount).toBe(1); 154 | expect(markup(root)).toBe('
World
'); 155 | 156 | let nextMessage = atom('NextWorld'); 157 | ReactDOM.render(, root); 158 | 159 | expect(renderCount).toBe(2); 160 | expect(markup(root)).toBe('
NextWorld
'); 161 | }); 162 | 163 | it('re-renders on regular prop change', function() { 164 | let message = atom('World'); 165 | ReactDOM.render(, root); 166 | 167 | expect(renderCount).toBe(1); 168 | expect(markup(root)).toBe('
World
'); 169 | 170 | ReactDOM.render(, root); 171 | 172 | expect(renderCount).toBe(2); 173 | expect(markup(root)).toBe('
World
'); 174 | }); 175 | 176 | it('ignores inactive value changes', function() { 177 | let message = atom('World'); 178 | ReactDOM.render(, root); 179 | 180 | expect(renderCount).toBe(1); 181 | expect(markup(root)).toBe('
World
'); 182 | 183 | let nextMessage = atom('NextWorld'); 184 | ReactDOM.render(, root); 185 | 186 | expect(renderCount).toBe(2); 187 | expect(markup(root)).toBe('
NextWorld
'); 188 | 189 | message.set('Nope'); 190 | 191 | expect(renderCount).toBe(2); 192 | expect(markup(root)).toBe('
NextWorld
'); 193 | }); 194 | 195 | it('batches updates (via react and via derivable)', function() { 196 | let message = atom('World'); 197 | ReactDOM.render(, root); 198 | 199 | expect(renderCount).toBe(1); 200 | expect(markup(root)).toBe('
World
'); 201 | 202 | ReactDOM.unstable_batchedUpdates(() => { 203 | ReactDOM.render(, root); 204 | message.set('!!!'); 205 | }); 206 | 207 | expect(renderCount).toBe(2); 208 | expect(markup(root)).toBe('
!!!
'); 209 | }); 210 | 211 | it('batches updates (both via derivable)', function() { 212 | let message = atom('World'); 213 | ReactDOM.render(, root); 214 | 215 | expect(renderCount).toBe(1); 216 | expect(markup(root)).toBe('
World
'); 217 | 218 | ReactDOM.unstable_batchedUpdates(() => { 219 | message.set('oops'); 220 | message.set('!!!'); 221 | }); 222 | 223 | expect(renderCount).toBe(2); 224 | expect(markup(root)).toBe('
!!!
'); 225 | }); 226 | 227 | it('batches updates (both via derivable, different)', function() { 228 | let message = atom('World'); 229 | let x = atom('x'); 230 | ReactDOM.render(, root); 231 | 232 | expect(renderCount).toBe(1); 233 | expect(markup(root)).toBe('
World
'); 234 | 235 | ReactDOM.unstable_batchedUpdates(() => { 236 | x.set('oops'); 237 | message.set('!!!'); 238 | }); 239 | 240 | expect(renderCount).toBe(2); 241 | expect(markup(root)).toBe('
!!!
'); 242 | }); 243 | 244 | it('does not update if values does not change', function() { 245 | let message = atom('World'); 246 | ReactDOM.render(, root); 247 | 248 | expect(renderCount).toBe(1); 249 | expect(markup(root)).toBe('
World
'); 250 | 251 | message.set('World'); 252 | 253 | expect(renderCount).toBe(1); 254 | expect(markup(root)).toBe('
World
'); 255 | }); 256 | 257 | it('does not update after unmount', function() { 258 | let message = atom('World'); 259 | ReactDOM.render(, root); 260 | 261 | expect(renderCount).toBe(1); 262 | expect(markup(root)).toBe('
World
'); 263 | 264 | ReactDOM.unmountComponentAtNode(root); 265 | message.set('!!!'); 266 | 267 | expect(renderCount).toBe(1); 268 | }); 269 | }); 270 | }); 271 | }); 272 | 273 | describe('reactive(Component)', function() { 274 | makeComponentDecoratorTestSuite(reactive); 275 | 276 | [reactive(ClassBased), reactive(Functional)].forEach(function(Component) { 277 | describe(Component.displayName || Component.name, function() { 278 | it('re-renders on reactive prop change (same value)', function() { 279 | let message = atom('World'); 280 | ReactDOM.render(, root); 281 | 282 | expect(renderCount).toBe(1); 283 | expect(markup(root)).toBe('
World
'); 284 | 285 | let nextMessage = atom('World'); 286 | ReactDOM.render(, root); 287 | 288 | expect(renderCount).toBe(2); 289 | expect(markup(root)).toBe('
World
'); 290 | }); 291 | 292 | it('re-renders on regular prop change (same prop value)', function() { 293 | let message = atom('World'); 294 | ReactDOM.render(, root); 295 | 296 | expect(renderCount).toBe(1); 297 | expect(markup(root)).toBe('
World
'); 298 | 299 | ReactDOM.render(, root); 300 | 301 | expect(renderCount).toBe(2); 302 | expect(markup(root)).toBe('
World
'); 303 | }); 304 | }); 305 | }); 306 | }); 307 | 308 | describe('pure(Component)', function() { 309 | makeComponentDecoratorTestSuite(pure); 310 | 311 | [pure(ClassBased), pure(Functional)].forEach(function(Component) { 312 | describe(Component.displayName || Component.name, function() { 313 | it('ignores reactive prop change (same value)', function() { 314 | let message = atom('World'); 315 | ReactDOM.render(, root); 316 | 317 | expect(renderCount).toBe(1); 318 | expect(markup(root)).toBe('
World
'); 319 | 320 | let nextMessage = atom('World'); 321 | ReactDOM.render(, root); 322 | 323 | expect(renderCount).toBe(1); 324 | expect(markup(root)).toBe('
World
'); 325 | }); 326 | 327 | it('ignores reactive prop change (same value) but re-subscribes', function() { 328 | let message = atom('World'); 329 | ReactDOM.render(, root); 330 | 331 | expect(renderCount).toBe(1); 332 | expect(markup(root)).toBe('
World
'); 333 | 334 | let nextMessage = atom('World'); 335 | ReactDOM.render(, root); 336 | 337 | expect(renderCount).toBe(1); 338 | expect(markup(root)).toBe('
World
'); 339 | 340 | message.set('x'); 341 | expect(renderCount).toBe(1); 342 | expect(markup(root)).toBe('
World
'); 343 | 344 | nextMessage.set('y'); 345 | expect(renderCount).toBe(2); 346 | expect(markup(root)).toBe('
y
'); 347 | 348 | nextMessage.set('z'); 349 | expect(renderCount).toBe(3); 350 | expect(markup(root)).toBe('
z
'); 351 | }); 352 | 353 | it('ignores regular prop change (same prop value)', function() { 354 | let message = atom('World'); 355 | ReactDOM.render(, root); 356 | 357 | expect(renderCount).toBe(1); 358 | expect(markup(root)).toBe('
World
'); 359 | 360 | ReactDOM.render(, root); 361 | 362 | expect(renderCount).toBe(1); 363 | expect(markup(root)).toBe('
World
'); 364 | }); 365 | }); 366 | }); 367 | }); 368 | 369 | describe('pure(Component) w/ custom equality', function() { 370 | makeComponentDecoratorTestSuite(Component => 371 | pure(Component).withEquality(Immutable.is), 372 | ); 373 | 374 | class ClassBased extends React.Component { 375 | render() { 376 | let {title, message, x} = this.props; 377 | renderCount += 1; 378 | return ( 379 |
380 | {message.get().get('a')} 381 |
382 | ); 383 | } 384 | } 385 | 386 | function Functional({title, message, x}) { 387 | renderCount += 1; 388 | return ( 389 |
390 | {message.get().get('a')} 391 |
392 | ); 393 | } 394 | 395 | [ 396 | pure(ClassBased).withEquality(Immutable.is), 397 | pure(Functional).withEquality(Immutable.is), 398 | ].forEach(function(Component) { 399 | describe(Component.displayName || Component.name, function() { 400 | it('ignores reactive prop change (same value)', function() { 401 | let message = atom(Immutable.Map().set('a', 'World')); 402 | ReactDOM.render(, root); 403 | 404 | expect(renderCount).toBe(1); 405 | expect(markup(root)).toBe('
World
'); 406 | 407 | let nextMessage = atom(Immutable.Map().set('a', 'World')); 408 | ReactDOM.render(, root); 409 | 410 | expect(renderCount).toBe(1); 411 | expect(markup(root)).toBe('
World
'); 412 | }); 413 | 414 | it('ignores reactive prop change (same value) but re-subscribes', function() { 415 | let message = atom(Immutable.Map().set('a', 'World')); 416 | ReactDOM.render(, root); 417 | 418 | expect(renderCount).toBe(1); 419 | expect(markup(root)).toBe('
World
'); 420 | 421 | let nextMessage = atom(Immutable.Map().set('a', 'World')); 422 | ReactDOM.render(, root); 423 | 424 | expect(renderCount).toBe(1); 425 | expect(markup(root)).toBe('
World
'); 426 | 427 | message.update(m => m.set('a', 'x')); 428 | expect(renderCount).toBe(1); 429 | expect(markup(root)).toBe('
World
'); 430 | 431 | nextMessage.update(m => m.set('a', 'y')); 432 | expect(renderCount).toBe(2); 433 | expect(markup(root)).toBe('
y
'); 434 | 435 | nextMessage.update(m => m.set('a', 'z')); 436 | expect(renderCount).toBe(3); 437 | expect(markup(root)).toBe('
z
'); 438 | }); 439 | 440 | it('ignores regular prop change (same prop value)', function() { 441 | let message = atom(Immutable.Map().set('a', 'World')); 442 | let title = Immutable.Map().set('a', 'ok'); 443 | ReactDOM.render(, root); 444 | 445 | expect(renderCount).toBe(1); 446 | expect(markup(root)).toBe('
World
'); 447 | 448 | let nextTitle = Immutable.Map().set('a', 'ok'); 449 | ReactDOM.render(, root); 450 | 451 | expect(renderCount).toBe(1); 452 | expect(markup(root)).toBe('
World
'); 453 | }); 454 | }); 455 | }); 456 | }); 457 | }); 458 | }); 459 | 460 | function makeComponentDecoratorTestSuite(decorate) { 461 | it('preserves displayName for class based components', function() { 462 | class X extends React.Component { 463 | render() { 464 | return null; 465 | } 466 | } 467 | expect(decorate(X).displayName).toBe('X'); 468 | }); 469 | 470 | it('preserves displayName for class based components with custom displayName', function() { 471 | class X extends React.Component { 472 | static displayName = 'Y'; 473 | render() { 474 | return null; 475 | } 476 | } 477 | expect(decorate(X).displayName).toBe('Y'); 478 | }); 479 | 480 | it('preserves displayName for functional components', function() { 481 | function X() { 482 | return null; 483 | } 484 | expect(decorate(X).displayName).toBe('X'); 485 | }); 486 | 487 | it('preserves displayName for functional components with custom displayName', function() { 488 | function X() { 489 | return null; 490 | } 491 | X.displayName = 'Y'; 492 | expect(decorate(X).displayName).toBe('Y'); 493 | }); 494 | 495 | it('preserves propTypes for class components', function() { 496 | class X extends React.Component { 497 | static propTypes = {x: PropTypes.string}; 498 | render() { 499 | return null; 500 | } 501 | } 502 | expect(decorate(X).propTypes.x).toBe(PropTypes.string); 503 | }); 504 | 505 | it('preserves propTypes for functional components', function() { 506 | function X() { 507 | return null; 508 | } 509 | X.propTypes = {x: PropTypes.string}; 510 | expect(decorate(X).propTypes.x).toBe(PropTypes.string); 511 | }); 512 | 513 | it('preserves contextTypes for class components', function() { 514 | class X extends React.Component { 515 | static contextTypes = {x: PropTypes.string}; 516 | render() { 517 | return null; 518 | } 519 | } 520 | expect(decorate(X).contextTypes.x).toBe(PropTypes.string); 521 | }); 522 | 523 | it('preserves contextTypes for functional components', function() { 524 | function X() { 525 | return null; 526 | } 527 | X.contextTypes = {x: PropTypes.string}; 528 | expect(decorate(X).contextTypes.x).toBe(PropTypes.string); 529 | }); 530 | 531 | it('preserves defaultProps for class components', function() { 532 | class X extends React.Component { 533 | static defaultProps = {x: 42}; 534 | render() { 535 | return null; 536 | } 537 | } 538 | expect(decorate(X).defaultProps.x).toBe(42); 539 | }); 540 | 541 | it('preserves defaultProps for functional components', function() { 542 | function X() { 543 | return null; 544 | } 545 | X.defaultProps = {x: 42}; 546 | expect(decorate(X).defaultProps.x).toBe(42); 547 | }); 548 | } 549 | -------------------------------------------------------------------------------- /src/devtool.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @copyright 2016-present, Andrey Popp <8mayday@gmail.com> 3 | */ 4 | 5 | import {ReactUpdateReactor} from './index'; 6 | 7 | /** 8 | * Collect all React component instances which reference the derivable value 9 | * provided as argument. 10 | */ 11 | export function collectReactComponentList(derivable) { 12 | return _collectReactComponentList(derivable, []); 13 | } 14 | 15 | function _collectReactComponentList(derivable, componentList) { 16 | if (derivable._activeChildren) { 17 | for (let i = 0; i < derivable._activeChildren.length; i++) { 18 | let child = derivable._activeChildren[i]; 19 | if ( 20 | child.constructor === ReactUpdateReactor && 21 | componentList.indexOf(child.component) === -1 22 | ) { 23 | componentList.push(child.component); 24 | } else { 25 | _collectReactComponentList(child, componentList); 26 | } 27 | } 28 | } 29 | return componentList; 30 | } 31 | -------------------------------------------------------------------------------- /src/devtool.js.flow: -------------------------------------------------------------------------------- 1 | /** 2 | * @copyright 2016-present, Andrey Popp <8mayday@gmail.com> 3 | * @flow 4 | */ 5 | 6 | import type {Component} from 'react'; 7 | import type {Derivable} from 'derivable'; 8 | 9 | declare export function collectReactComponentList(derivable: Derivable<*>): Array>; 10 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @copyright 2016, Prometheus Research, LLC 3 | * @copyright 2016-present, Andrey Popp <8mayday@gmail.com> 4 | */ 5 | 6 | import invariant from 'invariant'; 7 | import React from 'react'; 8 | import {__Reactor as Reactor, __captureDereferences, struct, isDerivable} from 'derivable'; 9 | 10 | /** 11 | * Reactor which wraps React component and schedules its update when 12 | * dependencies change. 13 | */ 14 | export class ReactUpdateReactor extends Reactor { 15 | constructor(component) { 16 | super(); 17 | this.react = this.constructor.prototype.react; 18 | this.component = component; 19 | this.dependencies = null; 20 | } 21 | 22 | setDependenciesFrom(thunk) { 23 | let result; 24 | this.setDependencies(__captureDereferences(() => (result = thunk()))); 25 | return result; 26 | } 27 | 28 | setDependencies(dependencies) { 29 | this._parent = struct(dependencies); 30 | this.dependencies = dependencies; 31 | } 32 | 33 | react() { 34 | this.stop(); 35 | this.component.forceUpdate(); 36 | } 37 | 38 | shutdown() { 39 | this.stop(); 40 | this.dependencies = null; 41 | this.component = null; 42 | } 43 | } 44 | 45 | /** 46 | * Produce reactive component out of original React component. 47 | * 48 | * Reactive component re-render when one of the reactive values used in render() 49 | * funciton changes. 50 | * 51 | * @example 52 | * 53 | * let Hello = props => 54 | *
{props.message.get()}
55 | * 56 | * let ReactiveHello = reactive(Hello) 57 | * 58 | * @example 59 | * 60 | * class Hello extends React.Component { 61 | * render() { 62 | * return
{props.message.get()}
63 | * } 64 | * } 65 | * 66 | * let ReactiveHello = reactive(Hello) 67 | */ 68 | export function reactive(Component) { 69 | return decorateWith(Component, makeReactiveComponent); 70 | } 71 | 72 | /** 73 | * Produce pure reactive component out of original React component. 74 | * 75 | * Pure reactive component behave the same as reactive component but have 76 | * additional optimization through shouldComponentUpdate which prevents 77 | * re-rendering if props / state aren't changed. 78 | * 79 | * @example 80 | * 81 | * let Hello = props => 82 | *
{props.message.get()}
83 | * 84 | * let PureHello = pure(Hello) 85 | * 86 | * @example 87 | * 88 | * class Hello extends React.Component { 89 | * render() { 90 | * return
{props.message.get()}
91 | * } 92 | * } 93 | * 94 | * let PureHello = pure(Hello) 95 | */ 96 | export function pure(Component) { 97 | Component = decorateWith(Component, makeReactiveComponent); 98 | return decorateWith(Component, makePureComponent); 99 | } 100 | 101 | function decorateWith(Component, decorator) { 102 | let DecoratedComponent; 103 | if (Component.prototype && Component.prototype.isReactComponent) { 104 | DecoratedComponent = decorator(Component); 105 | } else { 106 | DecoratedComponent = decorator(React.Component, Component); 107 | } 108 | transferComponentStaticProperties(Component, DecoratedComponent); 109 | return DecoratedComponent; 110 | } 111 | 112 | function makeReactiveComponent(Base, render = null) { 113 | return class extends Base { 114 | constructor(props, context) { 115 | super(props, context); 116 | this._reactor = new ReactUpdateReactor(this); 117 | } 118 | 119 | render() { 120 | let element = this._reactor.setDependenciesFrom( 121 | () => (render === null ? super.render() : render(this.props, this.context)), 122 | ); 123 | this._reactor.start(); 124 | return element; 125 | } 126 | 127 | componentWillUpdate(...args) { 128 | this._reactor.stop(); 129 | if (super.componentWillUpdate) { 130 | super.componentWillUpdate(...args); 131 | } 132 | } 133 | 134 | componentWillUnmount(...args) { 135 | this._reactor.shutdown(); 136 | this._reactor = null; 137 | if (super.componentWillUnmount) { 138 | super.componentWillUnmount(...args); 139 | } 140 | } 141 | }; 142 | } 143 | 144 | function makePureComponent(ReactiveBase, render = null) { 145 | invariant( 146 | ReactiveBase.prototype.shouldComponentUpdate === undefined, 147 | 'pure(Component): shouldComponentUpdate already defined', 148 | ); 149 | 150 | return class extends ReactiveBase { 151 | static withEquality(eq) { 152 | return class extends this { 153 | static eq = eq; 154 | }; 155 | } 156 | 157 | static eq = Object.is; 158 | 159 | render() { 160 | return render === null ? super.render() : render(this.props, this.context); 161 | } 162 | 163 | shouldComponentUpdate(nextProps, nextState) { 164 | // Here we compare nextProps and nextState against this.props and 165 | // this.state. 166 | // 167 | // Comparison is made by shallowEqual function which is modified to 168 | // compare reactive values (respecting custom equality rules if any). 169 | // 170 | // On each equivalent (but not equal!) reactive value we receive a 171 | // call back which allows us to populate new list of dependencies and then 172 | // in case we don't do a real re-render — update what we are listening 173 | // to. 174 | // 175 | // Why it is safe not to re-render on equivalent but not equal reactive 176 | // values? Because observation of both equivalent reactive values leads to 177 | // the same render result! 178 | let dependencies = this._reactor.dependencies.slice(0); 179 | let onDerivableReplace = (prev, next) => { 180 | let idx = dependencies.indexOf(prev); 181 | if (idx > -1) { 182 | dependencies.splice(idx, 1, next); 183 | } 184 | }; 185 | let shouldUpdate = 186 | !shallowEqual(this.props, nextProps, this.constructor.eq, onDerivableReplace) || 187 | !shallowEqual(this.state, nextState, this.constructor.eq, onDerivableReplace); 188 | // if we shouldn't update we just re-subscribe to the new set of 189 | // dependencies 190 | if (!shouldUpdate) { 191 | this._reactor.stop(); 192 | this._reactor.setDependencies(dependencies); 193 | this._reactor.start(); 194 | } 195 | return shouldUpdate; 196 | } 197 | }; 198 | } 199 | 200 | function transferComponentStaticProperties(From, To) { 201 | To.displayName = From.displayName || From.name; 202 | To.propTypes = From.propTypes; 203 | To.contextTypes = From.contextTypes; 204 | To.defaultProps = From.defaultProps; 205 | } 206 | 207 | let hasOwnProperty = Object.prototype.hasOwnProperty; 208 | 209 | export function shallowEqual(objPrev, objNext, eq = Object.is, onDerivableReplace) { 210 | if (eq(objPrev, objNext)) { 211 | return true; 212 | } 213 | 214 | if ( 215 | typeof objPrev !== 'object' || 216 | objPrev === null || 217 | typeof objNext !== 'object' || 218 | objNext === null 219 | ) { 220 | return false; 221 | } 222 | 223 | let keysPrev = Object.keys(objPrev); 224 | let keysNext = Object.keys(objNext); 225 | 226 | if (keysPrev.length !== keysNext.length) { 227 | return false; 228 | } 229 | 230 | for (let i = 0; i < keysPrev.length; i++) { 231 | let key = keysPrev[i]; 232 | if (!hasOwnProperty.call(objNext, key)) { 233 | return false; 234 | } 235 | let valPrev = objPrev[key]; 236 | let valNext = objNext[key]; 237 | if (isDerivable(valPrev) && isDerivable(valNext)) { 238 | if (valPrev._equals !== valNext._equals) { 239 | return false; 240 | } else if (valPrev._equals !== null) { 241 | if (!valPrev._equals(valPrev.get(), valNext.get())) { 242 | return false; 243 | } else { 244 | onDerivableReplace && onDerivableReplace(valPrev, valNext); 245 | } 246 | } else if (!eq(valPrev.get(), valNext.get())) { 247 | return false; 248 | } else { 249 | onDerivableReplace && onDerivableReplace(valPrev, valNext); 250 | } 251 | } else if (!eq(valPrev, valNext)) { 252 | return false; 253 | } 254 | } 255 | 256 | return true; 257 | } 258 | 259 | export default reactive; 260 | -------------------------------------------------------------------------------- /src/index.js.flow: -------------------------------------------------------------------------------- 1 | /** 2 | * @copyright 2016-present, Andrey Popp <8mayday@gmail.com> 3 | * @flow 4 | */ 5 | 6 | declare export function reactive(component: T): T; 7 | declare export function pure(component: T): T; 8 | -------------------------------------------------------------------------------- /test_flow/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | /node_modules/fbjs/.*.flow 3 | /node_modules/fbjs/.*.js 4 | /node_modules/.store/fbjs@.*/.*.flow 5 | /node_modules/.store/fbjs@.*/.*.js 6 | 7 | [include] 8 | 9 | [libs] 10 | 11 | [options] 12 | suppress_comment=\\(.\\|\\n\\)*\\$ExpectError 13 | -------------------------------------------------------------------------------- /test_flow/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @flow 3 | */ 4 | 5 | import type {Derivable} from 'derivable'; 6 | 7 | import {atom} from 'derivable'; 8 | import * as React from 'react'; 9 | import * as ReactDerivable from 'react-derivable'; 10 | 11 | class X extends React.Component { 12 | 13 | props: { 14 | message: Derivable 15 | }; 16 | 17 | render() { 18 | return
{this.props.message.get()}
; 19 | } 20 | } 21 | 22 | // $ExpectError 23 | ; 24 | 25 | // $ExpectError 26 | ; 27 | 28 | // $ExpectError 29 | ; 30 | 31 | let Y = ReactDerivable.reactive(X); 32 | 33 | // $ExpectError 34 | ; 35 | 36 | // $ExpectError 37 | ; 38 | 39 | // $ExpectError 40 | ; 41 | 42 | let Z = ReactDerivable.pure(X); 43 | 44 | // $ExpectError 45 | ; 46 | 47 | // $ExpectError 48 | ; 49 | 50 | // $ExpectError 51 | ; 52 | 53 | function Counter({num}: {num: Derivable}) { 54 | return
{num}
; 55 | } 56 | 57 | let RCounter = ReactDerivable.reactive(Counter); 58 | 59 | ; 60 | 61 | // $ExpectError 62 | 63 | 64 | let PCounter = ReactDerivable.pure(Counter); 65 | 66 | ; 67 | 68 | // $ExpectError 69 | 70 | 71 | // $ExpectError 72 | ReactDerivable.pure(42); 73 | 74 | // $ExpectError 75 | ReactDerivable.reactive('oops'); 76 | -------------------------------------------------------------------------------- /test_flow/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-derivable-test-flow", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "derivable": "1.0.0-beta11", 6 | "flow-bin": "^0.37.0", 7 | "react": "^15.2.1", 8 | "react-derivable": "file:../" 9 | } 10 | } 11 | --------------------------------------------------------------------------------