├── .babelrc ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── rollup.config.js ├── src └── index.js └── test ├── index.test.js └── setup.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "build": { 4 | "presets": [["env", { "modules": false }], "stage-3", "react"], 5 | "plugins": ["external-helpers", "transform-class-properties"] 6 | }, 7 | "buildProd": { 8 | "presets": [["env", { "modules": false }], "stage-3", "react"], 9 | "plugins": [ 10 | "external-helpers", 11 | "transform-class-properties", 12 | [ 13 | "transform-react-remove-prop-types", 14 | { 15 | "mode": "remove", 16 | "removeImport": true 17 | } 18 | ] 19 | ] 20 | }, 21 | "es": { 22 | "presets": [["env", { "modules": false }], "stage-3", "react"], 23 | "plugins": ["transform-class-properties"] 24 | }, 25 | "commonjs": { 26 | "plugins": [ 27 | ["transform-es2015-modules-commonjs", { "loose": true }], 28 | "transform-class-properties" 29 | ], 30 | "presets": ["stage-3", "react"] 31 | }, 32 | "test": { 33 | "presets": ["env", "stage-3", "react"], 34 | "plugins": ["transform-class-properties"] 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | .vscode 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | package-lock.json 26 | dist 27 | es 28 | lib -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxBracketSameLine": true, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | sudo: false 5 | notifications: 6 | email: false 7 | after_success: 8 | # Upload to coveralls, but don't _fail_ if coveralls is down. 9 | - cat coverage/lcov.info | node_modules/.bin/coveralls || echo "Coveralls upload failed" 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### v5.0.3 (2022/4/28) 4 | 5 | - Add React 18 to peer dependencies 6 | 7 | ### v5.0.2 (2021/11/5) 8 | 9 | - Add React 17 to peer dependencies 10 | 11 | ### v5.0.1 (2018/7/29) 12 | 13 | - Smaller file size 14 | 15 | ### v5.0.0 (2018/3/22) 16 | 17 | **Breaking Changes** 18 | 19 | - When a function is included in the `components` array, it will now be called 20 | with a different signature. Previously, it was called with one argument, `results`, 21 | an array of the currently-accumulated results. 22 | 23 | In React Composer v5, the function will be called with an object with two properties, 24 | `results` and `render`. `results` is the same value as before, and `render` is the 25 | render prop that you should place on the [React element](https://reactjs.org/docs/glossary.html#elements) 26 | that is returned by the function. 27 | 28 | - `mapResult` and `renderPropName` have been removed. The new signature of the function 29 | described above gives you the information that you need to map the results, or to use 30 | a custom render prop name. 31 | 32 | If you need help migrating from an earlier version of React Composer, we encourage you to 33 | read the new examples in the README. They demonstrate how you can use the new 34 | API to accomplish the things that you previously used `renderPropName` and `mapResult` for. 35 | 36 | ### v4.1.0 (2018/2/10) 37 | 38 | **Improvements** 39 | 40 | - ~20% smaller file size. 41 | 42 | ### v4.0.0 (2018/2/8) 43 | 44 | **Breaking** 45 | 46 | - The components now render in the opposite order. What this means is that the 47 | first item in the `components` array will not be the _outermost_ element. 48 | Previously, it was the _innermost_ element. 49 | 50 | Although this is technically a breaking change, the most typical usage of 51 | Composer is agnostic to the render order, so there is a chance that you 52 | may not run into any problems when upgrading from v3 to v4. 53 | 54 | ### v3.1.1 (2018/2/8) 55 | 56 | **Bug Fixes** 57 | 58 | - This ensures that the argument passed to functions within the `components` 59 | array is always a new array. 60 | 61 | ### v3.1.0 (2018/2/8) 62 | 63 | **New Features** 64 | 65 | - Within the `components` array, you may now specify a `function` that returns 66 | a [React Element](https://reactjs.org/docs/glossary.html#elements). 67 | The function will be called with the currently accumulated results. 68 | 69 | ### v3.0.1 (2018/2/3) 70 | 71 | **Bug Fixes** 72 | 73 | - Ensures the array passed to the render prop is a new object each time 74 | 75 | ### v3.0.0 (2018/2/3) 76 | 77 | React's new Context API has been finalized, and it uses functional `children` rather than a prop 78 | named `render`. Accordingly, this library has been updated to use `children` as the default. 79 | 80 | **Breaking** 81 | 82 | - `` now uses `children` as the render prop, rather than `render`. 83 | - The default `renderPropName` is now `"children"` rather than `"render"` 84 | 85 | ### v2.1.0 (2018/1/22) 86 | 87 | **New Features** 88 | 89 | - A new prop has been added: `mapResult`. This allows you to use React Composer with 90 | render prop components that call their own render prop with more than one argument. 91 | 92 | ### v2.0.0 (2018/1/22) 93 | 94 | This was the first release of the library. 95 | -------------------------------------------------------------------------------- /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 contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at jamesplease2@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | > :wave: Hey there! Thanks for your interest in helping out. If you happen to 4 | > run into any issues following this guide, please 5 | > [open an issue](https://github.com/jamesplease/react-composer/issues/new?title=Contributing+help), 6 | > and we'll do our best to help out. 7 | 8 | To begin contributing, you'll first need to 9 | [clone this repository](https://help.github.com/articles/cloning-a-repository/), 10 | then navigate into the repository's directory. 11 | 12 | ```sh 13 | git clone git@github.com:jamesplease/react-composer.git 14 | 15 | cd react-composer 16 | ``` 17 | 18 | Next, install the dependencies using [npm](https://www.npmjs.com/). 19 | 20 | ```js 21 | npm install 22 | ``` 23 | 24 | ### Contributing to the Code 25 | 26 | The source files can be found in `./src`. As you work, and/or when you're done, run 27 | `npm test` to run the unit tests. 28 | 29 | Once you're done, go ahead and open a Pull Request. 30 | 31 | > :information_desk_person: Don't sweat it if you can't get the tests to pass, 32 | > or if you can't finish the changes you'd like to make. If you still open up a 33 | > Pull Request, we'll make sure it gets figured out. 34 | 35 | ### One More Thing... 36 | 37 | Thanks again! 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 James, please 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Composer 2 | 3 | [![Travis build status](http://img.shields.io/travis/jamesplease/react-composer.svg?style=flat)](https://travis-ci.org/jamesplease/react-composer) 4 | [![npm version](https://img.shields.io/npm/v/react-composer.svg)](https://www.npmjs.com/package/react-composer) 5 | [![npm downloads](https://img.shields.io/npm/dm/react-composer.svg)](https://www.npmjs.com/package/react-composer) 6 | [![Test Coverage](https://coveralls.io/repos/github/jamesplease/react-composer/badge.svg?branch=master)](https://coveralls.io/github/jamesplease/react-composer?branch=master) 7 | [![gzip size](http://img.badgesize.io/https://unpkg.com/react-composer/dist/react-composer.min.js?compression=gzip)](https://unpkg.com/react-composer/dist/react-composer.min.js) 8 | 9 | Compose [render prop](https://reactjs.org/docs/render-props.html) components. 10 | 11 | ## Motivation 12 | 13 | Render props are great. Using a component with a render prop looks like the following: 14 | 15 | ```jsx 16 | 17 | {result => } 18 | 19 | ``` 20 | 21 | Sometimes you need the result of multiple render prop components inside of `MyComponent`. This 22 | can get messy. 23 | 24 | ```jsx 25 | 26 | {resultOne => ( 27 | 28 | {resultTwo => ( 29 | 30 | {resultThree => ( 31 | 32 | )} 33 | 34 | )} 35 | 36 | )} 37 | 38 | ``` 39 | 40 | Nesting render prop components leads to rightward drift of your code. Use React Composer to 41 | prevent that drift. 42 | 43 | ```jsx 44 | import Composer from 'react-composer'; 45 | 46 | , 49 | , 50 | 51 | ]}> 52 | {([resultOne, resultTwo, resultThree]) => ( 53 | 54 | )} 55 | ; 56 | ``` 57 | 58 | ## Installation 59 | 60 | Install using [npm](https://www.npmjs.com): 61 | 62 | ``` 63 | npm install react-composer 64 | ``` 65 | 66 | or [yarn](https://yarnpkg.com/): 67 | 68 | ``` 69 | yarn add react-composer 70 | ``` 71 | 72 | ## API 73 | 74 | This library has one, default export: `Composer`. 75 | 76 | ### `` 77 | 78 | Compose multiple render prop components together. The props are as 79 | follows: 80 | 81 | ### `props.children` 82 | 83 | A render function that is called with an array of results accumulated from the render prop components. 84 | 85 | ```jsx 86 | 87 | {results => { 88 | /* Do something with results... Return a valid React element. */ 89 | }} 90 | 91 | ``` 92 | 93 | ### `props.components` 94 | 95 | The render prop components to compose. This is an array of [React elements](https://reactjs.org/docs/glossary.html#elements) and/or render functions that are invoked with a render function and the currently accumulated results. 96 | 97 | ```jsx 98 | , 103 | 104 | // Render functions may be passed for added flexibility and control 105 | ({ results, render }) => ( 106 | 107 | ) 108 | ]}> 109 | {([outerResult, middleResult]) => { 110 | /* Do something with results... Return a valid React element. */ 111 | }} 112 | 113 | ``` 114 | 115 | > **Note:** You do not need to provide `props.children` to the React element entries in `props.components`. If you do provide `props.children` to these elements, it will be ignored and overwritten. 116 | 117 | #### `props.components` as render functions 118 | 119 | A render function may be passed instead of a React element for added flexibility. 120 | 121 | Render functions provided must return a valid React element. Render functions will be invoked with an object containing 2 properties: 122 | 123 | 1. `results`: The currently accumulated results. You can use this for render prop components which depend on the results of other render prop components. 124 | 2. `render`: The render function for the component to invoke with the value produced. Plug this into your render prop component. This will typically be plugged in as `props.children` or `props.render`. 125 | 126 | ```jsx 127 | , 131 | ({ /* results, */ render }) => 132 | ]}> 133 | {results => { 134 | /* Do something with results... */ 135 | }} 136 | 137 | ``` 138 | 139 | ## Examples and Guides 140 | 141 | ### Example: Render prop component(s) depending on the result of other render prop component(s) 142 | 143 | ```jsx 144 | , 147 | ({ results: [outerResult], render }) => ( 148 | 149 | ), 150 | ({ results, render }) => ( 151 | 152 | ) 153 | // ... 154 | ]}> 155 | {([outerResult, middleResult, innerResult]) => { 156 | /* Do something with results... */ 157 | }} 158 | 159 | ``` 160 | 161 | ### Example: Render props named other than `props.children`. 162 | 163 | By default, `` will enhance your React elements with `props.children`. 164 | 165 | Render prop components typically use `props.children` or `props.render` as their render prop. Some even accept both. For cases when your render prop component's render prop is not `props.children` you can plug `render` in directly yourself. Example: 166 | 167 | ```jsx 168 | , 172 | ({ render }) => , 173 | ({ render }) => , 174 | ({ render }) => 175 | // ... 176 | ]}> 177 | {results => { 178 | /* Do something with results... */ 179 | }} 180 | 181 | ``` 182 | 183 | ### Example: Render prop component(s) that produce multiple arguments 184 | 185 | Example of how to handle cases when a component passes multiple arguments to its render prop rather than a single argument. 186 | 187 | ```jsx 188 | , 191 | // Differing render prop signature (multi-arg producers) 192 | ({ render }) => ( 193 | 194 | {(one, two) => render([one, two])} 195 | 196 | ), 197 | 198 | ]}> 199 | {([outerResult, [one, two], innerResult]) => { 200 | /* Do something with results... */ 201 | }} 202 | 203 | ``` 204 | 205 | ### Limitations 206 | 207 | This library only works for render prop components that have a single render 208 | prop. So, for instance, this library will not work if your component has an API like the following: 209 | 210 | ```jsx 211 | 212 | ``` 213 | 214 | ### Render Order 215 | 216 | The first item in the `components` array will be the outermost component that is rendered. So, for instance, 217 | if you pass 218 | 219 | ```jsx 220 | , , ]}> 221 | ``` 222 | 223 | then your tree will render like so: 224 | 225 | ``` 226 | - A 227 | - B 228 | - C 229 | ``` 230 | 231 | ### Console Warnings 232 | 233 | Render prop components often specify with [PropTypes](https://reactjs.org/docs/typechecking-with-proptypes.html) 234 | that the render prop is required. When using these components with React Composer, you may get a warning in the 235 | console. 236 | 237 | One way to eliminate the warnings is to define the render prop as an empty function knowning that `Composer` will 238 | overwrite it with the real render function. 239 | 240 | ```jsx 241 | null} /> 244 | ]} 245 | // ... 246 | > 247 | ``` 248 | 249 | Alternatively, you can leverage the flexibility of the `props.components` as functions API and plug the render function in directly yourself. 250 | 251 | ```jsx 252 | 255 | ]} 256 | // ... 257 | > 258 | ``` 259 | 260 | ### Example Usage 261 | 262 | Here are some examples of render prop components that benefit from React Composer: 263 | 264 | * React's [Context API](https://reactjs.org/docs/context.html). See [this example](https://codesandbox.io/s/92pj14134y) by [Kent Dodds](https://twitter.com/kentcdodds). 265 | * [React Request](https://github.com/jamesplease/react-request) 266 | * Apollo's [Query component](https://www.apollographql.com/docs/react/essentials/queries.html#basic) 267 | 268 | Do you know of a component that you think benefits from React Composer? Open a Pull Request and add it to the list! 269 | 270 | ## Contributing 271 | 272 | Are you interested in helping out with this project? That's awesome – thank you! Head on over to 273 | [the contributing guide](./CONTRIBUTING.md) to get started. 274 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | collectCoverageFrom: ['src/**/*.{js,jsx}', '!**/node_modules/**'], 4 | coverageDirectory: 'coverage', 5 | setupTestFrameworkScriptFile: './test/setup.js', 6 | testURL: 'http://localhost/' 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-composer", 3 | "version": "5.0.3", 4 | "description": "Compose render prop components", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "scripts": { 8 | "clean": "rimraf dist es tmp lib", 9 | "test": "jest", 10 | "prepublishOnly": "npm run build", 11 | "prebuild": "npm run clean", 12 | "build": "npm run build:umd && npm run build:umd:min && npm run build:es && npm run build:commonjs", 13 | "build:commonjs": "cross-env BABEL_ENV=commonjs babel src --out-dir lib", 14 | "build:es": "cross-env BABEL_ENV=es babel src --out-dir es", 15 | "build:umd": "cross-env NODE_ENV=development BABEL_ENV=build rollup -c -i src/index.js -o dist/react-composer.js", 16 | "build:umd:min": "cross-env NODE_ENV=production BABEL_ENV=buildProd rollup -c -i src/index.js -o dist/react-composer.min.js" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/jamesplease/react-composer.git" 21 | }, 22 | "keywords": [ 23 | "react", 24 | "render", 25 | "prop", 26 | "compose", 27 | "children" 28 | ], 29 | "author": "James Smith ", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/jamesplease/react-composer/issues" 33 | }, 34 | "files": [ 35 | "dist", 36 | "lib", 37 | "es" 38 | ], 39 | "peerDependencies": { 40 | "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" 41 | }, 42 | "devDependencies": { 43 | "babel-cli": "^6.26.0", 44 | "babel-core": "^6.26.0", 45 | "babel-jest": "^22.1.0", 46 | "babel-plugin-external-helpers": "^6.22.0", 47 | "babel-plugin-transform-class-properties": "^6.24.1", 48 | "babel-plugin-transform-react-remove-prop-types": "^0.4.14", 49 | "babel-preset-env": "^1.6.1", 50 | "babel-preset-react": "^6.24.1", 51 | "babel-preset-stage-3": "^6.24.1", 52 | "coveralls": "^3.0.0", 53 | "cross-env": "^5.1.3", 54 | "enzyme": "^3.3.0", 55 | "enzyme-adapter-react-16": "^1.1.1", 56 | "jest": "^22.1.4", 57 | "react": "^16.2.0", 58 | "react-dom": "^16.2.0", 59 | "react-test-renderer": "^16.2.0", 60 | "rimraf": "^2.6.2", 61 | "rollup": "^0.57.1", 62 | "rollup-plugin-babel": "^3.0.3", 63 | "rollup-plugin-commonjs": "^9.1.0", 64 | "rollup-plugin-node-resolve": "^3.3.0", 65 | "rollup-plugin-replace": "^2.0.0", 66 | "rollup-plugin-uglify": "^3.0.0" 67 | }, 68 | "dependencies": { 69 | "prop-types": "^15.6.0" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from 'rollup-plugin-node-resolve'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import babel from 'rollup-plugin-babel'; 4 | import uglify from 'rollup-plugin-uglify'; 5 | import replace from 'rollup-plugin-replace'; 6 | 7 | var env = process.env.NODE_ENV; 8 | var config = { 9 | output: { 10 | name: 'ReactComposer', 11 | globals: { 12 | react: 'React' 13 | }, 14 | format: 'umd' 15 | }, 16 | external: ['react'], 17 | context: 'this', 18 | plugins: [ 19 | nodeResolve({ 20 | jsnext: true 21 | }), 22 | commonjs({ 23 | include: 'node_modules/**' 24 | }), 25 | babel({ 26 | exclude: 'node_modules/**' 27 | }), 28 | replace({ 29 | 'process.env.NODE_ENV': JSON.stringify(env) 30 | }) 31 | ] 32 | }; 33 | 34 | if (env === 'production') { 35 | config.plugins.push( 36 | uglify({ 37 | compress: { 38 | pure_getters: true, 39 | unsafe: true, 40 | unsafe_comps: true, 41 | warnings: false 42 | } 43 | }) 44 | ); 45 | } 46 | 47 | export default config; 48 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { cloneElement } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default function Composer(props) { 5 | return renderRecursive(props.children, props.components); 6 | } 7 | 8 | Composer.propTypes = { 9 | children: PropTypes.func.isRequired, 10 | components: PropTypes.arrayOf( 11 | PropTypes.oneOfType([PropTypes.element, PropTypes.func]) 12 | ).isRequired 13 | }; 14 | 15 | /** 16 | * Recursively build up elements from props.components and accumulate `results` along the way. 17 | * @param {function} render 18 | * @param {Array.} remaining 19 | * @param {Array} [results] 20 | * @returns {ReactElement} 21 | */ 22 | function renderRecursive(render, remaining, results) { 23 | results = results || []; 24 | // Once components is exhausted, we can render out the results array. 25 | if (!remaining[0]) { 26 | return render(results); 27 | } 28 | 29 | // Continue recursion for remaining items. 30 | // results.concat([value]) ensures [...results, value] instead of [...results, ...value] 31 | function nextRender(value) { 32 | return renderRecursive(render, remaining.slice(1), results.concat([value])); 33 | } 34 | 35 | // Each props.components entry is either an element or function [element factory] 36 | return typeof remaining[0] === 'function' 37 | ? // When it is a function, produce an element by invoking it with "render component values". 38 | remaining[0]({ results, render: nextRender }) 39 | : // When it is an element, enhance the element's props with the render prop. 40 | cloneElement(remaining[0], { children: nextRender }); 41 | } 42 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | import Composer from '../src'; 4 | 5 | // Does nothing other than render so that its props may be inspected 6 | function MyComponent(/* props */) { 7 | return
Inspect my props!
; 8 | } 9 | 10 | function Echo({ value, children }) { 11 | return children({ value }); 12 | } 13 | 14 | function EchoRenderProp({ value, renderProp }) { 15 | return renderProp({ value }); 16 | } 17 | 18 | function DoubleEcho({ value, children }) { 19 | return children(value, value.toUpperCase()); 20 | } 21 | 22 | /** 23 | * Assert expected hierarchy of components 24 | * @param {Wrapper} wrapper 25 | * @param {ReactComponent[]} components 26 | */ 27 | function expectComponentTree(wrapper, components) { 28 | const expectedMsg = 29 | 'Expected component tree: ' + 30 | components.map(({ displayName, name }) => displayName || name).join(' > '); 31 | expect([ 32 | components.reduce((wrapper, selector) => { 33 | return wrapper.find(selector); 34 | }, wrapper).length, 35 | expectedMsg 36 | ]).toEqual([1, expectedMsg]); 37 | } 38 | 39 | describe('React Composer', () => { 40 | describe('Render, no components', () => { 41 | test('It should render the return from `children`', () => { 42 | const mockChildren = jest.fn(() =>
Sandwiches
); 43 | 44 | const wrapper = shallow( 45 | 46 | ); 47 | expect(wrapper.contains(
Sandwiches
)).toBe(true); 48 | expect(mockChildren).toHaveBeenCalledTimes(1); 49 | // The outer array represents all of the arguments passed to the 50 | // mock. The inner array is the empty array of `results` that 51 | // is passed as the first argument. 52 | expect(mockChildren.mock.calls[0]).toEqual([[]]); 53 | }); 54 | }); 55 | 56 | describe('Render, one component', () => { 57 | test('It should render the expected result', () => { 58 | const mockChildren = jest.fn(([result]) =>
{result.value}
); 59 | 60 | const wrapper = mount( 61 | ]} 63 | children={mockChildren} 64 | /> 65 | ); 66 | expect(wrapper.contains(
spaghetti
)).toBe(true); 67 | expect(mockChildren).toHaveBeenCalledTimes(1); 68 | expect(mockChildren.mock.calls[0]).toEqual([ 69 | [ 70 | { 71 | value: 'spaghetti' 72 | } 73 | ] 74 | ]); 75 | }); 76 | }); 77 | 78 | describe('Render, two components', () => { 79 | test('It should render the expected result', () => { 80 | const mockChildren = jest.fn(([resultOne, resultTwo]) => ( 81 |
82 | {resultOne.value} {resultTwo.value} 83 |
84 | )); 85 | 86 | const wrapper = mount( 87 | , ]} 89 | children={mockChildren} 90 | /> 91 | ); 92 | expect(wrapper.contains(
spaghetti pls
)).toBe(true); 93 | expect(mockChildren).toHaveBeenCalledTimes(1); 94 | expect(mockChildren.mock.calls[0]).toEqual([ 95 | [ 96 | { 97 | value: 'spaghetti' 98 | }, 99 | { 100 | value: 'pls' 101 | } 102 | ] 103 | ]); 104 | }); 105 | }); 106 | 107 | describe('Render order', () => { 108 | test('It renders first:Outer, last:Inner', () => { 109 | const Outer = ({ children }) => children('Outer result'); 110 | const Middle = ({ children }) => children('Middle result'); 111 | const Inner = ({ children }) => children('Inner result'); 112 | 113 | const wrapper = mount( 114 | , , ]} 116 | children={results => } 117 | /> 118 | ); 119 | 120 | [ 121 | [Outer, Middle], 122 | [Outer, Middle, Inner], 123 | [Outer, Middle, Inner, MyComponent] 124 | ].forEach(expectedTree => { 125 | expectComponentTree(wrapper, expectedTree); 126 | }); 127 | 128 | expect(wrapper.find(MyComponent).prop('results')).toEqual([ 129 | 'Outer result', 130 | 'Middle result', 131 | 'Inner result' 132 | ]); 133 | }); 134 | }); 135 | 136 | describe('props.components as functions', () => { 137 | test('It enables utilizing outer results for inner components', () => { 138 | const wrapper = mount( 139 | , 143 | 144 | // A function may be passed to produce an element. 145 | // It will be invoked with renderComponentValues. 146 | ({ render, results: [outerResult] }) => ( 147 | 148 | ), 149 | 150 | ({ render, results: [, middleResult] }) => ( 151 | 152 | ) 153 | ]} 154 | children={results => } 155 | /> 156 | ); 157 | 158 | expect(wrapper.find(Echo).length).toEqual(3); 159 | 160 | const outer = wrapper.childAt(0); 161 | expect(outer.prop('value')).toBe('outer'); 162 | 163 | const middle = outer.childAt(0); 164 | expect(middle.prop('value')).toBe('outer + middle'); 165 | 166 | const inner = middle.childAt(0); 167 | expect(inner.prop('value')).toBe('outer + middle + inner'); 168 | 169 | expect(wrapper.find(MyComponent).prop('results')).toEqual([ 170 | { value: 'outer' }, 171 | { value: 'outer + middle' }, 172 | { value: 'outer + middle + inner' } 173 | ]); 174 | }); 175 | 176 | test('It enables composing with varying render prop names', () => { 177 | const wrapper = mount( 178 | , 181 | ({ render }) => 182 | ]} 183 | children={results => } 184 | /> 185 | ); 186 | 187 | expect(wrapper.find(MyComponent).prop('results')).toEqual([ 188 | { value: 'one' }, 189 | { value: 'two' } 190 | ]); 191 | }); 192 | 193 | test('It enables composing with multi-argument producers', () => { 194 | const wrapper = mount( 195 | , 198 | // multi-argument producer 199 | ({ render }) => ( 200 | 201 | {(one, two) => render([one, two])} 202 | 203 | ) 204 | ]} 205 | children={results => } 206 | /> 207 | ); 208 | 209 | expect(wrapper.find(MyComponent).prop('results')).toEqual([ 210 | { value: 'one' }, 211 | ['two', 'TWO'] 212 | ]); 213 | }); 214 | }); 215 | }); 216 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | Enzyme.configure({ adapter: new Adapter() }); 5 | --------------------------------------------------------------------------------