├── .babelrc ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── README.md ├── index.html ├── index.js ├── package-lock.json ├── package.json ├── src ├── MVR.js └── MVRDom.js ├── test.html ├── test ├── ComponentTests.js ├── KeysTests.js └── LifecycleTests.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | 3 | "plugins": ["transform-runtime", "transform-class-properties"], 4 | 5 | "presets": ["es2015"] 6 | 7 | } 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "airbnb", 3 | "plugins": [ 4 | "react", 5 | "jsx-a11y", 6 | "import" 7 | ], 8 | "globals": { 9 | document: true, 10 | window: true, 11 | describe: true, 12 | it: true, 13 | before: true, 14 | beforeEach: true, 15 | after: true, 16 | sinon: true, 17 | expect: true 18 | }, 19 | "rules": { 20 | "react/jsx-no-bind": [0], 21 | "react/prop-types": [0], 22 | "react/no-multi-comp": [0], 23 | "react/prefer-stateless-function": [0], 24 | "react/style-prop-object": [0], 25 | indent: ["error", 4], 26 | "comma-dangle": ["error", { 27 | "arrays": "never", 28 | "objects": "never", 29 | "imports": "never", 30 | "exports": "never", 31 | "functions": "never" 32 | }], 33 | "max-len": [0], 34 | "no-param-reassign": [0], 35 | "no-console": [0], 36 | "no-underscore-dangle": [0], 37 | "no-else-return": [0], 38 | "new-cap": [0], 39 | "no-lonely-if": [0], 40 | "one-var": [0], 41 | "no-plusplus": [0] 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | lib 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | src 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Minimum Viable React 2 | 3 | A ~500 line React style framework I created to better understand VDOM. 4 | [Read more here](https://www.vividbytes.io/Minimum-Viable-React_Part-1/) 5 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Todo App 5 | 11 | 12 | 13 | 14 |
15 | 16 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | MVR: require('./lib/MVR').default, 3 | MVRDom: require('./lib/MVRDom').default 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minimum-viable-react", 3 | "version": "0.0.3", 4 | "description": "A minimal React type framework", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/vividbytes/MinimumViableReact.git" 8 | }, 9 | "devDependencies": { 10 | "chai": "^3.4.1", 11 | "mocha": "^3.0.1", 12 | "babel": "^6.5.2", 13 | "sinon": "^2.2.0", 14 | "sinon-chai": "^2.8.0", 15 | "babel-core": "^6.22.1", 16 | "babel-eslint": "^7.1.1", 17 | "babel-loader": "^6.2.10", 18 | "babel-plugin-transform-class-properties": "^6.22.0", 19 | "babel-plugin-transform-runtime": "^6.22.0", 20 | "babel-preset-es2015": "^6.22.0", 21 | "babel-preset-react": "^6.22.0", 22 | "babel-preset-stage-0": "^6.22.0", 23 | "eslint": "^3.14.1", 24 | "eslint-config-airbnb": "^14.0.0", 25 | "eslint-plugin-import": "^2.2.0", 26 | "eslint-plugin-jsx-a11y": "^3.0.2", 27 | "eslint-plugin-react": "^6.9.0", 28 | "webpack": "^1.14.0" 29 | }, 30 | "dependencies": { 31 | "babel-runtime": "^6.22.0" 32 | }, 33 | "scripts": { 34 | "prepublish": "node_modules/.bin/babel -d lib src --copy-files" 35 | }, 36 | "author": "vividbytes", 37 | "license": "MIT" 38 | } 39 | -------------------------------------------------------------------------------- /src/MVR.js: -------------------------------------------------------------------------------- 1 | function flatten(arr) { 2 | return arr.reduce((flat, toFlatten) => 3 | flat.concat(Array.isArray(toFlatten) ? 4 | flatten(toFlatten) : 5 | toFlatten 6 | ) 7 | , []); 8 | } 9 | 10 | const createElement = (type, attributes = {}, children = []) => { 11 | const childElements = flatten(children).map(child => ( 12 | typeof child === 'string' ? 13 | createElement('text', { textContent: child }) : 14 | child 15 | )).filter(child => child); 16 | 17 | return { 18 | type, 19 | children: childElements, 20 | props: Object.assign( 21 | { children: childElements }, 22 | attributes 23 | ) 24 | }; 25 | }; 26 | 27 | class Component { 28 | constructor(props) { 29 | this.props = props || {}; 30 | this.state = {}; 31 | this.onStateChange = () => {}; 32 | this._domElement = null; 33 | } 34 | 35 | componentWillMount() {} 36 | 37 | componentDidMount() {} 38 | 39 | componentWillReceiveProps() {} 40 | 41 | shouldComponentUpdate() { return true; } 42 | 43 | componentWillUpdate() {} 44 | 45 | componentDidUpdate() {} 46 | 47 | componentWillUnmount() {} 48 | 49 | setState(newState) { 50 | const prevState = this.state; 51 | const nextState = Object.assign({}, prevState || {}, newState); 52 | this.onStateChange(this, nextState); 53 | } 54 | 55 | setStateCallback(cb) { 56 | this.onStateChange = cb; 57 | } 58 | 59 | setChild(component) { 60 | this._child = component; 61 | component._parentComponent = this; 62 | } 63 | 64 | getDomElement() { 65 | return this._domElement; 66 | } 67 | 68 | setDomElement(domElement) { 69 | this._domElement = domElement; 70 | } 71 | 72 | getChild() { 73 | return this._child; 74 | } 75 | 76 | getRoot() { 77 | let component = this; 78 | let res; 79 | while (component) { 80 | res = component; 81 | component = component._parentComponent; 82 | } 83 | return res; 84 | } 85 | 86 | updateProps(newProps) { 87 | this.props = newProps; 88 | } 89 | 90 | updateState(newState) { 91 | this.state = newState; 92 | } 93 | 94 | render() {} 95 | } 96 | 97 | export default { 98 | createElement, 99 | Component 100 | }; 101 | -------------------------------------------------------------------------------- /src/MVRDom.js: -------------------------------------------------------------------------------- 1 | const Reconciler = { 2 | 3 | diff: (virtualElement, container, oldDomElement, parentComponent) => { 4 | const oldVirtualElement = oldDomElement && oldDomElement._virtualElement; 5 | const oldComponent = oldVirtualElement && oldVirtualElement.component; 6 | 7 | if (typeof virtualElement.type === 'function') { 8 | Reconciler.diffComponent(virtualElement, oldComponent, container, oldDomElement, parentComponent); 9 | } else if ( 10 | oldVirtualElement && 11 | oldVirtualElement.type === virtualElement.type && 12 | oldComponent === virtualElement.component 13 | ) { 14 | if (oldVirtualElement.type === 'text') { 15 | Reconciler.updateTextNode(oldDomElement, virtualElement, oldVirtualElement); 16 | } else { 17 | Reconciler.updateDomElement(oldDomElement, virtualElement, oldVirtualElement); 18 | } 19 | 20 | // save the virtualElement on the domElement 21 | // so that we can retrieve it next time 22 | oldDomElement._virtualElement = virtualElement; 23 | 24 | Reconciler.diffList(virtualElement.children, oldDomElement); 25 | } else { 26 | Reconciler.mountElement(virtualElement, container, oldDomElement); 27 | } 28 | }, 29 | 30 | getKey: (virtualElement) => { 31 | if (!virtualElement) { return undefined; } 32 | 33 | const component = virtualElement.component; 34 | 35 | return component ? component.props.key : virtualElement.props.key; 36 | }, 37 | 38 | diffList: (virtualElements, parentDomElement) => { 39 | const keyedElements = {}; 40 | const unkeyedElements = []; 41 | 42 | for (let i = 0; i < parentDomElement.childNodes.length; i += 1) { 43 | const domElement = parentDomElement.childNodes[i]; 44 | const key = Reconciler.getKey(domElement._virtualElement); 45 | 46 | if (key) { 47 | keyedElements[key] = domElement; 48 | } else { 49 | unkeyedElements.push(domElement); 50 | } 51 | } 52 | 53 | let unkeyedIndex = 0; 54 | virtualElements.forEach((virtualElement, i) => { 55 | const key = virtualElement.props.key; 56 | if (key) { 57 | const keyedDomElement = keyedElements[key]; 58 | if (keyedDomElement) { 59 | // move to correct location 60 | if ( 61 | parentDomElement.childNodes[i] && 62 | !parentDomElement.childNodes[i].isSameNode(keyedDomElement) 63 | ) { 64 | if (parentDomElement.childNodes[i]) { 65 | parentDomElement.insertBefore( 66 | keyedDomElement, 67 | parentDomElement.childNodes[i] 68 | ); 69 | } else { 70 | parentDomElement.append(keyedDomElement); 71 | } 72 | } 73 | 74 | Reconciler.diff(virtualElement, parentDomElement, keyedDomElement); 75 | } else { 76 | const placeholder = document.createElement('span'); 77 | if (parentDomElement.childNodes[i]) { 78 | parentDomElement.insertBefore(placeholder, parentDomElement.childNodes[i]); 79 | } else { 80 | parentDomElement.append(placeholder); 81 | } 82 | Reconciler.mountElement(virtualElement, parentDomElement, placeholder); 83 | } 84 | } else { 85 | const unkeyedDomElement = unkeyedElements[unkeyedIndex]; 86 | if (unkeyedElements) { 87 | if ( 88 | parentDomElement.childNodes[i] && 89 | !parentDomElement.childNodes[i].isSameNode(unkeyedDomElement) 90 | ) { 91 | if (parentDomElement.childNodes[i]) { 92 | parentDomElement.insertBefore( 93 | unkeyedDomElement, 94 | parentDomElement.childNodes[i] 95 | ); 96 | } else { 97 | parentDomElement.append(unkeyedDomElement); 98 | } 99 | } 100 | 101 | Reconciler.diff(virtualElement, parentDomElement, unkeyedDomElement); 102 | } else { 103 | const placeholder = document.createElement('span'); 104 | if (parentDomElement.childNodes[i]) { 105 | parentDomElement.insertBefore(placeholder, parentDomElement.childNodes[i]); 106 | } else { 107 | parentDomElement.append(placeholder); 108 | } 109 | Reconciler.mountElement(virtualElement, parentDomElement, placeholder); 110 | } 111 | unkeyedIndex += 1; 112 | } 113 | }); 114 | 115 | 116 | // remove extra children 117 | const oldChildren = parentDomElement.childNodes; 118 | while (oldChildren.length > virtualElements.length) { 119 | Reconciler.unmountNode(oldChildren[virtualElements.length]); 120 | } 121 | }, 122 | 123 | diffComponent: (newVirtualElement, oldComponent, container, domElement, parentComponent) => { 124 | if ( 125 | oldComponent && 126 | newVirtualElement.type === oldComponent.constructor 127 | ) { 128 | oldComponent.componentWillReceiveProps(newVirtualElement.props); 129 | 130 | if (oldComponent.shouldComponentUpdate(newVirtualElement.props)) { 131 | const prevProps = oldComponent.props; 132 | oldComponent.componentWillUpdate(newVirtualElement.props, oldComponent.state); 133 | 134 | // update component 135 | oldComponent.updateProps(newVirtualElement.props); 136 | const nextElement = oldComponent.render(); 137 | nextElement.component = parentComponent || oldComponent; 138 | 139 | const childComponent = oldComponent.getChild(); 140 | 141 | if (childComponent) { 142 | Reconciler.diffComponent( 143 | nextElement, 144 | childComponent, 145 | container, 146 | domElement, 147 | oldComponent 148 | ); 149 | } else { 150 | Reconciler.diff(nextElement, container, domElement, oldComponent); 151 | } 152 | 153 | oldComponent.componentDidUpdate(prevProps); 154 | } 155 | } else { 156 | let component = oldComponent; 157 | while (component) { 158 | component.componentWillUnmount(); 159 | component._didUnmount = true; 160 | component.setDomElement(null); 161 | component = component.getChild(); 162 | } 163 | 164 | Reconciler.mountElement(newVirtualElement, container, domElement, parentComponent); 165 | } 166 | }, 167 | 168 | unmountNode: (domElement, parentComponent) => { 169 | const virtualElement = domElement._virtualElement; 170 | if (!virtualElement) { 171 | domElement.remove(); 172 | return; 173 | } 174 | 175 | if (!parentComponent) { 176 | let component = virtualElement.component; 177 | while (component && !component._didUnmount) { 178 | component.componentWillUnmount(); 179 | component.setDomElement(undefined); 180 | component = component.getChild(); 181 | } 182 | } 183 | 184 | while (domElement.childNodes.length > 0) { 185 | Reconciler.unmountNode(domElement.firstChild); 186 | } 187 | 188 | if (virtualElement.props.ref) { 189 | virtualElement.props.ref(null); 190 | } 191 | 192 | Object.keys(virtualElement.props).forEach((propName) => { 193 | if (propName.slice(0, 2) === 'on') { 194 | const event = propName.toLowerCase().slice(2); 195 | const handler = virtualElement.props[propName]; 196 | domElement.removeEventListener(event, handler); 197 | } 198 | }); 199 | 200 | domElement.remove(); 201 | }, 202 | 203 | updateTextNode: (domElement, newVirtualElement, oldVirtualElement) => { 204 | if (newVirtualElement.props.textContent !== oldVirtualElement.props.textContent) { 205 | domElement.textContent = newVirtualElement.props.textContent; 206 | } 207 | 208 | // save the virtualElement on the domElement 209 | // so that we can retrieve it next time 210 | domElement._virtualElement = newVirtualElement; 211 | }, 212 | 213 | updateDomElement: (domElement, newVirtualElement, oldVirtualElement = {}) => { 214 | const newProps = newVirtualElement.props; 215 | const oldProps = oldVirtualElement.props || {}; 216 | 217 | Object.keys(newProps).forEach((propName) => { 218 | const newProp = newProps[propName]; 219 | const oldProp = oldProps[propName]; 220 | 221 | if (newProp !== oldProp) { 222 | if (propName.slice(0, 2) === 'on') { 223 | // prop is an event handler 224 | const eventName = propName.toLowerCase().slice(2); 225 | domElement.addEventListener(eventName, newProp, false); 226 | if (oldProp) { 227 | domElement.removeEventListener(eventName, oldProp, false); 228 | } 229 | } else if (propName === 'value' || propName === 'checked') { 230 | // this are special attributes that cannot be set 231 | // using setAttribute 232 | domElement[propName] = newProp; 233 | } else if (propName !== 'key' && propName !== 'children') { // ignore the 'children' prop 234 | domElement.setAttribute(propName, newProps[propName]); 235 | } 236 | } 237 | }); 238 | 239 | // remove oldProps 240 | Object.keys(oldProps).forEach((propName) => { 241 | const newProp = newProps[propName]; 242 | const oldProp = oldProps[propName]; 243 | 244 | if (!newProp) { 245 | if (propName.slice(0, 2) === 'on') { 246 | // prop is an event handler 247 | domElement.removeEventListener(propName, oldProp, false); 248 | } else if (propName !== 'children') { // ignore the 'children' prop 249 | domElement.removeAttribute(propName); 250 | } 251 | } 252 | }); 253 | }, 254 | 255 | mountComponent: (virtualElement, container, oldDomElement, parentComponent) => { 256 | const component = new virtualElement.type(virtualElement.props); 257 | component.setStateCallback(Reconciler.handleComponentStateChange); 258 | 259 | const nextElement = component.render(); 260 | 261 | if (parentComponent) { 262 | const root = parentComponent.getRoot(); 263 | nextElement.component = root; 264 | parentComponent.setChild(component); 265 | } else { 266 | nextElement.component = component; 267 | } 268 | 269 | component.componentWillMount(); 270 | 271 | if (typeof nextElement.type === 'function') { 272 | Reconciler.mountComponent(nextElement, container, oldDomElement, component); 273 | } else { 274 | Reconciler.mountElement(nextElement, container, oldDomElement, parentComponent); 275 | } 276 | 277 | component.componentDidMount(); 278 | 279 | if (component.props.ref) { 280 | component.props.ref(component); 281 | } 282 | }, 283 | 284 | handleComponentStateChange(component, nextState) { 285 | const prevState = component.state; 286 | if (component.shouldComponentUpdate(component.props, nextState)) { 287 | component.componentWillUpdate(component.props, nextState); 288 | component.updateState(nextState); 289 | 290 | const nextElement = component.render(); 291 | nextElement.component = component.getRoot(); 292 | 293 | // start the normal diffing process here 294 | const domElement = component.getDomElement(); 295 | const container = domElement.parentNode; 296 | const childComponent = component.getChild(); 297 | if (childComponent) { 298 | Reconciler.diffComponent( 299 | nextElement, 300 | childComponent, 301 | container, 302 | domElement, 303 | component 304 | ); 305 | } else { 306 | Reconciler.diff(nextElement, container, domElement, component); 307 | } 308 | 309 | component.componentDidUpdate(component.props, prevState); 310 | } 311 | }, 312 | 313 | mountSimpleNode: (virtualElement, container, oldDomElement, parentComponent) => { 314 | let newDomElement; 315 | const nextSibling = oldDomElement && oldDomElement.nextSibling; 316 | 317 | if (virtualElement.type === 'text') { 318 | newDomElement = document.createTextNode(virtualElement.props.textContent); 319 | } else { 320 | newDomElement = document.createElement(virtualElement.type); 321 | // set dom-node attributes 322 | Reconciler.updateDomElement(newDomElement, virtualElement); 323 | } 324 | 325 | // save the virtualElement on the domElement 326 | // so that we can retrieve it next time 327 | newDomElement._virtualElement = virtualElement; 328 | 329 | // remove the old node from the dom if one exists 330 | if (oldDomElement) { 331 | Reconciler.unmountNode(oldDomElement, parentComponent); 332 | } 333 | 334 | // add the newly created node to the dom 335 | if (nextSibling) { 336 | container.insertBefore(newDomElement, nextSibling); 337 | } else { 338 | container.appendChild(newDomElement); 339 | } 340 | 341 | // add reference to domElement into component 342 | let component = virtualElement.component; 343 | while (component) { 344 | component.setDomElement(newDomElement); 345 | component = component.getChild(); 346 | } 347 | 348 | // recursively call mountElement with all child virtualElements 349 | virtualElement.children.forEach((childElement) => { 350 | Reconciler.mountElement(childElement, newDomElement); 351 | }); 352 | 353 | if (virtualElement.props.ref) { 354 | virtualElement.props.ref(newDomElement); 355 | } 356 | }, 357 | 358 | mountElement: (virtualElement, container, oldDomElement, parentComponent) => { 359 | if (typeof virtualElement.type === 'function') { 360 | Reconciler.mountComponent(virtualElement, container, oldDomElement, parentComponent); 361 | } else { 362 | Reconciler.mountSimpleNode(virtualElement, container, oldDomElement, parentComponent); 363 | } 364 | } 365 | }; 366 | 367 | export default { 368 | 369 | render: (virtualElement, container) => { 370 | Reconciler.diff(virtualElement, container, container.firstChild); 371 | } 372 | 373 | }; 374 | 375 | -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Mocha Tests 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /test/ComponentTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | /* eslint-disable no-bitwise */ 3 | /* eslint-disable class-methods-use-this */ 4 | 5 | import sinon from 'sinon'; 6 | import chai from 'chai'; 7 | import sinonChai from 'sinon-chai'; 8 | import MVR from '../src/part4/MVR'; 9 | import MVRDom from '../src/part4/MVRDom'; 10 | 11 | const expect = chai.expect; 12 | chai.use(sinonChai); 13 | 14 | function getAttributes(node) { 15 | const attrs = {}; 16 | if (node.attributes) { 17 | for (let i = node.attributes.length; i--;) { 18 | attrs[node.attributes[i].name] = node.attributes[i].value; 19 | } 20 | } 21 | return attrs; 22 | } 23 | 24 | // hacky normalization of attribute order across browsers. 25 | function sortAttributes(html) { 26 | return html.replace(/<([a-z0-9-]+)((?:\s[a-z0-9:_.-]+=".*?")+)((?:\s*\/)?>)/gi, (s, pre, attrs, after) => { 27 | const list = attrs.match(/\s[a-z0-9:_.-]+=".*?"/gi).sort((a, b) => (a > b ? 1 : -1)); 28 | if (~after.indexOf('/')) after = `>`; 29 | return `<${pre + list.join('') + after}`; 30 | }); 31 | } 32 | 33 | describe('Components', () => { 34 | let scratch; 35 | 36 | before(() => { 37 | scratch = document.createElement('div'); 38 | (document.body || document.documentElement).appendChild(scratch); 39 | }); 40 | 41 | beforeEach(() => { 42 | scratch.innerHTML = ''; 43 | }); 44 | 45 | after(() => { 46 | scratch.parentNode.removeChild(scratch); 47 | scratch = null; 48 | }); 49 | 50 | it('should render components', () => { 51 | class C1 extends MVR.Component { 52 | render() { 53 | return MVR.createElement('div', {}, ['C1']); 54 | } 55 | } 56 | 57 | sinon.spy(C1.prototype, 'render'); 58 | 59 | MVRDom.render(MVR.createElement(C1), scratch); 60 | 61 | expect(C1.prototype.render) 62 | .to.have.been.calledOnce 63 | .and.to.have.returned(sinon.match({ type: 'div' })); 64 | 65 | expect(scratch.innerHTML).to.equal('
C1
'); 66 | }); 67 | 68 | it('should render components with props', () => { 69 | const PROPS = { foo: 'bar', onBaz: () => {} }; 70 | let constructorProps; 71 | 72 | class C2 extends MVR.Component { 73 | constructor(props) { 74 | super(props); 75 | constructorProps = props; 76 | } 77 | 78 | render() { 79 | return MVR.createElement('div', this.props); 80 | } 81 | } 82 | 83 | sinon.spy(C2.prototype, 'render'); 84 | 85 | MVRDom.render(MVR.createElement(C2, PROPS), scratch); 86 | 87 | expect(constructorProps).to.deep.include(PROPS); 88 | 89 | expect(C2.prototype.render) 90 | .to.have.been.calledOnce 91 | .and.to.have.returned(sinon.match({ 92 | type: 'div', 93 | props: PROPS 94 | })); 95 | 96 | expect(scratch.innerHTML).to.equal('
'); 97 | }); 98 | 99 | it('should remove orphaned elements replaced by Components', () => { 100 | class Comp extends MVR.Component { 101 | render() { 102 | return MVR.createElement('span', {}, ['span in a component']); 103 | } 104 | } 105 | 106 | let root; 107 | function test(content) { 108 | root = MVRDom.render(content, scratch, root); 109 | } 110 | 111 | test(MVR.createElement(Comp)); 112 | test(MVR.createElement('div', {}, ['just a div'])); 113 | test(MVR.createElement(Comp)); 114 | 115 | expect(scratch.innerHTML).to.equal('span in a component'); 116 | }); 117 | 118 | describe('High-Order Components', () => { 119 | it('should render nested components', () => { 120 | const PROPS = { foo: 'bar', onBaz: () => {} }; 121 | 122 | class Inner extends MVR.Component { 123 | render() { 124 | return MVR.createElement('div', this.props, ['inner']); 125 | } 126 | } 127 | 128 | class Outer extends MVR.Component { 129 | render() { 130 | return MVR.createElement(Inner, this.props); 131 | } 132 | } 133 | 134 | const InnerSpy = sinon.spy(Inner.prototype, 'render'); 135 | const OuterSpy = sinon.spy(Outer.prototype, 'render'); 136 | 137 | MVRDom.render(MVR.createElement(Outer, PROPS), scratch); 138 | 139 | expect(OuterSpy) 140 | .to.have.been.calledOnce 141 | .and.to.have.returned(sinon.match({ 142 | type: Inner, 143 | props: PROPS 144 | })); 145 | 146 | expect(InnerSpy) 147 | .to.have.been.calledOnce 148 | .and.to.have.returned(sinon.match({ 149 | type: 'div', 150 | props: PROPS, 151 | children: [MVR.createElement('text', { textContent: 'inner' })] 152 | })); 153 | 154 | expect(scratch.innerHTML).to.equal('
inner
'); 155 | }); 156 | 157 | it('should re-render nested components', () => { 158 | let doRender = null, 159 | alt = false; 160 | 161 | let j = 0; 162 | class Inner extends MVR.Component { 163 | constructor(...args) { 164 | super(); 165 | this._constructor(...args); 166 | } 167 | _constructor() {} 168 | componentWillMount() {} 169 | componentDidMount() {} 170 | componentWillUnmount() {} 171 | render() { 172 | return MVR.createElement('div', Object.assign( 173 | { 174 | j: ++j // eslint-disable-line no-plusplus 175 | }, 176 | this.props 177 | ), ['inner']); 178 | } 179 | } 180 | 181 | class Outer extends MVR.Component { 182 | componentDidMount() { 183 | let i = 1; 184 | this.state = {}; 185 | doRender = () => this.setState({ i: ++i }); // eslint-disable-line no-plusplus 186 | } 187 | componentWillUnmount() { 188 | } 189 | render() { 190 | if (alt) return MVR.createElement('div', { 'is-alt': true }); 191 | return MVR.createElement(Inner, Object.assign({ i: this.state.i }, this.props)); 192 | } 193 | } 194 | 195 | sinon.spy(Outer.prototype, 'render'); 196 | sinon.spy(Outer.prototype, 'componentDidMount'); 197 | sinon.spy(Outer.prototype, 'componentWillUnmount'); 198 | 199 | sinon.spy(Inner.prototype, '_constructor'); 200 | sinon.spy(Inner.prototype, 'render'); 201 | sinon.spy(Inner.prototype, 'componentWillMount'); 202 | sinon.spy(Inner.prototype, 'componentDidMount'); 203 | sinon.spy(Inner.prototype, 'componentWillUnmount'); 204 | 205 | MVRDom.render(MVR.createElement(Outer, { foo: 'bar' }), scratch); 206 | 207 | expect(Outer.prototype.componentDidMount).to.have.been.calledOnce; 208 | 209 | doRender(); 210 | 211 | expect(Outer.prototype.componentWillUnmount).not.to.have.been.called; 212 | 213 | expect(Inner.prototype._constructor).to.have.been.calledOnce; 214 | expect(Inner.prototype.componentWillUnmount).not.to.have.been.called; 215 | expect(Inner.prototype.componentWillMount).to.have.been.calledOnce; 216 | expect(Inner.prototype.componentDidMount).to.have.been.calledOnce; 217 | expect(Inner.prototype.render).to.have.been.calledTwice; 218 | 219 | expect(Inner.prototype.render.secondCall) 220 | .to.have.returned(sinon.match({ 221 | props: { 222 | j: 2, 223 | i: 2, 224 | foo: 'bar' 225 | } 226 | })); 227 | 228 | expect(getAttributes(scratch.firstElementChild)).to.eql({ 229 | j: '2', 230 | i: '2', 231 | foo: 'bar' 232 | }); 233 | 234 | expect(sortAttributes(scratch.innerHTML)).to.equal(sortAttributes('
inner
')); 235 | 236 | doRender(); 237 | 238 | expect(Inner.prototype.componentWillUnmount).not.to.have.been.called; 239 | expect(Inner.prototype.componentWillMount).to.have.been.calledOnce; 240 | expect(Inner.prototype.componentDidMount).to.have.been.calledOnce; 241 | expect(Inner.prototype.render).to.have.been.calledThrice; 242 | 243 | expect(Inner.prototype.render.thirdCall) 244 | .to.have.returned(sinon.match({ 245 | props: { 246 | j: 3, 247 | i: 3, 248 | foo: 'bar' 249 | } 250 | })); 251 | 252 | expect(getAttributes(scratch.firstElementChild)).to.eql({ 253 | j: '3', 254 | i: '3', 255 | foo: 'bar' 256 | }); 257 | 258 | 259 | alt = true; 260 | doRender(); 261 | 262 | expect(Inner.prototype.componentWillUnmount).to.have.been.calledOnce; 263 | 264 | expect(scratch.innerHTML).to.equal('
'); 265 | 266 | alt = false; 267 | doRender(); 268 | 269 | expect(sortAttributes(scratch.innerHTML)).to.equal(sortAttributes('
inner
')); 270 | }); 271 | 272 | it('should unmount children of high-order components without unmounting parent', () => { 273 | let outer; 274 | let inner2; // eslint-disable-line no-unused-vars 275 | let counter = 0; 276 | 277 | class Outer extends MVR.Component { 278 | constructor(props, context) { 279 | super(props, context); 280 | outer = this; 281 | this.state = { 282 | child: this.props.child 283 | }; 284 | } 285 | componentWillMount() {} 286 | componentDidMount() {} 287 | componentWillUnmount() { 288 | } 289 | render() { 290 | return MVR.createElement(this.state.child); 291 | } 292 | } 293 | 294 | sinon.spy(Outer.prototype, 'render'); 295 | sinon.spy(Outer.prototype, 'componentDidMount'); 296 | sinon.spy(Outer.prototype, 'componentWillMount'); 297 | sinon.spy(Outer.prototype, 'componentWillUnmount'); 298 | 299 | class Inner extends MVR.Component { 300 | componentWillUnmount() {} 301 | componentWillMount() {} 302 | componentDidMount() {} 303 | render() { 304 | return MVR.createElement('span', {}, [`element ${++counter}`]); 305 | } 306 | } 307 | 308 | sinon.spy(Inner.prototype, 'render'); 309 | sinon.spy(Inner.prototype, 'componentDidMount'); 310 | sinon.spy(Inner.prototype, 'componentWillMount'); 311 | sinon.spy(Inner.prototype, 'componentWillUnmount'); 312 | 313 | class Inner2 extends MVR.Component { 314 | constructor(props, context) { 315 | super(props, context); 316 | inner2 = this; 317 | } 318 | componentWillUnmount() {} 319 | componentWillMount() {} 320 | componentDidMount() {} 321 | render() { 322 | return MVR.createElement('span', {}, [`element ${++counter}`]); 323 | } 324 | } 325 | 326 | sinon.spy(Inner2.prototype, 'render'); 327 | sinon.spy(Inner2.prototype, 'componentDidMount'); 328 | sinon.spy(Inner2.prototype, 'componentWillMount'); 329 | sinon.spy(Inner2.prototype, 'componentWillUnmount'); 330 | 331 | MVRDom.render(MVR.createElement(Outer, { child: Inner }), scratch); 332 | 333 | // outer should only have been mounted once 334 | expect(Outer.prototype.componentWillMount, 'outer initial').to.have.been.calledOnce; 335 | expect(Outer.prototype.componentDidMount, 'outer initial').to.have.been.calledOnce; 336 | expect(Outer.prototype.componentWillUnmount, 'outer initial').not.to.have.been.called; 337 | 338 | // inner should only have been mounted once 339 | expect(Inner.prototype.componentWillMount, 'inner initial').to.have.been.calledOnce; 340 | expect(Inner.prototype.componentDidMount, 'inner initial').to.have.been.calledOnce; 341 | expect(Inner.prototype.componentWillUnmount, 'inner initial').not.to.have.been.called; 342 | 343 | outer.setState({ child: Inner2 }); 344 | 345 | expect(Inner2.prototype.render).to.have.been.calledOnce; 346 | 347 | // outer should still only have been mounted once 348 | expect(Outer.prototype.componentWillMount, 'outer swap').to.have.been.calledOnce; 349 | expect(Outer.prototype.componentDidMount, 'outer swap').to.have.been.calledOnce; 350 | expect(Outer.prototype.componentWillUnmount, 'outer swap').not.to.have.been.called; 351 | 352 | // inner2 should only have been mounted once 353 | expect(Inner2.prototype.componentWillMount, 'inner2 swap').to.have.been.calledOnce; 354 | expect(Inner2.prototype.componentDidMount, 'inner2 swap').to.have.been.calledOnce; 355 | expect(Inner2.prototype.componentWillUnmount, 'inner2 swap').not.to.have.been.called; 356 | 357 | outer.setState({ child: Inner2 }); 358 | 359 | expect(Inner2.prototype.render, 'inner2 update').to.have.been.calledTwice; 360 | expect(Inner2.prototype.componentWillMount, 'inner2 update').to.have.been.calledOnce; 361 | expect(Inner2.prototype.componentDidMount, 'inner2 update').to.have.been.calledOnce; 362 | expect(Inner2.prototype.componentWillUnmount, 'inner2 update').not.to.have.been.called; 363 | }); 364 | }); 365 | }); 366 | -------------------------------------------------------------------------------- /test/KeysTests.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import sinonChai from 'sinon-chai'; 3 | import MVR from '../src/part4/MVR'; 4 | import MVRDom from '../src/part4/MVRDom'; 5 | 6 | const expect = chai.expect; 7 | chai.use(sinonChai); 8 | 9 | describe('keys', () => { 10 | let scratch; 11 | 12 | before(() => { 13 | scratch = document.createElement('div'); 14 | (document.body || document.documentElement).appendChild(scratch); 15 | }); 16 | 17 | beforeEach(() => { 18 | scratch.innerHTML = ''; 19 | }); 20 | 21 | after(() => { 22 | scratch.parentNode.removeChild(scratch); 23 | scratch = null; 24 | }); 25 | 26 | // See developit/preact-compat#21 27 | it('should remove orphaned keyed nodes', () => { 28 | MVRDom.render(( 29 | MVR.createElement('div', {}, [ 30 | MVR.createElement('div', {}, ['1']), 31 | MVR.createElement('li', { key: 'a' }, ['a']), 32 | MVR.createElement('li', { key: 'b' }, ['b']) 33 | ]) 34 | ), scratch); 35 | 36 | MVRDom.render(( 37 | MVR.createElement('div', {}, [ 38 | MVR.createElement('div', {}, ['2']), 39 | MVR.createElement('li', { key: 'b' }, ['b']), 40 | MVR.createElement('li', { key: 'c' }, ['c']) 41 | ]) 42 | ), scratch); 43 | 44 | expect(scratch.innerHTML).to.equal('
2
  • b
  • c
  • '); 45 | }); 46 | 47 | it('should remove keyed nodes', () => { 48 | class BusyIndicator extends MVR.Component { 49 | render() { 50 | const { children, busy } = this.props; 51 | return MVR.createElement('div', { 52 | class: busy ? 'busy' : '' 53 | }, [ 54 | children && children.length ? 55 | children : 56 | MVR.createElement('div', { 57 | class: 'busy-placeholder' 58 | }), 59 | MVR.createElement('div', { 60 | class: 'indicator' 61 | }, [ 62 | MVR.createElement('div', {}, ['indicator']), 63 | MVR.createElement('div', {}, ['indicator']), 64 | MVR.createElement('div', {}, ['indicator']) 65 | ]) 66 | ]); 67 | } 68 | } 69 | 70 | class App extends MVR.Component { 71 | componentDidMount() { 72 | setTimeout(() => this.setState({ opened: true, loading: true }), 10); 73 | setTimeout(() => this.setState({ opened: true, loading: false }), 20); 74 | } 75 | 76 | render() { 77 | const { opened, loading } = this.props; 78 | return ( 79 | MVR.createElement(BusyIndicator, { 80 | id: 'app', 81 | busy: loading 82 | }, [ 83 | MVR.createElement('div', {}, [ 84 | 'This div needs to be here for this to break' 85 | ]), 86 | opened && !loading && MVR.createElement('div', {}, [[]]) 87 | ]) 88 | ); 89 | } 90 | } 91 | 92 | 93 | MVRDom.render(MVR.createElement(App), scratch); 94 | MVRDom.render(MVR.createElement(App, { 95 | opened: true, 96 | loading: true 97 | }), scratch); 98 | MVRDom.render(MVR.createElement(App, { 99 | opened: true 100 | }), scratch); 101 | 102 | const html = String(scratch.firstChild.innerHTML).replace(/ class=""/g, ''); 103 | expect(html).to.equal('
    This div needs to be here for this to break
    indicator
    indicator
    indicator
    '); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /test/LifecycleTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | /* eslint-disable class-methods-use-this */ 3 | 4 | import sinon from 'sinon'; 5 | import chai from 'chai'; 6 | import sinonChai from 'sinon-chai'; 7 | import MVR from '../src/part4/MVR'; 8 | import MVRDom from '../src/part4/MVRDom'; 9 | 10 | const expect = chai.expect; 11 | chai.use(sinonChai); 12 | 13 | const EMPTY_CHILDREN = []; 14 | 15 | describe('Lifecycle methods', () => { 16 | let scratch; 17 | 18 | before(() => { 19 | scratch = document.createElement('div'); 20 | (document.body || document.documentElement).appendChild(scratch); 21 | }); 22 | 23 | beforeEach(() => { 24 | scratch.innerHTML = ''; 25 | }); 26 | 27 | after(() => { 28 | scratch.parentNode.removeChild(scratch); 29 | scratch = null; 30 | }); 31 | 32 | describe('#componentWillUpdate', () => { 33 | it('should NOT be called on initial render', () => { 34 | class ReceivePropsComponent extends MVR.Component { 35 | componentWillUpdate() {} 36 | render() { 37 | return MVR.createElement('div'); 38 | } 39 | } 40 | sinon.spy(ReceivePropsComponent.prototype, 'componentWillUpdate'); 41 | MVRDom.render(MVR.createElement(ReceivePropsComponent), scratch); 42 | expect(ReceivePropsComponent.prototype.componentWillUpdate).not.to.have.been.called; 43 | }); 44 | 45 | it('should be called when rerender with new props from parent', () => { 46 | let doRender; 47 | 48 | class Inner extends MVR.Component { 49 | componentWillUpdate(nextProps, nextState) { 50 | expect(nextProps).to.be.deep.equal({ children: EMPTY_CHILDREN, i: 1 }); 51 | expect(nextState).to.be.deep.equal({}); 52 | } 53 | render() { 54 | return MVR.createElement('div'); 55 | } 56 | } 57 | 58 | class Outer extends MVR.Component { 59 | constructor(p, c) { 60 | super(p, c); 61 | this.state = { i: 0 }; 62 | } 63 | componentDidMount() { 64 | doRender = () => this.setState({ i: this.state.i + 1 }); 65 | } 66 | render() { 67 | const { i } = this.state; 68 | return MVR.createElement(Inner, Object.assign({ i }, this.props)); 69 | } 70 | } 71 | 72 | sinon.spy(Inner.prototype, 'componentWillUpdate'); 73 | sinon.spy(Outer.prototype, 'componentDidMount'); 74 | 75 | // Initial render 76 | MVRDom.render(MVR.createElement(Outer), scratch); 77 | expect(Inner.prototype.componentWillUpdate).not.to.have.been.called; 78 | 79 | // Rerender inner with new props 80 | doRender(); 81 | expect(Inner.prototype.componentWillUpdate).to.have.been.called; 82 | }); 83 | 84 | it('should be called on new state', () => { 85 | let doRender; 86 | class ReceivePropsComponent extends MVR.Component { 87 | componentWillUpdate() {} 88 | componentDidMount() { 89 | doRender = () => this.setState({ i: this.state.i + 1 }); 90 | } 91 | render() { 92 | return MVR.createElement('div'); 93 | } 94 | } 95 | sinon.spy(ReceivePropsComponent.prototype, 'componentWillUpdate'); 96 | MVRDom.render(MVR.createElement(ReceivePropsComponent), scratch); 97 | expect(ReceivePropsComponent.prototype.componentWillUpdate).not.to.have.been.called; 98 | 99 | doRender(); 100 | expect(ReceivePropsComponent.prototype.componentWillUpdate).to.have.been.called; 101 | }); 102 | 103 | it('should be called after children are mounted', () => { 104 | const log = []; 105 | 106 | class Inner extends MVR.Component { 107 | componentDidMount() { 108 | log.push('Inner mounted'); 109 | 110 | // Verify that the component is actually mounted when this 111 | // callback is invoked. 112 | expect(scratch.querySelector('#inner')).to.equal(this._domElement); 113 | } 114 | 115 | render() { 116 | return MVR.createElement('div', { 117 | id: 'inner' 118 | }); 119 | } 120 | } 121 | 122 | class Outer extends MVR.Component { 123 | componentDidUpdate() { 124 | log.push('Outer updated'); 125 | } 126 | 127 | render() { 128 | return this.props.renderInner ? 129 | MVR.createElement(Inner) : 130 | MVR.createElement('div'); 131 | } 132 | } 133 | 134 | MVRDom.render( 135 | MVR.createElement(Outer, { 136 | renderInner: false 137 | }), 138 | scratch 139 | ); 140 | 141 | MVRDom.render( 142 | MVR.createElement(Outer, { 143 | renderInner: true 144 | }), 145 | scratch 146 | ); 147 | 148 | expect(log).to.deep.equal(['Inner mounted', 'Outer updated']); 149 | }); 150 | }); 151 | 152 | describe('#componentWillReceiveProps', () => { 153 | it('should NOT be called on initial render', () => { 154 | class ReceivePropsComponent extends MVR.Component { 155 | componentWillReceiveProps() {} 156 | render() { 157 | return MVR.createElement('div'); 158 | } 159 | } 160 | sinon.spy(ReceivePropsComponent.prototype, 'componentWillReceiveProps'); 161 | MVRDom.render(MVR.createElement(ReceivePropsComponent), scratch); 162 | expect(ReceivePropsComponent.prototype.componentWillReceiveProps).not.to.have.been.called; 163 | }); 164 | 165 | it('should be called when rerender with new props from parent', () => { 166 | let doRender; 167 | 168 | class Inner extends MVR.Component { 169 | componentWillMount() { 170 | expect(this.props.i).to.be.equal(0); 171 | } 172 | componentWillReceiveProps(nextProps) { 173 | expect(nextProps.i).to.be.equal(1); 174 | } 175 | render() { 176 | return MVR.createElement('div'); 177 | } 178 | } 179 | 180 | class Outer extends MVR.Component { 181 | constructor(p, c) { 182 | super(p, c); 183 | this.state = { i: 0 }; 184 | } 185 | componentDidMount() { 186 | doRender = () => this.setState({ i: this.state.i + 1 }); 187 | } 188 | render() { 189 | const { i } = this.state; 190 | return MVR.createElement(Inner, Object.assign({ i }, this.props)); 191 | } 192 | } 193 | 194 | sinon.spy(Inner.prototype, 'componentWillReceiveProps'); 195 | sinon.spy(Outer.prototype, 'componentDidMount'); 196 | 197 | // Initial render 198 | MVRDom.render(MVR.createElement(Outer), scratch); 199 | expect(Inner.prototype.componentWillReceiveProps).not.to.have.been.called; 200 | 201 | // Rerender inner with new props 202 | doRender(); 203 | expect(Inner.prototype.componentWillReceiveProps).to.have.been.called; 204 | }); 205 | 206 | it('should be called in right execution order', () => { 207 | let doRender; 208 | 209 | class Inner extends MVR.Component { 210 | componentDidUpdate() { 211 | expect(Inner.prototype.componentWillReceiveProps).to.have.been.called; 212 | expect(Inner.prototype.componentWillUpdate).to.have.been.called; 213 | } 214 | componentWillReceiveProps() { 215 | expect(Inner.prototype.componentWillUpdate).not.to.have.been.called; 216 | expect(Inner.prototype.componentDidUpdate).not.to.have.been.called; 217 | } 218 | componentWillUpdate() { 219 | expect(Inner.prototype.componentWillReceiveProps).to.have.been.called; 220 | expect(Inner.prototype.componentDidUpdate).not.to.have.been.called; 221 | } 222 | render() { 223 | return MVR.createElement('div'); 224 | } 225 | } 226 | 227 | class Outer extends MVR.Component { 228 | constructor(p, c) { 229 | super(p, c); 230 | this.state = { i: 0 }; 231 | } 232 | componentDidMount() { 233 | doRender = () => this.setState({ i: this.state.i + 1 }); 234 | } 235 | render() { 236 | const { i } = this.state; 237 | return MVR.createElement(Inner, Object.assign({ i }, this.props)); 238 | } 239 | } 240 | 241 | sinon.spy(Inner.prototype, 'componentWillReceiveProps'); 242 | sinon.spy(Inner.prototype, 'componentDidUpdate'); 243 | sinon.spy(Inner.prototype, 'componentWillUpdate'); 244 | sinon.spy(Outer.prototype, 'componentDidMount'); 245 | 246 | MVRDom.render(MVR.createElement(Outer), scratch); 247 | doRender(); 248 | 249 | expect(Inner.prototype.componentWillReceiveProps).to.have.been.calledBefore(Inner.prototype.componentWillUpdate); 250 | expect(Inner.prototype.componentWillUpdate).to.have.been.calledBefore(Inner.prototype.componentDidUpdate); 251 | }); 252 | }); 253 | 254 | 255 | describe('top-level componentWillUnmount', () => { 256 | it('should invoke componentWillUnmount for top-level components', () => { 257 | class Foo extends MVR.Component { 258 | componentDidMount() {} 259 | componentWillUnmount() {} 260 | render() { 261 | return MVR.createElement('div'); 262 | } 263 | } 264 | 265 | class Bar extends MVR.Component { 266 | componentDidMount() {} 267 | componentWillUnmount() {} 268 | render() { 269 | return MVR.createElement('div'); 270 | } 271 | } 272 | 273 | sinon.spy(Foo.prototype, 'componentDidMount'); 274 | sinon.spy(Foo.prototype, 'componentWillUnmount'); 275 | sinon.spy(Bar.prototype, 'componentDidMount'); 276 | sinon.spy(Bar.prototype, 'componentWillUnmount'); 277 | 278 | MVRDom.render(MVR.createElement(Foo), scratch); 279 | expect(Foo.prototype.componentDidMount, 'initial render').to.have.been.calledOnce; 280 | 281 | MVRDom.render(MVR.createElement(Bar), scratch); 282 | expect(Foo.prototype.componentWillUnmount, 'when replaced').to.have.been.calledOnce; 283 | expect(Bar.prototype.componentDidMount, 'when replaced').to.have.been.calledOnce; 284 | 285 | MVRDom.render(MVR.createElement('div'), scratch); 286 | expect(Bar.prototype.componentWillUnmount, 'when removed').to.have.been.calledOnce; 287 | }); 288 | }); 289 | 290 | 291 | describe('#constructor and component(Did|Will)(Mount|Unmount)', () => { 292 | let setState; 293 | 294 | class LifecycleTestComponent extends MVR.Component { 295 | constructor(p, c) { super(p, c); this._constructor(); } 296 | _constructor() {} 297 | componentWillMount() {} 298 | componentDidMount() {} 299 | componentWillUnmount() {} 300 | render() { return MVR.createElement('div'); } 301 | } 302 | 303 | class InnerMost extends LifecycleTestComponent { 304 | render() { return MVR.createElement('div'); } 305 | } 306 | 307 | class Inner extends LifecycleTestComponent { 308 | render() { 309 | return ( 310 | MVR.createElement('div', {}, [ 311 | MVR.createElement(InnerMost) 312 | ]) 313 | ); 314 | } 315 | } 316 | 317 | class Outer extends MVR.Component { 318 | constructor(p, c) { 319 | super(p, c); 320 | this.state = { show: true }; 321 | setState = s => this.setState(s); 322 | } 323 | render() { 324 | const { show } = this.state; 325 | return ( 326 | MVR.createElement('div', {}, [ 327 | show && ( 328 | MVR.createElement(Inner, this.props) 329 | ) 330 | ]) 331 | ); 332 | } 333 | } 334 | 335 | const spies = ['_constructor', 'componentWillMount', 'componentDidMount', 'componentWillUnmount']; 336 | 337 | const verifyLifycycleMethods = (TestComponent) => { 338 | const proto = TestComponent.prototype; 339 | spies.forEach(s => sinon.spy(proto, s)); 340 | const reset = () => spies.forEach(s => proto[s].reset()); 341 | 342 | it('should be invoked for components on initial render', () => { 343 | reset(); 344 | MVRDom.render(MVR.createElement(Outer), scratch); 345 | expect(proto._constructor).to.have.been.called; 346 | expect(proto.componentDidMount).to.have.been.called; 347 | expect(proto.componentWillMount).to.have.been.calledBefore(proto.componentDidMount); 348 | expect(proto.componentDidMount).to.have.been.called; 349 | }); 350 | 351 | it('should be invoked for components on unmount', () => { 352 | reset(); 353 | setState({ show: false }); 354 | 355 | expect(proto.componentWillUnmount).to.have.been.called; 356 | }); 357 | 358 | it('should be invoked for components on re-render', () => { 359 | reset(); 360 | setState({ show: true }); 361 | 362 | expect(proto._constructor).to.have.been.called; 363 | expect(proto.componentDidMount).to.have.been.called; 364 | expect(proto.componentWillMount).to.have.been.calledBefore(proto.componentDidMount); 365 | expect(proto.componentDidMount).to.have.been.called; 366 | }); 367 | }; 368 | 369 | describe('inner components', () => { 370 | verifyLifycycleMethods(Inner); 371 | }); 372 | 373 | describe('innermost components', () => { 374 | verifyLifycycleMethods(InnerMost); 375 | }); 376 | 377 | describe('when shouldComponentUpdate() returns false', () => { 378 | let setState; // eslint-disable-line no-shadow 379 | 380 | // eslint-disable-next-line no-shadow 381 | class Inner extends MVR.Component { 382 | shouldComponentUpdate() { return false; } 383 | componentWillMount() {} 384 | componentDidMount() {} 385 | componentWillUnmount() {} 386 | render() { 387 | return MVR.createElement('div'); 388 | } 389 | } 390 | 391 | // eslint-disable-next-line no-shadow 392 | class Outer extends MVR.Component { 393 | constructor() { 394 | super(); 395 | this.state = { show: true }; 396 | setState = s => this.setState(s); 397 | } 398 | render() { 399 | const { show } = this.state; 400 | return ( 401 | MVR.createElement('div', {}, [ 402 | show && ( 403 | MVR.createElement('div', {}, [ 404 | MVR.createElement(Inner, this.props) 405 | ]) 406 | ) 407 | ]) 408 | ); 409 | } 410 | } 411 | 412 | const proto = Inner.prototype; 413 | // eslint-disable-next-line no-shadow 414 | const spies = ['componentWillMount', 'componentDidMount', 'componentWillUnmount']; 415 | spies.forEach(s => sinon.spy(proto, s)); 416 | 417 | const reset = () => spies.forEach(s => proto[s].reset()); 418 | 419 | beforeEach(() => reset()); 420 | 421 | it('should be invoke normally on initial mount', () => { 422 | MVRDom.render(MVR.createElement(Outer), scratch); 423 | expect(proto.componentWillMount).to.have.been.called; 424 | expect(proto.componentWillMount).to.have.been.calledBefore(proto.componentDidMount); 425 | expect(proto.componentDidMount).to.have.been.called; 426 | }); 427 | 428 | it('should be invoked normally on unmount', () => { 429 | setState({ show: false }); 430 | 431 | expect(proto.componentWillUnmount).to.have.been.called; 432 | }); 433 | 434 | it('should still invoke mount for shouldComponentUpdate():false', () => { 435 | setState({ show: true }); 436 | 437 | expect(proto.componentWillMount).to.have.been.called; 438 | expect(proto.componentWillMount).to.have.been.calledBefore(proto.componentDidMount); 439 | expect(proto.componentDidMount).to.have.been.called; 440 | }); 441 | 442 | it('should still invoke unmount for shouldComponentUpdate():false', () => { 443 | setState({ show: false }); 444 | 445 | expect(proto.componentWillUnmount).to.have.been.called; 446 | }); 447 | }); 448 | }); 449 | 450 | 451 | describe('shouldComponentUpdate', () => { 452 | let setState; 453 | 454 | class Should extends MVR.Component { 455 | constructor() { 456 | super(); 457 | this.state = { show: true }; 458 | setState = s => this.setState(s); 459 | } 460 | render() { 461 | const { show } = this.state; 462 | return show ? MVR.createElement('div') : MVR.createElement('span'); 463 | } 464 | } 465 | 466 | class ShouldNot extends Should { 467 | shouldComponentUpdate() { 468 | return false; 469 | } 470 | } 471 | 472 | sinon.spy(Should.prototype, 'render'); 473 | sinon.spy(ShouldNot.prototype, 'shouldComponentUpdate'); 474 | 475 | beforeEach(() => Should.prototype.render.reset()); 476 | 477 | it('should rerender component on change by default', () => { 478 | MVRDom.render(MVR.createElement(Should), scratch); 479 | setState({ show: false }); 480 | 481 | expect(Should.prototype.render).to.have.been.calledTwice; 482 | }); 483 | 484 | it('should not rerender component if shouldComponentUpdate returns false', () => { 485 | MVRDom.render(MVR.createElement(ShouldNot), scratch); 486 | setState({ show: false }); 487 | 488 | expect(ShouldNot.prototype.shouldComponentUpdate).to.have.been.calledOnce; 489 | expect(ShouldNot.prototype.render).to.have.been.calledOnce; 490 | }); 491 | }); 492 | 493 | 494 | describe('Lifecycle DOM Timing', () => { 495 | it('should be invoked when dom does (DidMount, WillUnmount) or does not (WillMount, DidUnmount) exist', () => { 496 | let setState; 497 | 498 | class Inner extends MVR.Component { 499 | componentWillMount() { 500 | expect(document.getElementById('InnerDiv'), 'Inner componentWillMount').to.not.exist; 501 | } 502 | componentDidMount() { 503 | expect(document.getElementById('InnerDiv'), 'Inner componentDidMount').to.exist; 504 | } 505 | componentWillUnmount() { 506 | setTimeout(() => { 507 | expect(document.getElementById('InnerDiv'), 'Inner after componentWillUnmount').to.not.exist; 508 | }, 0); 509 | } 510 | 511 | render() { 512 | return MVR.createElement('div', { id: 'InnerDiv' }); 513 | } 514 | } 515 | 516 | class Outer extends MVR.Component { 517 | constructor() { 518 | super(); 519 | this.state = { show: true }; 520 | setState = (s) => { 521 | this.setState(s); 522 | }; 523 | } 524 | componentWillMount() { 525 | expect(document.getElementById('OuterDiv'), 'Outer componentWillMount').to.not.exist; 526 | } 527 | componentDidMount() { 528 | expect(document.getElementById('OuterDiv'), 'Outer componentDidMount').to.exist; 529 | } 530 | componentWillUnmount() { 531 | expect(document.getElementById('OuterDiv'), 'Outer componentWillUnmount').to.exist; 532 | setTimeout(() => { 533 | expect(document.getElementById('OuterDiv'), 'Outer after componentWillUnmount').to.not.exist; 534 | }, 0); 535 | } 536 | render() { 537 | const { show } = this.state; 538 | return ( 539 | MVR.createElement('div', { id: 'OuterDiv' }, [ 540 | show && ( 541 | MVR.createElement('div', {}, [ 542 | MVR.createElement(Inner, this.props) 543 | ]) 544 | ) 545 | ]) 546 | ); 547 | } 548 | } 549 | 550 | const proto = Inner.prototype; 551 | const spies = ['componentWillMount', 'componentDidMount', 'componentWillUnmount']; 552 | spies.forEach(s => sinon.spy(proto, s)); 553 | 554 | const reset = () => spies.forEach(s => proto[s].reset()); 555 | 556 | MVRDom.render(MVR.createElement(Outer), scratch); 557 | expect(proto.componentWillMount).to.have.been.called; 558 | expect(proto.componentWillMount).to.have.been.calledBefore(proto.componentDidMount); 559 | expect(proto.componentDidMount).to.have.been.called; 560 | 561 | reset(); 562 | setState({ show: false }); 563 | 564 | expect(proto.componentWillUnmount).to.have.been.called; 565 | 566 | reset(); 567 | setState({ show: true }); 568 | 569 | expect(proto.componentWillMount).to.have.been.called; 570 | expect(proto.componentWillMount).to.have.been.calledBefore(proto.componentDidMount); 571 | expect(proto.componentDidMount).to.have.been.called; 572 | }); 573 | }); 574 | 575 | it('should remove this._domElement for HOC', (done) => { 576 | const promises = []; 577 | 578 | const createComponent = (name, fn) => { 579 | class C extends MVR.Component { 580 | componentWillUnmount() { 581 | expect(this._domElement, `${name}.componentWillUnmount`).to.exist; 582 | promises.push(new Promise((resolve, reject) => { 583 | setTimeout(() => { 584 | try { 585 | expect(this._domElement, `after ${name}.componentWillUnmount`).not.to.exist; 586 | resolve(); 587 | } catch (e) { 588 | reject(e); 589 | } 590 | }, 0); 591 | })); 592 | } 593 | render() { return fn(this.props); } 594 | } 595 | return C; 596 | }; 597 | 598 | class Wrapper extends MVR.Component { 599 | render() { 600 | return MVR.createElement('div', { 601 | class: 'wrapper' 602 | }, this.props.children); 603 | } 604 | } 605 | 606 | const One = createComponent('One', () => MVR.createElement(Wrapper, {}, ['one'])); 607 | const Two = createComponent('Two', () => MVR.createElement(Wrapper, {}, ['two'])); 608 | const Three = createComponent('Three', () => MVR.createElement(Wrapper, {}, ['three'])); 609 | 610 | const components = [One, Two, Three]; 611 | 612 | class Selector extends MVR.Component { 613 | render() { 614 | const Child = components[this.props.page]; 615 | return Child ? 616 | MVR.createElement(Child) : 617 | MVR.createElement('div'); 618 | } 619 | } 620 | 621 | class App extends MVR.Component { 622 | render() { 623 | const { page } = this.state; 624 | return MVR.createElement(Selector, { page }); 625 | } 626 | } 627 | 628 | let app; 629 | MVRDom.render( 630 | MVR.createElement('div', {}, [ 631 | MVR.createElement(App, { 632 | ref: (c) => { app = c; } 633 | }) 634 | ]), 635 | scratch 636 | ); 637 | 638 | for (let i = 0; i < 20; i++) { 639 | app.setState({ page: i % components.length }); 640 | } 641 | 642 | Promise.all(promises) 643 | .then(() => { done(); }) 644 | .catch((e) => { done(e); }); 645 | }); 646 | }); 647 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | 4 | const babelConfig = fs.readFileSync(path.resolve(__dirname, './.babelrc')); 5 | 6 | module.exports = { 7 | 8 | entry: { 9 | ComponentTests: './test/ComponentTests.js', 10 | KeysTests: './test/KeysTests.js', 11 | LifecycleTests: './test/LifecycleTests.js' 12 | }, 13 | 14 | output: { 15 | path: path.resolve(__dirname, 'dist'), 16 | filename: '[name].js' 17 | }, 18 | 19 | resolve: { 20 | extensions: ['', '.js'] 21 | }, 22 | 23 | module: { 24 | loaders: [ 25 | { 26 | loader: 'babel-loader', 27 | 28 | // Skip any files outside of your project's `src` directory 29 | exclude: [ 30 | path.resolve(__dirname, 'node_modules') 31 | ], 32 | 33 | // Only run `.js` and `.jsx` files through Babel 34 | test: /\.jsx?$/, 35 | 36 | // Options to configure babel with 37 | query: JSON.parse(babelConfig) 38 | } 39 | ] 40 | } 41 | }; 42 | --------------------------------------------------------------------------------