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 += ''; } 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 => (
  1. {value}
  2. ))}
); /** * 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(
Good
, scratch); expect(scratch.innerHTML).to.eql(`
Good
`); }); it('should nest empty nodes', () => { render(
, scratch ); expect(scratch.childNodes).to.have.length(1); expect(scratch.childNodes[0].nodeName).to.equal('DIV'); let c = scratch.childNodes[0].childNodes; expect(c).to.have.length(3); expect(c[0].nodeName).to.equal('SPAN'); expect(c[1].nodeName).to.equal('FOO'); expect(c[2].nodeName).to.equal('X-BAR'); }); it('should reorder child pairs', () => { render(
a b
, 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(
b a
, scratch ); expect(scratch.firstChild.firstChild).to.equal(b); expect(scratch.firstChild.lastChild).to.equal(a); }); it('should remove class attributes', () => { const App = props => (
Bye
); render(, scratch); expect(scratch.innerHTML).to.equal( '
Bye
' ); render(, scratch); expect(scratch.innerHTML).to.equal('
Bye
'); }); // keys.test.js it('should remove orphaned keyed nodes', () => { render(
1
  • a
  • b
  • , scratch ); render(
    2
  • b
  • c
  • , scratch ); expect(scratch.innerHTML).to.equal( '
    2
  • b
  • c
  • ' ); }); it('should append new keyed elements', () => { const values = ['a', 'b']; render(, scratch); expect(scratch.textContent).to.equal('ab'); values.push('c'); clearLog(); render(, scratch); expect(scratch.textContent).to.equal('abc'); expect(getLog()).to.deep.equal([ '
  • .insertBefore(#text, Null)', '
      ab.insertBefore(
    1. c, Null)' ]); }); it('should remove keyed elements from the end', () => { const values = ['a', 'b', 'c', 'd']; render(, scratch); expect(scratch.textContent).to.equal('abcd'); values.pop(); clearLog(); render(, scratch); expect(scratch.textContent).to.equal('abc'); expect(getLog()).to.deep.equal(['
    2. d.remove()']); }); it('should prepend keyed elements to the beginning', () => { const values = ['b', 'c']; render(, scratch); expect(scratch.textContent).to.equal('bc'); values.unshift('a'); clearLog(); render(, scratch); expect(scratch.textContent).to.equal('abc'); // Comment out efficient reconciliation proof, would require a bigger diffing algo. // expect(getLog()).to.deep.equal([ // '
    3. .insertBefore(#text, Null)', // '
        bc.insertBefore(
      1. a,
      2. b)' // ]); }); it('should remove keyed elements from the beginning', () => { const values = ['z', 'a', 'b', 'c']; render(, scratch); expect(scratch.textContent).to.equal('zabc'); values.shift(); clearLog(); render(, scratch); expect(scratch.textContent).to.equal('abc'); // Comment out efficient reconciliation proof, would require a bigger diffing algo. // expect(getLog()).to.deep.equal(['
      3. z.remove()']); }); it('should insert new keyed children in the middle', () => { const values = ['a', 'c']; render(, scratch); expect(scratch.textContent).to.equal('ac'); values.splice(1, 0, 'b'); clearLog(); render(, scratch); expect(scratch.textContent).to.equal('abc'); // expect(getLog()).to.deep.equal([ // '
      4. .insertBefore(#text, Null)', // '
          ac.insertBefore(
        1. b,
        2. c)' // ]); }); it('should remove keyed children from the middle', () => { const values = ['a', 'b', 'x', 'y', 'z', 'c', 'd']; render(, scratch); expect(scratch.textContent).to.equal('abxyzcd'); values.splice(2, 3); clearLog(); render(, scratch); expect(scratch.textContent).to.equal('abcd'); // expect(getLog()).to.deep.equal([ // '
        3. z.remove()', // '
        4. y.remove()', // '
        5. x.remove()' // ]); }); it('should move keyed children to the end of the list', () => { const values = ['a', 'b', 'c', 'd']; render(, scratch); expect(scratch.textContent).to.equal('abcd'); // move to end move(values, 0, values.length - 1); clearLog(); render(, scratch); expect(scratch.textContent).to.equal('bcda', 'move to end'); // expect(getLog()).to.deep.equal( // ['
            abcd.insertBefore(
          1. a, Null)'], // 'move to end' // ); // move to beginning move(values, values.length - 1, 0); clearLog(); render(, scratch); expect(scratch.textContent).to.equal('abcd', 'move to beginning'); // expect(getLog()).to.deep.equal( // ['
              bcda.insertBefore(
            1. a,
            2. b)'], // 'move to beginning' // ); }); it('should reverse keyed children effectively', () => { const values = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']; render(, scratch); expect(scratch.textContent).to.equal(values.join('')); // reverse list values.reverse(); clearLog(); render(, scratch); expect(scratch.textContent).to.equal(values.join('')); // expect(getLog()).to.deep.equal([ // '
                abcdefghij.insertBefore(
              1. j,
              2. a)', // '
                  jabcdefghi.insertBefore(
                1. i,
                2. a)', // '
                    jiabcdefgh.insertBefore(
                  1. h,
                  2. a)', // '
                      jihabcdefg.insertBefore(
                    1. g,
                    2. a)', // '
                        jihgabcdef.insertBefore(
                      1. f,
                      2. a)', // '
                          jihgfabcde.insertBefore(
                        1. e,
                        2. a)', // '
                            jihgfeabcd.insertBefore(
                          1. d,
                          2. a)', // '
                              jihgfedabc.insertBefore(
                            1. c,
                            2. a)', // '
                                jihgfedcab.insertBefore(
                              1. a, Null)' // ]); }); // fragment.test.js it('should render a single child', () => { clearLog(); render( foo , scratch ); expect(scratch.innerHTML).to.equal('foo'); expect(getLog()).to.deep.equal([ '.insertBefore(#text, Null)', '
                                .insertBefore(foo, Null)' ]); }); it('should render multiple children via noop renderer', () => { render( hello world , scratch ); expect(scratch.innerHTML).to.equal('hello world'); }); it.skip('should handle reordering components that return Fragments #1325', () => { const X = (props) => { return props.children; } const App = (props) => { if (props.i === 0) { return (
                                1 2
                                ); } return (
                                2 1
                                ); } render(, scratch); expect(scratch.textContent).to.equal('12'); clearLog(); console.log('----------------------------------'); render(, scratch); console.log(getLog()); expect(scratch.textContent).to.equal('21'); }); // refs.test.js it('should support createRef', () => { const r = { current: null }; expect(r.current).to.equal(null); render(
                                , scratch); expect(r.current).to.equal(scratch.firstChild); }); // createRoot.js it('should apply string attributes', () => { render(
                                , scratch); expect(serializeHtml(scratch)).to.equal( '
                                ' ); }); it('should apply class as String', () => { render(
                                , scratch); expect(scratch.childNodes[0]).to.have.property('className', 'foo'); }); it('should set checked attribute on custom elements without checked property', () => { render(, scratch); expect(scratch.innerHTML).to.equal( '' ); }); it('should set value attribute on custom elements without value property', () => { render(, scratch); expect(scratch.innerHTML).to.equal(''); }); it('should mask value on password input elements', () => { render(, scratch); expect(scratch.innerHTML).to.equal(''); }); }); ================================================ FILE: test/web-test-runner.config.js ================================================ import { esbuildPlugin } from "@web/dev-server-esbuild"; export default { nodeResolve: true, plugins: [ esbuildPlugin({ jsx: true, jsxFactory: "h", jsxFragment: "Fragment" }), ], };