Repository: luwes/little-vdom
Branch: main
Commit: e6db8d217fe9
Files: 9
Total size: 25.9 KB
Directory structure:
gitextract_91a3b87y/
├── .gitignore
├── README.md
├── dist/
│ └── little-vdom.js
├── little-vdom.js
├── package.json
└── test/
├── _util/
│ ├── helpers.js
│ └── logCall.js
├── test.jsx
└── web-test-runner.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
/node_modules/
================================================
FILE: README.md
================================================
# 🍼 little-vdom
> Forked from developit's [little-vdom](https://gist.github.com/developit/2038b141b31287faa663f410b6649a87) gist.
**npm**: `npm i @luwes/little-vdom`
**cdn**: [unpkg.com/@luwes/little-vdom](https://unpkg.com/@luwes/little-vdom)
---
- 650B Virtual DOM
- Components
- State
- Diffing
- Keys
- Fragments
- Refs
- Style maps
Use reactive JSX with minimal overhead.
## Usage ([Codepen](https://codepen.io/luwes/pen/ZEXPbzE?editors=0011))
```jsx
/** @jsx h */
// Components get passed (props, state, setState)
function Counter(props, { count = 0 }, update) {
const increment = () => update({ count: ++count });
return
}
function Since({ time }, state, update) {
setTimeout(update, 1000); // update every second
const ago = (Date.now() - time) / 1000 | 0;
return
}
render(
Hello
,
document.body
);
```
================================================
FILE: dist/little-vdom.js
================================================
const n=(n,e,...t)=>({t:n,o:e,i:t.filter((n=>!1!==n)),key:e&&e.key}),e=n=>n.children,t=(t,c,o=c.l||(c.l={}))=>r(n(e,{},[t]),c,o),r=(n,e,t,o)=>{if(n.pop)return c(e,n,t);if(n.t.call){n.u=t.u||{};const c={children:n.i,...n.o},i=n.t(c,n.u,(t=>(Object.assign(n.u,t),r(n,e,n))));return n.p=r(i,e,t&&t.p||{},o),e.l=n}{const r=t.dom||(n.t?document.createElement(n.t):new Text(n.o));if(n.o!=t.o)if(n.t){const{key:e,ref:c,...o}=n.o;c&&(c.current=r);for(let n in o){const e=o[n];if("style"!==n||e.trim)e!=(t.o&&t.o[n])&&(n in r||(n=n.toLowerCase())in r?r[n]=e:null!=e?r.setAttribute(n,e):r.removeAttribute(n));else for(const n in e)r.style[n]=e[n]}}else r.data=n.o;return c(r,n.i,t),t.dom&&null==o||e.insertBefore(n.dom=r,e.childNodes[o+1]||null),e.l=Object.assign(t,n)}},c=(e,t,c)=>{const i=c._||[];return c._=t.concat.apply([],t).map(((t,c)=>{const o=t.i?t:n("",""+t),l=i.find(((n,e)=>n&&n.t==o.t&&n.key==o.key&&(e==c&&(c=void 0),i[e]=0,n)))||{};return r(o,e,l,c)})),i.map(o),c};function o(n){const{i:e=[],p:t}=n;e.concat(t).map((n=>n&&o(n))),n.dom&&n.dom.remove()}export{n as h,e as Fragment,t as render,r as diff};
================================================
FILE: little-vdom.js
================================================
// Adapted and fixed bugs from little-vdom.js
// https://gist.github.com/developit/2038b141b31287faa663f410b6649a87
// https://gist.github.com/marvinhagemeister/8950b1032d67918d21950b3985259d78
// Added refs, style maps
const h = (type, props, ...children) => {
return {
_type: type,
_props: props, // An object for components and DOM nodes, a string for text nodes.
_children: children.filter((_) => _ !== false),
key: props && props.key,
};
};
const Fragment = (props) => {
return props.children;
};
const render = (newVNode, dom, oldVNode = dom._vnode || (dom._vnode = {})) => {
return diff(h(Fragment, {}, [newVNode]), dom, oldVNode);
};
const diff = (newVNode, dom, oldVNode, currentChildIndex) => {
// Check if we are in fact dealing with an array of nodes. A more common
// and faster version of this check is Array.isArray()
if (newVNode.pop) {
return diffChildren(dom, newVNode, oldVNode);
}
// Check if we have a component. Only functions have a .call() method.
// Here components have a different signature compared to Preact or React:
//
// (props, state, updateFn) => VNode;
//
// The 3rd argument is basically similar concept-wise to setState
else if (newVNode._type.call) {
// Initialize state of component if necessary
newVNode._state = oldVNode._state || {};
// Add children to props
const props = { children: newVNode._children, ...newVNode._props };
const renderResult = newVNode._type(
props,
newVNode._state,
// Updater function that is passed as 3rd argument to components
(nextState) => {
// Update state with new value
Object.assign(newVNode._state, nextState);
return diff(newVNode, dom, newVNode);
}
);
newVNode._patched = diff(
renderResult,
dom,
(oldVNode && oldVNode._patched) || {},
currentChildIndex
);
return (dom._vnode = newVNode);
}
// Standard DOM elements
else {
// Create a DOM element and assign it to the vnode. If one already exists,
// we will reuse the existing one and not create a new node.
const newDom =
oldVNode.dom ||
(newVNode._type
? document.createElement(newVNode._type)
: // If we have a text node, vnode.props will be a string
new Text(newVNode._props));
// diff props
if (newVNode._props != oldVNode._props) {
// If newVNode.type is truthy (=not an empty string) we have a DOM node
if (newVNode._type) {
const { key, ref, ...newProps } = newVNode._props;
if (ref) ref.current = newDom;
for (let name in newProps) {
const value = newProps[name];
// A string object has a trim method.
if (name === 'style' && !value.trim) {
for (const n in value) {
newDom.style[n] = value[n];
}
} else if (value != (oldVNode._props && oldVNode._props[name])) {
if (name in newDom || (name = name.toLowerCase()) in newDom) {
newDom[name] = value;
} else if (value != null) {
newDom.setAttribute(name, value);
} else {
newDom.removeAttribute(name);
}
}
}
}
// Otherwise a text node
else {
// Update text node content
newDom.data = newVNode._props;
}
}
// diff children (typed/keyed)
diffChildren(newDom, newVNode._children, oldVNode);
// insert at position
if (!oldVNode.dom || currentChildIndex != undefined) {
dom.insertBefore(
(newVNode.dom = newDom),
dom.childNodes[currentChildIndex + 1] || null
);
}
return (dom._vnode = Object.assign(oldVNode, newVNode));
}
};
const diffChildren = (parentDom, newChildren, oldVNode) => {
const oldChildren = oldVNode._normalizedChildren || [];
oldVNode._normalizedChildren = newChildren.concat
.apply([], newChildren)
.map((child, index) => {
// If the vnode has no children we assume that we have a string and
// convert it into a text vnode.
const nextNewChild = child._children ? child : h('', '' + child);
// If we have previous children we search for one that matches our
// current vnode.
const nextOldChild =
oldChildren.find((oldChild, childIndex) => {
let result =
oldChild &&
oldChild._type == nextNewChild._type &&
oldChild.key == nextNewChild.key &&
(childIndex == index && (index = undefined),
(oldChildren[childIndex] = 0),
oldChild);
// if (result) console.log('found vnode', result);
return result;
}) || {};
// Continue diffing recursively against the next child.
return diff(nextNewChild, parentDom, nextOldChild, index);
});
// remove old children if there are any
oldChildren.map(removePatchedChildren)
return oldVNode;
};
function removePatchedChildren(child) {
const { _children = [], _patched } = child
// remove children
_children.concat(_patched).map(c => c && removePatchedChildren(c))
// remove dom
child.dom && child.dom.remove()
}
export { h, Fragment, render, diff };
================================================
FILE: package.json
================================================
{
"name": "@luwes/little-vdom",
"version": "0.3.4",
"description": "Fork from developit/little-vdom",
"type": "module",
"main": "dist/little-vdom.js",
"scripts": {
"lint": "eslint '*.{js,jsx}'",
"build": "npm run minify && npm run size",
"minify": "terser little-vdom.js -c -m toplevel=true --mangle-props regex=/^_/ -o dist/little-vdom.js",
"size": "echo \"gzip: $(cat dist/little-vdom.js | gzip -c9 | wc -c)\" && echo \"brotli: $(cat dist/little-vdom.js | brotli | wc -c)\" && echo ''",
"test": "web-test-runner **/*test.jsx --config test/web-test-runner.config.js",
"test:watch": "npm run test -- --watch"
},
"license": "MIT",
"devDependencies": {
"@esm-bundle/chai": "^4.3.4-fix.0",
"@web/dev-server": "^0.1.29",
"@web/dev-server-esbuild": "^0.2.16",
"@web/test-runner": "^0.13.25",
"eslint": "^8.7.0",
"eslint-plugin-react": "^7.28.0",
"prettier": "^2.5.1",
"terser": "^5.10.0"
},
"prettier": {
"tabWidth": 2,
"singleQuote": true,
"semi": true
},
"eslintConfig": {
"env": {
"browser": true,
"es6": true,
"node": true,
"mocha": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended"
],
"parserOptions": {
"ecmaVersion": 2019,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"settings": {
"react": {
"pragma": "h"
}
},
"rules": {
"no-shadow": "error",
"react/prop-types": 0,
"react/no-unknown-property": [
2,
{
"ignore": [
"class"
]
}
]
}
}
}
================================================
FILE: test/_util/helpers.js
================================================
import { clearLog, getLog } from './logCall';
/**
* Setup the test environment
* @returns {HTMLDivElement}
*/
export function setupScratch() {
const scratch = document.createElement('div');
scratch.id = 'scratch';
(document.body || document.documentElement).appendChild(scratch);
return scratch;
}
/**
* Teardown test environment and reset preact's internal state
* @param {HTMLDivElement} scratch
*/
export function teardown(scratch) {
if (scratch) {
scratch.parentNode.removeChild(scratch);
}
if (getLog().length > 0) {
clearLog();
}
}
export function serializeHtml(node) {
let str = '';
let child = node.firstChild;
while (child) {
str += serializeDomTree(child);
child = child.nextSibling;
}
return str;
}
const VOID_ELEMENTS = /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/;
function encodeEntities(str) {
return str.replace(/&/g, '&');
}
/**
* Normalize svg paths spacing. Some browsers insert spaces around letters,
* others do not.
* @param {string} str
* @returns {string}
*/
function normalizePath(str) {
let len = str.length;
let out = '';
for (let i = 0; i < len; i++) {
const char = str[i];
if (/[A-Za-z]/.test(char)) {
if (i == 0) out += char + ' ';
else
out += (str[i - 1] == ' ' ? '' : ' ') + char + (i < len - 1 ? ' ' : '');
} else if (char == '-' && str[i - 1] !== ' ') out += ' ' + char;
else out += char;
}
return out.replace(/\s\s+/g, ' ').replace(/z/g, 'Z');
}
/**
* Serialize a DOM tree.
* Uses deterministic sorting where necessary to ensure consistent tests.
* @param {Element|Node} node The root node to serialize
* @returns {string} html
*/
function serializeDomTree(node) {
if (node.nodeType === 3) {
return encodeEntities(node.data);
} else if (node.nodeType === 8) {
return '';
} else if (node.nodeType === 1 || node.nodeType === 9) {
let str = '<' + node.localName;
const attrs = [];
for (let i = 0; i < node.attributes.length; i++) {
attrs.push(node.attributes[i].name);
}
attrs.sort();
for (let i = 0; i < attrs.length; i++) {
const name = attrs[i];
let value = node.getAttribute(name);
// don't render attributes with null or undefined values
if (value == null) continue;
// normalize empty class attribute
if (!value && name === 'class') continue;
str += ' ' + name;
value = encodeEntities(value);
// normalize svg
if (node.localName === 'path' && name === 'd') {
value = normalizePath(value);
}
str += '="' + value + '"';
}
str += '>';
// For elements that don't have children (e.g. ) don't descend.
if (!VOID_ELEMENTS.test(node.localName)) {
// IE puts the value of a textarea as its children while other browsers don't.
// Normalize those differences by forcing textarea to not have children.
if (node.localName != 'textarea') {
let child = node.firstChild;
while (child) {
str += serializeDomTree(child);
child = child.nextSibling;
}
}
str += '' + node.localName + '>';
}
return str;
}
}
================================================
FILE: test/_util/logCall.js
================================================
/**
* Serialize an object
* @param {Object} obj
* @return {string}
*/
function serialize(obj) {
if (obj instanceof Text) return '#text';
if (obj instanceof Element) return `<${obj.localName}>${obj.textContent}`;
if (obj === document) return 'document';
if (typeof obj == 'string') return obj;
return Object.prototype.toString.call(obj).replace(/(^\[object |\]$)/g, '');
}
/** @type {string[]} */
let log = [];
/**
* Modify obj's original method to log calls and arguments on logger object
* @template T
* @param {T} obj
* @param {keyof T} method
*/
export function logCall(obj, method) {
let old = obj[method];
obj[method] = function(...args) {
let c = '';
for (let i = 0; i < args.length; i++) {
if (c) c += ', ';
c += serialize(args[i]);
}
// Normalize removeChild -> remove to keep output clean and readable
const operation =
method != 'removeChild'
? `${serialize(this)}.${method}(${c})`
: `${serialize(c)}.remove()`;
log.push(operation);
return old.apply(this, args);
};
return () => (obj[method] = old);
}
/**
* Return log object
* @return {string[]} log
*/
export function getLog() {
return log;
}
/** Clear log object */
export function clearLog() {
log = [];
}
export function getLogSummary() {
/** @type {{ [key: string]: number }} */
const summary = {};
for (let entry of log) {
summary[entry] = (summary[entry] || 0) + 1;
}
return summary;
}
================================================
FILE: test/test.jsx
================================================
/**
Cherry picked tests from Preact
The MIT License (MIT)
Copyright (c) 2015-present Jason Miller
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import { expect } from '@esm-bundle/chai';
import { h, Fragment, render } from '../little-vdom.js';
import { clearLog, getLog, logCall } from './_util/logCall.js';
import { setupScratch, teardown, serializeHtml } from './_util/helpers.js';
describe('all', () => {
let scratch;
let resetAppendChild;
let resetInsertBefore;
let resetRemoveChild;
let resetRemove;
beforeEach(() => {
scratch = setupScratch();
});
afterEach(() => {
teardown(scratch);
});
before(() => {
resetAppendChild = logCall(Element.prototype, 'appendChild');
resetInsertBefore = logCall(Element.prototype, 'insertBefore');
resetRemoveChild = logCall(Element.prototype, 'removeChild');
resetRemove = logCall(Element.prototype, 'remove');
});
after(() => {
resetAppendChild();
resetInsertBefore();
resetRemoveChild();
resetRemove();
});
/** @type {(props: {values: any[]}) => any} */
const List = props => (
{props.values.map(value => (
{value}
))}
);
/**
* Move an element in an array from one index to another
* @param {any[]} values The array of values
* @param {number} from The index to move from
* @param {number} to The index to move to
*/
function move(values, from, to) {
const value = values[from];
values.splice(from, 1);
values.splice(to, 0, value);
}
it('should register on* functions as handlers', () => {
let count = 0;
let onclick = () => (++count);
render(, scratch);
expect(scratch.childNodes[0].attributes.length).to.equal(0);
scratch.childNodes[0].click();
expect(count).to.equal(1);
});
// render.test.js
it('should rerender when value from "" to 0', () => {
render('', scratch);
expect(scratch.innerHTML).to.equal('');
render(0, scratch);
expect(scratch.innerHTML).to.equal('0');
});
it('change content', () => {
render(
Bad
, scratch);
render(
Good
, scratch);
expect(scratch.innerHTML).to.eql(`
Good
`);
});
it('should allow node type change with content', () => {
render(Bad, scratch);
render(
,
scratch
);
let a = scratch.firstChild.firstChild;
let b = scratch.firstChild.lastChild;
expect(a).to.have.property('nodeName', 'A');
expect(b).to.have.property('nodeName', 'B');
render(