tunnckoCore / ideas

:notebook: My centralized place for ideas, thoughts and todos. Raw, PoC implementations and so on... :star:
http://j.mp/1stW47C
6 stars 0 forks source link

min* - mindom, minscript, minmorph #95

Open tunnckoCore opened 6 years ago

tunnckoCore commented 6 years ago

The 1.1kb library.

https://www.webpackbin.com/bins/-L3NghOrCy3F_f8TxWM8 (updated 21 Jan 2018)

Initially it was the idea behind mich project and the mich-h package. But that's a bit different and all of the names are simple and free.

minmorph

It is almost exact copy of the nanomorph, but a lot simplified and a lot smaller, which pass nanomorph tests.

mindom

It is almost the same as undom, but is more small (a bit smaller) for only needed things of our purpose, but also exposes things to be extended. It is pending discussion undom to support some kind of plugins and etc. But still it won't be my thing, unless it just provide access to each class.

Not to mention that the size don't matter in my case, since it is only needed in server side, because minscript and minmorph works perfectly in the browser.

tunnckoCore commented 6 years ago

mindom

/**
 * @copyright 2017-present, Charlike Mike Reagent <olsten.larck@gmail.com>
 * @license Apache-2.0
 */
const NO_SUFFIX = /acit|ex(?:s|g|n|p|$)|rph|ows|mnc|ntw|ine[ch]|zoo|^ord/i

function decamelize (str) {
  return str
    .replace(/([a-z\d])([A-Z])/g, '$1-$2')
    .replace(/([A-Z]+)([A-Z][a-z\d]+)/g, '$1-$2')
    .toLowerCase()
}

function toLower (str) {
  return String(str).toLowerCase()
}

function isObject (val) {
  return val && typeof val === 'object' && !Array.isArray(val)
}

/* eslint-disable max-params, fp/no-loops */
function splice (arr, item, add, byValueOnly) {
  let i = arr ? findWhere(arr, item, true, byValueOnly) : -1
  if (~i) add ? arr.splice(i, 0, add) : arr.splice(i, 1)
  return i
}

function findWhere (arr, fn, returnIndex, byValueOnly) {
  let i = arr.length
  while (i--) {
    if (typeof fn === 'function' && !byValueOnly ? fn(arr[i]) : arr[i] === fn) {
      break
    }
  }
  return returnIndex ? i : arr[i]
}

function createAttributeFilter (ns, name) {
  return (o) => o.namespaceURI === ns && toLower(o.name) === toLower(name)
}

function addStyle (node, value, attribute) {
  if (isObject(value)) {
    let cssText = ''
    Object.keys(value).forEach((k) => {
      let val = value[k]
      const suffix = typeof val === 'number' && NO_SUFFIX.test(k) === false

      val = suffix ? val + 'px' : val
      cssText += `${decamelize(k)}:${val};`

      this.style[k] = val
      this.style.cssText = cssText

      attribute.value = cssText

      // node.attributes.style is `Attribute` object,
      // so the `.value` is same as `node.style.cssText`
      this.attributes.style.value = cssText
    })
  } else {
    this.style.cssText = String(value)
  }
}

class Attribute {
  constructor (name, value, ns) {
    this.nodeType = 2
    this.name = name
    this.value = value
    this.namespaceURI = ns || null
  }
}

class Node {
  constructor (nodeType, nodeName, namespaceURI) {
    this.namespaceURI = namespaceURI || null
    this.nodeType = nodeType
    this.nodeName = nodeName
    this.childNodes = []
  }
  appendChild (child) {
    this.insertBefore(child)
  }
  insertBefore (child, ref) {
    child.remove()
    child.parentNode = this
    if (!ref) this.childNodes.push(child)
    else splice(this.childNodes, ref, child)
  }
  replaceChild (child, ref) {
    if (ref.parentNode === this) {
      this.insertBefore(child, ref)
      ref.remove()
    }
  }
  removeChild (child) {
    splice(this.childNodes, child)
  }
  remove () {
    if (this.parentNode) this.parentNode.removeChild(this)
  }
}

class Text extends Node {
  constructor (text) {
    super(3, '#text') // TEXT_NODE
    this.nodeValue = text
  }
  set textContent (text) {
    this.nodeValue = text
  }
  get textContent () {
    return this.nodeValue
  }
}

class Element extends Node {
  constructor (nodeName, namespaceURI) {
    super(1, nodeName, namespaceURI)
    this.__handlers = {}
    this.attributes = []
    this.style = {}

    Object.defineProperty(this, 'className', {
      set: (val) => {
        this.setAttribute('class', val)
      },
      get: () => this.getAttribute('class'),
    })
  }

  get children () {
    return this.childNodes.filter((node) => node.nodeType === 1)
  }

  hasAttribute (key) {
    return this.hasAttributeNS(null, key)
  }

  hasAttributeNS (namespaceURI, key) {
    return (
      this.attributes.hasOwnProperty(key) &&
      this.attributes[key].namespaceURI === namespaceURI
    )
  }

  setAttribute (key, value) {
    this.setAttributeNS(this.namespaceURI, key, value)
  }

  setAttributeNS (namespaceURI, name, value) {
    const attributeFilter = createAttributeFilter(namespaceURI, name)
    let attribute = findWhere(this.attributes, attributeFilter)

    if (!attribute) {
      attribute = new Attribute(name, value, namespaceURI)
      this.attributes.push(attribute)
      this.attributes[attribute.name] = attribute
    } else {
      attribute.value = String(value)

      if (attribute.name === 'style') {
        addStyle(this, value, attribute)
      }
    }
  }

  getAttribute (key) {
    return this.getAttributeNS(null, key)
  }

  getAttributeNS (namespaceURI, name) {
    const attributeFilter = createAttributeFilter(namespaceURI, name)
    let attr = findWhere(this.attributes, attributeFilter)
    return attr && attr.value
  }

  removeAttribute (key) {
    this.removeAttributeNS(null, key)
  }

  removeAttributeNS (namespaceURI, name) {
    splice(this.attributes, createAttributeFilter(namespaceURI, name))
  }

  addEventListener (type, handler) {
    const name = 'on' + toLower(type)
    ;(this.__handlers[name] || (this.__handlers[name] = [])).push(handler)
  }
  removeEventListener (type, handler) {
    type = toLower(type)
    splice(this.__handlers['on' + type], handler, 0, true)
  }
}

function createElement (nodeName) {
  return new Element(String(nodeName).toUpperCase())
}
function createElementNS (ns, nodeName) {
  return new Element(String(nodeName).toUpperCase(), ns)
}

function createTextNode (text) {
  return new Text(text)
}

class Document extends Element {
  constructor () {
    super(9, '#document')
  }
}

export {
  Node,
  Text,
  Element,
  Document,
  Attribute,
  createElement,
  createElementNS,
  createTextNode,
  // expose utils for easier extending
  findWhere,
  splice,
  createAttributeFilter
}

mindom/import ("register" for esm like import 'mindom/import';

/**
 * @copyright 2017-present, Charlike Mike Reagent <olsten.larck@gmail.com>
 * @license Apache-2.0
 */
import { Element, Document, createElement, createTextNode } from './mindom.mjs'

global.Element = Element
global.document = Object.assign(new Document(), {
  createElement,
  createTextNode,
})
global.document.body = createElement('body')
tunnckoCore commented 6 years ago

minscript

/**
 * @copyright 2017-present, Charlike Mike Reagent <olsten.larck@gmail.com>
 * @license Apache-2.0
 */
const isString = (val) => typeof val === 'string'
const isObject = (val) => val && typeof val === 'object' && !Array.isArray(val)

export default function hyperscript (tag, ...rest) {
  const args = [].concat(...rest)
  const node = document.createElement(tag)

  args.forEach((item) => {
    if (isString(item) || typeof item === 'number') {
      node.appendChild(document.createTextNode(item))
    } else if (item instanceof Element) {
      node.appendChild(item)
    } else if (isObject(item)) {
      addProps(node, item)
    }
  })

  return node
}

function addProps (node, props) {
  Object.keys(props).forEach((name) => {
    const value = props[name]

    if (name === 'class' || name === 'className') {
      addClass(node, value)
    } else if (name.startsWith('on')) {
      addEvents(node, name, value)
    } else {
      node.setAttribute(name, value)
    }

    return node
  })
}

function addClass (node, value) {
  if (Array.isArray(value)) {
    node.setAttribute('class', value.filter(Boolean).join(' '))
  } else if (isObject(value)) {
    const val = Object.keys(value)
      .filter((name) => Boolean(value[name]))
      .map((name) => name)
      .join(' ')

    node.setAttribute('class', val)
  } else {
    // assume string add explicit check if errors come
    node.setAttribute('class', value)
  }
}

function addEvents (node, name, value) {
  name = name.toLowerCase()
  if (name === 'on' && isObject(value)) {
    Object.keys(value).forEach((name) => {
      const prop = `on${name}`
      node.addEventListener(name, value[name])
      node[prop] = value[name]
    })
  } else {
    node.addEventListener(name, value)
    node[name] = value
  }
}
tunnckoCore commented 6 years ago

minmorph (biggest one...)

/**
 * @copyright 2017-present, Charlike Mike Reagent <olsten.larck@gmail.com>
 * @license Apache-2.0
 */
export default function minmorph (left, right) {
  if (!left) {
    return right
  } else if (!right) {
    return null
  } else if (right.isSameNode && right.isSameNode(left)) {
    return left
  } else if (right.nodeName !== left.nodeName) {
    return right
  } else {
    morph(left, right)
    return left
  }
}

function morph (left, right) {
  // if Element Node
  if (right.nodeType === 1) {
    morphProps(left, right)
  }

  // if Text Node
  if (right.nodeType === 3) {
    left.nodeValue = right.nodeValue
  }

  // Some DOM nodes are weird
  // https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
  if (right.nodeName === 'INPUT') {
    morphInput(left, right)
  }
  if (right.nodeName === 'OPTION') {
    morphAttribute(left, right, 'selected')
  }
  if (right.nodeName === 'TEXTAREA') {
    morphTextarea(left, right)
  }

  morphEvents(left, right)
  morphChildren(left, right)
}

function morphProps (left, right) {
  const isBrowser = typeof global !== 'undefined' && global.document
  const newKeys = Object.keys(right.attributes)
  const oldKeys = Object.keys(right.attributes)
  const rightAttrKeys = isBrowser ? newKeys : newKeys.slice(newKeys.length)
  const leftAttrKeys = isBrowser ? oldKeys : oldKeys.slice(oldKeys.length)

  rightAttrKeys.forEach((name) => {
    const attr = right.attributes[name]
    const ns = attr.namespaceURI // always `null` or actual namespace!

    if (!left.hasAttributeNS(ns, attr.name)) {
      left.setAttributeNS(ns, attr.name, attr.value)
    } else {
      const value = left.getAttributeNS(ns, attr.name)
      if (value !== attr.value) {
        // directly set new attribute
        left.setAttributeNS(ns, attr.name, attr.value)

        // and if input, select/option OR textarea,
        // then these will apply something.

        // Otherwise they won't touch the old node anyway,
        // so it is perfectly safe.
        morphInput(left, right)
        morphAttribute(left, right, 'selected')
        morphTextarea(left, right)
      }
    }
  })

  leftAttrKeys.forEach((name) => {
    const attr = left.attributes[name] || {}

    // attr.namespaceURI is always actual namespace or `null` so...
    if (attr.specified && !right.hasAttributeNS(attr.namespaceURI, attr.name)) {
      left.removeAttributeNS(attr.namespaceURI, attr.name)
    }
  })

  return left
}

function morphInput (left, right) {
  let oldValue = left.value
  let newValue = right.value

  morphAttribute(left, right, 'checked')
  morphAttribute(left, right, 'disabled')

  if (oldValue !== newValue) {
    left.setAttribute('value', newValue)
    left.value = newValue
  }

  if (newValue === 'null') {
    left.removeAttribute('value')
    left.value = ''
  }

  if (!right.hasAttribute('value')) {
    left.removeAttribute('value')
  } else if (left.type === 'range') {
    // this is so elements like slider move their UI thingy
    left.value = newValue
  }
}

function morphAttribute (left, right, name) {
  if (right[name] !== left[name]) {
    left[name] = right[name]

    if (right[name]) {
      left.setAttribute(name, '')
    } else {
      left.removeAttribute(name)
    }
  }
}

function morphTextarea (left, right) {
  const firstChild = left.childNodes[0]

  if (right.value !== left.value) {
    left.value = right.value
  }

  if (firstChild && firstChild.nodeValue !== right.value) {
    // Needed for IE. Apparently IE sets the placeholder as the
    // node value and vise versa. This ignores an empty update.
    if (right.value === '' && firstChild.nodeValue === left.placeholder) {
      return
    }

    firstChild.nodeValue = right.value
  }
}

function morphEvents (left, right) {
  const { node, eventNames } = getEvents(right)
  const leftNode = left.__handlers || left
  const leftRemove = (name) => left.removeEventListener(name, leftNode[name])

  eventNames.forEach((name) => {
    if (node[name]) {
      leftRemove(name)
      left.addEventListener(name, right[name])
    }
    if (leftNode[name]) {
      leftRemove(name)
    }
  })
}

/* eslint-disable max-statements, fp/no-loops */

function getEvents (node) {
  if (node.__handlers) {
    return { node, eventNames: Object.keys(node.__handlers) }
  }

  const eventNames = []
  node = node.__handlers || node

  for (let key in node) {
    if (key.startsWith('on')) {
      eventNames.push(key)
    }
  }
  return { node, eventNames }
}

function morphChildren (newNode, oldNode) {
  let oldChild, newChild, morphed, oldMatch

  // The offset is only ever increased, and used for [i - offset] in the loop
  let offset = 0

  for (let i = 0; ; i++) {
    oldChild = oldNode.childNodes[i]
    newChild = newNode.childNodes[i - offset]
    // Both nodes are empty, do nothing
    if (!oldChild && !newChild) {
      break

      // There is no new child, remove old
    } else if (!newChild) {
      oldNode.removeChild(oldChild)
      i--

      // There is no old child, add new
    } else if (!oldChild) {
      oldNode.appendChild(newChild)
      offset++

      // Both nodes are the same, morph
    } else if (same(newChild, oldChild)) {
      morphed = morph(oldChild, newChild)
      if (morphed && morphed !== oldChild) {
        oldNode.replaceChild(morphed, oldChild)
        offset++
      }

      // Both nodes do not share an ID or a placeholder, try reorder
    } else {
      oldMatch = null

      // Try and find a similar node somewhere in the tree
      for (let j = i; j < oldNode.childNodes.length; j++) {
        if (same(oldNode.childNodes[j], newChild)) {
          oldMatch = oldNode.childNodes[j]
          break
        }
      }

      // If there was a node with the same ID or placeholder in the old list
      if (oldMatch) {
        morphed = morph(oldMatch, newChild)
        if (morphed !== oldMatch) offset++
        oldNode.insertBefore(morphed, oldChild)

        // It's safe to morph two nodes in-place if neither has an ID
      } else if (!newChild.id && !oldChild.id) {
        morphed = morph(oldChild, newChild)
        if (morphed && morphed !== oldChild) {
          oldNode.replaceChild(morphed, oldChild)
          offset++
        }

        // Insert the node at the index if we couldn't morph or find a matching node
      } else {
        oldNode.insertBefore(newChild, oldChild)
        offset++
      }
    }
  }
}

function same (a, b) {
  if (a.nodeType === 3) return a.nodeValue === b.nodeValue
  if (a.isSameNode) return a.isSameNode(b)
  if (a.attributes.id) return a.attributes.id === b.attributes.id
  if (a.nodeName !== b.nodeName) return false
  return false
}
tunnckoCore commented 6 years ago

all is damn is working. around 1.1kb min+gz

tunnckoCore commented 6 years ago

nanomorph tests - all passing w/o 1

const test = require('tape')
const hyperx = require('hyperx')

// eslint-disable-next-line
// require('minda/require')

// eslint-disable-next-line
let h = require('./packages/minscript/dest')
let morph = require('./packages/minmorph/dest')
const { renderToString } = require('./render')

const html = hyperx(h)

test('root level', (t) => {
  t.test('should return right if not left', (t) => {
    t.plan(1)
    let a = html`<p></p>`
    let b = html`<p><span>hello world</span></p>`

    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected, 'result was expected')
  })
  t.test('should replace a node', (t) => {
    t.plan(1)
    let a = html`<p>hello world</p>`
    let b = html`<div>hello world</div>`

    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected, 'result was expected')
  })

  t.test('should morph a node', (t) => {
    t.plan(1)
    let a = html`<p>hello world</p>`
    let b = html`<p>hello you</p>`

    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected, 'result was expected')
  })

  t.test('should morph a node with namespaced attribute', (t) => {
    t.plan(1)
    let a = html`<svg><use xlink:href="#heybooboo"></use></svg>`
    let b = html`<svg><use xlink:href="#boobear"></use></svg>`

    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected, 'result was expected')
  })

  t.test('should ignore if node is same', (t) => {
    t.plan(1)
    let a = html`<p>hello world</p>`

    let expected = renderToString(a)
    let actual = renderToString(morph(a, a))
    t.strictEqual(actual, expected, 'result was expected')
  })
})

test('nested', (t) => {
  t.test('should replace a tag node', (t) => {
    t.plan(1)
    let a = html`<main><p>hello world</p></main>`
    let b = html`<main><div>hello world</div></main>`

    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected, 'result was expected')
  })

  t.test('should replace a text node', (t) => {
    t.plan(1)
    let a = html`<main><p>hello world</p></main>`
    let b = html`<main><p>hello you</p></main>`

    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected, 'result was expected')
  })

  t.test('should replace a node', (t) => {
    t.plan(1)
    let a = html`<main><p>hello world</p></main>`

    let expected = renderToString(a)
    let actual = renderToString(morph(a, a))
    t.strictEqual(actual, expected, 'result was expected')
  })

  t.test('should append a node', (t) => {
    t.plan(1)
    let a = html`<main></main>`
    let b = html`<main><p>hello you</p></main>`

    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected, 'result was expected')
  })

  t.test('should remove a node', (t) => {
    t.plan(1)
    let a = html`<main><p>hello you</p></main>`
    let b = html`<main></main>`

    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected, 'result was expected')
  })
})

test('events', (t) => {
  t.test('should copy onclick events', (t) => {
    t.plan(1)

    function fail (e) {
      console.log('xxx')
      // t.fail('should not be called')
    }

    function pass (e) {
      console.log('zzzzzz')
      t.ok('called')
    }

    let a = html`<button onclick=${fail}>OLD</button>`
    let b = html`<button>NEWx</button>`
    let res = morph(a, b)
    console.log(res.childNodes[0])

    res.onclick && res.onclick()

    a = html`<button>OLD</button>`
    b = html`<button onclick=${pass}>NEW</button>`
    res = morph(a, b)

    res.onclick()
  })
})

test('values', (t) => {
  t.test('if new tree has no value and old tree does, remove value', (t) => {
    t.plan(2)
    let a = html`<input type="text" value="howdy" />`
    let b = html`<input type="text" />`
    let res = morph(a, b)
    t.strictEqual(res.value, undefined)

    a = html`<input type="text" value="howdy" />`
    b = html`<input type="text" value=${null} />`
    res = morph(a, b)
    t.strictEqual(res.value, undefined)
  })
  /* eslint-disable max-statements */
  t.test(
    'if new tree has value and old tree does too, set value from new tree',
    (t) => {
      t.plan(4)
      let a = html`<input type="text" value="howdy" />`
      let b = html`<input type="text" value="hi" />`
      let res = morph(a, b)
      t.strictEqual(res.value, 'hi')

      a = html`<input type="text"/>`
      a.value = 'howdy'
      b = html`<input type="text"/>`
      b.value = 'hi'
      res = morph(a, b)
      t.strictEqual(res.value, 'hi')

      a = html`<input type="text" value="howdy"/>`
      b = html`<input type="text"/>`
      b.value = 'hi'
      res = morph(a, b)
      t.strictEqual(res.value, 'hi')

      a = html`<input type="text"/>`
      a.value = 'howdy'
      b = html`<input type="text" value="hi"/>`
      res = morph(a, b)
      t.strictEqual(res.value, 'hi')
    }
  )
  /* eslint-enable max-statements */
})

test('isSameNode', (t) => {
  t.test('should return a if true', (t) => {
    t.plan(1)
    let a = html`<div>YOLO</div>`
    let b = html`<div>FOMO</div>`
    b.isSameNode = function (el) {
      return true
    }
    let res = morph(a, b)
    t.strictEqual(res.childNodes[0].value, 'YOLO')
  })

  t.test('should return b if false', (t) => {
    t.plan(1)
    let a = html`<div>YOLO</div>`
    let b = html`<div>FOMO</div>`
    b.isSameNode = function (el) {
      return false
    }
    let res = morph(a, b)
    t.strictEqual(res.childNodes[0].value, 'FOMO')
  })
})

test('lists', (t) => {
  t.test('should append nodes', (t) => {
    t.plan(1)
    let a = html`<ul></ul>`
    let b = html`<ul><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li></ul>`
    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected, 'result was expected')
  })

  t.test('should remove nodes', (t) => {
    t.plan(1)
    let a = html`<ul><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li></ul>`
    let b = html`<ul></ul>`
    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected, 'result was expected')
  })
})

test('selectables', (t) => {
  t.test('should append nodes', (t) => {
    t.plan(1)
    let a = html`<select></select>`
    let b = html`<select><option>1</option><option>2</option><option>3</option><option>4</option></select>`

    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected, 'result was expected')
  })

  t.test('should append nodes (including optgroups)', (t) => {
    t.plan(1)
    let a = html`<select></select>`
    let b = html`<select><optgroup><option>1</option><option>2</option></optgroup><option>3</option><option>4</option></select>`

    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected, 'result was expected')
  })

  t.test('should remove nodes', (t) => {
    t.plan(1)
    let a = html`<select><option>1</option><option>2</option><option>3</option><option>4</option></select>`
    let b = html`<select></select>`

    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected, 'result was expected')
  })

  t.test('should remove nodes (including optgroups)', (t) => {
    t.plan(1)
    let a = html`<select><optgroup><option>1</option><option>2</option></optgroup><option>3</option><option>4</option></select>`
    let b = html`<select></select>`

    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected, 'result was expected')
  })

  t.test('should add selected', (t) => {
    t.plan(1)
    let a = html`<select><option>1</option><option>2</option></select>`
    let b = html`<select><option>1</option><option selected>2</option></select>`

    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected, 'result was expected')
  })

  t.test('should add selected (xhtml)', (t) => {
    t.plan(1)
    let a = html`<select><option>1</option><option>2</option></select>`
    let b = html`<select><option>1</option><option selected="selected">2</option></select>`

    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected, 'result was expected')
  })

  t.test('should switch selected', (t) => {
    t.plan(1)
    let a = html`<select><option selected="selected">1</option><option>2</option></select>`
    let b = html`<select><option>1</option><option selected="selected">2</option></select>`

    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected, 'result was expected')
  })
})

test('should replace nodes', (t) => {
  t.plan(1)
  let a = html`<ul><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li></ul>`
  let b = html`<ul><div>1</div><li>2</li><p>3</p><li>4</li><li>5</li></ul>`

  let expected = renderToString(b)
  let actual = renderToString(morph(a, b))
  t.strictEqual(actual, expected, 'result was expected')
})

test('should replace nodes after multiple iterations', (t) => {
  t.plan(2)

  let a = html`<ul></ul>`
  let b = html`<ul><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li></ul>`
  let expected = renderToString(b)
  a = morph(a, b)
  t.strictEqual(renderToString(a), expected, 'result was expected')

  b = html`<ul><div>1</div><li>2</li><p>3</p><li>4</li><li>5</li></ul>`
  expected = renderToString(b)

  let c = morph(a, b)
  t.strictEqual(renderToString(c), expected, 'result was expected')
})

test('use id as a key hint', (t) => {
  t.test('append an element', (t) => {
    let a = html`<ul><li id="a"></li><li id="b"></li><li id="c"></li></ul>`
    let b = html`<ul><li id="a"></li><li id="new"></li><li id="b"></li><li id="c"></li></ul>`
    let expected = renderToString(b)

    let oldFirst = a.childNodes[0]
    let oldSecond = a.childNodes[1]
    let oldThird = a.childNodes[2]

    let c = morph(a, b)
    t.deepEqual(oldFirst, c.childNodes[0], 'first is equal')
    t.deepEqual(oldSecond, c.childNodes[1], 'moved second is equal')
    t.deepEqual(oldThird, c.childNodes[2], 'moved third is equal')
    t.strictEqual(renderToString(c), expected)
    t.end()
  })

  t.test('handle non-id elements', (t) => {
    let a = html`<ul>
        <li></li>
        <li id="a"></li>
        <li id="b"></li>
        <li id="c"></li>
        <li></li>
      </ul>`
    let b = html`<ul>
        <li></li>
        <li id="a"></li>
        <li id="new"></li>
        <li id="b"></li>
        <li id="c"></li>
        <li></li>
      </ul>`
    let expected = renderToString(b)

    let oldSecond = a.childNodes[2]
    let oldThird = a.childNodes[4]
    let oldForth = a.childNodes[6]

    let c = morph(a, b)
    t.deepEqual(oldSecond, c.childNodes[2], 'second is equal')
    t.deepEqual(oldThird, c.childNodes[4], 'moved third is equal')
    t.deepEqual(oldForth, c.childNodes[6], 'moved forth is equal')
    t.strictEqual(renderToString(c), expected)
    t.end()
  })

  t.test('copy over children', (t) => {
    let a = html`<section>'hello'</section>`
    let b = html`<section><div>hi</div></section>`
    let expected = renderToString(b)

    let c = morph(a, b)
    t.strictEqual(renderToString(c), expected, expected)
    t.end()
  })

  t.test('remove an element', (t) => {
    let a = html`<ul><li id="a"></li><li id="b"></li><li id="c"></li></ul>`
    let b = html`<ul><li id="a"></li><li id="c"></li></ul>`

    let oldFirst = a.childNodes[0]
    let oldThird = a.childNodes[2]
    let expected = renderToString(b)

    let c = morph(a, b)

    t.deepEqual(c.childNodes[0], oldFirst, 'first is equal')
    t.deepEqual(c.childNodes[1], oldThird, 'second untouched')
    t.strictEqual(renderToString(c), expected)
    t.end()
  })

  t.test('swap proxy elements', (t) => {
    let nodeA = html`<li id="a"></li>`
    let placeholderA = html`<div id="a" data-placeholder=true></div>`
    placeholderA.isSameNode = function (el) {
      return el === nodeA
    }

    let nodeB = html`<li id="b"></li>`
    let placeholderB = html`<div id="b" data-placeholder=true></div>`
    placeholderB.isSameNode = function (el) {
      return el === nodeB
    }

    let a = html`<ul>${nodeA}${nodeB}</ul>`
    let b = html`<ul>${placeholderB}${placeholderA}</ul>`
    let c = morph(a, b)

    t.deepEqual(c.childNodes[0], nodeB, 'c.childNodes[0] === nodeB')
    t.deepEqual(c.childNodes[1], nodeA, 'c.childNodes[1] === nodeA')
    t.end()
  })

  t.test('id match still morphs', (t) => {
    let a = html`<li id="12">FOO</li>`
    let b = html`<li id="12">BAR</li>`
    let target = renderToString(b)
    let c = morph(a, b)
    t.strictEqual(renderToString(c), target)
    t.end()
  })

  t.test('remove orphaned keyed nodes', (t) => {
    let a = html`
        <div>
          <div>1</div>
          <li id="a">a</li>
        </div>
      `
    let b = html`
        <div>
          <div>2</div>
          <li id="b">b</li>
        </div>
      `
    let expected = renderToString(b)
    let actual = renderToString(morph(a, b))
    t.strictEqual(actual, expected)
    t.end()
  })

  t.test('whitespace', (t) => {
    let a = html`<ul>
  </ul>`
    let b = html`<ul><li></li><li></li>
  </ul>`
    let expected = b
    let actual = morph(a, b)
    t.deepEqual(actual, expected)
    t.end()
  })

  t.test('nested with id', (t) => {
    let child = html`<div id="child"></div>`
    let placeholder = html`<div id="child"></div>`
    placeholder.isSameNode = function (el) {
      return el === child
    }

    let a = html`<div><div id="parent">${child}</div></div>`
    let b = html`<div><div id="parent">${placeholder}</div></div>`

    let c = morph(a, b)
    t.deepEqual(c.childNodes[0].childNodes[0], child, 'is the same node')
    t.end()
  })

  t.skip('nested without id', (t) => {
    let child = html`<div id="child">child</div>`
    let placeholder = html`<div id="child">placeholder</div>`
    placeholder.isSameNode = function (el) {
      return el === child
    }

    let a = html`<div><div>${child}</div></div>`
    let b = html`<div><div>${placeholder}</div></div>`

    let c = morph(a, b)
    t.deepEqual(c.childNodes[0].childNodes[0], child, 'is the same node')
    t.end()
  })
})
tunnckoCore commented 6 years ago

update: rewritten again, performance is amazing and the benchmarks against current nanomorph is absolutely phenomenal!

/* eslint-disable no-param-reassign, no-multi-assign, no-plusplus, max-lines */

/**
 * Helpers
 */

const NO_SUFFIX = /acit|ex(?:s|g|n|p|$)|rph|ows|mnc|ntw|ine[ch]|zoo|^ord/i

function isSameTextNodes (left, right) {
  return isTextNode(left) && isTextNode(right) && isEqualText(left, right)
}

function isTextNode (val) {
  return val && val.nodeType === 3
}

function isEqualText (left, right) {
  return right.nodeValue === left.nodeValue
}

function decamelize (str) {
  return str
    .replace(/([a-z\d])([A-Z])/g, '$1-$2')
    .replace(/([A-Z]+)([A-Z][a-z\d]+)/g, '$1-$2')
    .toLowerCase()
}

/**
 * left: old node
 * right: new node
 */
module.exports = patch
function patch (left, right) {
  if (!left) {
    return right
  } else if (!right) {
    return null
  } else if (left.nodeName !== right.nodeName) {
    return right
  } else if (isTextNode(right) && !isEqualText(left, right)) {
    return right
  } else if (
    (right.isSameNode && right.isSameNode(left)) ||
    isSameTextNodes(left, right)
  ) {
    return left
  }
  // probably not needed
  // if (isTextNode(left) && isTextNode(right) && isEqualText(left, right)) {
  //   return left
  // }

  const props = {}
  diff(left, right, props)
  morphChilds(left, right)

  return left
}

/**
 * Here we are sure that `left` and `right` exist and
 * they are the same tag names `<div>` and `<div>`.
 *
 * @param {*} left
 * @param {*} right
 */

function diff (left, right, props) {
  // if Element Node
  if (right.nodeType === 1) {
    morphProps(left, right, props)
  }

  // if Text Nodes
  if (right.nodeType === 3) {
    left.nodeValue = right.nodeValue
  }

  if (left.nodeName === 'INPUT') {
    updateInput(left, right)
  }
  if (left.nodeName === 'OPTION') {
    updateProp(left, right, 'selected')
  }
  if (left.nodeName === 'TEXTAREA') {
    updateTextarea(left, right)
  }

  return left
}

function morphProps (left, right, props) {
  for (let j = 0; j < left.attributes.length; j++) {
    const attr = left.attributes[j]
    props[attr.name] = {
      name: attr.name,
      value: attr.value,
      ns: attr.namespaceURI,
    }
  }

  for (let i = 0; i < right.attributes.length; i++) {
    const attrNode = right.attributes[i]
    const attrValue = attrNode.value
    const attrName = attrNode.name

    // important: always `null` by default, or actual namespace!
    // so just use it instead of checking and using both
    // the `(set|get|has)AttributeNS` and `(set|get|has)Attribute` methods.
    const ns = attrNode.namespaceURI

    morphAttribute({ left, right }, props, {
      ns,
      attrName,
      attrValue,
      attrNode,
    })
  }

  // eslint-disable-next-line
  for (const name in props) {
    const oldAttr = props[name]
    if (!right.attributes[name]) {
      left.removeAttributeNS(oldAttr.ns, name)
    }
  }
}

/**
 * Important notes from here on:
 * - `.nodeName` is always uppercase, no matter of the browser?!
 *
 * @param {any} { left, right }
 * @param {any} props
 * @param {any} opts
 */
function morphAttribute ({ left, right }, props, opts) {
  if (opts.attrName === 'style') {
    updateStyle({ left, right }, props, opts)
  } else {
    updateAttribute({ left }, props, opts)
  }
}

/**
 *
 *
 * @param {any} { left, right }
 * @param {any} props
 * @param {any} opts
 */
function updateStyle ({ left, right }, props, opts) {
  const { attrName, attrValue, attrNode } = opts

  // hint: `attrName` is "style"
  if (!(attrValue && props[attrName].value !== right.style.cssText)) return

  /* eslint-disable no-param-reassign */
  if (typeof attrValue === 'object') {
    let cssText = ''

    /* eslint-disable no-restricted-syntax, guard-for-in */
    for (const k in attrValue) {
      let val = attrValue[k]

      // if the value of the style property, e.g.
      // <div style={{ fontSize: 12 }} /> and div style={{ fontSize: 55 }} />
      // values 12 and 55 are not equal, right?
      if (left.style[k] !== val) {
        const suffix = typeof val === 'number' && NO_SUFFIX.test(k) === false

        val = suffix ? `${val}px` : val
        cssText += `${decamelize(k)}:${val};`

        left.style[k] = val

        left.style.cssText = attrNode.value = cssText

        // left.attributes.style is `Attribute` object too!!
        // so the `.value` is same as `left.style.cssText`.
        // Update the cache of oldProps a.k.a `props` here of `left`

        left.attributes.style.value = props[attrName].value = cssText
      }
    }
  } else {
    left.style.cssText = String(attrValue)
  }
}

function updateAttribute ({ left }, props, opts) {
  const { attrName, attrValue, ns } = opts
  const oldProp = props[attrName]
  const hasIn = attrName in props

  if (!hasIn) {
    setAttr({ left }, props, opts)
  }
  if (hasIn && oldProp.value !== attrValue) {
    if (attrValue === 'null' || attrValue === 'undefined') {
      left.removeAttributeNS(ns, attrName)
      delete props[attrName] // eslint-disable-line no-param-reassign
    } else {
      setAttr({ left }, props, opts)
    }
  }
}

function setAttr ({ left }, props, opts) {
  const { attrName, attrValue, ns } = opts

  left.setAttributeNS(ns, attrName, attrValue)

  props[attrName] = props[attrName] || {}
  props[attrName].value = attrValue
  props[attrName].ns = ns
  return left
}

function updateInput (left, right) {
  updateProp(left, right, 'value')
  updateProp(left, right, 'checked')
  updateProp(left, right, 'disabled')

  if (right.value === 'null') {
    left.removeAttribute('value')
    left.value = ''
  }

  if (!right.hasAttributeNS(null, 'value')) {
    left.removeAttribute('value')
  } else if (left.type === 'range') {
    // this is so elements like slider move their UI thingy
    left.value = right.value
  }
}

function updateProp (left, right, name) {
  if (left[name] !== right[name]) {
    left[name] = right[name]

    // we don't use setAttribute and removeAttribute
    // because we already did that
    // in the `morphProps -> morphAttribute -> updateAttribute` step

    if (right[name]) {
      left.setAttribute(name, '')
    } else {
      left.removeAttribute(name)
      // delete left[name]
    }
  }
}

function updateTextarea (left, right) {
  updateProp(left, right, 'value')

  const firstChild = left.childNodes[0]
  if (firstChild && firstChild.nodeValue !== right.value) {
    // Needed for IE. Apparently IE sets the placeholder as the
    // node value and vise versa. This ignores an empty update.
    if (right.value === '' && firstChild.nodeValue === left.placeholder) {
      return
    }

    firstChild.nodeValue = right.value
  }
}

/* eslint-disable max-statements, max-depth */

function getKey (node) {
  if (!node) return null

  return node.key || (node.attributes && node.attributes.key) || node.id
}

function morphChilds (left, right) {
  let morphed = null
  let offset = 0

  const oldLen = left.childNodes.length
  const newLen = right.childNodes.length
  const fragment = left.ownerDocument.createDocumentFragment()

  // const keyed = {}
  // for (let i = 0; i < oldLen; i++) {
  //   const oldChild = left.childNodes[i]
  //   const oldKey = getKey(oldChild)

  //   if (oldKey) {
  //     keyed[oldKey] = oldChild
  //   }
  // }

  for (let j = 0; j <= newLen; j++) {
    if (j === newLen) {
      left.appendChild(fragment)
      break
    }

    const oldChild = left.childNodes[j]
    const newChild = right.childNodes[j - offset]

    if (!oldChild && !newChild) {
      break
    } else if (!oldChild && newChild) {
      fragment.appendChild(newChild)
      offset++
    } else if (oldChild && !newChild) {
      left.removeChild(oldChild)
    } else if (isTextNode(oldChild) && isTextNode(newChild)) {
      if (!isEqualText(oldChild, newChild)) {
        left.replaceChild(newChild, oldChild)
      }
    } else {
      morphed = patch(oldChild, newChild)
      if (morphed && morphed !== oldChild) {
        left.replaceChild(morphed, oldChild)
        offset++
      }
    }
  }

  if (newLen === 0) {
    const range = left.ownerDocument.createRange()
    range.selectNodeContents(left)
    range.deleteContents()
  }
}

// left: old node / right: new node
// function same (left, right) {
//   if (!right || !left) return false
//   if (right.id) return right.id === left.id
//   // if (right.nodeType === 3) return right.nodeValue === left.nodeValue
//   if (right.isSameNode) return right.isSameNode(left)
//   if (right.nodeName === left.nodeName) return true
//   return false
// }