├── .babelrc ├── .gitignore ├── .npmrc ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── examples ├── .DS_Store ├── 1-hello-world │ └── index.html ├── 2-basic │ ├── .babelrc │ ├── .gitignore │ ├── index.html │ ├── package.json │ └── src │ │ └── index.js ├── 3-redux-server-side-rendering │ ├── .babelrc │ ├── .gitignore │ ├── package.json │ └── server.js ├── 4-server-side-rendering │ ├── .babelrc │ ├── .gitignore │ ├── package.json │ └── server.js ├── 5-autocomplete │ ├── index.html │ └── script.js └── 6-router │ ├── .babelrc │ ├── .gitignore │ ├── index.html │ ├── package.json │ └── src │ ├── App.js │ ├── Colors.js │ ├── Echo.js │ ├── Greeting.js │ ├── Message.js │ ├── configureStore.js │ ├── delay.js │ ├── index.js │ └── tasks.js ├── index.js ├── nodemon.json ├── package.json ├── redux.js ├── src ├── actions.ts ├── adapters │ └── redux │ │ ├── ReduxTransactContext.ts │ │ └── middleware.ts ├── components │ └── TransactContext.tsx ├── core.ts ├── decorators │ ├── route.tsx │ └── transact.tsx ├── effects.ts ├── interfaces.ts ├── internals │ ├── Call.ts │ ├── Task.ts │ ├── TaskQueue.ts │ ├── helpers.ts │ ├── resolve.ts │ └── taskCreator.ts └── redux.ts ├── test ├── integration │ ├── redux │ │ ├── reduxMiddleware-test.js │ │ ├── transact-test.js │ │ └── transact.route-test.js │ ├── resolve-test.js │ ├── route-test.js │ └── transact-test.js ├── setup.js └── unit │ ├── effects-test.js │ └── internals │ ├── Call-test.js │ ├── Task-test.js │ ├── TaskQueue-test.js │ ├── helpers-test.js │ └── taskCreator-test.js ├── tsconfig.json ├── typings.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | lib/ 4 | typings/ 5 | umd/ 6 | npm-debug.log -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | 2 | save-exact = true 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5" 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at jack.hsu@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Pressly Inc. 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Transact 2 | 3 | Simple, effortless way to fetch async data or perform computations, then make 4 | the results available to React components. 5 | 6 | Works with Redux and React-Router out of the box. Also supports server-side 7 | rendering! 8 | 9 | This project draws a lot of inspiration from the [`data.task`](https://github.com/folktale/data.task) 10 | library of [Folktale](http://folktalejs.org/). I highly recommend you check Folktale out! 11 | 12 | For a quick in-browser example, check out the [Counter app](http://embed.plnkr.co/OLH7WaNguDDake7yB6aQ/). 13 | 14 | **Note:** This is an early release of this library, so expect API to change 15 | in the future. I will try to keep public API as stable as possible, and strive 16 | to make additive changes as much as possible. 17 | 18 | ## Goal 19 | 20 | The main goal of this project is to make data fetching as simple, effortless, and robust 21 | as possible. There should only be one obvious way to fetch and populate data in 22 | an application. Fetching data must be guaranteed to succeed or fail, as well 23 | maintain ordering (e.g. total ordering of fetch requests). Mechanisms for failure 24 | recovery should be dead simple -- this is currently a work-in-progress, and only exposed 25 | as a low-level API (`Task#orElse`). 26 | 27 | React Transact aims to make data fetching declarative and robust. This is 28 | achieved via the `@transact` decorator and the `Task` type. All data 29 | fetching should go through `transact.run` function, 30 | which will ensure correct ordering, and predictable resolution. (`transact` 31 | is provided as a prop to `@transact`ed components) 32 | 33 | The `Task` structure represents a disjunction for actions that depend on 34 | time. It can either contain a failure action of type `A`, or a successful action of type `B`. 35 | Projections on `Task` is biased towards the right, successful value (of type `B`). 36 | 37 | ```js 38 | import {Task} from 'react-transact' 39 | new Task.resolve({ type: 'RESOLVED', payload: 1 }) 40 | .map(({ type, payload }) => ({ type, payload: payload + 1 }) 41 | // This fork will succeed with `{ type: 'RESOLVED', payload: 2 }` 42 | // because the `map` will map over the successful payload value. 43 | .fork( 44 | (failedAction) => console.log('Something went wrong', failedAction), 45 | (successAction) => console.log('Yay', successAction) 46 | ) 47 | ``` 48 | 49 | ## Usage 50 | 51 | The following examples show how React Transact can be used in applications. 52 | 53 | ### Basic 54 | 55 | The following is an async Hello World example. 56 | 57 | ```js 58 | import React, {Component} from 'react' 59 | import {RunContext, transact, taskCreator} from 'react-transact' 60 | 61 | // Creates a function that returns a Task when invoked. 62 | const sendMessage = taskCreator( 63 | 'ERROR', // action type for failure 64 | 'MESSAGE', // action type for success 65 | async x => x // async function for payload resolution 66 | ) 67 | 68 | // Wrap the HelloWorld component with @transact decorator. 69 | // Note: You can also use it as a plain function `transact(...)(HelloWorld)`. 70 | @transact( 71 | (state, props, commit) => [ 72 | sendMessage('Hello World!') 73 | ] 74 | ) 75 | class HelloWorld extends Component { 76 | render() { 77 | // `transact` prop is passed down from `@transact` 78 | // It makse the store available whether you use Redux or not. 79 | const { message } = this.props.transact.store.getState() 80 | return

{message}

81 | } 82 | ) 83 | 84 | // Reducer for our RunContext's local component state. (Redux not used here) 85 | const stateReducer = (state = {}, action) => { 86 | if (action.type === 'MESSAGE') { 87 | return { message: action.payload } 88 | } else { 89 | return state 90 | } 91 | } 92 | 93 | ReactDOM.render( 94 | 95 | 96 | , 97 | document.getElementById('app') 98 | ) 99 | ``` 100 | 101 | Please see the [examples](./examples) folder more use-cases, including 102 | [server-side rendering](./examples/6-server-side-rendering/server.js). 103 | 104 | ### Redux and React Router 105 | 106 | This is the main use-case of React Transact. 107 | 108 | Install the Redux middleware and render `RouterRunContext`: 109 | 110 | ```js 111 | import React from 'react' 112 | import ReactDOM from 'react-dom' 113 | import {install, RouterRunContext, transact, taskCreator} from 'react-transact' 114 | import {Provider, connect} from 'react-redux' 115 | import {Router, Route} from 'react-router' 116 | import {createStore, applyMiddleware} from 'redux' 117 | 118 | const reducer = (state = {}, action) => { 119 | if (action.type === 'ECHO') 120 | return { message: action.payload } 121 | else 122 | return state 123 | } 124 | 125 | const transactMiddleware = install() 126 | 127 | // Note: `install()` returns a middleware with a `done` prop that is a Promise 128 | // that resolves when all tasks are resolved on matched routes. 129 | // This is needed if you are doing server-side rendering. 130 | transactMiddleware.done.then(() => console.log('data loaded!')) 131 | 132 | const store = createStore(reducer, undefined, applyMiddleware(transactMiddleware)) 133 | 134 | const echo = taskCreator('FAILED', 'ECHO', x => x) 135 | 136 | @transact.route( 137 | { 138 | params: ['what'] // This will map to the path param as defined by the route. 139 | }, 140 | ({ what }) => echo(what) // This will only execute when `what` changes. 141 | ) 142 | @connect(state => state.message) 143 | class EchoHandler extends React.Component { 144 | render() { 145 | return

{ this.props.message }

146 | } 147 | } 148 | 149 | ReactDOM.render( 150 | 151 | }> 152 | 153 | 154 | , 155 | document.getElementById('app') 156 | ) 157 | ``` 158 | 159 | ## Development 160 | 161 | Fork and clone this repo. 162 | 163 | Install dependencies: 164 | 165 | ``` 166 | npm install 167 | ``` 168 | 169 | Run tests: 170 | 171 | ``` 172 | npm test 173 | ``` 174 | 175 | Or, with faucet (recommended): 176 | 177 | ``` 178 | npm test | faucet 179 | ``` 180 | 181 | Run tests with watch support (working on improving this): 182 | 183 | ``` 184 | npm run test:watch 185 | ``` 186 | 187 | Build to ES5 (output is `umd/ReactTransact.js`): 188 | 189 | ``` 190 | npm run build 191 | ``` 192 | 193 | ## Contributing 194 | 195 | Contributions are welcome! If you find any bugs, please create an issue 196 | with as much detail as possible to help debug. 197 | 198 | If you have any ideas for features, please open up an issue or pull-request. 199 | 200 | All pull-requests will be carefully reviewed, and merged once deemed satisfactory. 201 | 202 | Documentation, fixing typos, etc. are definitely welcome as well! 203 | 204 | ## Alternative Projects 205 | 206 | Here are other projects that solve the async data problem. 207 | 208 | - [ReduxAsyncConnect](https://github.com/Rezonans/redux-async-connect) - Allows you to request async data, store them in Redux state and connect them to your react component. 209 | - [AsyncProps](https://github.com/ryanflorence/async-props) - Co-located data loading for React Router. 210 | -------------------------------------------------------------------------------- /examples/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pressly/react-transact/830eed747297b4ebc5e1bd6350b9083049f3c461/examples/.DS_Store -------------------------------------------------------------------------------- /examples/1-hello-world/index.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | React Transact Hello World 8 | 9 | 10 |
11 | 12 | 13 | 14 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /examples/2-basic/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-1", "react"], 3 | "plugins": ["transform-decorators-legacy"] 4 | } 5 | -------------------------------------------------------------------------------- /examples/2-basic/.gitignore: -------------------------------------------------------------------------------- 1 | bundle.js -------------------------------------------------------------------------------- /examples/2-basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Basic React Transact Example 5 | 6 | 50 | 51 |
52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /examples/2-basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basic-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "./node_modules/.bin/browserify src/index.js -t babelify --outfile bundle.js" 8 | }, 9 | "author": "Jack Hsu (http://jaysoo.ca/)", 10 | "license": "ISC", 11 | "dependencies": { 12 | "babel": "^6.5.2", 13 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 14 | "babel-preset-es2015": "^6.6.0", 15 | "babel-preset-react": "^6.5.0", 16 | "babel-preset-stage-1": "^6.5.0", 17 | "babel-preset-stage-2": "^6.5.0", 18 | "babelify": "^7.3.0", 19 | "browserify": "^13.0.0", 20 | "react": "^15.0.1", 21 | "react-dom": "^15.0.1", 22 | "react-transact": "../.." 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/2-basic/src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { transact, call, TransactContext } from 'react-transact' 4 | 5 | /* 6 | * Action types that can be handled in this application. 7 | */ 8 | const MESSAGE_CHANGED = 'MESSAGE_CHANGED' 9 | const MESSAGE_ERROR = 'MESSAGE_ERROR' 10 | 11 | // Helper to delay value dispatches. Used in the @transact below. 12 | export const delay = (ms) => (x) => new Promise((res) => { 13 | setTimeout(() => res(x), ms) 14 | }) 15 | 16 | // When this component is mounted, dispatch the actions from the supplied tasks. 17 | @transact(({ onMessage }) => 18 | call(onMessage, 'You can write your own message here.'), 19 | { onMount: true }) 20 | class Messenger extends Component { 21 | render() { 22 | const { onMessage, onError } = this.props 23 | return ( 24 |
25 |
{ 26 | evt.preventDefault() 27 | const { run } = this.props.transact 28 | const { value } = this.refs.input 29 | 30 | if (value) { 31 | run(onMessage(value)) 32 | } else { 33 | run(onError('Hmm, you left the message empty. Was that an error? :(')) 34 | } 35 | 36 | this.refs.input.value = '' 37 | }}> 38 | 39 | 40 |
41 |

42 | (Psst, try typing an empty string "") 43 |

44 |
45 | ) 46 | } 47 | } 48 | 49 | 50 | class Container extends Component { 51 | constructor(props) { 52 | super(props) 53 | this.state = { message: '' } 54 | } 55 | render() { 56 | return ( 57 |
58 |
59 |

{this.state.message}

60 | { 62 | this.setState({ message, hasError: false }) 63 | }} 64 | onError={(message) => { 65 | this.setState({ message, hasError: true }) 66 | }}/> 67 |
68 |
69 | ) 70 | } 71 | } 72 | 73 | ReactDOM.render( 74 | 75 | 76 | , 77 | document.getElementById('app') 78 | ) 79 | -------------------------------------------------------------------------------- /examples/3-redux-server-side-rendering/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-1", "react"], 3 | "plugins": ["transform-decorators-legacy"] 4 | } 5 | -------------------------------------------------------------------------------- /examples/3-redux-server-side-rendering/.gitignore: -------------------------------------------------------------------------------- 1 | server.dist.js -------------------------------------------------------------------------------- /examples/3-redux-server-side-rendering/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "6-server-side-rendering", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "build": "babel server.js --out-file server.dist.js", 8 | "start": "npm run build; node server.dist.js" 9 | }, 10 | "author": "Jack Hsu (http://jaysoo.ca/)", 11 | "license": "ISC", 12 | "dependencies": { 13 | "babel": "^6.5.2", 14 | "babel-cli": "^6.8.0", 15 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 16 | "babel-preset-es2015": "^6.6.0", 17 | "babel-preset-react": "^6.5.0", 18 | "babel-preset-stage-1": "^6.5.0", 19 | "express": "^4.13.4", 20 | "react": "^15.0.2", 21 | "react-dom": "^15.0.0", 22 | "react-redux": "^4.4.5", 23 | "react-router": "^2.4.0", 24 | "react-transact": "../..", 25 | "redux": "^3.5.2" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/3-redux-server-side-rendering/server.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import React from 'react' 3 | import ReactDOM from 'react-dom/server' 4 | import {Route, match, RouterContext } from 'react-router' 5 | import {Provider, connect} from 'react-redux' 6 | import {createStore, applyMiddleware} from 'redux' 7 | import {transact} from '../../index' 8 | import {ReduxTransactContext, reduxTransact, taskCreator} from '../../redux' 9 | 10 | const server = express() 11 | 12 | const reducer = (state = { message: '' }, action) => { 13 | if (action.type === 'APPEND') return { ...state, message: `${state.message}${action.payload}` } 14 | if (action.type === 'COLOR') return { ...state, color: action.payload } 15 | else return state 16 | } 17 | 18 | /* 19 | * Task creators that will be scheduled and run by the middleware. 20 | */ 21 | const appendText = taskCreator('ERROR', 'APPEND', (s) => s) 22 | const changeColor = taskCreator('ERROR', 'COLOR', (s) => s) 23 | const appendTextAsync = taskCreator('ERROR', 'APPEND', (s) => new Promise(res => { 24 | // Adding artificial delay here to simulate async requests. 25 | setTimeout(() => res(s), 20) 26 | })) 27 | 28 | /* 29 | * Component that creates tasks to be run, as well as connect to redux state. 30 | */ 31 | @transact( 32 | () => { 33 | return [ 34 | appendText('Hello'), 35 | appendText(' World'), 36 | changeColor('purple'), 37 | appendTextAsync('!') 38 | ] 39 | } 40 | ) 41 | @connect(state => ({ 42 | message: state.message, 43 | color: state.color 44 | })) 45 | class App extends React.Component { 46 | render() { 47 | const { color, message } = this.props 48 | return ( 49 |
50 |

{message}

51 |
52 | ) 53 | } 54 | } 55 | 56 | const routes = 57 | 58 | server.listen(8080, () => { 59 | server.all('/', (req, res) => { 60 | match({ routes, location: req.url }, (err, redirect, routerProps) => { 61 | // Install function returns a middleware, and a `done` promise. 62 | const reduxTransactMiddleware = reduxTransact(routerProps) 63 | 64 | const store = createStore(reducer, undefined, applyMiddleware(reduxTransactMiddleware)) 65 | 66 | const documentElement = ( 67 | 68 | 69 | 70 | 71 | 72 | ) 73 | 74 | // Wait for all route tasks to resolve. 75 | reduxTransactMiddleware.done.then(() => { 76 | // Now call render to get the final HTML. 77 | const markup = ReactDOM.renderToStaticMarkup(documentElement) 78 | res.send(` 79 | 80 | ${markup} 81 |
State = ${JSON.stringify(store.getState(), null, 2)}
82 | `) 83 | }) 84 | }) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /examples/4-server-side-rendering/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-1", "react"], 3 | "plugins": ["transform-decorators-legacy"] 4 | } 5 | -------------------------------------------------------------------------------- /examples/4-server-side-rendering/.gitignore: -------------------------------------------------------------------------------- 1 | server.dist.js -------------------------------------------------------------------------------- /examples/4-server-side-rendering/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "4-server-side-rendering", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "build": "babel server.js --out-file server.dist.js", 8 | "start": "npm run build; node server.dist.js" 9 | }, 10 | "author": "Jack Hsu (http://jaysoo.ca/)", 11 | "license": "ISC", 12 | "dependencies": { 13 | "babel": "^6.5.2", 14 | "babel-cli": "^6.10.1", 15 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 16 | "babel-preset-es2015": "^6.6.0", 17 | "babel-preset-react": "^6.5.0", 18 | "babel-preset-stage-1": "^6.5.0", 19 | "express": "^4.13.4", 20 | "react": "^15.0.2", 21 | "react-dom": "^15.0.0", 22 | "react-router": "^2.4.0", 23 | "react-transact": "../..", 24 | "react-tunnel": "^0.1.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/4-server-side-rendering/server.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import React, {Component} from 'react' 3 | import ReactDOM from 'react-dom/server' 4 | import {Route, match, Redirect, RouterContext } from 'react-router' 5 | import {transact, TransactContext, call, resolve} from '../../index' 6 | import {Provider, inject} from 'react-tunnel' 7 | 8 | const server = express() 9 | 10 | /* 11 | * Component that creates tasks to be run, as well as connect to redux state. 12 | */ 13 | @transact.route({ 14 | params: ['color'], 15 | query: ['message'], 16 | defaults: { 17 | color: 'purple', 18 | message: 'Hello World!' 19 | } 20 | }, 21 | ({ mutateState, color, message }) => { 22 | return [ 23 | call(mutateState, { message }), 24 | call(mutateState, { color }) 25 | ] 26 | }) 27 | class RouteHandler extends Component { 28 | render() { 29 | const { color, message } = this.props 30 | return ( 31 |
32 |

{message}

33 |
34 | ) 35 | } 36 | } 37 | 38 | const routes = ( 39 | 40 | ) 41 | 42 | server.listen(8080, () => { 43 | server.get('*', (req, res) => { 44 | if (req.accepts('html')) { 45 | match({ routes, location: req.url }, (err, redirect, routeProps) => { 46 | const appState = { 47 | color: '', 48 | message: '' 49 | } 50 | 51 | // Artificially make mutation async and delayed. 52 | const mutateState = (newState) => new Promise((res) => { 53 | setTimeout(() => { 54 | Object.assign(appState, newState) 55 | res() 56 | }, Math.random() * 200) 57 | }) 58 | 59 | const extraProps = { appState, mutateState } 60 | 61 | // Wait for all route tasks to resolve. 62 | resolve(routeProps, extraProps).then(() => { 63 | // Now call render to get the final HTML. 64 | const markup = ReactDOM.renderToStaticMarkup( 65 | 66 | 67 | 68 | ) 69 | 70 | res.send(` 71 | 72 | ${markup} 73 |
State = ${JSON.stringify(appState, null, 2)}
74 | `) 75 | }) 76 | }) 77 | } else { 78 | res.status(404).end() 79 | } 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /examples/5-autocomplete/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | React Transact Autocomplete Example 5 | 44 | 45 | 46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /examples/5-autocomplete/script.js: -------------------------------------------------------------------------------- 1 | const Task = ReactTransact.Task 2 | const transact = ReactTransact.transact 3 | const taskCreator = ReactTransactRedux.taskCreator 4 | const reduxTransact = ReactTransactRedux.reduxTransact 5 | const ReduxTransactContext = ReactTransactRedux.ReduxTransactContext 6 | const SCHEDULED_TASKS_PENDING = ReactTransactRedux.SCHEDULED_TASKS_PENDING 7 | const SCHEDULED_TASKS_COMPLETED = ReactTransactRedux.SCHEDULED_TASKS_COMPLETED 8 | const h = React.createElement 9 | 10 | // Tasks 11 | const clear = Task.resolve({ type: 'CLEAR' }) 12 | const searchWikipedia = taskCreator( 13 | 'ERROR', 14 | 'RESULTS', 15 | 'PENDING', 16 | (term) => 17 | $.ajax({ 18 | url: 'http://en.wikipedia.org/w/api.php', 19 | dataType: 'jsonp', 20 | data: { 21 | action: 'opensearch', 22 | format: 'json', 23 | search: term 24 | } 25 | }).promise().then(data => ({ results: data[1], term: term })) 26 | ) 27 | 28 | const handleTermChange = (dispatch, value) => { 29 | if (value) { 30 | dispatch(searchWikipedia(value)) 31 | } else { 32 | dispatch(clear) 33 | } 34 | } 35 | 36 | // Create a HOC with transact. 37 | const Container = transact()( 38 | ReactRedux.connect(state => ({ pending: state.pending, results: state.results }))( 39 | // The state and transact props are coming from ReduxTransactContext. 40 | ({ dispatch, results, pending }) => { 41 | return h('div', {}, 42 | h('div', { className: 'container', children: [ 43 | h('div', { children: [ 44 | h('input', { 45 | autoFocus: true, 46 | placeholder: 'Start typing...', 47 | type: 'text', 48 | onChange: (evt) => handleTermChange(dispatch, evt.target.value) 49 | }) 50 | ]}), 51 | h('p', { className: 'loading' }, pending ? 'Loading...' : ''), 52 | results.length > 0 53 | ? ( 54 | h('div', { children: [ 55 | h('h3', {}, 'Results'), 56 | h('ul', { 57 | children: results.map(r => h('li', { key: r }, [r])) 58 | }) 59 | ]}) 60 | ) 61 | : null 62 | ]}) 63 | ) 64 | } 65 | ) 66 | ) 67 | 68 | const reducer = (state = { results: [] }, action) => { 69 | switch (action.type) { 70 | case 'RESULTS': 71 | return { results: action.payload.results, pending: false } 72 | case 'CLEAR': 73 | return { results: [], pending: false } 74 | case 'PENDING': 75 | return Object.assign({}, state, { pending: true }) 76 | default: 77 | return state 78 | } 79 | } 80 | 81 | const store = Redux.createStore(reducer, undefined, Redux.applyMiddleware(reduxTransact())) 82 | 83 | // Render the application with state reducer and starting props. 84 | ReactDOM.render( 85 | h(ReactRedux.Provider, { store: store, children: 86 | h(ReduxTransactContext, {}, 87 | h(Container) 88 | ) 89 | }), 90 | document.getElementById('app') 91 | ) 92 | -------------------------------------------------------------------------------- /examples/6-router/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-1", "react"], 3 | "plugins": ["transform-decorators-legacy"] 4 | } 5 | -------------------------------------------------------------------------------- /examples/6-router/.gitignore: -------------------------------------------------------------------------------- 1 | bundle.js -------------------------------------------------------------------------------- /examples/6-router/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | React Transact Example 5 | 6 | 45 | 46 |
47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /examples/6-router/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "router-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "./node_modules/.bin/browserify src/index.js -t babelify --outfile bundle.js" 8 | }, 9 | "author": "Jack Hsu (http://jaysoo.ca/)", 10 | "license": "ISC", 11 | "dependencies": { 12 | "babel": "^6.5.2", 13 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 14 | "babel-preset-es2015": "^6.6.0", 15 | "babel-preset-react": "^6.5.0", 16 | "babel-preset-stage-1": "^6.5.0", 17 | "babel-preset-stage-2": "^6.5.0", 18 | "babelify": "^7.3.0", 19 | "browserify": "^13.0.0", 20 | "ramda": "^0.21.0", 21 | "react": "^15.0.1", 22 | "react-dom": "^15.0.1", 23 | "react-redux": "^4.4.5", 24 | "react-router": "^2.4.0", 25 | "react-transact": "../..", 26 | "redux": "^3.5.2" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/6-router/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Link } from 'react-router' 3 | import { transact } from 'react-transact' 4 | import { taskCreator } from 'react-transact/redux' 5 | 6 | const itemStyle = { display: 'inline-block', margin: '0 3px', fontSize: '18px' } 7 | 8 | export default class App extends Component { 9 | render() { 10 | return ( 11 |
12 |
    13 |
  • 14 | Colors 15 |
  • 16 |
  • 17 | Echo 18 |
  • 19 |
  • 20 | Greeting 21 |
  • 22 |
  • 23 | Message 24 |
  • 25 |
26 |
27 | { this.props.children } 28 |
29 |
30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/6-router/src/Colors.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import { transact } from 'react-transact' 4 | import { taskCreator } from 'react-transact/redux' 5 | import { changeColor } from './tasks' 6 | 7 | const delay = (ms) => (x) => new Promise((res) => { 8 | setTimeout(() => res(x), ms) 9 | }) 10 | 11 | @transact.route({ 12 | params: ['startingColor'] 13 | }, ({ startingColor }) => { 14 | console.log('?????') 15 | return [ 16 | changeColor(startingColor).map(delay(1000)), 17 | changeColor('yellow').map(delay(2000)), 18 | changeColor('red').map(delay(3000)), 19 | changeColor('blue').map(delay(4000)), 20 | changeColor('green').map(delay(5000)) 21 | ] 22 | }) 23 | @connect(state => ({ 24 | color: state.color 25 | })) 26 | export default class Colors extends Component { 27 | render() { 28 | const { color } = this.props 29 | return ( 30 |
31 |

{ color }

32 |
33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/6-router/src/Echo.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import { transact } from 'react-transact' 4 | import { taskCreator } from 'react-transact/redux' 5 | import { always as k, times } from 'ramda' 6 | 7 | const say = taskCreator('ERROR', 'ECHO', (what, n) => times(k(what), n).join(' ')) 8 | 9 | @transact.route( 10 | { 11 | params: ['what'], 12 | query: ['times'], 13 | defaults: { times: '3' } 14 | }, 15 | (props) => say(props.what, Number(props.times)) 16 | ) 17 | @connect(state => ({ 18 | what: state.what 19 | })) 20 | export default class Echo extends Component { 21 | render() { 22 | const { what } = this.props 23 | return ( 24 |
25 | { what } 26 |
27 | ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/6-router/src/Greeting.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { transact } from 'react-transact' 3 | import { taskCreator } from 'react-transact/redux' 4 | import { connect } from 'react-redux' 5 | import delay from './delay' 6 | 7 | @transact.route({}, () => [ 8 | taskCreator('ERROR', 'NAME_CHANGED', () => delay('Alice', 1000))(), 9 | taskCreator('ERROR', 'NAME_CHANGED', () => delay('Bob', 2000))(), 10 | taskCreator('ERROR', 'NAME_CHANGED', () => delay(null, 3000, true))(), 11 | taskCreator('ERROR', 'NAME_CHANGED', () => delay('World', 4000))() 12 | ]) 13 | @connect( 14 | state => ({ 15 | error: state.error, 16 | name: state.name 17 | }) 18 | ) 19 | export default class Greeting extends Component { 20 | render() { 21 | const { name, error } = this.props 22 | return ( 23 |
24 | Hello {name}! 25 |
26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/6-router/src/Message.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { taskCreator } from 'react-transact/redux' 3 | import { connect } from 'react-redux' 4 | 5 | const changeMessage = taskCreator( 6 | 'MESSAGE_ERROR', 7 | 'MESSAGE_CHANGED', 8 | (message) => message 9 | ) 10 | 11 | @connect(state => ({ message: state.message })) 12 | export default class Message extends Component { 13 | render() { 14 | const { message, dispatch } = this.props 15 | return ( 16 |
17 |

You said: "{ message }"

18 | 19 |

20 | Say something else: 21 |

22 |
{ 23 | evt.preventDefault() 24 | dispatch(changeMessage(this.refs.input.value)) 25 | }}> 26 | 27 | 28 |
29 |
30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/6-router/src/configureStore.js: -------------------------------------------------------------------------------- 1 | import {applyMiddleware, createStore} from 'redux' 2 | import {reduxTransact} from 'react-transact/redux' 3 | 4 | const m = reduxTransact() 5 | 6 | export default (initialState = {}) => createStore((state = {}, action) => { 7 | console.log('Action', action) 8 | switch (action.type) { 9 | case 'COLOR_CHANGED': 10 | return { ...state, color: action.payload, error: false } 11 | case 'NAME_CHANGED': 12 | return { ...state, name: action.payload, error: false } 13 | case 'ECHO': 14 | return { ...state, what: action.payload, error: false } 15 | case 'MESSAGE_CHANGED': 16 | return { ...state, message: action.payload, error: false } 17 | case 'ERROR': 18 | return { ...state, error: true } 19 | default: 20 | return state 21 | } 22 | }, initialState, applyMiddleware(m)) 23 | 24 | m.done.then(() => console.log('initialized!')) 25 | -------------------------------------------------------------------------------- /examples/6-router/src/delay.js: -------------------------------------------------------------------------------- 1 | export default (value, ms, reject = false) => ( 2 | new Promise((res, rej) => { 3 | setTimeout(() => reject ? rej(value) : res(value), ms) 4 | }) 5 | ) 6 | -------------------------------------------------------------------------------- /examples/6-router/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { Provider } from 'react-redux' 4 | import { Redirect, Router, Route, IndexRoute, hashHistory } from 'react-router' 5 | import { ReduxTransactContext } from 'react-transact/redux' 6 | import configureStore from './configureStore' 7 | import App from './App' 8 | import Colors from './Colors' 9 | import Echo from './Echo' 10 | import Greeting from './Greeting' 11 | import Message from './Message' 12 | 13 | const store = configureStore({ name: 'World', error: false, color: 'cyan' }) 14 | 15 | ReactDOM.render( 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
, 31 | document.getElementById('app') 32 | ) 33 | -------------------------------------------------------------------------------- /examples/6-router/src/tasks.js: -------------------------------------------------------------------------------- 1 | import { taskCreator } from 'react-transact/redux' 2 | 3 | export const changeColor = taskCreator('ERROR', 'COLOR_CHANGED', (color) => color) 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./umd/core').default 2 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | ".git", 4 | "node_modules" 5 | ], 6 | "watch": [ 7 | "test/", 8 | "lib/" 9 | ], 10 | "env": { 11 | "NODE_ENV": "development" 12 | }, 13 | "ext": "js json" 14 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-transact", 3 | "version": "1.0.0", 4 | "description": "Simple, effortless way to fetch data and make them available to React components.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/pressly/react-transact" 8 | }, 9 | "main": "index.js", 10 | "files": [ 11 | "umd", 12 | "lib", 13 | "index.js", 14 | "redux.js", 15 | "effects.js", 16 | "router.js", 17 | "LICENSE", 18 | "README.md" 19 | ], 20 | "scripts": { 21 | "clean": "rm -rf lib umd", 22 | "build": "npm run clean && npm run compile:ts && npm run compile:core && npm run compile:redux", 23 | "release": "npm test && npm run build && xyz --repo git@github.com:pressly/react-transact.git", 24 | "compile:ts": "tsc", 25 | "compile:core": "webpack -p lib/core.js umd/core.js --library ReactTransact", 26 | "compile:redux": "webpack -p lib/redux.js umd/redux.js --library ReactTransactRedux", 27 | "test": "npm run compile:ts && tape 'test/**/*.js'", 28 | "test:watch": "concurrently 'tsc -w' 'nodemon --delay 2000ms --exec tape \"test/**/*.js\"'", 29 | "readme:toc": "doctoc README.md" 30 | }, 31 | "author": { 32 | "name": "Jack Hsu", 33 | "email": "jack.hsu@gmail.com", 34 | "url": "https://github.com/jaysoo", 35 | "company": "https://pressly.com" 36 | }, 37 | "license": "ISC", 38 | "keywords": [ 39 | "react" 40 | ], 41 | "dependencies": {}, 42 | "peerDependencies": { 43 | "react": "^0.14.0 || ^15.0.0", 44 | "react-router": "^2.0.0" 45 | }, 46 | "devDependencies": { 47 | "babel-loader": "6.2.4", 48 | "babel-preset-es2015": "6.6.0", 49 | "concurrently": "2.0.0", 50 | "doctoc": "1.0.0", 51 | "enzyme": "2.2.0", 52 | "jsdom": "8.4.1", 53 | "nodemon": "1.9.1", 54 | "react": "15.0.1", 55 | "react-addons-test-utils": "15.0.1", 56 | "react-dom": "15.0.1", 57 | "react-redux": "4.4.5", 58 | "react-router": "2.4.0", 59 | "redux": "3.5.2", 60 | "sinon": "1.17.3", 61 | "tape": "4.5.1", 62 | "typescript": "1.8.9", 63 | "typings": "0.7.9", 64 | "webpack": "1.13.0", 65 | "xyz": "0.5.0", 66 | "yargs": "4.7.1" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /redux.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./umd/redux') 2 | -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | export const SCHEDULE_TASKS = '@@react-transact/SCHEDULE_TASKS' 2 | export const RUN_SCHEDULED_TASKS = '@@react-transact/RUN_SCHEDULED_TASKS' 3 | export const SCHEDULED_TASKS_COMPLETED = '@@react-transact/SCHEDULED_TASKS_COMPLETED' 4 | export const SCHEDULED_TASKS_PENDING = '@@react-transact/SCHEDULED_TASKS_PENDING' 5 | export const STANDALONE_INIT = '@@react-transact/STANDALONE_INIT' 6 | -------------------------------------------------------------------------------- /src/adapters/redux/ReduxTransactContext.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { IStore, IResolveOptions, ITask } from '../../interfaces' 3 | import {SCHEDULE_TASKS, RUN_SCHEDULED_TASKS, STANDALONE_INIT} from '../../actions' 4 | 5 | const defaultResolveOpts = { 6 | immediate: false 7 | } 8 | 9 | const { any, func, object, shape, bool } = React.PropTypes 10 | 11 | export default class ReduxTransactContext extends React.Component { 12 | static displayName = 'ReduxTransactContext' 13 | static contextTypes = { 14 | store: object 15 | } 16 | static childContextTypes = { 17 | transact: shape({ 18 | skipInitialRoute: any, 19 | store: any, 20 | resolve: func, 21 | run: func 22 | }) 23 | } 24 | static propTypes = { 25 | skipInitialRoute: bool 26 | } 27 | 28 | state: any 29 | context: any 30 | store: IStore 31 | 32 | constructor(props, context) { 33 | super(props, context) 34 | 35 | this.state = { initialized: false } 36 | 37 | // Using store from redux 38 | this.store = context.store || props.store 39 | 40 | setTimeout(() => this.runTasks(), 0) 41 | } 42 | 43 | getChildContext() { 44 | return { 45 | transact: { 46 | initialized: this.state.initialized, 47 | skipInitialRoute: this.props.skipInitialRoute, 48 | store: this.store, 49 | resolve: this.resolve.bind(this), 50 | run: this.run.bind(this) 51 | } 52 | } 53 | } 54 | 55 | componentDidMount() { 56 | this.setState({ initialized: true }) 57 | } 58 | 59 | componentWillReceiveProps() { 60 | setTimeout(() => this.runTasks(), 0) 61 | } 62 | 63 | resolve(tasks: Array> | ITask, opts: IResolveOptions = defaultResolveOpts): void { 64 | this.store.dispatch({ type: SCHEDULE_TASKS, payload: tasks }) 65 | if (opts.immediate) { 66 | // Bump to next tick to avoid synchronous component render issue. 67 | setTimeout(() => this.runTasks()) 68 | } 69 | } 70 | 71 | run(tasks: Array> | ITask, props: any): void { 72 | this.store.dispatch({ 73 | type: SCHEDULE_TASKS, 74 | payload: tasks 75 | }) 76 | this.runTasks() 77 | } 78 | 79 | runTasks(): void { 80 | this.store.dispatch({ type: RUN_SCHEDULED_TASKS }) 81 | } 82 | 83 | render() { 84 | const { children } = this.props 85 | const onlyChild = React.Children.only(children) 86 | return React.cloneElement(onlyChild, { store: this.store }) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/adapters/redux/middleware.ts: -------------------------------------------------------------------------------- 1 | import {IStore, IAction, IMapTasks, IRouterProps} from "../../interfaces" 2 | import {RUN_SCHEDULED_TASKS, SCHEDULE_TASKS, SCHEDULED_TASKS_PENDING, SCHEDULED_TASKS_COMPLETED} from '../../actions' 3 | import TaskQueue from '../../internals/TaskQueue' 4 | import {getTaskMappers} from '../../internals/helpers' 5 | import Task from '../../internals/Task' 6 | 7 | 8 | const makeMiddleware = (routerProps: IRouterProps) => { 9 | const queue = new TaskQueue() 10 | 11 | let _res: Function = (a :any) => {} 12 | const done = new Promise((res) => { 13 | _res = res 14 | }) 15 | let pendingCount = 0 16 | 17 | const middleware: any = (store: IStore) => { 18 | if (routerProps) { 19 | const mappers = getTaskMappers(routerProps.components) 20 | 21 | // After store is created, run initial tasks, if any. 22 | setTimeout(() => { 23 | mappers.forEach((mapper: IMapTasks) => { 24 | store.dispatch({ type: SCHEDULE_TASKS, payload: mapper(routerProps) }) 25 | }) 26 | store.dispatch({ type: RUN_SCHEDULED_TASKS }) 27 | }, 0) 28 | } 29 | 30 | return (next: Function) => (action: IAction | Task) => { 31 | // If a task is returned, then schedule and run it. 32 | // TODO: Should come up with a better abstraction for Task vs action dispatches 33 | // so that we don't need to fork the code like this with a duplicated resolve call. 34 | if (action instanceof Task) { 35 | queue.push(action) 36 | // queue.push({ mapper: () => [action], props: {}}) 37 | setTimeout(() => store.dispatch({ type: SCHEDULED_TASKS_PENDING })) 38 | queue 39 | .run(store.dispatch) 40 | .then((results) => { 41 | _res(results) 42 | setTimeout(() => store.dispatch({ type: SCHEDULED_TASKS_COMPLETED, payload: { results } })) 43 | }) 44 | // Otherwise, check the action type. 45 | } else { 46 | switch (action.type) { 47 | case SCHEDULE_TASKS: 48 | queue.push(action.payload) 49 | return 50 | case RUN_SCHEDULED_TASKS: 51 | if (pendingCount === 0) { 52 | // Need to push to next tick in case we are in the middle of a render. 53 | setTimeout(() => store.dispatch({ type: SCHEDULED_TASKS_PENDING })) 54 | } 55 | pendingCount = pendingCount + 1 56 | queue 57 | .run(store.dispatch) 58 | // .run(store.dispatch, store.getState()) 59 | .then((results) => { 60 | pendingCount = pendingCount - 1 61 | if (pendingCount === 0) { 62 | // Need to push to next tick in case we are in the middle of a render. 63 | setTimeout(() => store.dispatch({ type: SCHEDULED_TASKS_COMPLETED, payload: { results } })) 64 | setTimeout(() => _res(results)) 65 | } 66 | }) 67 | return 68 | default: 69 | return next(action) 70 | } 71 | } 72 | } 73 | } 74 | 75 | middleware.done = done 76 | 77 | return middleware 78 | } 79 | 80 | export default makeMiddleware 81 | -------------------------------------------------------------------------------- /src/components/TransactContext.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | import * as React from 'react' 3 | import {IResolveOptions, TasksOrEffects} from '../interfaces' 4 | import TaskQueue from "../internals/TaskQueue"; 5 | import {toTasks} from "../internals/helpers"; 6 | 7 | const defaultResolveOpts = { immediate: false } 8 | 9 | const { bool, func, shape } = React.PropTypes 10 | 11 | export default class TransactContext extends React.Component { 12 | static displayName = 'TransactContext' 13 | 14 | static childContextTypes = { 15 | transact: shape({ 16 | skipInitialRoute: bool, 17 | resolve: func, 18 | run: func 19 | }) 20 | } 21 | 22 | static propsTypes = { 23 | onReady: func, 24 | onBeforeRun: func, 25 | onAfterRun: func, 26 | onResult: func 27 | } 28 | 29 | static defaultProps = { 30 | onReady: () => {}, 31 | onBeforeRun: () => {}, 32 | onAfterRun: () => {}, 33 | onResult: () => {} 34 | } 35 | 36 | state: any 37 | ready: Function 38 | context: any 39 | taskQueue: TaskQueue 40 | 41 | constructor(props, context) { 42 | super(props, context) 43 | this.state = { initialized: false } 44 | this.taskQueue = new TaskQueue() 45 | setTimeout(() => this.runTasks(), 0) 46 | 47 | new Promise(res => { 48 | this.ready = res 49 | }).then(() => this.props.onReady()) 50 | } 51 | 52 | getChildContext() { 53 | return { 54 | transact: { 55 | initialized: this.state.initialized, 56 | skipInitialRoute: this.props.skipInitialRoute, 57 | resolve: this.resolve.bind(this), 58 | run: this.run.bind(this) 59 | } 60 | } 61 | } 62 | 63 | componentDidMount() { 64 | this.setState({ initialized: true }) 65 | } 66 | 67 | resolve(tasksOrEffects: TasksOrEffects, opts: IResolveOptions = defaultResolveOpts): void { 68 | this.taskQueue.push(toTasks(tasksOrEffects)) 69 | if (opts.immediate) { 70 | this.runTasks() 71 | } 72 | } 73 | 74 | run(tasksOrEffects: TasksOrEffects): void { 75 | this.taskQueue.push(toTasks(tasksOrEffects)) 76 | this.runTasks() 77 | } 78 | 79 | runTasks(): void { 80 | const { onBeforeRun, onAfterRun, onResult } = this.props 81 | onBeforeRun() 82 | // Bump to next tick to avoid synchronous component render issue. 83 | setTimeout(() => this.taskQueue.run(onResult).then(() => { 84 | onAfterRun() 85 | this.ready() 86 | })) 87 | } 88 | 89 | render() { 90 | return this.props.children 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/core.ts: -------------------------------------------------------------------------------- 1 | import Task from './internals/Task' 2 | import resolve from './internals/resolve' 3 | import route from './decorators/route' 4 | import _transact from './decorators/transact' 5 | import TransactContext from './components/TransactContext' 6 | import { call, tap, trace } from './effects' 7 | 8 | const transact: any = _transact 9 | transact.route = route 10 | 11 | export { 12 | call, 13 | tap, 14 | trace, 15 | transact, 16 | route, 17 | Task, 18 | resolve, 19 | TransactContext 20 | } 21 | 22 | export default { 23 | call, 24 | tap, 25 | trace, 26 | transact, 27 | route, 28 | Task, 29 | resolve, 30 | TransactContext 31 | } 32 | -------------------------------------------------------------------------------- /src/decorators/route.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import transact from './transact' 3 | import { invariant, getDisplayName, shallowEqual, hoistStatics } from '../internals/helpers' 4 | import { IMapTasks } from "../interfaces"; 5 | 6 | // Takes in route params and location query and returns a component props object. 7 | // If the value is empty, then the default is used. 8 | const toProps = (paramNames, queryNames, defaults: any = {}, props: any) => 9 | props 10 | ? Object.assign( 11 | queryNames.reduce((acc, name) => 12 | Object.assign(acc, { 13 | [name]: props.location && props.location.query && props.location.query[name] ? props.location.query[name] : defaults[name] 14 | }) 15 | , {}), 16 | paramNames.reduce((acc, name) => 17 | Object.assign(acc, { 18 | [name]: props.params && props.params[name] ? props.params[name] : defaults[name] 19 | }) 20 | , {}) 21 | ) 22 | : null 23 | 24 | type IProps = { 25 | transact: any 26 | params: any 27 | } 28 | 29 | type IState = { 30 | routeProps: Array 31 | } 32 | 33 | type RouteDescriptor = { 34 | params?: Array 35 | query?: Array 36 | defaults?: { 37 | [key: string]: string 38 | } 39 | } 40 | 41 | /* 42 | * The @transact.route decorator is used to decorate a router handler to resolve tasks 43 | * based on the declared params and query props. 44 | * 45 | * When the param or query values change, the decorated route handler will resolve its 46 | * tasks again. 47 | */ 48 | export default (first: RouteDescriptor | Array, mapper: IMapTasks): IMapTasks => { 49 | let paramNames: Array 50 | let queryNames: Array 51 | let defaults: { [key: string]: string } 52 | 53 | // Task mapper is either the first and only argument, or it is the last. 54 | if (first instanceof Array) { 55 | paramNames = first 56 | queryNames = [] 57 | defaults = {} 58 | } else { 59 | paramNames = first.params || [] 60 | queryNames = first.query || [] 61 | defaults = first.defaults 62 | } 63 | 64 | invariant( 65 | typeof mapper === 'function', 66 | '@transact.router called without a task mapper function as the last argument' 67 | ) 68 | 69 | return (Wrappee: any): any => { 70 | const Inner = transact(mapper, { trigger: 'manual' })((props) => 71 | React.createElement( 72 | Wrappee, 73 | props 74 | )) 75 | 76 | class Wrapped extends React.Component { 77 | static displayName = `TransactRoute(${getDisplayName(Wrappee)})` 78 | 79 | static _mapTasks = (props) => ( 80 | mapper(Object.assign(toProps(paramNames, queryNames, defaults, props), props)) 81 | ) 82 | 83 | static contextTypes = { 84 | router: React.PropTypes.any, 85 | transact: React.PropTypes.object 86 | } 87 | 88 | context: IProps 89 | 90 | _inner: any = null 91 | 92 | constructor(props, context) { 93 | super(props, context) 94 | const { initialized, skipInitialRoute } = context.transact 95 | this.state = { 96 | routeProps: initialized || !skipInitialRoute ? null : toProps(paramNames, queryNames, defaults, props) 97 | } 98 | } 99 | 100 | componentWillMount() { 101 | this.maybeUpdateFromProps(this.props) 102 | } 103 | 104 | componentWillReceiveProps(nextProps) { 105 | this.maybeUpdateFromProps(nextProps) 106 | } 107 | 108 | maybeUpdateFromProps(props) { 109 | const nextParamProps = toProps(paramNames, queryNames, defaults, props) 110 | if (!shallowEqual(this.state.routeProps, nextParamProps)) { 111 | // Set the state, then call the @transact component to resolve its tasks again. 112 | this.setState({ routeProps: nextParamProps }, () => { 113 | this._inner.forceResolve() 114 | }) 115 | } 116 | } 117 | 118 | render() { 119 | return React.createElement( 120 | Inner, 121 | Object.assign({ 122 | ref: r => this._inner = r, 123 | children: this.props.children 124 | }, this.props, this.state.routeProps) 125 | ) 126 | } 127 | } 128 | 129 | return hoistStatics(Wrapped, Wrappee) 130 | } 131 | } 132 | 133 | -------------------------------------------------------------------------------- /src/decorators/transact.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {IMapTasks, IDecoratorOptions, IResolveOptions, ITask} from '../interfaces' 3 | import { invariant, getDisplayName, hoistStatics } from '../internals/helpers' 4 | 5 | type ITransact = { 6 | resolve: (tasks: Array> | ITask, opts: IResolveOptions) => void 7 | run: (tasks: Array> | ITask, props: any) => void 8 | store: any 9 | } 10 | 11 | type IProps = { 12 | transact: ITransact 13 | } 14 | 15 | const defaultOpts = { 16 | onMount: false, 17 | trigger: 'auto' 18 | } 19 | 20 | export default (mapTasks: IMapTasks, opts: IDecoratorOptions = defaultOpts): Function => { 21 | return (Wrappee: any): any => { 22 | class Wrapped extends React.Component { 23 | // For router context 24 | static _mapTasks = mapTasks 25 | static displayName = `Transact(${getDisplayName(Wrappee)})` 26 | static contextTypes = { 27 | router: React.PropTypes.any, 28 | transact: React.PropTypes.object 29 | } 30 | 31 | context: IProps 32 | transact: ITransact 33 | 34 | constructor(props, context) { 35 | super(props, context) 36 | this.transact = context.transact || props.transact 37 | 38 | invariant( 39 | this.transact !== null && this.transact !== undefined, 40 | 'Cannot find `transact` from context or props. Perhaps you forgot to mount `TransactContext` as a parent?' 41 | ) 42 | 43 | if (opts.trigger !== 'manual' && typeof mapTasks === 'function') { 44 | // Resolve in the next tick to avoid setting state during constructor. 45 | this.transact.resolve(mapTasks(props), { immediate: opts.onMount }) 46 | } 47 | 48 | if (typeof mapTasks === 'function' && context.router && !props.routeParams && !opts.onMount) { 49 | console.warn( 50 | `${Wrapped.displayName} is mounted in a router context, but is not a route handler. This can cause data loading issues on route change. You may want to add \`@transact(..., { onMount: true })\`.` 51 | ) 52 | } 53 | } 54 | 55 | // Internal helper to force tasks to be resolved. 56 | forceResolve() { 57 | this.transact.resolve(mapTasks(this.props), {immediate: true }) 58 | } 59 | 60 | render() { 61 | return React.createElement( 62 | Wrappee, 63 | Object.assign({}, this.props, { 64 | transact: this.transact 65 | }) 66 | ) 67 | } 68 | } 69 | 70 | return hoistStatics(Wrapped, Wrappee) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/effects.ts: -------------------------------------------------------------------------------- 1 | import Task from './internals/Task' 2 | import {IActionThunk, IChainTask, IEffect} from './interfaces' 3 | import { applyValueOrPromise } from './internals/helpers' 4 | import Call from "./internals/Call"; 5 | 6 | /* 7 | * When given a function and a value, returns a new Task that, when forked, 8 | * will apply the value to the function, then resolve the value. 9 | * 10 | * This is useful for creating side-effects off of a computation chain (i.e. logging). 11 | * 12 | * Example: 13 | * 14 | * ``` 15 | * taskCreator('BAD', 'GOOD', x => x)('Hello') 16 | * .chain(tap(x => console.log(x))) 17 | * .fork((action) => { ... }) // This will cause the above log function to execute. 18 | * ``` 19 | */ 20 | export const tap = (fn: IActionThunk): IChainTask => (x: any) => new Task((rej: any, res: any) => { 21 | applyValueOrPromise(fn, x) 22 | res(x) 23 | }) 24 | 25 | /* 26 | * Given a message, returns a task creator function that 27 | * can be chained with another Task. Useful for debugging. 28 | * 29 | * Example: 30 | * 31 | * ``` 32 | * taskCreator('BAD', 'GOOD', x => x)('Hello!') 33 | * .chain(trace('Message received')) 34 | * .fork((action) => { ... }) // This will cause "Message received, 'Hello!'" to be logged. 35 | * ``` 36 | */ 37 | export const trace = (msg: string): IChainTask => tap((a: any) => console.log(msg, a)) 38 | 39 | export const call = (fn: IEffect, ...args: any[]) => new Call(fn, ...args) 40 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface IAction { 2 | type: T 3 | payload?: any 4 | } 5 | 6 | export interface IActionThunk { 7 | (action: IAction): void 8 | } 9 | 10 | export interface IComputation { 11 | (reject: IActionThunk, resolve: IActionThunk, progress?: IActionThunk, cancel?: IActionThunk): void 12 | } 13 | 14 | export interface ITask { 15 | fork(rej: IActionThunk, res: IActionThunk, progress?: IActionThunk, cancel?: IActionThunk): void 16 | chain(g: (arg: any) => ITask): ITask 17 | } 18 | 19 | export interface ITransaction { 20 | tasks: Array> 21 | } 22 | 23 | export interface IChainTask { 24 | (x: IAction): ITask 25 | } 26 | 27 | export interface ITaskCreator { 28 | (a: A, b: B, f: Function): ITask 29 | } 30 | 31 | export interface IMapTasks { 32 | (props: any): Array> | ITask 33 | } 34 | 35 | export type ITaskResult = { 36 | task: ITask 37 | result: A | B 38 | isRejected: boolean 39 | } 40 | 41 | export type IEffect = (...args: any[]) => Promise | T 42 | 43 | export type TasksOrEffects = Array | IEffect> | ITask | IEffect 44 | 45 | export interface IStore { 46 | dispatch: (action: IAction) => void 47 | getState: () => any 48 | subscribe: (listener: () => void) => () => void 49 | replaceReducer: ((any, IAction) => void) 50 | } 51 | 52 | export type IResolveOptions = { 53 | immediate: boolean 54 | } 55 | 56 | export type IDecoratorOptions = { 57 | onMount?: boolean, 58 | trigger?: string 59 | } 60 | 61 | export type IRouterProps = { 62 | components: any[] 63 | } 64 | -------------------------------------------------------------------------------- /src/internals/Call.ts: -------------------------------------------------------------------------------- 1 | import {ITask, IEffect} from "../interfaces"; 2 | 3 | /* 4 | * Wraps a potentially effectful function inside the structure Call. 5 | * This allows testing against structures as opposed mocking side effects. 6 | */ 7 | export default class Call implements ITask { 8 | computation: IEffect = null 9 | args: any[] = null 10 | 11 | constructor(computation: IEffect, ...args: any[]) { 12 | this.computation = computation 13 | this.args = args 14 | } 15 | 16 | fork(callback, progress?, cancel?) { 17 | try { 18 | const a = this.computation(...this.args) 19 | if (a instanceof Promise) { 20 | a.then(c => callback(c), d => callback(d)) 21 | } else { 22 | callback(a) 23 | } 24 | } catch (e) { 25 | callback(e) 26 | } 27 | } 28 | 29 | chain(g: any): Call { 30 | return new Call(() => { 31 | return new Promise((res, rej) => { 32 | this.fork((x) => { 33 | try { 34 | const u = g(x) 35 | res(u) 36 | } catch (e) { 37 | rej(e) 38 | } 39 | }) 40 | }) 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/internals/Task.ts: -------------------------------------------------------------------------------- 1 | import {IAction, IActionThunk, IComputation, ITask, ITaskCreator, IChainTask} from './../interfaces' 2 | 3 | class Task implements ITask { 4 | computation: IComputation 5 | cleanup: Function 6 | 7 | static resolve(action: IAction): ITask { 8 | return new Task((__: IActionThunk, res: IActionThunk) => { 9 | res(action) 10 | }) 11 | } 12 | 13 | static reject(action: IAction): ITask { 14 | return new Task((rej: IActionThunk, __: IActionThunk) => { 15 | rej(action) 16 | }) 17 | } 18 | 19 | /* 20 | * An empty task that will never resolve. 21 | */ 22 | static empty(): ITask { 23 | return new Task((__: IActionThunk, ___: IActionThunk) => {}) 24 | } 25 | 26 | /* 27 | * When given a function and a task, returns a task that when forked will 28 | * first apply the returned action (either A or B) the supplied function. 29 | * The resulting action is then chained. 30 | */ 31 | static tap(fn: Function) { 32 | return (task: ITask): ITask => { 33 | return new Task((rej:IActionThunk, res:IActionThunk) => { 34 | task.fork( 35 | (a:IAction) => { 36 | fn(this, a, true) 37 | rej(a) 38 | }, 39 | (b:IAction) => { 40 | fn(this, b, false) 41 | res(b) 42 | } 43 | ) 44 | }) 45 | } 46 | } 47 | 48 | constructor(computation: IComputation, cleanup: Function = () => {}) { 49 | this.computation = computation 50 | this.cleanup = cleanup 51 | } 52 | 53 | fork(rej: IActionThunk, res: IActionThunk, progress?: IActionThunk, cancel?: IActionThunk): void { 54 | this.computation( 55 | (a: IAction): void => rej(a), 56 | (b: IAction): void => res(b), 57 | (c: IAction): void => progress(c), 58 | (d: IAction): void => cancel(d) 59 | ) 60 | } 61 | 62 | chain(g: (arg: IAction)=> ITask): ITask { 63 | return new Task((rej: IActionThunk, res: IActionThunk) => { 64 | this.fork( 65 | (action: IAction) => { 66 | rej(action) 67 | }, 68 | (action: IAction) => { 69 | g(action).fork( 70 | (action: IAction) => rej(action), 71 | (action: IAction) => res(action) 72 | ) 73 | } 74 | ) 75 | }) 76 | } 77 | 78 | map(g: (arg: IAction)=> any): ITask { 79 | return new Task((rej: IActionThunk, res: IActionThunk) => { 80 | this.fork( 81 | (action: IAction) => rej(action), 82 | (action: IAction) => { 83 | const valueOrPromise = g(action) 84 | if (typeof valueOrPromise.then === 'function') { 85 | valueOrPromise.then((value) => { 86 | res(value) 87 | }) 88 | } else { 89 | res(valueOrPromise) 90 | } 91 | } 92 | ) 93 | }) 94 | } 95 | 96 | orElse(f: IChainTask): ITask { 97 | return new Task((rej: IActionThunk, res: IActionThunk) => { 98 | return this.fork( 99 | (action: IAction) => { 100 | f(action).fork(rej, res) 101 | }, 102 | (action: IAction) => { 103 | res(action) 104 | } 105 | ) 106 | }) 107 | } 108 | 109 | cancel(): void { 110 | this.cleanup() 111 | this.computation = () => {} 112 | } 113 | } 114 | 115 | export default Task 116 | -------------------------------------------------------------------------------- /src/internals/TaskQueue.ts: -------------------------------------------------------------------------------- 1 | import { IAction, IActionThunk, ITask, ITaskResult, ITransaction } from '../interfaces' 2 | import Call from './Call' 3 | import Task from './Task' 4 | import {compact} from "./helpers"; 5 | type RunResults = Array> 6 | type PendingRunReturn = Promise 7 | 8 | /* 9 | * The `TaskQueue` is responsible for queueing and resolving tasks. Each `run` call 10 | * returns a promise that resolves with the results of the tasks. 11 | * 12 | * Total order is guaranteed for all task results, and each run calls. That is, every 13 | * call to `run` will only resolve afer the previous `run` has resolved. The results 14 | * array is ordered by the same ordering as the submitted tasks array. 15 | */ 16 | class TaskQueue { 17 | private queue: Array 18 | private pending: PendingRunReturn 19 | 20 | constructor() { 21 | this.queue = [] 22 | this.pending = Promise.resolve([]) 23 | } 24 | 25 | get size() { return this.queue.length } 26 | 27 | push(a: Array> | ITask) { 28 | let tasks: Array> 29 | 30 | if (Array.isArray((a))) { 31 | tasks = a 32 | } else if (a instanceof Task || a instanceof Call) { 33 | tasks = [a] 34 | } else { 35 | throw new Error('TaskQueue#push must be passed a Task instance.') 36 | } 37 | 38 | this.queue.push({ tasks: compact(tasks) }) 39 | } 40 | 41 | run(onResult?: IActionThunk): PendingRunReturn { 42 | const size = this.size 43 | const prevPending = this.pending 44 | 45 | // Chaining the previous pending tasks so they will resolve in order. 46 | const chained = new Promise((outerResolve) => { 47 | if (size === 0) { 48 | outerResolve([]) 49 | } else { 50 | // WARNING: Mutating the queue so the next run call won't run through same queued tasks. 51 | const currentQueue = this.queue 52 | this.queue = [] 53 | 54 | // WARNING: Watch out! This will mutate! 55 | let count = 0 56 | 57 | currentQueue.reduce((acc: PendingRunReturn, transaction: ITransaction): PendingRunReturn => { 58 | const { tasks } = transaction 59 | 60 | return acc.then((accResults: RunResults) => { 61 | return new Promise((innerResolve) => { 62 | // No tasks to run? Resolve immediately. 63 | if (tasks.length === 0) { 64 | innerResolve(accResults) 65 | } 66 | 67 | let results = [] 68 | 69 | tasks.forEach((task: ITask) => { 70 | count = count + 1 71 | 72 | const rejAndRes = (a: IAction) => { 73 | count = count - 1 74 | 75 | // We could be running an effect, so there is no resolve value 76 | // to provide. 77 | if (a) { 78 | // Ensure the previous `run` completes before we invoke the callback. 79 | // This is done to guarantee total ordering of action dispatches. 80 | prevPending.then(() => onResult(a)) 81 | results.push({ task, result: a }) 82 | } 83 | 84 | // Once the last computation finishes, resolve promise. 85 | if (count === 0) { 86 | innerResolve(accResults.concat(results)) 87 | } 88 | } 89 | 90 | // Bump to next tick so we give all tasks a chance to increment 91 | // count before being forked. 92 | setTimeout(() => task.fork( 93 | rejAndRes, 94 | rejAndRes, 95 | (c) => prevPending.then(() => c && onResult(c)) 96 | ), 0) 97 | }) 98 | }) 99 | }) 100 | }, Promise.resolve([])).then((results: RunResults) => { 101 | outerResolve(results) 102 | }) 103 | } 104 | }) 105 | 106 | // Set the pending promise to the next in chain. 107 | this.pending = this.pending.then(() => chained) 108 | 109 | // Return new pending promise so the caller can wait for all previously scheduled tasks 110 | // and currently scheduled tasks to complete before resolution (total ordering). 111 | return this.pending 112 | } 113 | } 114 | 115 | export default TaskQueue 116 | -------------------------------------------------------------------------------- /src/internals/helpers.ts: -------------------------------------------------------------------------------- 1 | import {ITask, TasksOrEffects, IEffect} from "../interfaces" 2 | import Call from "./Call" 3 | import Task from "./Task" 4 | 5 | export const invariant = (predicate: boolean, message: string) => { 6 | if (!predicate) { 7 | throw new Error(message) 8 | } 9 | } 10 | 11 | export const shallowEqual = (a, b) => { 12 | if (a === b) { 13 | return true 14 | } 15 | 16 | if (!a && b) { 17 | return false 18 | } 19 | 20 | if (a && !b) { 21 | return false 22 | } 23 | 24 | const keysA = Object.keys(a) 25 | const keysB = Object.keys(b) 26 | 27 | if (keysA.length !== keysB.length) { 28 | return false 29 | } 30 | 31 | const hasOwn = Object.prototype.hasOwnProperty 32 | for (let i = 0; i < keysA.length; i++) { 33 | if (!hasOwn.call(b, keysA[i]) || 34 | a[keysA[i]] !== b[keysA[i]]) { 35 | return false 36 | } 37 | } 38 | 39 | return true 40 | } 41 | 42 | export const getDisplayName = (C: any): string => C.displayName || C.name || 'Component' 43 | 44 | export const compact = (a: Array): Array => { 45 | return a.filter(x => x !== null && typeof x !== 'undefined') 46 | } 47 | 48 | /* 49 | * Applies the value `A` or `Promise` to the function `fn` 50 | */ 51 | export const applyValueOrPromise = (fn: (T) => any, x: T | Promise): void => { 52 | if (typeof (x as Promise).then === 'function') { 53 | (x as Promise).then(fn) 54 | } else { 55 | fn(x) 56 | } 57 | } 58 | 59 | export const flattenComponents = (components: any[]): any[] => { 60 | return recur(components, []) 61 | 62 | function recur(components: any[], acc: any[]): any[] { 63 | const nonNilComponents = compact(components) 64 | if (nonNilComponents.length === 0) { 65 | return acc 66 | } else { 67 | return recur( 68 | nonNilComponents.reduce((cs, c) => { 69 | if (c.props && c.props.children && c.props.children.length > 0) { 70 | return cs.concat(c.props.children) 71 | } else { 72 | return cs 73 | } 74 | }, []), 75 | acc.concat(nonNilComponents) 76 | ) 77 | } 78 | } 79 | } 80 | 81 | export const getTaskMappers = (components: any[]): Function[] => { 82 | const flattened = flattenComponents(components) 83 | return flattened.map(c => c._mapTasks).filter(m => typeof m !== 'undefined') 84 | } 85 | 86 | export const toTasks = (x: TasksOrEffects): Array> => { 87 | const arr: Array | IEffect> = Array.isArray(x) ? x : [x] 88 | return arr.map((a) => { 89 | if (a instanceof Task || a instanceof Call) { 90 | return a 91 | } else if (a instanceof Function) { 92 | return new Call(a) 93 | } 94 | }) 95 | } 96 | 97 | // Adapted from: https://github.com/mridgway/hoist-non-react-statics/ 98 | 99 | const REACT_STATICS = { 100 | childContextTypes: true, 101 | contextTypes: true, 102 | defaultProps: true, 103 | displayName: true, 104 | getDefaultProps: true, 105 | mixins: true, 106 | propTypes: true, 107 | type: true 108 | } 109 | 110 | const KNOWN_STATICS = { 111 | name: true, 112 | length: true, 113 | prototype: true, 114 | caller: true, 115 | arguments: true, 116 | arity: true 117 | } 118 | 119 | export function hoistStatics(targetComponent, sourceComponent) { 120 | if (typeof sourceComponent !== 'string') { 121 | let keys: any[] = Object.getOwnPropertyNames(sourceComponent) 122 | 123 | if (typeof Object.getOwnPropertySymbols === 'function') { 124 | keys = keys.concat(Object.getOwnPropertySymbols(sourceComponent)) 125 | } 126 | 127 | keys.forEach((key) => { 128 | if (!REACT_STATICS[key] && !KNOWN_STATICS[key]) { 129 | targetComponent[key] = sourceComponent[key] 130 | } 131 | }) 132 | } 133 | 134 | return targetComponent 135 | } 136 | -------------------------------------------------------------------------------- /src/internals/resolve.ts: -------------------------------------------------------------------------------- 1 | import {IRouterProps, IMapTasks} from "../interfaces" 2 | import {getTaskMappers, toTasks} from "./helpers" 3 | import TaskQueue from "./TaskQueue" 4 | 5 | /* 6 | * Resolves all data tasks based on matched routes. 7 | */ 8 | const resolve = (routerProps: IRouterProps, extraProps?: any) => { 9 | const queue = new TaskQueue() 10 | 11 | const mappers = getTaskMappers(routerProps.components) 12 | const props = Object.assign({}, extraProps, routerProps) 13 | 14 | // After store is created, run initial tasks, if any. 15 | mappers.forEach((mapper: IMapTasks) => { 16 | const tasks = toTasks(mapper(props)) 17 | queue.push(tasks) 18 | }) 19 | 20 | return new Promise((res, rej) => { 21 | try { 22 | queue.run().then(res) 23 | } catch (e) { 24 | rej(e) 25 | } 26 | }) 27 | } 28 | 29 | export default resolve 30 | -------------------------------------------------------------------------------- /src/internals/taskCreator.ts: -------------------------------------------------------------------------------- 1 | import { IActionThunk, ITaskCreator } from './../interfaces' 2 | import Task from './Task' 3 | 4 | export default (rejectType: A, resolveType: B, arg3: any, arg4: any): ITaskCreator => { 5 | return (...args: any[]) => 6 | new Task((rej: IActionThunk, res: IActionThunk, progress?: IActionThunk, cancel?: IActionThunk) => { 7 | try { 8 | const fn = typeof arg3 === 'function' ? arg3 : arg4 9 | const progressType = typeof arg3 !== 'function' ? arg3 : null 10 | 11 | if (progressType !== null) { 12 | progress({ type: progressType }) 13 | } 14 | 15 | const promiseOrValue = fn(...args) 16 | 17 | if (typeof promiseOrValue.then === 'function') { 18 | promiseOrValue.then( 19 | (value: any) => res({ type: resolveType, payload: value }), 20 | (err: any) => rej({ type: rejectType, payload: err }) 21 | ) 22 | } else { 23 | res({ type: resolveType, payload: promiseOrValue }) 24 | } 25 | } catch (err) { 26 | rej({ type: rejectType, payload: err }) 27 | } 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /src/redux.ts: -------------------------------------------------------------------------------- 1 | import route from './decorators/route' 2 | import _transact from './decorators/transact' 3 | import ReduxTransactContext from './adapters/redux/ReduxTransactContext' 4 | import middleware from './adapters/redux/middleware' 5 | import {RUN_SCHEDULED_TASKS, SCHEDULE_TASKS, STANDALONE_INIT, SCHEDULED_TASKS_PENDING, SCHEDULED_TASKS_COMPLETED} from './actions' 6 | import taskCreator from './internals/taskCreator' 7 | 8 | const transact: any = _transact 9 | transact.route = route 10 | 11 | export { 12 | RUN_SCHEDULED_TASKS, 13 | SCHEDULE_TASKS, 14 | STANDALONE_INIT, 15 | SCHEDULED_TASKS_PENDING, 16 | SCHEDULED_TASKS_COMPLETED, 17 | taskCreator, 18 | ReduxTransactContext, 19 | middleware as reduxTransact 20 | } 21 | 22 | export default { 23 | RUN_SCHEDULED_TASKS, 24 | SCHEDULE_TASKS, 25 | STANDALONE_INIT, 26 | SCHEDULED_TASKS_PENDING, 27 | SCHEDULED_TASKS_COMPLETED, 28 | taskCreator, 29 | ReduxTransactContext, 30 | reduxTransact: middleware 31 | } 32 | -------------------------------------------------------------------------------- /test/integration/redux/reduxMiddleware-test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | const React = require('react') 3 | const reduxMiddleware = require('../../../lib/adapters/redux/middleware').default 4 | const applyMiddleware = require('redux').applyMiddleware 5 | const actions = require('../../../lib/actions') 6 | const Task = require('../../../lib/internals/Task').default 7 | const ReduxTransactContext = require('../../../lib/adapters/redux/ReduxTransactContext').default 8 | const transact = require('../../../lib/decorators/transact').default 9 | const createStore = require('redux').createStore 10 | const sinon = require('sinon') 11 | 12 | test('reduxMiddleware (no tasks)', (t) => { 13 | const identity = (state) => state 14 | const m = reduxMiddleware() 15 | const store = createStore(identity, {}, applyMiddleware(m)) 16 | 17 | t.ok(typeof m.done.then === 'function', 'returns a done promise on reduxMiddleware') 18 | 19 | store.dispatch({ type: actions.RUN_SCHEDULED_TASKS }) 20 | 21 | m.done.then(() => { 22 | t.ok(true, 'resolves done promise') 23 | t.end() 24 | }) 25 | }) 26 | 27 | test('reduxMiddleware (run one task)', (t) => { 28 | const reducer = (state = '', action = {}) => { 29 | if (action.type === 'OK') return 'called' 30 | return state 31 | } 32 | const m = reduxMiddleware() 33 | const store = createStore(reducer, undefined, applyMiddleware(m)) 34 | 35 | store.dispatch({ type: actions.SCHEDULE_TASKS, payload: [Task.resolve({ type: 'OK' })] }) 36 | 37 | store.dispatch({ type: actions.RUN_SCHEDULED_TASKS }) 38 | 39 | m.done.then((results) => { 40 | t.equal(store.getState(), 'called', 'state is updated before done resolves') 41 | t.equal(results.length, 1, 'results array is dispatched with `TASKS_RESOLVED` action') 42 | t.deepEqual(results[0].result, { type: 'OK' }, 'action is in results') 43 | t.end() 44 | }) 45 | }) 46 | 47 | test('reduxMiddleware middleware (multiple tasks)', (t) => { 48 | const reducer = (state = [], action = {}) => { 49 | if (action.type === 'OK' || action.type === actions.SCHEDULED_TASKS_PENDING || action.type === actions.SCHEDULED_TASKS_COMPLETED) { 50 | return state.concat([action]) 51 | } else { 52 | return state 53 | } 54 | } 55 | const m = reduxMiddleware() 56 | const store = createStore(reducer, undefined, applyMiddleware(m)) 57 | 58 | // This should resolve after all tasks are finished. 59 | m.done.then(() => { 60 | const state = store.getState() 61 | t.equals(state[0].type, actions.SCHEDULED_TASKS_PENDING) 62 | t.deepEqual(state.slice(1, 7), [ 63 | { type: 'OK', payload: 1 }, 64 | { type: 'OK', payload: 2 }, 65 | { type: 'OK', payload: 3 }, 66 | { type: 'OK', payload: 4 }, 67 | { type: 'OK', payload: 5 }, 68 | { type: 'OK', payload: 6 } 69 | ], 'waits for all tasks to resolve and dispatches their actions') 70 | t.equals(state[7].type, actions.SCHEDULED_TASKS_COMPLETED) 71 | t.end() 72 | }) 73 | 74 | store.dispatch({ type: actions.SCHEDULE_TASKS, payload: [Task.resolve({ type: 'OK', payload: 1 })] }) 75 | 76 | store.dispatch({ type: actions.RUN_SCHEDULED_TASKS }) 77 | 78 | store.dispatch({ type: actions.SCHEDULE_TASKS, payload: [Task.resolve({ type: 'OK', payload: 2 })] }) 79 | 80 | store.dispatch({ type: actions.RUN_SCHEDULED_TASKS }) 81 | 82 | store.dispatch({ type: actions.SCHEDULE_TASKS, payload: [ 83 | Task.resolve({ type: 'OK', payload: 3 }), 84 | Task.resolve({ type: 'OK', payload: 4 }), 85 | Task.resolve({ type: 'OK', payload: 5 }) 86 | ]}) 87 | 88 | store.dispatch({ type: actions.SCHEDULE_TASKS, payload: [Task.resolve({ type: 'OK', payload: 6 })] }) 89 | 90 | store.dispatch({ type: actions.RUN_SCHEDULED_TASKS }) 91 | }) 92 | 93 | test('reduxMiddleware middleware (with routes)', (t) => { 94 | const h = React.createElement 95 | const reducer = (state = [], action = {}) => { 96 | if (action.type === 'OK') return state.concat([action]) 97 | else return state 98 | } 99 | 100 | const WrappedA = transact( 101 | () => [ 102 | Task.resolve({ type: 'OK', payload: 1 }), 103 | Task.resolve({ type: 'OK', payload: 2 }) 104 | ] 105 | )( 106 | () => null 107 | ) 108 | 109 | const WrappedB = transact( 110 | () => [ 111 | Task.resolve({ type: 'OK', payload: 3 }), 112 | Task.resolve({ type: 'OK', payload: 4 }) 113 | ] 114 | )( 115 | () => null 116 | ) 117 | 118 | const routeComponent = 119 | h('div', { children: [ 120 | WrappedA, 121 | WrappedB 122 | ]}) 123 | 124 | const m = reduxMiddleware({ components: [routeComponent] }) 125 | 126 | const store = createStore(reducer, undefined, applyMiddleware(m)) 127 | 128 | m.done.then(() => { 129 | t.deepEqual(store.getState(), [ 130 | { type: 'OK', payload: 1 }, 131 | { type: 'OK', payload: 2 }, 132 | { type: 'OK', payload: 3 }, 133 | { type: 'OK', payload: 4 } 134 | ], 'resolves all initial tasks') 135 | t.end() 136 | }) 137 | }) 138 | 139 | test('reduxMiddleware middleware (returning task from action creator)', (t) => { 140 | const reducer = (state = '', action = {}) => { 141 | if (action.type === 'OK') return 'called' 142 | return state 143 | } 144 | const m = reduxMiddleware() 145 | const store = createStore(reducer, undefined, applyMiddleware(m)) 146 | 147 | store.dispatch(Task.resolve({ type: 'OK' })) 148 | 149 | m.done.then((results) => { 150 | t.equal(store.getState(), 'called', 'state is updated before done resolves') 151 | t.equal(results.length, 1, 'results array is dispatched with `TASKS_RESOLVED` action') 152 | t.deepEqual(results[0].result, { type: 'OK' }, 'action is in results') 153 | t.end() 154 | }) 155 | }) 156 | -------------------------------------------------------------------------------- /test/integration/redux/transact-test.js: -------------------------------------------------------------------------------- 1 | require('./../../setup') 2 | const mount = require('enzyme').mount 3 | const applyMiddleware = require('redux').applyMiddleware 4 | const createStore = require('redux').createStore 5 | const connect = require('react-redux').connect 6 | const Provider = require('react-redux').Provider 7 | const React = require('react') 8 | const test = require('tape') 9 | const sinon = require('sinon') 10 | const ReduxTransactContext = require('../../../lib/adapters/redux/ReduxTransactContext').default 11 | const transact = require('../../../lib/decorators/transact').default 12 | const Task = require('../../../lib/internals/Task').default 13 | const taskCreator = require('../../../lib/internals/taskCreator').default 14 | const reduxMiddleware = require('../../../lib/adapters/redux/middleware').default 15 | 16 | const h = React.createElement 17 | const noop = () => {} 18 | 19 | /* 20 | * This test covers all of the basic usages of @transact decorator. It is pretty long, 21 | * but is more of a journey test than integration. :) 22 | */ 23 | test('ReduxTransactContext with transact decorator', (t) => { 24 | const m = reduxMiddleware() 25 | const store = makeStore({ 26 | history: [], 27 | message: '' 28 | }, m) 29 | const Message = ({ message }) => h('p', {}, [ message ]) 30 | 31 | const Wrapped = transact( 32 | (props) => [ 33 | Task.resolve({ type: MESSAGE, payload: 'Hello Alice!' }), 34 | // Delayed task should still resolve in order 35 | taskCreator(ERROR, MESSAGE, () => new Promise((res, rej) => { 36 | setTimeout(() => { 37 | rej('Boo-urns') 38 | }, 10) 39 | }))() 40 | ] 41 | )( 42 | connect((state) => ({ message: state.message }))( 43 | Message 44 | ) 45 | ) 46 | 47 | // This component will not be mounted until `showSecondWrappedElement` is set to true. 48 | const WrappedRunOnMount = transact((props) => [ 49 | Task.resolve({ type: MESSAGE, payload: 'Bye Alice' }) 50 | ], { onMount: true })( 51 | () => null 52 | ) 53 | 54 | class Root extends React.Component { 55 | constructor(props) { 56 | super(props) 57 | this.state = { showSecondWrappedElement: false } 58 | } 59 | componentDidMount() { 60 | this.setState({ showSecondWrappedElement: true }) 61 | } 62 | render() { 63 | return ( 64 | h(Provider, { store }, 65 | h(ReduxTransactContext, {}, 66 | h('div', { children: [ 67 | h(Wrapped), 68 | this.state.showSecondWrappedElement ? h(WrappedRunOnMount) : null 69 | ]}) 70 | ) 71 | ) 72 | ) 73 | } 74 | } 75 | 76 | const element = h(Root) 77 | 78 | const wrapped = mount(element) 79 | 80 | m.done.then(() => { 81 | t.deepEqual(store.getState().history, [ 82 | { type: MESSAGE, payload: 'Hello Alice!' }, 83 | { type: ERROR, payload: 'Boo-urns' }, 84 | { type: MESSAGE, payload: 'Bye Alice' } 85 | ], 'actions are dispatched in order') 86 | 87 | t.equal(wrapped.text(), 'Bye Alice', 'text shows results of last dispatched action') 88 | 89 | t.end() 90 | }) 91 | }) 92 | 93 | test('transact decorator (warnings)', (t) => { 94 | const store = { dispatch: noop, getState: noop } 95 | const warn = sinon.spy(console, 'warn') 96 | const Foo = () => h('div') 97 | Foo.displayName = 'Foo' 98 | const Wrapped = transact(() => [])(Foo) 99 | class FakeRouterContext extends React.Component { 100 | getChildContext() { 101 | return { router: {} } 102 | } 103 | render() { 104 | return h(ReduxTransactContext, { store }, this.props.children) 105 | } 106 | } 107 | FakeRouterContext.childContextTypes = { 108 | router: React.PropTypes.any 109 | } 110 | 111 | mount(h(FakeRouterContext, {}, h(Wrapped))) 112 | t.ok(warn.called, 'warns user if non-route handler @transact component is mounted without { onMount: true }') 113 | t.ok(/Foo/.test(warn.firstCall.args[0]), 'should include component name in warning message') 114 | warn.reset() 115 | 116 | const WrappedOnMount = transact(() => [], { onMount: true })(Foo) 117 | mount(h(FakeRouterContext, {}, h(WrappedOnMount))) 118 | t.ok(!warn.called, 'no warning if `onMount: true` is specified in options') 119 | warn.reset() 120 | 121 | const WrappedWithoutTasks = transact()(Foo) 122 | mount(h(FakeRouterContext, {}, h(WrappedWithoutTasks))) 123 | t.ok(!warn.called, 'no warning if @transact component has no tasks') 124 | 125 | warn.restore() 126 | t.end() 127 | }) 128 | 129 | // Test helpers 130 | 131 | const MESSAGE = 'MESSAGE' 132 | const ERROR = 'ERROR' 133 | 134 | const Greeter = ({ name }) => h('p', { 135 | className: 'message' 136 | }, [`Hello ${name}!`]) 137 | 138 | const makeStore = (initialState = {}, m) => createStore((state = {}, action) => { 139 | switch (action.type) { 140 | case MESSAGE: 141 | return { message: action.payload, history: state.history.concat([action]) } 142 | case ERROR: 143 | return { message: 'Hello Error!', history: state.history.concat([action]) } 144 | default: 145 | return state 146 | } 147 | }, initialState, applyMiddleware(m)) 148 | -------------------------------------------------------------------------------- /test/integration/redux/transact.route-test.js: -------------------------------------------------------------------------------- 1 | require('./../../setup') 2 | const mount = require('enzyme').mount 3 | const applyMiddleware = require('redux').applyMiddleware 4 | const createStore = require('redux').createStore 5 | const Provider = require('react-redux').Provider 6 | const React = require('react') 7 | const test = require('tape') 8 | const sinon = require('sinon') 9 | const ReduxTransactContext = require('../../../lib/adapters/redux/ReduxTransactContext').default 10 | const route = require('../../../lib/decorators/route').default 11 | const Task = require('../../../lib/internals/Task').default 12 | const taskCreator = require('../../../lib/internals/taskCreator').default 13 | const reduxMiddleware = require('../../../lib/adapters/redux/middleware').default 14 | 15 | const h = React.createElement 16 | 17 | test('route decorator - with declarative params array', (t) => { 18 | const m = reduxMiddleware() 19 | const store = makeStore({ 20 | history: [], 21 | message: '' 22 | }, m) 23 | 24 | const SUT = route(['what'], (props) => 25 | Task.resolve({ type: MESSAGE, payload: props.what }) 26 | )(() => h('p')) 27 | 28 | const wrapper = mount(h(createRoot(SUT), { params: { what: 'hello' }, store })) 29 | 30 | m.done.then(() => { 31 | t.deepEqual(store.getState(), { 32 | message: 'hello', 33 | history: [ 34 | { type: 'MESSAGE', payload: 'hello' } 35 | ] 36 | }, 'dispatches tasks mapped by route params') 37 | 38 | wrapper.setProps({ params: { what: 'bye' } }) 39 | 40 | // Bumping to next tick to allow tasks to complete. 41 | setTimeout(() => { 42 | t.deepEqual(store.getState(), { 43 | message: 'bye', 44 | history: [ 45 | { type: 'MESSAGE', payload: 'hello' }, 46 | { type: 'MESSAGE', payload: 'bye' } 47 | ] 48 | }, 'dispatches tasks mapped by route params') 49 | t.end() 50 | }, 10) 51 | }) 52 | }) 53 | 54 | test('route decorator - with route descriptor', (t) => { 55 | const m = reduxMiddleware() 56 | const store = makeStore({ 57 | history: [], 58 | message: '' 59 | }, m) 60 | 61 | const SUT = route({ 62 | params: ['what'], 63 | query: ['who'], 64 | defaults: { what: 'Hey', who: 'Alice' } 65 | }, ({ what, who }) => 66 | Task.resolve({ type: MESSAGE, payload: `${what} ${who}` }) 67 | )(() => h('p')) 68 | 69 | const wrapper = mount(h(createRoot(SUT), { 70 | params: { what: 'Hello' }, 71 | location: { query: {} } 72 | , store })) 73 | 74 | m.done.then(() => { 75 | t.deepEqual(store.getState(), { 76 | message: 'Hello Alice', 77 | history: [ 78 | { type: 'MESSAGE', payload: 'Hello Alice' } 79 | ] 80 | }, 'dispatches tasks mapped by route params') 81 | 82 | wrapper.setProps({ params: { what: 'Bye' }, location: { query: { who: 'Bob' } } }) 83 | wrapper.setProps({ params: {}, location: { query: { who: 'Joe' } } }) 84 | 85 | // Bumping to next tick to allow tasks to complete. 86 | setTimeout(() => { 87 | t.deepEqual(store.getState(), { 88 | message: 'Hey Joe', 89 | history: [ 90 | { type: 'MESSAGE', payload: 'Hello Alice' }, 91 | { type: 'MESSAGE', payload: 'Bye Bob' }, 92 | { type: 'MESSAGE', payload: 'Hey Joe' } 93 | ] 94 | }, 'dispatches tasks mapped by route params') 95 | t.end() 96 | }, 10) 97 | }) 98 | }) 99 | 100 | // Test helpers 101 | 102 | const MESSAGE = 'MESSAGE' 103 | const ERROR = 'ERROR' 104 | 105 | const makeStore = (initialState = {}, m) => createStore((state = {}, action) => { 106 | switch (action.type) { 107 | case MESSAGE: 108 | return { message: action.payload, history: state.history.concat([action]) } 109 | case ERROR: 110 | return { message: 'Hello Error!', history: state.history.concat([action]) } 111 | default: 112 | return state 113 | } 114 | }, initialState, applyMiddleware(m)) 115 | 116 | const createRoot = Child => class extends React.Component { 117 | render() { 118 | // Simulated route params 119 | return ( 120 | h('div', { children: 121 | h(Provider, { store: this.props.store, children: 122 | h(ReduxTransactContext, { children: 123 | h(Child, { 124 | params: this.props.params, 125 | location: this.props.location, 126 | transact: { resolve: () => {} } 127 | }) 128 | }) 129 | }) 130 | }) 131 | ) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /test/integration/resolve-test.js: -------------------------------------------------------------------------------- 1 | require('../setup') 2 | const React = require('react') 3 | const test = require('tape') 4 | const sinon = require('sinon') 5 | const TransactContext = require('../../lib/components/TransactContext').default 6 | const route = require('../../lib/decorators/route').default 7 | const call = require('../../lib/effects').call 8 | const resolve = require('../../lib/internals/resolve').default 9 | 10 | const h = React.createElement 11 | 12 | test('resolve function', (assert) => { 13 | const spy = sinon.spy() 14 | 15 | const SyncChild = route( 16 | { params: ['what'] }, 17 | ({ what }) => call(spy, `Received A: ${what}`))( 18 | (props) => h('p', {}, props.message) 19 | ) 20 | 21 | const AsyncChild = route( 22 | { params: ['what'] }, 23 | ({ what, extraProp }) => call(() => new Promise((res) => { 24 | setTimeout(() => { 25 | spy(`Received B: ${what}`) 26 | spy(`Received extraProp: ${extraProp}`) 27 | res() 28 | }, 10) 29 | })) 30 | )( 31 | (props) => h('p', {}, `${props.message}`) 32 | ) 33 | 34 | const routeComponent = h('div', { children: [ 35 | SyncChild, 36 | AsyncChild 37 | ]}) 38 | 39 | const routeProps = { components: [routeComponent], params: { what: 'hello' } } 40 | 41 | resolve(routeProps, { extraProp: 'bye' }).then(() => { 42 | assert.equal(spy.callCount, 3, 'callbacks are invoked') 43 | assert.equal(spy.getCall(0).args[0], 'Received A: hello', 'first callback invoked with arguments') 44 | assert.equal(spy.getCall(1).args[0], 'Received B: hello', 'second callback invoked with arguments') 45 | assert.equal(spy.getCall(2).args[0], 'Received extraProp: bye', 'called with extra props') 46 | assert.end() 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /test/integration/route-test.js: -------------------------------------------------------------------------------- 1 | require('../setup') 2 | const mount = require('enzyme').mount 3 | const React = require('react') 4 | const test = require('tape') 5 | const sinon = require('sinon') 6 | const TransactContext = require('../../lib/components/TransactContext').default 7 | const route = require('../../lib/decorators/route').default 8 | const Task = require('../../lib/internals/Task').default 9 | const call = require('../../lib/effects').call 10 | const taskCreator = require('../../lib/internals/taskCreator').default 11 | 12 | const h = React.createElement 13 | const delay = (ms) => new Promise((res) => setTimeout(res, ms)) 14 | 15 | test('route decorator', (t) => { 16 | const factory = route({}, (state, props) => []) 17 | 18 | t.ok(typeof factory === 'function', 'returns a higher-order component') 19 | 20 | const Greeter = ({ name }) => h('p', { 21 | className: 'message' 22 | }, [`Hello ${name}!`]) 23 | Greeter.someStaticProp = 'hello' 24 | 25 | const Wrapped = factory(Greeter) 26 | 27 | t.ok(typeof Wrapped.displayName === 'string', 'returns a React component') 28 | t.equal(Wrapped.someStaticProp, 'hello', 'hoists static props') 29 | 30 | t.end() 31 | }) 32 | 33 | 34 | const spy = sinon.spy() 35 | 36 | const Wrapped = route({ 37 | params: ['a'], 38 | query: ['b'] 39 | }, () => call(spy))(() => h('p')) 40 | 41 | class Root extends React.Component { 42 | constructor(props) { 43 | super(props) 44 | this.state = { message: '' } 45 | this.messages = [] 46 | } 47 | onMessageChange(message) { 48 | this.messages.push(message) 49 | this.setState({message}) 50 | return message 51 | } 52 | render() { 53 | return ( 54 | h(TransactContext, { 55 | skipInitialRoute: true 56 | }, h('div', { children: [ 57 | h(Wrapped, { 58 | params: this.props.params, 59 | location: this.props.location, 60 | message: this.state.message, 61 | onMessageChange: this.onMessageChange.bind(this) 62 | }) 63 | ]}) 64 | ) 65 | ) 66 | } 67 | } 68 | 69 | test('skipping initial tasks if skipInitialRoute is true', (t) => { 70 | // Same route props as initial 71 | const wrapper = mount(h(Root, { 72 | params: { 73 | a: 'foo' 74 | }, 75 | location: { 76 | query: { b: 'bar' } 77 | } 78 | })) 79 | 80 | delay(10).then(() => { 81 | t.ok(!spy.called, 'callback not invoked when skipInitialRoute is true') 82 | 83 | wrapper.setProps({ params: { a: 'foo' } }) 84 | 85 | delay(10).then(() => { 86 | t.ok(!spy.called, 'callback invoked when route props remains the same') 87 | 88 | wrapper.setProps({ params: { a: 'different' } }) 89 | 90 | delay(10).then(() => { 91 | t.ok(spy.called, 'callback invoked when route props change') 92 | t.end() 93 | }) 94 | }) 95 | }) 96 | }) 97 | 98 | test('invoking initial tasks if route props are not specified', (t) => { 99 | // different route props from initial 100 | mount(h(Root, { 101 | params: { 102 | a: 'Something else!' 103 | }, 104 | location: { 105 | } 106 | })) 107 | 108 | delay(10).then(() => { 109 | t.ok(spy.called, 'callback is invoked when route props are different') 110 | t.end() 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /test/integration/transact-test.js: -------------------------------------------------------------------------------- 1 | require('../setup') 2 | const mount = require('enzyme').mount 3 | const React = require('react') 4 | const test = require('tape') 5 | const sinon = require('sinon') 6 | const TransactContext = require('../../lib/components/TransactContext').default 7 | const transact = require('../../lib/decorators/transact').default 8 | const Task = require('../../lib/internals/Task').default 9 | const taskCreator = require('../../lib/internals/taskCreator').default 10 | 11 | const h = React.createElement 12 | const noop = () => {} 13 | 14 | test('transact decorator (empty)', (t) => { 15 | const resolve = () => {} 16 | const factory = transact((state, props) => []) 17 | 18 | t.ok(typeof factory === 'function', 'returns a higher-order component') 19 | 20 | const Wrapped = factory(Greeter) 21 | 22 | t.ok(typeof Wrapped.displayName === 'string', 'returns a React component') 23 | t.equal(Wrapped.someStaticProp, 'hello', 'hoists static props') 24 | 25 | const result = mount( 26 | h(Wrapped, { 27 | transact: { resolve }, 28 | name: 'Alice' 29 | }) 30 | ) 31 | 32 | t.ok(result.find('.message').length > 0) 33 | t.equal(result.find('.message').text(), 'Hello Alice!') 34 | 35 | // Guards 36 | t.throws(() => { 37 | mount( 38 | h(Wrapped, { 39 | transact: null, 40 | name: 'Alice' 41 | }) 42 | ) 43 | }, /TransactContext/, 'transact must be present in context') 44 | 45 | t.end() 46 | }) 47 | 48 | test('transact decorator (with tasks)', (t) => { 49 | const resolve = sinon.spy() 50 | const Empty = () => h('p') 51 | const tasks = [ 52 | Task.resolve({ type: 'GOOD', payload: 42 }) 53 | ] 54 | 55 | const Wrapped = transact(() => tasks)(Empty) 56 | 57 | mount( 58 | h('div', { children: [ 59 | h(Wrapped, { transact: { resolve } }), 60 | h(Wrapped, { transact: { resolve } }) 61 | ]}) 62 | ) 63 | 64 | t.equal(resolve.callCount, 2, 'calls resolve for each @transact component') 65 | t.equal(resolve.firstCall.args[0], tasks, 'calls resolve with task run mapper') 66 | t.equal(resolve.secondCall.args[0], tasks, 'calls resolve with task run mapper') 67 | 68 | t.end() 69 | }) 70 | 71 | test('transact decorator (run on mount)', (t) => { 72 | const resolve = sinon.spy() 73 | const Empty = () => h('p') 74 | const tasks = [ 75 | Task.resolve({ type: 'GOOD', payload: 42 }) 76 | ] 77 | 78 | const Wrapped = transact(() => tasks, { onMount: true })(Empty) 79 | 80 | mount( 81 | h('div', { children: [ 82 | h(Wrapped, { transact: { resolve } }) 83 | ]}) 84 | ) 85 | 86 | t.equal(resolve.firstCall.args[0], tasks, 'calls resolve with task run mapper') 87 | t.deepEqual(resolve.firstCall.args[1], { immediate: true }, 'enables immediate flag') 88 | 89 | t.end() 90 | }) 91 | 92 | test('TransactContext with transact decorator', (t) => { 93 | const Message = ({ message }) => { 94 | return h('p', {}, [ message ]) 95 | } 96 | 97 | const Wrapped = transact( 98 | (props) => { 99 | return [ 100 | props.onMessageChange('Hello Alice!'), 101 | props.onAsyncMessageChange('Bye Alice!') 102 | ] 103 | } 104 | )(Message) 105 | 106 | class Root extends React.Component { 107 | constructor(props) { 108 | super(props) 109 | this.state = { message: '' } 110 | this.messages = [] 111 | } 112 | onMessageChange(message) { 113 | this.messages.push(message) 114 | this.setState({ message }) 115 | return message 116 | } 117 | onAsyncMessageChange(message) { 118 | return new Promise((res) => { 119 | this.messages.push(message) 120 | this.setState({ message }) 121 | // Randomly resolve message within 0-50 ms 122 | setTimeout(() => { 123 | res(message) 124 | }, Math.random() * 50) 125 | }) 126 | } 127 | render() { 128 | return ( 129 | h(TransactContext, { onReady: () => { 130 | t.deepEqual(this.messages, [ 131 | 'Hello Alice!', 132 | 'Bye Alice!' 133 | ], 'receives both messages') 134 | 135 | t.equal(wrapped.text(), 'Bye Alice!', 'text shows results of last dispatched action') 136 | 137 | t.end() 138 | } }, 139 | h('div', { children: [ 140 | h(Wrapped, { 141 | message: this.state.message, 142 | onMessageChange: this.onMessageChange.bind(this), 143 | onAsyncMessageChange: this.onAsyncMessageChange.bind(this) 144 | }) 145 | ]}) 146 | ) 147 | ) 148 | } 149 | } 150 | 151 | const wrapped = mount(h(Root)) 152 | }) 153 | 154 | test('transact decorator (warnings)', (t) => { 155 | const store = { dispatch: noop, getState: noop } 156 | const warn = sinon.spy(console, 'warn') 157 | const Foo = () => h('div') 158 | Foo.displayName = 'Foo' 159 | const Wrapped = transact(() => [])(Foo) 160 | class FakeRouterContext extends React.Component { 161 | getChildContext() { 162 | return { router: {} } 163 | } 164 | render() { 165 | return h(TransactContext, { store }, this.props.children) 166 | } 167 | } 168 | FakeRouterContext.childContextTypes = { 169 | router: React.PropTypes.any 170 | } 171 | 172 | mount(h(FakeRouterContext, {}, h(Wrapped))) 173 | t.ok(warn.called, 'warns user if non-route handler @transact component is mounted without { onMount: true }') 174 | t.ok(/Foo/.test(warn.firstCall.args[0]), 'should include component name in warning message') 175 | warn.reset() 176 | 177 | const WrappedOnMount = transact(() => [], { onMount: true })(Foo) 178 | mount(h(FakeRouterContext, {}, h(WrappedOnMount))) 179 | t.ok(!warn.called, 'no warning if `onMount: true` is specified in options') 180 | warn.reset() 181 | 182 | const WrappedWithoutTasks = transact()(Foo) 183 | mount(h(FakeRouterContext, {}, h(WrappedWithoutTasks))) 184 | t.ok(!warn.called, 'no warning if @transact component has no tasks') 185 | 186 | warn.restore() 187 | t.end() 188 | }) 189 | 190 | // Test helpers 191 | 192 | const MESSAGE = 'MESSAGE' 193 | const ERROR = 'ERROR' 194 | 195 | const Greeter = ({ name }) => h('p', { 196 | className: 'message' 197 | }, [`Hello ${name}!`]) 198 | Greeter.someStaticProp = 'hello' 199 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | const jsdom = require('jsdom') 2 | global.document = jsdom.jsdom('
') 3 | global.window = document.defaultView 4 | global.navigator = { userAgent: 'node.js' } 5 | -------------------------------------------------------------------------------- /test/unit/effects-test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | const sinon = require('sinon') 3 | const effects = require('../../lib/effects') 4 | const Task = require('../../lib/internals/Task').default 5 | 6 | test('trace', (t) => { 7 | const { trace } = effects 8 | const hello = new Task((rej, res) => res({ type: 'MESSAGE', payload: 'Hello' })) 9 | 10 | const log = sinon.spy(console, 'log') 11 | 12 | hello.chain(trace('Message is')).fork(() => {}, () => { 13 | t.ok(log.called, 'console.log is called') 14 | t.equal(log.firstCall.args[0], 'Message is', 'message is printed') 15 | t.deepEqual(log.firstCall.args[1], { type: 'MESSAGE', payload: 'Hello' }, 'payload is printed') 16 | log.restore() 17 | t.end() 18 | }) 19 | }) 20 | 21 | test('tap', (t) => { 22 | const { tap } = effects 23 | const hello = new Task((rej, res) => res({ type: 'MESSAGE', payload: 'Hello' })) 24 | const fn = sinon.spy() 25 | 26 | hello.chain(tap(fn)).fork(() => {}, () => { 27 | t.ok(fn.called, 'function is called') 28 | t.deepEqual(fn.firstCall.args[0], { type: 'MESSAGE', payload: 'Hello' }, 'function is applied with payload') 29 | t.end() 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /test/unit/internals/Call-test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | const sinon = require('sinon') 3 | const Call = require('../../../lib/internals/Call').default 4 | 5 | test('Call structure', (t) => { 6 | const inc = (x) => x + 1 7 | const call = new Call(inc, 42) 8 | t.equal(call.computation, inc, 'stores effect') 9 | t.deepEqual(call.args, [42], 'stores args') 10 | t.end() 11 | }) 12 | 13 | test('Call#fork', (t) => { 14 | t.plan(4) 15 | 16 | const good = new Call((x) => x, 42) 17 | const goodAsync = new Call((x) => Promise.resolve(x), 42) 18 | const bad = new Call(() => { throw new Error('oops') }) 19 | const badAsync = new Call(() => Promise.reject('oops')) 20 | 21 | good.fork((x) => { 22 | t.equal(x, 42, 'calls with returned value') 23 | }) 24 | 25 | goodAsync.fork((x) => { 26 | t.equal(x, 42, 'calls with resolved promise value') 27 | }) 28 | 29 | bad.fork((x) => { 30 | t.equal(x.message, 'oops', 'calls with error value') 31 | }) 32 | 33 | badAsync.fork((x) => { 34 | t.equal(x, 'oops', 'calls with rejected promise value') 35 | }) 36 | }) 37 | 38 | test('Call#chain', (t) => { 39 | t.plan(4) 40 | 41 | const good = new Call((x) => x, 42) 42 | const goodAsync = new Call((x) => Promise.resolve(x), 42) 43 | const bad = new Call(() => { throw new Error('oops') }) 44 | const badAsync = new Call(() => Promise.reject('oops')) 45 | 46 | good.chain((x) => x + 1).fork((x) => { 47 | t.equal(x, 43, 'chains with returned value') 48 | }) 49 | 50 | goodAsync.chain((x) => x + 1).fork((x) => { 51 | t.equal(x,43, 'chains with resolved promise value') 52 | }) 53 | 54 | bad.chain((e) => 'Got: ' + e.message).fork((x) => { 55 | t.equal(x, 'Got: oops', 'chains with error value') 56 | }) 57 | 58 | badAsync.chain((e) => 'Got: ' + e).fork((x) => { 59 | t.equal(x, 'Got: oops', 'chains with rejected promise value') 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /test/unit/internals/Task-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tape') 4 | const sinon = require('sinon') 5 | const Task = require('../../../lib/internals/Task').default 6 | 7 | test('Task#fork (with two thunks)', (t) => { 8 | t.plan(2) 9 | 10 | const left = new Task((rej, res) => rej({ type: 'BAD', payload: 'Oops' })) 11 | const right = new Task((rej, res) => res({ type: 'GOOD', payload: 42 })) 12 | 13 | left.fork((action) => { 14 | t.deepEqual(action, { 15 | type: 'BAD', 16 | payload: 'Oops' 17 | }, 'dispatches to the left side of disjunction') 18 | }) 19 | 20 | right.fork(() => {}, (action) => { 21 | t.deepEqual(action, { 22 | type: 'GOOD', 23 | payload: 42 24 | }, 'dispatches to the right side of disjunction') 25 | }) 26 | }) 27 | 28 | test('Task#chain', (t) => { 29 | t.plan(2) 30 | 31 | const k = x => Task.resolve({ type: 'VALUE', payload: x }) 32 | const inc = x => Task.resolve({ type: 'INCREMENT', payload: x + 1 }) 33 | const bad = () => Task.reject({ type: 'BAD', payload: 'Boo-urns' }) 34 | 35 | k(1).chain(({ payload }) => inc(payload)).fork(() => {}, (action) => { 36 | t.deepEqual(action, { 37 | type: 'INCREMENT', 38 | payload: 2 39 | }, 'it chains the computation') 40 | }) 41 | 42 | bad().chain(k).fork((action) => { 43 | t.deepEqual(action, { 44 | type: 'BAD', 45 | payload: 'Boo-urns' 46 | }, 'it stops the chain on failure') 47 | }) 48 | }) 49 | 50 | test('Task#map', (t) => { 51 | t.plan(1) 52 | 53 | const k = Task.resolve({ type: 'VALUE', payload: 'Hello' }) 54 | 55 | k 56 | .map(({ type, payload }) => ({ 57 | type, 58 | payload: `${payload} World!` 59 | })) 60 | .map(({ type, payload }) => Promise.resolve({ 61 | type, 62 | payload: payload.toUpperCase() 63 | })) 64 | .fork(() => {}, (action) => { 65 | t.deepEqual(action, { 66 | type: 'VALUE', 67 | payload: 'HELLO WORLD!' 68 | }, 'maps over payload') 69 | }) 70 | }) 71 | 72 | test('Task.resolve', (t) => { 73 | t.plan(2) 74 | 75 | const task = Task.resolve({ type: 'GOOD', payload: 42 }) 76 | 77 | t.ok(task instanceof Task, 'returns instance of task') 78 | 79 | task.fork(() => {}, (action) => { 80 | t.deepEqual(action, { 81 | type: 'GOOD', 82 | payload: 42 83 | }, 'returns resolved task') 84 | }) 85 | }) 86 | 87 | test('Task.reject', (t) => { 88 | t.plan(2) 89 | 90 | const task = Task.reject({ type: 'BAD', payload: 'Oops' }) 91 | 92 | t.ok(task instanceof Task, 'returns instance of task') 93 | 94 | task.fork((action) => { 95 | t.deepEqual(action, { 96 | type: 'BAD', 97 | payload: 'Oops' 98 | }, 'returns rejected task') 99 | }) 100 | }) 101 | 102 | test('Task#tap', (t) => { 103 | const task = Task.resolve({ type: 'GOOD', payload: 'Hello' }) 104 | const spy = sinon.spy() 105 | Task.tap(spy)(task).fork(() => {}, (action) => { 106 | t.deepEqual(action, { type: 'GOOD', payload: 'Hello' }) 107 | t.ok(spy.called, 'tap is called') 108 | t.deepEqual(spy.firstCall.args[1], { type: 'GOOD', payload: 'Hello' }, 'tap is called with action') 109 | t.equal(spy.firstCall.args[2], false, 'tap is called with rejected boolean') 110 | t.end() 111 | }) 112 | }) 113 | 114 | test('Task#orElse', (t) => { 115 | const goodTask = Task.resolve({ type: 'GOOD', payload: 'great!' }) 116 | const badTask = Task.reject({ type: 'BAD', payload: 'error' }) 117 | const elseCreator = ({ payload }) => Task.resolve({ type: 'GOOD', payload: `${payload} has been handled :)`}) 118 | 119 | badTask.orElse(elseCreator).fork(() => {}, (action) => { 120 | t.deepEqual(action, { type: 'GOOD', payload:'error has been handled :)' }, 'transforms failures to new Task') 121 | }) 122 | 123 | goodTask.orElse(elseCreator).fork(() => {}, (action) => { 124 | t.deepEqual(action, { type: 'GOOD', payload:'great!' }, 'does nothing if Task is already successful') 125 | }) 126 | 127 | t.end() 128 | 129 | }) 130 | 131 | test('Task#cancel', (t) => { 132 | const spy = sinon.spy() 133 | const cleanup = sinon.spy() 134 | const task = new Task((rej, res) => { 135 | res({ type: 'RESOLVED' }) 136 | }, cleanup) 137 | 138 | task.cancel() 139 | task.fork(spy) 140 | 141 | t.ok(!spy.called, 'task should be cancelled') 142 | t.ok(cleanup.called, 'cleanup function should be called') 143 | 144 | t.end() 145 | }) 146 | 147 | test('Task.empty', (t) => { 148 | const task = Task.empty() 149 | task.fork(() => {}, () => { 150 | t.ok(false, 'should not resolve empty task') 151 | }) 152 | // Give the Task a chance to fork. 153 | setTimeout(() => { 154 | t.end() 155 | }, 10) 156 | }) -------------------------------------------------------------------------------- /test/unit/internals/TaskQueue-test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | const sinon = require('sinon') 3 | const call = require('../../../lib/effects').call 4 | const Task = require('../../../lib/internals/Task').default 5 | const TaskQueue = require('../../../lib/internals/TaskQueue').default 6 | 7 | test('TaskQueue', (t) => { 8 | const onResult = sinon.spy() 9 | const queue = new TaskQueue() 10 | 11 | queue.push([ 12 | Task.resolve({ type: 'GOOD', payload: 'Hello Alice!' }), 13 | Task.reject({ type: 'BAD', payload: 'Hello Bob!' }) 14 | ]) 15 | 16 | queue.push(Task.reject({ type: 'BAD', payload: 'Bye!' })) 17 | 18 | queue.run(onResult).then(() => { 19 | t.equal(onResult.callCount, 3) 20 | 21 | t.deepEqual(onResult.firstCall.args[0], { 22 | type: 'GOOD', 23 | payload: 'Hello Alice!' 24 | }) 25 | 26 | t.deepEqual(onResult.secondCall.args[0], { 27 | type: 'BAD', 28 | payload: 'Hello Bob!' 29 | }) 30 | 31 | t.deepEqual(onResult.thirdCall.args[0], { 32 | type: 'BAD', 33 | payload: 'Bye!' 34 | }) 35 | 36 | t.end() 37 | }) 38 | }) 39 | 40 | test('TaskQueue#run (completion)', (t) => { 41 | t.plan(13) 42 | 43 | const effectSpy = sinon.spy() 44 | const onResult = sinon.spy() 45 | const queue = new TaskQueue() 46 | const effect = call(effectSpy, 'Hello Effect!') 47 | 48 | queue.push(effect) 49 | queue.push(Task.resolve({ type: 'GOOD', payload: 'Alice says "Hello!"'})) 50 | 51 | const p = queue.run(onResult) 52 | 53 | t.ok(typeof p.then === 'function', 'returns a promise') 54 | 55 | p.then((results) => { 56 | t.ok(effectSpy.called, 'effect was resolved') 57 | t.ok(effectSpy.calledWith('Hello Effect!'), 'arguments are correct on effect resolution') 58 | 59 | t.ok(true, 'resolves promise on computation completion') 60 | t.equal(queue.size, 0, 'removes successfully completed tasks') 61 | t.deepEqual(onResult.firstCall.args[0], { 62 | type: 'GOOD', payload: 'Alice says "Hello!"' 63 | }) 64 | t.equal(results.length, 1, 'returns results of tasks run') 65 | t.deepEqual(results[0].result, { 66 | type: 'GOOD', payload: 'Alice says "Hello!"' 67 | }, 'result is returned') 68 | }) 69 | 70 | queue.push( 71 | new Task((rej, res, progress) => { 72 | progress({ type: 'PROGRESS' }) 73 | res({ type: 'GOOD', payload: 'Alice says "Bye!"' }) 74 | }) 75 | ) 76 | 77 | const p2 = queue.run(onResult) 78 | 79 | p2.then(() => { 80 | t.equal(onResult.callCount, 3, 'maintains total ordering (first run completes before second)') 81 | t.deepEqual(onResult.secondCall.args[0], { 82 | type: 'PROGRESS' 83 | }) 84 | t.deepEqual(onResult.thirdCall.args[0], { 85 | type: 'GOOD', payload: 'Alice says "Bye!"' 86 | }) 87 | }) 88 | 89 | const p3 = queue.run(onResult) 90 | 91 | p3.then(() => { 92 | t.equal(onResult.callCount, 3, 'resolves promise when no computations are queued') 93 | }) 94 | 95 | queue.push([null, undefined]) 96 | 97 | const p4 = queue.run(onResult) 98 | 99 | p4.then(() => { 100 | t.equal(onResult.callCount, 3, 'resolves promise when tasks are nil') 101 | }) 102 | }) 103 | 104 | test('Task#run (ordering)', (t) => { 105 | const onResult = sinon.spy() 106 | const queue = new TaskQueue() 107 | const message = (msg) => 108 | new Task((rej, res) => { 109 | setTimeout(() => { 110 | res({ type: 'MESSAGE', payload: msg }) 111 | }, Math.random() * 100) 112 | }) 113 | 114 | const error = (msg) => 115 | new Task((rej, res) => { 116 | setTimeout(() => { 117 | rej({ type: 'ERROR', payload: msg }) 118 | }, Math.random() * 100) 119 | }) 120 | 121 | queue.push(message('Hey')) 122 | 123 | queue.push(message('Hello')) 124 | 125 | // Run in the middle before queuing additional tasks. 126 | // Need to ensure that we still maintain ordering even across multiple runs. 127 | queue.run(onResult) 128 | 129 | queue.push(error('Oops')) 130 | 131 | queue.push(message('Bye')) 132 | 133 | queue.run(onResult).then(() => { 134 | t.equal(onResult.callCount, 4, 'dispatched all actions') 135 | 136 | t.deepEqual(onResult.firstCall.args[0], { 137 | type: 'MESSAGE', 138 | payload: 'Hey' 139 | }, 'correctly dispatches first action') 140 | 141 | t.deepEqual(onResult.secondCall.args[0], { 142 | type: 'MESSAGE', 143 | payload: 'Hello' 144 | }, 'correctly dispatches second action') 145 | 146 | t.deepEqual(onResult.thirdCall.args[0], { 147 | type: 'ERROR', 148 | payload: 'Oops' 149 | }, 'correctly dispatches third action') 150 | 151 | t.deepEqual(onResult.getCall(3).args[0], { 152 | type: 'MESSAGE', 153 | payload: 'Bye' 154 | }, 'correctly dispatches last action') 155 | 156 | t.end() 157 | }) 158 | }) 159 | -------------------------------------------------------------------------------- /test/unit/internals/helpers-test.js: -------------------------------------------------------------------------------- 1 | const React = require('react') 2 | const test = require('tape') 3 | const sinon = require('sinon') 4 | const h = require('../../../lib/internals/helpers') 5 | const transact = require('../../../lib/decorators/transact').default 6 | const ReduxTransactContext = require('../../../lib/adapters/redux/ReduxTransactContext').default 7 | const Task = require('../../../lib/internals/Task').default 8 | const call = require('../../../lib/effects').call 9 | 10 | test('compact', (t) => { 11 | t.deepEqual( 12 | h.compact([null, 1, 2, undefined, false, null]), 13 | [1, 2, false], 14 | 'removes nil values from array' 15 | ) 16 | t.end() 17 | }) 18 | 19 | test('applyValueOrPromise', (t) => { 20 | const fn = sinon.spy() 21 | const v = 1 22 | const p = Promise.resolve(2) 23 | 24 | h.applyValueOrPromise(fn, v) 25 | h.applyValueOrPromise(fn, p) 26 | 27 | p.then(() => { 28 | t.ok(fn.callCount, 2, 'applies function with value or promise') 29 | t.equal(fn.firstCall.args[0], 1, 'value is applied to function') 30 | t.equal(fn.secondCall.args[0], 2, 'resolved value of promise is applied to function') 31 | 32 | t.end() 33 | }) 34 | }) 35 | 36 | test('TransactContext: toTasks', (t) => { 37 | const input = [ 38 | call((x) => x, 42), 39 | () => 42, 40 | Task.resolve(42), 41 | Task.reject('oops'), 42 | () => Promise.resolve(42) 43 | ] 44 | 45 | const output = h.toTasks(input) 46 | 47 | t.ok(output.every(x => isComputation(x)), 'returns flat list of computations') 48 | t.end() 49 | }) 50 | 51 | 52 | const isComputation = (x) => 53 | typeof x.fork === 'function' && typeof x.chain === 'function' 54 | -------------------------------------------------------------------------------- /test/unit/internals/taskCreator-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tape') 4 | const Task = require('../../../lib/internals/Task').default 5 | const taskCreator = require('../../../lib/internals/taskCreator').default 6 | 7 | test('taskCreator (sync)', (t) => { 8 | t.plan(4) 9 | 10 | const x = taskCreator('BAD', 'GOOD', () => 42) 11 | const y = taskCreator('BAD', 'GOOD', () => { throw 'Oops' }) 12 | const z = taskCreator('BAD', 'GOOD', 'PENDING', () => 42) 13 | 14 | t.ok(x() instanceof Task, 'returns a task creator') 15 | 16 | x().fork(() => {}, (action) => { 17 | t.deepEqual(action, { 18 | type: 'GOOD', 19 | payload: 42 20 | }, 'forks to right side of the disjunction') 21 | }) 22 | 23 | y().fork((action) => { 24 | t.deepEqual(action, { 25 | type: 'BAD', 26 | payload: 'Oops' 27 | }, 'forks to left side of the disjunction') 28 | }, () => {}) 29 | 30 | z().fork(() => {}, () => {}, (action) => { 31 | t.deepEqual(action, { 32 | type: 'PENDING' 33 | }, 'fork calls progress callback') 34 | }) 35 | }) 36 | 37 | test('taskCreator (async)', (t) => { 38 | t.plan(2) 39 | 40 | const x = taskCreator('BAD', 'GOOD', () => Promise.resolve(42)) 41 | const y = taskCreator('BAD', 'GOOD', () => Promise.reject('Oops')) 42 | 43 | x().fork(() => {}, (action) => { 44 | t.deepEqual(action, { 45 | type: 'GOOD', 46 | payload: 42 47 | }, 'forks to right side of the disjunction') 48 | }) 49 | 50 | y().fork((action) => { 51 | t.deepEqual(action, { 52 | type: 'BAD', 53 | payload: 'Oops' 54 | }, 'forks to left side of the disjunction') 55 | }, () => {}) 56 | }) 57 | 58 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.8.9", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "target": "es2015", 6 | "jsx": "react", 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "outDir": "lib" 10 | }, 11 | "exclude": [ 12 | "typings/browser", 13 | "typings/browser.d.ts", 14 | "node_modules" 15 | ] 16 | } -------------------------------------------------------------------------------- /typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "es6-promise": "registry:npm/es6-promise#3.0.0+20160211003958", 4 | "react-redux": "registry:npm/react-redux#4.4.0+20160207114942", 5 | "redux": "registry:npm/redux#3.0.6+20160214194820" 6 | }, 7 | "ambientDependencies": { 8 | "react": "registry:dt/react#0.14.0+20160319053454", 9 | "react-addons-test-utils": "registry:dt/react-addons-test-utils#0.14.0+20160427035638", 10 | "react-dom": "registry:dt/react-dom#0.14.0+20160316155526", 11 | "react-router": "registry:dt/react-router#2.0.0+20160423062133", 12 | "react-router/history": "registry:dt/react-router/history#2.0.0+20160316155526" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const argv = require('yargs').argv 3 | 4 | module.exports = { 5 | output: { 6 | library: argv.library, 7 | libraryTarget: 'umd' 8 | }, 9 | externals: [ 10 | { 11 | react: { 12 | root: 'React', 13 | commonjs2: 'react', 14 | commonjs: 'react', 15 | amd: 'react', 16 | }, 17 | 'react-router': { 18 | root: 'ReactRouter', 19 | commonjs2: 'react-router', 20 | commonjs: 'react-router', 21 | amd: 'react-router' 22 | }, 23 | redux: { 24 | root: 'Redux', 25 | commonjs2: 'redux', 26 | commonjs: 'redux', 27 | amd: 'redux' 28 | }, 29 | 'react-redux': { 30 | root: 'ReactRedux', 31 | commonjs2: 'react-redux', 32 | commonjs: 'react-redux', 33 | amd: 'react-redux' 34 | } 35 | } 36 | ], 37 | module: { 38 | loaders: [ 39 | { test: /\.js$/, exclude: /node_modules/, loader: 'babel' } 40 | ] 41 | }, 42 | node: { 43 | Buffer: false 44 | }, 45 | plugins: [ 46 | new webpack.optimize.OccurenceOrderPlugin(), 47 | new webpack.DefinePlugin({ 48 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) 49 | }) 50 | ] 51 | } 52 | --------------------------------------------------------------------------------