├── .babelrc.js ├── .browserslistrc ├── .eslintignore ├── .eslintrc.json ├── README.md ├── jsconfig.json ├── package-lock.json ├── package.json ├── public ├── App.js ├── index.html └── mini-react.js ├── src ├── component.js ├── constants.js ├── dom │ ├── constructing.js │ ├── dom.utils.js │ ├── patching.js │ ├── rendering.js │ └── unmounting.js ├── index.js ├── utils.js └── vdom │ ├── node.js │ └── vdom.utils.js ├── test ├── component.test.js ├── dom.test.js ├── utils.test.js └── vdom.test.js └── webpack.config.js /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "useBuiltIns": false, 5 | "modules": false 6 | }] 7 | ], 8 | "env": { 9 | "test":{ 10 | "presets": [ 11 | ["@babel/preset-env", { 12 | "modules": "commonjs" 13 | }] 14 | ] 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | # Minimun supoprted browsers based on public/index.html boilerplate 2 | edge >= 12 3 | firefox >= 36 4 | chrome >= 49 5 | ios_saf >= 10 6 | opera >= 36 7 | and_chr >= 67 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | public 2 | node_modules -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "standard" 4 | ], 5 | "env": { 6 | "browser": true, 7 | "jest": true 8 | } 9 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `MiniReact` is a fairly working React implementation which aims to play like some basic React functionality. It was written with these keys concepts in mind: 2 | 3 | - Immutability; 4 | - Separation of concerns; 5 | - Unit tests; 6 | - Error handling; 7 | 8 | # What we take into account 9 | 10 | As the boilerplate uses `arrows functions` and `class expressions`, MiniReact was developed with these browsers in mind: 11 | 12 | - Edge >= 12 13 | - Firefox >= 36 14 | - Chrome >= 49 15 | - Safari >= 10 16 | - Opera >= 36 17 | - Android Chrome >= 67 18 | 19 | We don't try to be a complete React implementation, but to permit the users to create complex tree dependencies between simple tag elements and components, include/remove attributes and event listeners on the fly 20 | 21 | # Components 22 | 23 | MiniReact lets you define components as classes. To define a MiniReact component class, you need to extend MiniReact.Component (available as a global Component class as well) 24 | 25 | ``` 26 | class MyComponent extends Component { 27 | render() { 28 | ... 29 | } 30 | } 31 | ``` 32 | 33 | ## setState(func) 34 | 35 | If you use a class component, you will have access to this method. Different from React, setState only accepts a function that should return an object, that object will be merged with component internal state, and it is `synchronous`. 36 | 37 | # Virtual DOM Node 38 | 39 | You can create a virtual dom node using the global factory `node`, it will return a VDOM to be used as result of a `render` function from a class component. 40 | 41 | node accepts two types of parameters: 42 | 43 | - blueprint object 44 | - string 45 | 46 | ## blueprint object 47 | 48 | - blueprint {object} 49 | - blueprint.tagName {string} - any valid html tag 50 | - blueprint[attribute_name] {any} - an attribute that should be applied to that tag 51 | - blueprint.children[] {blueprint|string} - an array of values that are accepted by `node` factory, if a textContent attribute is found this option will be ignored. 52 | 53 | ## string 54 | 55 | Any text is valid as it will be converted to a span vnode internally. 56 | 57 | # Internals 58 | 59 | Bellow you can see the basics decisions and conventions we used them while this lib was being developed 60 | 61 | ## Directory structure 62 | 63 | The directory structure was designed with separation of concerns in mind, each folder has a special meaning 64 | 65 | ``` 66 | |-- public 67 | |-- src 68 | |-- dom 69 | |-- vdom 70 | ``` 71 | 72 | ### public 73 | 74 | The public directory is where the result assets of this project are located in, it is designed to be the public folder on a hosted environment. Be carefull what you put there 75 | 76 | ### src 77 | 78 | This folder has the core code used by MiniReact and the main entrypoint 79 | 80 | #### vdom 81 | 82 | This directory hosts the Virtual DOM Implementation, diffing and all related code 83 | 84 | ### dom 85 | 86 | This directory hosts the DOM manipulation engine etc. Modules that has the name ending with `*ing` must be considered as non free of side effects 87 | 88 | ## How it works 89 | 90 | MiniReact has three phases which are: 91 | 92 | - constructing 93 | - patching 94 | - unmounting 95 | 96 | ### constructing 97 | 98 | At this phase, for each vnode we verify if it is a VNode Element, if true we create a DOM Element and returns a new VNode with this DOM Element as a child, otherwise we assume it is a Component, wire up the paching DOM mechanism and calls its render function and start over this phase recursively with the result of the render 99 | 100 | ### patching 101 | 102 | This phase is used to diff what we have in the past with what we have now as props (and state), we apply this modifications to the DOM 103 | 104 | ### unmounting 105 | 106 | This phase is the cleanup one, where we remove DOM Nodes and try to free the garbage 107 | 108 | ## Development scripts 109 | 110 | ### Requirements 111 | 112 | - NODE >=8.11.3 113 | - NPM ~5.6.0 114 | 115 | In order to build this application localy you must first install [nodejs](https://nodejs.org/en/) 116 | 117 | ### `npm install` 118 | 119 | > Installs package dependencies 120 | 121 | ### `npm run dev` 122 | 123 | > Start this project in development mode opening your browser at `http://localhost:3000` 124 | 125 | ### `npm run build` 126 | 127 | > Build this project for publishing 128 | 129 | ### `npm run start` 130 | 131 | > Build this project in production mode opening your browser at `http://localhost:3000` 132 | 133 | 134 | ### `npm run test` 135 | 136 | > Execute all tests 137 | 138 | ### `npm run test:watch` 139 | 140 | > Execute all tests, but keep watching for changes. Good for development. 141 | 142 | ### `npm run test:coverage` 143 | 144 | > Generate coverage report into the folder `./coverage/Icov-report/` 145 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext" 4 | }, 5 | "exclude": [ 6 | "node_modules", 7 | "public" 8 | ] 9 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mini-react", 3 | "version": "0.0.1", 4 | "description": "Mini React Like Lib", 5 | "scripts": { 6 | "test": "jest", 7 | "test:watch": "jest --watchAll", 8 | "test:coverage": "jest --coverage && http-server ./coverage/lcov-report -p 3000 -o", 9 | "dev": "concurrently --kill-others \"webpack --mode=development --watch\" \"http-server ./public -p 3000 -o -d\"", 10 | "build": "webpack --mode=production", 11 | "start": "npm run build && http-server ./public -p 3000 -o" 12 | }, 13 | "keywords": [], 14 | "author": "Cleiton Loiola ", 15 | "license": "ISC", 16 | "engines": { 17 | "node": ">=8.11.3", 18 | "npm": "~5.6.0" 19 | }, 20 | "jest": { 21 | "testEnvironment": "node", 22 | "testURL": "http://localhost", 23 | "transform": { 24 | "^.+\\.js$": "babel-jest" 25 | } 26 | }, 27 | "dependencies": { 28 | "@babel/polyfill": "^7.0.0-beta.55", 29 | "@babel/runtime": "^7.0.0-beta.55" 30 | }, 31 | "devDependencies": { 32 | "@babel/cli": "^7.0.0-beta.55", 33 | "@babel/core": "^7.0.0-beta.55", 34 | "@babel/plugin-transform-runtime": "^7.0.0-beta.55", 35 | "@babel/preset-env": "^7.0.0-beta.55", 36 | "babel-core": "^7.0.0-0", 37 | "babel-eslint": "^8.2.6", 38 | "babel-jest": "^23.4.2", 39 | "babel-loader": "^8.0.0-beta.4", 40 | "concurrently": "^3.6.1", 41 | "eslint": "^5.2.0", 42 | "eslint-config-standard": "^11.0.0", 43 | "eslint-plugin-babel": "^5.1.0", 44 | "eslint-plugin-import": "^2.13.0", 45 | "eslint-plugin-node": "^7.0.1", 46 | "eslint-plugin-promise": "^3.8.0", 47 | "eslint-plugin-standard": "^3.1.0", 48 | "http-server": "^0.11.1", 49 | "jest": "^23.4.2", 50 | "webpack": "^4.0.0-beta.3", 51 | "webpack-cli": "^3.1.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /public/App.js: -------------------------------------------------------------------------------- 1 | class App extends Component { 2 | constructor(props) { 3 | super(props); 4 | 5 | this.state = { 6 | definedLimit: 2500, 7 | maxLimit: 5000, 8 | }; 9 | } 10 | 11 | setDefinedLimit(e) { 12 | this.setState(() => ({ 13 | definedLimit: parseInt(e.target.value) 14 | })) 15 | } 16 | 17 | render() { 18 | return node({ 19 | tagName: 'div', 20 | children: [ 21 | { 22 | tagName: 'h1', 23 | textContent: 'Ajuste de limite' 24 | }, { 25 | tagName: 'input', 26 | type: 'text', 27 | value: this.state.definedLimit, 28 | onchange: e => this.setDefinedLimit(e) 29 | }, { 30 | tagName: 'p', 31 | textContent: `R$ ${ this.state.maxLimit - this.state.definedLimit } disponíveis` 32 | }, { 33 | tagName: 'input', 34 | type: 'range', 35 | min: 0, 36 | max: this.state.maxLimit, 37 | value: this.state.definedLimit, 38 | onchange: e => this.setDefinedLimit(e) 39 | } 40 | ], 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | Limit manager 9 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /public/mini-react.js: -------------------------------------------------------------------------------- 1 | !function(t,n){for(var e in n)t[e]=n[e]}(window,function(t){var n={};function e(r){if(n[r])return n[r].exports;var i=n[r]={i:r,l:!1,exports:{}};return t[r].call(i.exports,i,i.exports,e),i.l=!0,i.exports}return e.m=t,e.c=n,e.d=function(t,n,r){e.o(t,n)||Object.defineProperty(t,n,{enumerable:!0,get:r})},e.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},e.t=function(t,n){if(1&n&&(t=e(t)),8&n)return t;if(4&n&&"object"==typeof t&&t&&t.__esModule)return t;var r=Object.create(null);if(e.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:t}),2&n&&"string"!=typeof t)for(var i in t)e.d(r,i,function(n){return t[n]}.bind(null,i));return r},e.n=function(t){var n=t&&t.__esModule?function(){return t.default}:function(){return t};return e.d(n,"a",n),n},e.o=function(t,n){return Object.prototype.hasOwnProperty.call(t,n)},e.p="",e(e.s=0)}([function(t,n,e){"use strict";function r(t){return function(t){if(Array.isArray(t)){for(var n=0,e=new Array(t.length);n=0||(i[e]=t[e]);return i}(t,n);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(t);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(t,e)&&(i[e]=t[e])}return i}function x(t,n){if(t.kind!==n.kind)return function(t,n){var e=y(t),r=k(n);return e.parentNode.replaceChild(r.__child,e),P(t),r}(t,n);var e=u(n),r=t.attributes.textContent,i=j(t,n),o=i.textContent,c=S(i,["textContent"]);return e.__child=t.__child,function(t,n){for(var e in t)if(!(e in n))return!1;for(var r in n)if(!(r in t))return!1;for(var i in t)if(t[i]!==n[i])return!1;return!0}(t.attributes,n.attributes)||v(e.__child,c),!r&&o?(t.children.map(P),e.children=[],m(e.__child,o)):r&&!o?(e.__child.innerHTML="",e.children=n.children.map(k),e.children.map(function(t){var n=t.__child;return e.__child.appendChild(n)})):o?m(e.__child,o):n.children.length===t.children.length?e.children=t.children.map(function(t,e){return A(t,n.children[e])}):(e.__child.innerHTML="",e.children=n.children.map(k),e.children.map(function(t){var n=t.__child;return e.__child.appendChild(n)})),e}function A(t,n){if(w(t))return x(t,n);f("not able to patch this object")}function E(t,n){if(null==t)return{};var e,r,i=function(t,n){if(null==t)return{};var e,r,i={},o=Object.keys(t);for(r=0;r=0||(i[e]=t[e]);return i}(t,n);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(t);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(t,e)&&(i[e]=t[e])}return i}function k(t){return w(t)?function(t){var n=document.createElement(t.kind),e=u(t),r=e.attributes,i=r.textContent,o=E(r,["textContent"]);return e.__child=n,v(n,o),i?(m(n,i),e.children=[]):(e.children=t.children.map(k),e.children.map(function(t){var e=t.__child;return n.appendChild(e)})),e}(t):g(t)?function(t){var n=new t.constructor(t.attributes);n.state=t.state;var e=n.render(t.attributes,t.state);return n.__child=k(e),n.__patch=A,n.__child}(t):void f("not able to construct this vnode")}function C(t,n){for(var e=0;e=0||(i[e]=t[e]);return i}(t,n);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(t);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(t,e)&&(i[e]=t[e])}return i}var R=function t(n){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};!function(t,n){if(!(t instanceof n))throw new TypeError("Cannot call a class as a function")}(this,t),this.kind=n,this.attributes=e;for(var r=arguments.length,i=new Array(r>2?r-2:0),o=2;o dom.appendChild(__child)) 63 | } 64 | return ret 65 | } 66 | 67 | /** 68 | * Factory that will create a component or an element and its childs 69 | * @param {import('../vdom/dom').Node} parent 70 | * @param {import('../vdom/dom').Node} vnode 71 | * @returns {HTMLDOMElement} - return an unattached but constructed DOM node 72 | */ 73 | export default function construct (vnode) { 74 | if (isElement(vnode)) { 75 | return constructElement(vnode) 76 | } if (isComponent(vnode)) { 77 | return constructComponent(vnode) 78 | } else { 79 | throwError('not able to construct this vnode') 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/dom/dom.utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper functions used to manipulate the dom 3 | * Warning: all dom operations has side effects 4 | * @module dom.utils 5 | */ 6 | 7 | import { 8 | isEmpty 9 | } from '../utils' 10 | import { 11 | NODE_ATTR_PROPERTY 12 | } from '../constants' 13 | 14 | /** 15 | * Remove the given HTMLElement (only if attached to the DOM) 16 | * @param {HTMLElement} node 17 | */ 18 | export function removeNode (node) { 19 | node.parentNode && node.parentNode.removeChild(node) 20 | } 21 | /** 22 | * It will find the DOM element of a given constructed VDOM 23 | * @param {import('../vdom/node').Node} vnode 24 | */ 25 | export const findDOMElementComponent = (vnode) => vnode.__child ? findDOMElementComponent(vnode.__child) : vnode 26 | 27 | /** 28 | * Returns true if given object is a valid DOM 29 | */ 30 | 31 | export const isDOMElement = (obj) => obj instanceof HTMLElement 32 | 33 | /** 34 | * It will apply the attributes on the given DOM Element 35 | * @param {HTMLElement} dom 36 | * @param {object} attributes - a object where each key will be the attribute and value its value 37 | */ 38 | export function applyAttributes (dom, attributes) { 39 | Object.entries(attributes).forEach( 40 | ([attr, value]) => setAttribute(dom, attr, value) 41 | ) 42 | } 43 | 44 | /** 45 | * Set or Remove attribute in a given DOM element based on the presence of value 46 | * @param {HTMLElement} dom 47 | * @param {*} name - attribute name 48 | * @param {*} [value] - attribute value 49 | */ 50 | export function setAttribute (dom, name, value) { 51 | !dom[NODE_ATTR_PROPERTY] && (dom[NODE_ATTR_PROPERTY] = {}) 52 | if (name[0] === 'o' && name[1] === 'n') { 53 | const eventName = name.substring(2).toLowerCase() 54 | if (!dom[NODE_ATTR_PROPERTY][eventName]) { 55 | dom.addEventListener(eventName, onEvent) 56 | } else if (!value) { 57 | dom.removeEventListener(eventName, onEvent) 58 | } 59 | dom[NODE_ATTR_PROPERTY][eventName] = value 60 | } else { 61 | if (isEmpty(value)) { 62 | dom.removeAttribute(name) 63 | } else if (name === 'value') { 64 | dom.value = value 65 | } else { 66 | dom.setAttribute(name, value) 67 | } 68 | } 69 | } 70 | 71 | /** 72 | * Set text content on a given element 73 | * @param {HTMLElement} dom 74 | * @param {string} value 75 | */ 76 | export function setText (dom, value) { 77 | dom.innerHTML = '' 78 | dom.appendChild(document.createTextNode(value)) 79 | } 80 | 81 | /** 82 | * Internal helper function used as proxy to the event listeners 83 | * @param {*} ev 84 | */ 85 | function onEvent (ev) { 86 | this[NODE_ATTR_PROPERTY][ev.type](ev) 87 | } 88 | -------------------------------------------------------------------------------- /src/dom/patching.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Patching and Diffing 3 | * Warning: all dom operations has side effects 4 | * @module patching 5 | */ 6 | 7 | import { 8 | shallowCompare, 9 | shallowClone, 10 | throwError 11 | } from '../utils' 12 | import { 13 | isElement, 14 | diff 15 | } from '../vdom/vdom.utils' 16 | import { 17 | findDOMElementComponent, 18 | applyAttributes, 19 | setText 20 | } from './dom.utils' 21 | 22 | import unmount from './unmounting' 23 | import construct from './constructing' 24 | 25 | /** 26 | * Unmount, construct and replace previous vnode with the new one 27 | * @param {import('../vdom/dom').Node} previousVNode 28 | * @param {import('../vdom/dom').Node} nextVNode 29 | */ 30 | function replaceVNode (previousVNode, nextVNode) { 31 | const el = findDOMElementComponent(previousVNode) 32 | const newVNode = construct(nextVNode) 33 | /* DOM side effect */ 34 | el.parentNode.replaceChild(newVNode.__child, el) 35 | unmount(previousVNode) 36 | return newVNode 37 | } 38 | 39 | /** 40 | * It will update DOM Element related to this previousVNode with the nextVNode 41 | * @param {import('../vdom/dom').Node} previousVNode 42 | * @param {import('../vdom/dom').Node} nextVNode 43 | * @returns {import('../vdom/dom').Node} - returns a patched vnode 44 | */ 45 | function patchElement (previousVNode, nextVNode) { 46 | if (previousVNode.kind !== nextVNode.kind) { 47 | return replaceVNode(previousVNode, nextVNode) 48 | } else { 49 | const ret = shallowClone(nextVNode) 50 | const {textContent: previousTextContent} = previousVNode.attributes 51 | const {textContent: nextTextContent, ...attributes} = diff(previousVNode, nextVNode) 52 | 53 | ret.__child = previousVNode.__child 54 | 55 | if (!shallowCompare(previousVNode.attributes, nextVNode.attributes)) { 56 | /* DOM side effect */ 57 | applyAttributes(ret.__child, attributes) 58 | } 59 | 60 | if (!previousTextContent && nextTextContent) { 61 | previousVNode.children.map( 62 | unmount 63 | ) 64 | ret.children = [] 65 | /* DOM side effect */ 66 | setText(ret.__child, nextTextContent) 67 | } else if (previousTextContent && !nextTextContent) { 68 | /* DOM side effect */ 69 | ret.__child.innerHTML = '' 70 | ret.children = nextVNode.children.map(construct) 71 | /* DOM side effect */ 72 | ret.children.map(({__child}) => ret.__child.appendChild(__child)) 73 | } else if (nextTextContent) { 74 | /* DOM side effect */ 75 | setText(ret.__child, nextTextContent) 76 | } else if (nextVNode.children.length === previousVNode.children.length) { 77 | ret.children = previousVNode.children.map( 78 | (previousChildVNode, i) => patch(previousChildVNode, nextVNode.children[i]) 79 | ) 80 | } else { 81 | /* DOM side effect */ 82 | ret.__child.innerHTML = '' 83 | ret.children = nextVNode.children.map(construct) 84 | /* DOM side effect */ 85 | ret.children.map(({__child}) => ret.__child.appendChild(__child)) 86 | } 87 | return ret 88 | } 89 | } 90 | 91 | /** 92 | * It will update DOM Element related to this previousVNode with the nextVNode 93 | * @param {import('../vdom/dom').Node} previousVNode 94 | * @param {import('../vdom/dom').Node} nextVNode 95 | * @returns {import('../vdom/dom').Node} - returns a patched vnode 96 | */ 97 | export default function patch (previousVNode, nextVNode) { 98 | /* istanbul ignore else */ 99 | if (isElement(previousVNode)) { 100 | return patchElement(previousVNode, nextVNode) 101 | } else { 102 | throwError('not able to patch this object') 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/dom/rendering.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Render VDOM to DOM Implementation 3 | * @module rendering 4 | */ 5 | 6 | import { 7 | throwError 8 | } from '../utils' 9 | import { 10 | isDOMElement 11 | } from './dom.utils' 12 | import construct from './constructing' 13 | 14 | /** 15 | * Render the given VNode or Compoment instance on the dom 16 | * @param {import('../vnode/node').Node} vnode 17 | * @param {HTMLElement} node 18 | */ 19 | export function render (vnode, node) { 20 | !isDOMElement(node) && throwError('node must be a valid DOM Element') 21 | const constructed = construct(vnode) 22 | node.appendChild(constructed.__child) 23 | } 24 | -------------------------------------------------------------------------------- /src/dom/unmounting.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Functions related to unmount DOM elements 3 | * @module unmounting 4 | */ 5 | 6 | import { 7 | isElement 8 | } from '../vdom/vdom.utils' 9 | import { 10 | throwError 11 | } from '../utils' 12 | import { 13 | removeNode 14 | } from './dom.utils' 15 | 16 | /** 17 | * Unmount the given vnode element 18 | * @param {import('../vnode/node').Node} vnode 19 | */ 20 | function unmountElement (vnode) { 21 | vnode.children.map(unmount) 22 | vnode.__child && removeNode(vnode.__child) 23 | vnode.__child = null 24 | } 25 | 26 | /** 27 | * Unmount the given vnode 28 | * @param {import('../vnode/node').Node} vnode 29 | */ 30 | export default function unmount (vnode) { 31 | /* istanbul ignore else */ 32 | if (isElement(vnode)) { 33 | unmountElement(vnode) 34 | } else { 35 | throwError('not able to unmount this vnode') 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Here is where we expose our API to the world 3 | */ 4 | import { render } from './dom/rendering' 5 | export { Component } from './component' 6 | export { default as node } from './vdom/node' 7 | 8 | export const MiniReact = { 9 | render 10 | } 11 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * General utility classes 3 | * @module utils 4 | * */ 5 | 'use strict' 6 | /** 7 | * Returns true if the given parameter is a function 8 | * @param {*} f 9 | */ 10 | export const isFunction = f => typeof f === 'function' 11 | /** 12 | * Shallows clone the given object 13 | * returns an empty object if obj is not an object 14 | * @param {*} obj 15 | */ 16 | export const shallowClone = (obj) => ({ ...obj }) 17 | /** 18 | * Returns true if the given parameter is null 19 | * @param {*} o 20 | */ 21 | export const isNull = o => o === null 22 | /** 23 | * Returns true if the given parameter is a boolean 24 | * @param {*} o 25 | */ 26 | export const isBoolean = o => typeof o === 'boolean' 27 | /** 28 | * Returns true only if the given parameter is a literal object or a wrapped one 29 | * @param {*} o 30 | */ 31 | export const isValidObject = (o) => !isNull(o) && typeof o === 'object' && o.constructor === Object 32 | /** 33 | * Throws an error if the given message 34 | * @param {string} message 35 | */ 36 | export const throwError = message => { 37 | throw new Error(`Mini React: ${message}`) 38 | } 39 | /** 40 | * Returns true if the given parameter is null, undefined or an empty string 41 | * @param {*} str 42 | */ 43 | export const isEmpty = str => isNull(str) || str === void 0 || (str.constructor === String && str.length === 0) 44 | /** 45 | * Makes a deeply last win merge between two or more objects without mutating them. 46 | * @param {object} target 47 | * @param {object} origin 48 | * @param {...object} [otherOrigins] 49 | * 50 | * @returns {object} a new merged object 51 | */ 52 | export function mergeObjects () { 53 | const ret = {} 54 | Array.from(arguments).forEach( 55 | (argument, i) => { 56 | !isValidObject(argument) && throwError(`${i}º paramter must be a valid object`) 57 | Object.entries(argument).forEach( 58 | ([key, value]) => { 59 | if (isValidObject(value)) { 60 | ret[key] = mergeObjects(ret[key] || {}, value) 61 | } else if (Array.isArray(value)) { 62 | ret[key] = [...value] 63 | } else { 64 | ret[key] = value 65 | } 66 | } 67 | ) 68 | } 69 | ) 70 | return ret 71 | } 72 | 73 | /** 74 | * It will do a shallow comparison between two objects, returning true if the two are equal 75 | * @param {*} objectA - The first Object 76 | * @param {*} objectB - Object B to compare with 77 | */ 78 | export function shallowCompare (objectA, objectB) { 79 | for (let prop in objectA) { if (!(prop in objectB)) return false } 80 | for (let prop in objectB) { if (!(prop in objectA)) return false } 81 | for (let prop in objectA) { if (objectA[prop] !== objectB[prop]) return false } 82 | return true 83 | } 84 | -------------------------------------------------------------------------------- /src/vdom/node.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Very simple Virtual DOM Implementation 3 | * @module node 4 | */ 5 | 6 | import { isValidObject, isBoolean, isNull, isEmpty, throwError } from '../utils' 7 | import { COMPONENT_KIND } from '../constants' 8 | 9 | /** 10 | * A Virtual DOM Node 11 | */ 12 | export class Node { 13 | /** 14 | * 15 | * @param {(string|Function)} kind - If an string it will be a simple element otherwise a Component 16 | * @param {object} attributes - Attributes of the given node 17 | * @param {Array[(string | object | Node)]} children - Any valid Virtual DOM Node 18 | */ 19 | constructor (kind, attributes = {}, ...children) { 20 | this.kind = kind 21 | this.attributes = attributes 22 | this.children = children 23 | } 24 | } 25 | 26 | /** 27 | * Based on a basic blueprint creates an complete Virtual DOM Tree 28 | * @param {any} blueprint 29 | */ 30 | export default function factory (blueprint) { 31 | if (isValidObject(blueprint)) { 32 | const { tagName, children = [], ...attributes } = blueprint 33 | isEmpty(tagName) && throwError('tagName is required and must be valid string') 34 | return new Node( 35 | tagName, 36 | attributes, 37 | ...children.map( 38 | factory 39 | ) 40 | ) 41 | } else if (typeof blueprint === 'string') { 42 | let textContent = blueprint 43 | return new Node('span', { textContent }) 44 | } else if (blueprint.kind === COMPONENT_KIND) { 45 | return blueprint 46 | } else if (blueprint && blueprint.hasOwnProperty('kind') && blueprint.hasOwnProperty('attributes') && blueprint.hasOwnProperty('children')) { 47 | return blueprint 48 | } else { 49 | throwError('not a valid blueprint') 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/vdom/vdom.utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Virtual DOM function utilities 3 | * @module vdom.utils 4 | */ 5 | import { COMPONENT_KIND } from '../constants' 6 | 7 | /** 8 | * Returns true if the given object is a valid Component VNode 9 | * @param {Object} obj - any valid object that has a kind property 10 | * @param {*} obj.kind - a kind definition 11 | */ 12 | export const isComponent = (obj) => (obj && obj.kind === COMPONENT_KIND) 13 | /** 14 | * Returns true if the given object is a valid Element VNode 15 | * @param {Object} obj - any valid object that has a kind property 16 | * @param {*} obj.kind - a kind definition 17 | */ 18 | export const isElement = (obj) => (obj && obj.kind) && !isComponent(obj) 19 | 20 | /** 21 | * Returns an object based on the diff of two VNode attributes 22 | * it will return value as undefined for removed keys 23 | * @param {*} previousVNode 24 | * @param {*} nextVNode 25 | */ 26 | export const diff = (previousVNode, nextVNode) => Object.keys(previousVNode.attributes).reduce( 27 | (acc, attr) => ({[attr]: undefined, ...acc}), 28 | nextVNode.attributes 29 | ) 30 | -------------------------------------------------------------------------------- /test/component.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import Component from '../src/component' 6 | import { 7 | COMPONENT_KIND 8 | } from '../src/constants' 9 | 10 | describe('component', () => { 11 | it('should has passed props', () => { 12 | class C1 extends Component { 13 | } 14 | const someProps = {foo: 'bar'} 15 | const instance = new C1(someProps) 16 | expect(instance.props).toMatchObject(someProps) 17 | }) 18 | 19 | it('should be an component kind', () => { 20 | class C1 extends Component { 21 | } 22 | const instance = new C1() 23 | expect(instance.kind).toBe(COMPONENT_KIND) 24 | }) 25 | it('should be an component kind', () => { 26 | class C1 extends Component { 27 | } 28 | const instance = new C1() 29 | expect(instance.kind).toBe(COMPONENT_KIND) 30 | }) 31 | describe('setState', () => { 32 | class C1 extends Component { 33 | } 34 | class C2 extends Component { 35 | render () { 36 | } 37 | } 38 | let instance 39 | beforeEach(() => { 40 | instance = new C1() 41 | }) 42 | it('should throw a erros if not a function', () => { 43 | const t = () => instance.setState(null) 44 | expect(t).toThrow() 45 | }) 46 | it('should throw if no render function defined', () => { 47 | const setStateFunc = jest.fn() 48 | setStateFunc.mockReturnValueOnce({'baz': 'boo'}) 49 | const defaultProps = {'foo': 'bar'} 50 | instance = new C1(defaultProps) 51 | const t = () => instance.setState(setStateFunc) 52 | expect(t).toThrowError('no render function was defined') 53 | }) 54 | it('should throw if no wired up', () => { 55 | const setStateFunc = jest.fn() 56 | setStateFunc.mockReturnValueOnce({'baz': 'boo'}) 57 | const defaultProps = {'foo': 'bar'} 58 | instance = new C2(defaultProps) 59 | const t = () => instance.setState(setStateFunc) 60 | expect(t).toThrowError('this component was not correctly construted') 61 | expect(setStateFunc).toBeCalledWith({}, defaultProps) 62 | }) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /test/dom.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { render } from '../src/dom/rendering' 6 | import Component from '../src/component' 7 | import node from '../src/vdom/node' 8 | 9 | describe('dom', () => { 10 | class C1 extends Component { 11 | trigger () { 12 | this.setState(() => ({ textContent: 'Hello Bar' })) 13 | } 14 | constructor (props) { 15 | super(props) 16 | this.state = { 17 | textContent: 'hello world' 18 | } 19 | } 20 | render () { 21 | return node({ 22 | tagName: 'div', 23 | textContent: this.state.textContent, 24 | onclick: () => this.trigger() 25 | }) 26 | } 27 | } 28 | 29 | // --------- 30 | // constructing 31 | // --------- 32 | describe('render', () => { 33 | let scratch 34 | 35 | beforeEach(() => { 36 | scratch = document.createElement('div') 37 | }) 38 | 39 | it('should throw an error if not a vnode', () => { 40 | const vnode = {'not a valid': 'vnode'} 41 | const t = () => render(vnode, scratch) 42 | expect(t).toThrowError('not able to construct this vnode') 43 | }) 44 | 45 | it('should throw an error if not a DOM Element', () => { 46 | const vnode = node({ 47 | tagName: 'span', 48 | textContent: 'hello world' 49 | }) 50 | const t = () => render(vnode, {}) 51 | expect(t).toThrowError('node must be a valid DOM Element') 52 | }) 53 | 54 | it('should render an element', () => { 55 | const vnode = node({ 56 | tagName: 'span', 57 | textContent: 'hello world' 58 | }) 59 | 60 | render(vnode, scratch) 61 | expect(scratch.innerHTML).toBe('hello world') 62 | }) 63 | 64 | it('should render an element', () => { 65 | const vnode = node({ 66 | tagName: 'div', 67 | children: ['hello world'] 68 | }) 69 | render(vnode, scratch) 70 | expect(scratch.innerHTML).toBe('
hello world
') 71 | }) 72 | 73 | it('should render a component', () => { 74 | const vnode = new C1() 75 | 76 | render(vnode, scratch) 77 | expect(scratch.innerHTML).toBe('
hello world
') 78 | }) 79 | }) 80 | // --------- 81 | // patching 82 | // --------- 83 | describe('patching', () => { 84 | class C2 extends Component { 85 | trigger () { 86 | this.setState(() => ({ isA: false })) 87 | } 88 | constructor (props) { 89 | super(props) 90 | this.state.isA = true 91 | } 92 | render () { 93 | return this.state.isA ? node({ 94 | tagName: 'div', 95 | textContent: 'is a', 96 | onclick: () => this.trigger() 97 | }) : node({ 98 | tagName: 'span', 99 | textContent: 'is b' 100 | }) 101 | } 102 | } 103 | 104 | let scratch 105 | 106 | beforeEach(() => { 107 | scratch = document.createElement('div') 108 | document.body.appendChild(scratch) 109 | }) 110 | it('should nothing happens', () => { 111 | class C2 extends Component { 112 | trigger () { 113 | this.setState(() => ({'data-test': 'state'})) 114 | } 115 | constructor (props) { 116 | super(props) 117 | this.state = {'data-test': 'state'} 118 | this.trigger = this.trigger.bind(this) 119 | } 120 | render () { 121 | return node({ 122 | tagName: 'div', 123 | textContent: 'foobar', 124 | ...this.state, 125 | onclick: this.trigger 126 | }) 127 | } 128 | } 129 | const vnode = new C2() 130 | render(vnode, scratch) 131 | expect(scratch.innerHTML).toBe('
foobar
') 132 | var evt = document.createEvent('HTMLEvents') 133 | evt.initEvent('click', false, true) 134 | scratch.firstElementChild.dispatchEvent(evt) 135 | expect(scratch.innerHTML).toBe('
foobar
') 136 | }) 137 | it('should update a component', () => { 138 | const vnode = new C1() 139 | 140 | render(vnode, scratch) 141 | expect(scratch.innerHTML).toBe('
hello world
') 142 | var evt = document.createEvent('HTMLEvents') 143 | evt.initEvent('click', false, true) 144 | scratch.firstElementChild.dispatchEvent(evt) 145 | expect(scratch.innerHTML).toBe('
Hello Bar
') 146 | }) 147 | it('should update flip child component', () => { 148 | const vnode = new C2() 149 | render(vnode, scratch) 150 | 151 | expect(scratch.innerHTML).toBe('
is a
') 152 | 153 | var evt = document.createEvent('HTMLEvents') 154 | evt.initEvent('click', false, true) 155 | scratch.firstElementChild.dispatchEvent(evt) 156 | expect(scratch.innerHTML).toBe('is b') 157 | }) 158 | it('should remove event listener', () => { 159 | class C2 extends Component { 160 | trigger () { 161 | this.setState(({isA}) => ({ isA: !isA })) 162 | } 163 | constructor (props) { 164 | super(props) 165 | this.state.isA = true 166 | } 167 | render () { 168 | return node({ 169 | tagName: 'div', 170 | textContent: 'is a', 171 | alt: this.state.isA ? 'title' : undefined, 172 | onclick: this.state.isA ? () => this.trigger() : undefined 173 | }) 174 | } 175 | } 176 | const vnode = new C2() 177 | render(vnode, scratch) 178 | 179 | expect(scratch.innerHTML).toBe('
is a
') 180 | 181 | var evt = document.createEvent('HTMLEvents') 182 | evt.initEvent('click', false, true) 183 | scratch.firstElementChild.dispatchEvent(evt) 184 | expect(scratch.innerHTML).toBe('
is a
') 185 | evt.initEvent('click', false, true) 186 | scratch.firstElementChild.dispatchEvent(evt) 187 | expect(scratch.innerHTML).toBe('
is a
') 188 | }) 189 | it('should update flip child component with childs', () => { 190 | class C3 extends Component { 191 | trigger () { 192 | this.setState(({isA}) => ({ isA: !isA })) 193 | } 194 | constructor (props) { 195 | super(props) 196 | this.state.isA = true 197 | } 198 | render () { 199 | return this.state.isA ? node({ 200 | tagName: 'div', 201 | textContent: 'is a', 202 | onclick: () => this.trigger() 203 | }) : node({ 204 | tagName: 'div', 205 | onclick: () => this.trigger(), 206 | children: [ 207 | 'foo', 208 | 'bar' 209 | ] 210 | }) 211 | } 212 | } 213 | const vnode = new C3() 214 | render(vnode, scratch) 215 | 216 | expect(scratch.innerHTML).toBe('
is a
') 217 | 218 | var evt = document.createEvent('HTMLEvents') 219 | evt.initEvent('click', false, true) 220 | scratch.firstElementChild.dispatchEvent(evt) 221 | expect(scratch.innerHTML).toBe('
foobar
') 222 | scratch.firstElementChild.dispatchEvent(evt) 223 | expect(scratch.innerHTML).toBe('
is a
') 224 | }) 225 | it('should update flip child component with childs and its childs', () => { 226 | class C3 extends Component { 227 | trigger () { 228 | this.setState(({isA}) => ({ isA: !isA })) 229 | } 230 | constructor (props) { 231 | super(props) 232 | this.state.isA = true 233 | } 234 | render () { 235 | return this.state.isA ? node({ 236 | tagName: 'div', 237 | children: [ 238 | 'foo', 239 | 'bar', 240 | 'baz' 241 | ], 242 | onclick: () => this.trigger() 243 | }) : node({ 244 | tagName: 'div', 245 | onclick: () => this.trigger(), 246 | children: [ 247 | 'boo', 248 | 'barz' 249 | ] 250 | }) 251 | } 252 | } 253 | const vnode = new C3() 254 | render(vnode, scratch) 255 | 256 | expect(scratch.innerHTML).toBe('
foobarbaz
') 257 | 258 | var evt = document.createEvent('HTMLEvents') 259 | evt.initEvent('click', false, true) 260 | scratch.firstElementChild.dispatchEvent(evt) 261 | expect(scratch.innerHTML).toBe('
boobarz
') 262 | scratch.firstElementChild.dispatchEvent(evt) 263 | expect(scratch.innerHTML).toBe('
foobarbaz
') 264 | }) 265 | it('should update flip child component with childs and childs', () => { 266 | class C4 extends Component { 267 | trigger () { 268 | this.setState(({isA}) => ({ isA: !isA })) 269 | } 270 | constructor (props) { 271 | super(props) 272 | this.state.isA = true 273 | } 274 | render () { 275 | return this.state.isA ? node({ 276 | tagName: 'div', 277 | children: [ 278 | '1', 279 | '2', 280 | '3' 281 | ], 282 | onclick: () => this.trigger() 283 | }) : node({ 284 | tagName: 'div', 285 | onclick: () => this.trigger(), 286 | children: [ 287 | '4', 288 | '5', 289 | '6' 290 | ] 291 | }) 292 | } 293 | } 294 | const vnode = new C4() 295 | render(vnode, scratch) 296 | 297 | expect(scratch.innerHTML).toBe('
123
') 298 | 299 | var evt = document.createEvent('HTMLEvents') 300 | evt.initEvent('click', false, true) 301 | scratch.firstElementChild.dispatchEvent(evt) 302 | expect(scratch.innerHTML).toBe('
456
') 303 | scratch.firstElementChild.dispatchEvent(evt) 304 | expect(scratch.innerHTML).toBe('
123
') 305 | }) 306 | it('should update flip child component with child component', () => { 307 | class C5 extends Component { 308 | render () { 309 | return node({tagName: 'span', textContent: 'hello word'}) 310 | } 311 | } 312 | class C6 extends Component { 313 | trigger () { 314 | this.setState(({isA}) => ({ isA: !isA })) 315 | } 316 | constructor (props) { 317 | super(props) 318 | this.state.isA = false 319 | } 320 | render () { 321 | return this.state.isA ? node({ 322 | tagName: 'div', 323 | children: [ 324 | '1', 325 | '2', 326 | '3' 327 | ], 328 | onclick: () => this.trigger() 329 | }) : node({ 330 | tagName: 'div', 331 | onclick: () => this.trigger(), 332 | children: [ 333 | new C5() 334 | ] 335 | }) 336 | } 337 | } 338 | const vnode = new C6() 339 | render(vnode, scratch) 340 | 341 | expect(scratch.innerHTML).toBe('
hello word
') 342 | 343 | var evt = document.createEvent('HTMLEvents') 344 | evt.initEvent('click', false, true) 345 | scratch.firstElementChild.dispatchEvent(evt) 346 | expect(scratch.innerHTML).toBe('
123
') 347 | scratch.firstElementChild.dispatchEvent(evt) 348 | expect(scratch.innerHTML).toBe('
hello word
') 349 | }) 350 | it('should update flip child component a with child component b', () => { 351 | class C7 extends Component { 352 | render () { 353 | return node({tagName: 'span', textContent: 'hello word'}) 354 | } 355 | } 356 | class C8 extends Component { 357 | render () { 358 | return node({tagName: 'div', textContent: 'foo bar'}) 359 | } 360 | } 361 | class C9 extends Component { 362 | trigger () { 363 | this.setState(({isA}) => ({ isA: !isA })) 364 | } 365 | constructor (props) { 366 | super(props) 367 | this.state.isA = true 368 | } 369 | render () { 370 | return node({ 371 | tagName: 'div', 372 | children: [ 373 | this.state.isA ? new C7() : new C8() 374 | ], 375 | onclick: () => this.trigger() 376 | }) 377 | } 378 | } 379 | const vnode = new C9() 380 | render(vnode, scratch) 381 | 382 | expect(scratch.innerHTML).toBe('
hello word
') 383 | 384 | var evt = document.createEvent('HTMLEvents') 385 | evt.initEvent('click', false, true) 386 | scratch.firstElementChild.dispatchEvent(evt) 387 | expect(scratch.innerHTML).toBe('
foo bar
') 388 | scratch.firstElementChild.dispatchEvent(evt) 389 | expect(scratch.innerHTML).toBe('
hello word
') 390 | }) 391 | }) 392 | }) 393 | -------------------------------------------------------------------------------- /test/utils.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | isFunction, 3 | shallowClone, 4 | isNull, 5 | isBoolean, 6 | isValidObject, 7 | throwError, 8 | isEmpty, 9 | mergeObjects, 10 | shallowCompare 11 | } from '../src/utils' 12 | 13 | describe('utils', () => { 14 | describe('isFunction', () => { 15 | it('should return false', () => { 16 | [null, undefined, [], '', 1, false, /\s/, void 0, Symbol('foo')].forEach( 17 | (value) => expect(isFunction(value)).toBeFalsy() 18 | ) 19 | }) 20 | it('should return true', () => { 21 | [function () {}, () => ({}), function test () {}].forEach( 22 | (value) => expect(isFunction(value)).toBeTruthy() 23 | ) 24 | }) 25 | }) 26 | describe('isNull', () => { 27 | it('should return false', () => { 28 | [undefined, [], '', 1, false, /\s/, void 0, function () {}].forEach( 29 | (value) => expect(isNull(value)).toBeFalsy() 30 | ) 31 | }) 32 | it('should return true', () => { 33 | [null].forEach( 34 | (value) => expect(isNull(value)).toBeTruthy() 35 | ) 36 | }) 37 | }) 38 | describe('isBoolean', () => { 39 | it('should return false', () => { 40 | [undefined, [], '', 1, 0, /\s/, void 0, function () {}, null].forEach( 41 | (value) => expect(isBoolean(value)).toBeFalsy() 42 | ) 43 | }) 44 | it('should return true', () => { 45 | [true, false].forEach( 46 | (value) => expect(isBoolean(value)).toBeTruthy() 47 | ) 48 | }) 49 | }) 50 | describe('isValidObject', () => { 51 | it('should return false', () => { 52 | // eslint-disable-next-line no-new-object 53 | [new Object(9), Function, undefined, [], '', 1, /\s/, void 0, null, Symbol('foo')].forEach( 54 | (value) => expect(isValidObject(value)).toBeFalsy() 55 | ) 56 | }) 57 | it('should return true', () => { 58 | // eslint-disable-next-line no-new-object 59 | [{foo: 'bar'}, {}, new Object({foo: 'bar'})].forEach( 60 | (value) => expect(isValidObject(value)).toBeTruthy() 61 | ) 62 | }) 63 | }) 64 | describe('isEmpty', () => { 65 | it('should return false', () => { 66 | // eslint-disable-next-line no-new-wrappers 67 | [new String('foo bar'), 'foo bar'].forEach( 68 | (value) => expect(isEmpty(value)).toBeFalsy() 69 | ) 70 | }) 71 | it('should return true', () => { 72 | // eslint-disable-next-line no-new-wrappers 73 | ['', new String(''), null, undefined].forEach( 74 | (value) => expect(isEmpty(value)).toBeTruthy() 75 | ) 76 | }) 77 | }) 78 | describe('shallowClone', () => { 79 | it('should shallow clone', () => { 80 | const original = {foo: 'bar', bar: {foo: 'bar'}} 81 | const clone = shallowClone(original) 82 | 83 | expect(original).toMatchObject(clone) 84 | expect(original !== clone).toBeTruthy() 85 | }) 86 | it('should return an empty object', () => { 87 | const original = undefined 88 | const clone = shallowClone(original) 89 | 90 | expect({}).toMatchObject(clone) 91 | }) 92 | }) 93 | describe('throwError', () => { 94 | it('should throw an exception', () => { 95 | const t = () => throwError('foo bar') 96 | expect(t).toThrow( 97 | Error 98 | ) 99 | }) 100 | it('should throw an exception with custom message', () => { 101 | const t = () => throwError('foo bar') 102 | expect(t).toThrowError('Mini React: foo bar') 103 | }) 104 | }) 105 | describe('mergeObjects', () => { 106 | it('should merge two objects', () => { 107 | const a = {a: '1'} 108 | const b = {b: '2'} 109 | expect(mergeObjects(a, b)).toMatchObject({ 110 | a: '1', 111 | b: '2' 112 | }) 113 | }) 114 | it('should merge two objects', () => { 115 | const a = {a: '1', b: [4]} 116 | const b = {b: [1, 2, 3]} 117 | expect(mergeObjects(a, b)).toMatchObject({ 118 | a: '1', 119 | b: [1, 2, 3] 120 | }) 121 | }) 122 | it('should merge two objects deeply', () => { 123 | const a = {a: 1, c: 5, b: {c: 'b.c', d: {e: 'd.e'}}} 124 | const b = {c: [2], b: {d: {foo: 'bar', 'bar': {foo: '1'}}}} 125 | expect(mergeObjects(a, b)).toMatchObject({ 126 | a: 1, b: {c: 'b.c', d: {e: 'd.e', foo: 'bar', 'bar': {foo: '1'}}}, c: [2] 127 | }) 128 | }) 129 | it('should throw an exception with custom message', () => { 130 | const t = () => mergeObjects(null, null) 131 | expect(t).toThrow() 132 | }) 133 | }) 134 | describe('throwError', () => { 135 | it('should throw an exception with custom message', () => { 136 | const t = () => throwError('foo bar') 137 | expect(t).toThrowError('Mini React: foo bar') 138 | }) 139 | }) 140 | describe('shallowCompare', () => { 141 | it('should return false if `a` has a property that `b` does not have', () => { 142 | const a = { 143 | prop1: '1', 144 | prop2: '2' 145 | } 146 | const b = { 147 | prop2: '2' 148 | } 149 | expect(shallowCompare(a, b)).toBeFalsy() 150 | }) 151 | it('should return false if `b` has a property that `a` does not have', () => { 152 | const b = { 153 | prop1: '1', 154 | prop2: '2' 155 | } 156 | const a = { 157 | prop2: '2' 158 | } 159 | expect(shallowCompare(a, b)).toBeFalsy() 160 | }) 161 | it('should return false if `b` has a property that `a` does have but with diferente value', () => { 162 | const b = { 163 | prop1: '1', 164 | prop2: '2' 165 | } 166 | const a = { 167 | prop1: '1', 168 | prop2: '3' 169 | } 170 | expect(shallowCompare(a, b)).toBeFalsy() 171 | }) 172 | it('should return true if `b` has a property that `a` does have with same value', () => { 173 | const b = { 174 | prop1: '1', 175 | prop2: '2' 176 | } 177 | const a = { 178 | prop1: '1', 179 | prop2: '2' 180 | } 181 | expect(shallowCompare(a, b)).toBeTruthy() 182 | }) 183 | }) 184 | }) 185 | -------------------------------------------------------------------------------- /test/vdom.test.js: -------------------------------------------------------------------------------- 1 | import { default as NodeFactory, Node } from '../src/vdom/node' 2 | 3 | describe('node', () => { 4 | describe('Node', () => { 5 | it('should return an empty node definition', () => { 6 | const n = new Node('div') 7 | expect(n).toMatchObject( 8 | { 9 | kind: 'div', 10 | attributes: {}, 11 | children: [] 12 | } 13 | ) 14 | }) 15 | 16 | it('should return valid node definition with attributes', () => { 17 | const n = new Node('div', { attr: 1 }) 18 | expect(n).toMatchObject( 19 | { 20 | kind: 'div', 21 | attributes: { attr: 1 }, 22 | children: [] 23 | } 24 | ) 25 | }) 26 | 27 | it('should return valid node definition with children', () => { 28 | const n = new Node('div', { attr: 1 }, 'div') 29 | expect(n).toMatchObject( 30 | { 31 | kind: 'div', 32 | attributes: { attr: 1 }, 33 | children: ['div'] 34 | } 35 | ) 36 | }) 37 | }) 38 | 39 | describe('default', () => { 40 | it('should return simple text node definition', () => { 41 | const n = new NodeFactory('simple text') 42 | expect(n).toMatchObject( 43 | { 44 | kind: 'span', 45 | attributes: {textContent: 'simple text'}, 46 | children: [] 47 | } 48 | ) 49 | }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | entry: './src/index.js', 5 | module: { 6 | rules: [ 7 | { 8 | test: /\.js$/, 9 | exclude: /node_modules/, 10 | use: { 11 | loader: 'babel-loader' 12 | } 13 | } 14 | ] 15 | }, 16 | output: { 17 | path: path.resolve(__dirname, 'public'), 18 | filename: 'mini-react.js', 19 | libraryTarget: 'window' 20 | } 21 | } 22 | --------------------------------------------------------------------------------