├── .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 = `>${pre}>`;
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('');
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 |
--------------------------------------------------------------------------------