├── .babelrc ├── .gitignore ├── LICENSE ├── package.json ├── README.md └── src └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "stage-1" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | dist 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Bryan Smith 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aurelia-react-loader", 3 | "version": "1.1.0", 4 | "description": "Load React components directly from Aurelia views", 5 | "main": "dist/index.js", 6 | "directories": { 7 | "lib": "dist/" 8 | }, 9 | "files": [ 10 | "README.md", 11 | "dist" 12 | ], 13 | "scripts": { 14 | "build": "rm -rf dist && babel src --out-dir dist", 15 | "test": "echo \"Error: no test specified\"", 16 | "patch": "release patch", 17 | "minor": "release minor", 18 | "major": "release major" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/bryanrsmith/aurelia-react-loader.git" 23 | }, 24 | "keywords": [ 25 | "aurelia", 26 | "react", 27 | "preact" 28 | ], 29 | "contributors": [ 30 | { 31 | "name": "Bryan R Smith", 32 | "email": "BryanRSmith@gmail.com" 33 | }, 34 | { 35 | "name": "fragsalat", 36 | "email": "t.schlage@gmx.net" 37 | } 38 | ], 39 | "license": "MIT", 40 | "bugs": { 41 | "url": "https://github.com/bryanrsmith/aurelia-react-loader/issues" 42 | }, 43 | "homepage": "https://github.com/bryanrsmith/aurelia-react-loader#readme", 44 | "peerDependencies": { 45 | "react": "^0.14.7", 46 | "react-dom": "^0.14.7", 47 | "aurelia-metadata": "^1.0.0-beta.1.1.6", 48 | "aurelia-templating": "^1.0.0-beta.1.1.2" 49 | }, 50 | "devDependencies": { 51 | "babel": "6.5.2", 52 | "babel-cli": "6.6.4", 53 | "babel-preset-es2015": "6.6.0", 54 | "babel-preset-stage-1": "^6.24.1", 55 | "release-script": "1.0.2" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aurelia-react-loader 2 | This plugin allows you to load and render React/Preact components in your Aurelia application. 3 | 4 | ## Installation 5 | First install the loader plugin. 6 | 7 | ``` 8 | au install aurelia-react-loader 9 | ``` 10 | 11 | Then register the plugin with Aurelia. 12 | 13 | ```diff 14 | export function configure(aurelia) { 15 | aurelia.use 16 | .standardConfiguration() 17 | .developmentLogging() 18 | + .plugin('aurelia-react-loader'); 19 | 20 | aurelia.start().then(() => aurelia.setRoot()); 21 | } 22 | ``` 23 | 24 | ## Usage 25 | 26 | Import react components into Aurelia views just like you import a custom element. 27 | Just specify the `react-component!` loader before the module name. 28 | 29 | In `aurelia-view.html`: 30 | ```html 31 | 35 | ``` 36 | 37 | In `my-react-component.js`: 38 | ```jsx 39 | import React from 'react'; 40 | import PropTypes from 'prop-types'; 41 | 42 | export class MyReactComponent extends React.Component { 43 | static propTypes = { 44 | name: PropTypes.string, 45 | onClick: PropTypes.func 46 | } 47 | 48 | render() { 49 | let { name, onClick } = this.props; 50 | return (); 51 | } 52 | } 53 | ``` 54 | 55 | # Use Preact instead of React 56 | As Preact is way smaller than React I consider it to be the better library to choose (if possible) when adding 3rd-Party components. 57 | 58 | #### Install preact 59 | ```bash 60 | au install preact 61 | au install preact-compat 62 | ``` 63 | 64 | #### Mapping React to Preact 65 | First of all we have to tell our loader to map the load of react to load preact instead. For RequireJS you just have to 66 | add a small part to the loader section in your aurelia.json. For webpack or SystemJS I don't know. 67 | Feel free to tell me how it works so that I can add it here. 68 | ```json 69 | "loader": { 70 | "type": "require", 71 | "configTarget": "vendor-bundle.js", 72 | "includeBundleMetadataInConfig": "auto", 73 | "config": { 74 | "map": { 75 | "*": { 76 | "react": "preact", 77 | "react-dom": "preact-compat" 78 | } 79 | } 80 | }, 81 | ... 82 | } 83 | ``` 84 | 85 | With the new aurelia-cli (> 1.0.0-beta.1) this is not enough. To tell the tracer the same like RequireJS we have to rewrite 86 | the loaded module by using the `onRequiringModule` hook. 87 | ```js 88 | function writeBundles() { 89 | return buildCLI.dest({ 90 | onRequiringModule(moduleId) { 91 | if (moduleId === 'react') { 92 | return ['preact']; 93 | } 94 | if (moduleId === 'react-dom') { 95 | return ['preact-compat']; 96 | } 97 | } 98 | }); 99 | } 100 | ``` 101 | 102 | # How it works 103 | ### General 104 | The aurelia-react-loader hooks into the require loading process and receives an object of react component classes like `{MyReactComponent: class MyReactComponent}`. 105 | All keys within this object are the exported classes, vars, etc and the plugin uses them to register the html tag by converting the name to kebab case. 106 | Now the plugin creates a wrapper class which is actually a aurelia custom element which renders into it's element the react component. 107 | To be able to bind the component props via aurelia's binding engine the plugin creates a bindable attribute for each property defined in `propTypes`. 108 | If you haven't defined propTypes you can also bind a object to props. The plugin also considers the defaultProps and passes a merged object to the createElement function. 109 | 110 | The react element is rendered into a div container beside possible `` first. This ensures all styles are applied 111 | to your component and the lifecycle componentDidMount is really meaning that it's available in the dom. After the element is 112 | rendered and the children were projected the rendered component is appended to the custom element's div element and the previous container is removed. 113 | 114 | ### Content projection 115 | To enable the content projection into react components the plugin passes `createElement('slot')` as children to the component. 116 | Once your component used {this.props.children} and a `` element will be rendered as html the plugin know it must project the content. 117 | To do that the aurelia-react-loader takes the already rendered children from `` and appends them before the `` inside the component html. 118 | After the children are moved, the slot placeholder gets removed and the html is appended to the custom element's `
` element. 119 | 120 | ### Lifecycle 121 | - **CustomElement.attached()** -> React component will be rendered if not done yet and mounted to the dom and therefore `componentDidMount` will be called 122 | - **CustomElement.detached()** -> React component is unmounted from dom and `componentWillUnmount` will be called 123 | - **CustomElement.unbind()** -> React component will be completely destroyed and un-rendered 124 | - **CustomElement.anyChanged()** -> Once any bound attribute changes `componentWillReceiveProps` will be called and props on component will be updated 125 | 126 | 127 | A few things to note: 128 | * React component names are converted to kebab case for safe use in HTML. `` in jsx becomes `` in an HTML Aurelia template. 129 | * Pass props to the React component with the `props` binding. The component will be re-rendered when the binding changes. 130 | * If you need to reference the React component directly it is stored in the `component` property of the custom element's view model. You can use a `ref` binding to access it. 131 | * All functions exported from the required module are assumed to be React components, and wrapped with custom elements. Both stateful and stateless React components are supported. 132 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import {noView, customElement, bindable} from 'aurelia-templating'; 2 | import {decorators} from 'aurelia-metadata'; 3 | import {createElement} from 'react'; 4 | import {render} from 'react-dom'; 5 | 6 | /** 7 | * Configure the aurelia loader to use handle urls with !component 8 | * @param {FrameworkConfiguration} config 9 | */ 10 | export function configure(config) { 11 | const loader = config.aurelia.loader; 12 | loader.addPlugin('react-component', { 13 | fetch(address) { 14 | return loader.loadModule(address) 15 | .then(getComponents); 16 | } 17 | }); 18 | } 19 | 20 | /** 21 | * Extract the components from the loaded module 22 | * @param {Object} module Object containing all exported properties 23 | * @returns {Object} 24 | */ 25 | export function getComponents(module) { 26 | return Object.keys(module).reduce((elements, name) => { 27 | if (typeof module[name] === 'function') { 28 | const elementName = camelToKebab(name); 29 | elements[elementName] = wrapComponent(module[name], elementName); 30 | } 31 | return elements; 32 | }, {}); 33 | } 34 | 35 | /** 36 | * Converts camel case to kebab case 37 | * @param {string} str 38 | * @returns {string} 39 | */ 40 | function camelToKebab(str) { 41 | // Matches all places where a two upper case chars followed by a lower case char are and split them with an hyphen 42 | return str.replace(/([a-zA-Z])([A-Z][a-z])/g, (match, before, after) => 43 | `${before.toLowerCase()}-${after.toLowerCase()}` 44 | ).toLowerCase(); 45 | } 46 | 47 | /** 48 | * Wrap the React components into an ViewModel with bound attributes for the defined PropTypes 49 | * @param {Object} component 50 | * @param {string} elementName 51 | * @returns {Object} 52 | */ 53 | function wrapComponent(component, elementName) { 54 | let bindableProps = []; 55 | if (component.propTypes) { 56 | bindableProps = Object.keys(component.propTypes).map(prop => bindable({ 57 | name: prop, 58 | attribute: camelToKebab(prop), 59 | changeHandler: 'updateProps', 60 | defaultBindingMode: 1 61 | })); 62 | } 63 | return decorators( 64 | noView(), 65 | customElement(elementName), 66 | bindable({name: 'props', attribute: 'props', changeHandler: 'updateProps', defaultBindingMode: 1}), 67 | ...bindableProps 68 | ).on(createWrapperClass(component)); 69 | } 70 | 71 | /** 72 | * Create a wrapper class for the component 73 | * @param {Object} component 74 | * @returns {WrapperClass} 75 | */ 76 | function createWrapperClass(component) { 77 | return class WrapperClass { 78 | static inject = [Element]; 79 | 80 | /** 81 | * @param {Element} element 82 | */ 83 | constructor(element) { 84 | this.element = element; 85 | } 86 | 87 | /** 88 | * Re-render the Preact component when values changed 89 | */ 90 | attached() { 91 | if (!this.component) { 92 | this.render(); 93 | } else if (typeof this.component.componentDidMount === 'function') { 94 | this.component.componentDidMount(); 95 | } 96 | } 97 | 98 | /** 99 | * Triggers un-mound function to release events 100 | */ 101 | detached() { 102 | if (this.component && typeof this.component.componentWillUnmount === 'function') { 103 | this.component.componentWillUnmount(); 104 | } 105 | } 106 | 107 | /** 108 | * Un-render the component 109 | */ 110 | unbind() { 111 | this.component = null; 112 | this.element.component = null; 113 | render('', this.element, this.component); 114 | } 115 | 116 | /** 117 | * Determine props passed to create react elements 118 | * @returns {Object} 119 | */ 120 | getProps() { 121 | const props = this.props || {}; 122 | // Copy bound properties because Object.assign doesn't work deep 123 | for (const prop in this) { 124 | if (this[prop] !== undefined && typeof this[prop] !== 'function') { 125 | props[prop] = this[prop] === '' ? true : this[prop]; 126 | } 127 | } 128 | delete props.element; 129 | 130 | return Object.assign({}, component.defaultProps, props); 131 | } 132 | 133 | /** 134 | * Will be called when bindable updated 135 | */ 136 | updateProps() { 137 | if (this.component && typeof this.component.componentWillReceiveProps === 'function') { 138 | const props = this.getProps(); 139 | this.component.componentWillReceiveProps(props); 140 | this.component.props = props; 141 | } 142 | } 143 | 144 | /** 145 | * Render Preact component 146 | */ 147 | render() { 148 | // Create container in active dom to apply styles already 149 | const container = document.createElement('div'); 150 | this.element.appendChild(container); 151 | 152 | // Render react component with a slot as children into a container to possibly replace the slot with real children 153 | const reactElement = createElement(component, this.getProps(), createElement('slot')); 154 | this.component = render(reactElement, container); 155 | this.element.component = this.component; 156 | 157 | const slot = container.querySelector('slot'); 158 | // If no slot is rendered the component doesn't accept children 159 | if (slot) { 160 | const content = this.element.querySelector('au-content'); 161 | if (!content) { 162 | return; 163 | } 164 | // Move original children to slot position 165 | for (let i = 0; i < content.children.length; i++) { 166 | slot.parentNode.insertBefore(content.children[i], slot); 167 | } 168 | slot.parentNode.removeChild(slot); 169 | this.insertContainerContent(container, content); 170 | } else { 171 | this.insertContainerContent(container); 172 | } 173 | } 174 | 175 | /** 176 | * Moves content of the container into the correct place within this element 177 | * @param {HTMLElement} container 178 | * @param {HTMLElement} replacement 179 | */ 180 | insertContainerContent(container, replacement) { 181 | // Append child to fragment to get rid of container element which can break element flow 182 | const fragment = document.createDocumentFragment(); 183 | for (let i = 0; i < container.children.length; i++) { 184 | fragment.appendChild(container.children[i]); 185 | } 186 | // Either replace au-content or just append if no children are passed 187 | if (replacement) { 188 | this.element.replaceChild(fragment, replacement); 189 | } else { 190 | this.element.appendChild(fragment); 191 | } 192 | // Container is now obsolete as the children are laying directly under the parent 193 | this.element.removeChild(container); 194 | } 195 | }; 196 | } 197 | --------------------------------------------------------------------------------