diff --git a/.npmignore b/.npmignore index 121ef01..9daeafb 100644 --- a/.npmignore +++ b/.npmignore @@ -1,2 +1 @@ test -src diff --git a/README.md b/README.md index 6626c57..0e21732 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Etch components are ordinary JavaScript objects that conform to a minimal interf ```js /** @jsx etch.dom */ -import etch from 'etch' +const etch = require('etch') class MyComponent { // Required: Define an ordinary constructor to initialize your component. @@ -38,7 +38,7 @@ class MyComponent { async destroy () { // call etch.destroy to remove the element and destroy child components await etch.destroy(this) - // then perform custom teardown logic here... + // then perform custom teardown logic here... } } ``` @@ -74,7 +74,7 @@ This function is typically called at the end of your component's constructor: ```js /** @jsx etch.dom */ -import etch from 'etch' +const etch = require('etch') class MyComponent { constructor (properties) { @@ -102,7 +102,7 @@ This function takes a component that is already associated with an `.element` pr ```js /** @jsx etch.dom */ -import etch from 'etch' +const etch = require('etch') class MyComponent { constructor (properties) { @@ -181,7 +181,7 @@ Components can be nested within other components by referencing a child componen ```js /** @jsx etch.dom */ -import etch from 'etch' +const etch = require('etch') class ChildComponent { constructor () { @@ -212,7 +212,7 @@ A constructor function can always take the place of a tag name in any Etch JSX e ```js /** @jsx etch.dom */ -import etch from 'etch' +const etch = require('etch') class ChildComponent { constructor (properties, children) { @@ -356,15 +356,37 @@ Read comments in the [scheduler assignment][scheduler-assignment] and [default s ### Handling Events -This library doesn't currently prescribe or support a specific approach to binding event handlers. We are considering an API that integrates inline handlers directly into JSX expressions, but we're not convinced the utility warrants the added surface area. +Etch supports listening to arbitrary events on DOM nodes via the special `on` property, which can be used to assign a hash of `eventName: listenerFunction` pairs: -Compared to efficiently updating the DOM declaratively (the primary focus of this library), binding events is a pretty simple problem. You might try [dom-listener][dom-listener] if you're looking for a library that you could combine with Etch to deal with event binding. +```js +class ComponentWithEvents { + constructor () { + etch.initialize(this) + } + + render () { + return
+ } + + didClick (event) { + console.log(event) // ==> MouseEvent {...} + console.log(this) // ==> ComponentWithEvents {...} + } + + didFocus (event) { + console.log(event) // ==> FocusEvent {...} + console.log(this) // ==> ComponentWithEvents {...} + } +} +``` + +As you can see, the listener function's `this` value is automatically bound to the parent component. You should rely on this auto-binding facility rather than using arrow functions or `Function.bind` to avoid complexity and extraneous closure allocations. ### Feature Requests Etch aims to stay small and focused. If you have a feature idea, consider implementing it as a library that either wraps Etch or, even better, that can be used in concert with it. If it's impossible to implement your feature outside of Etch, we can discuss adding a hook that makes your feature possible. [babel]: https://babeljs.io/ -[scheduler-assignment]: https://github.com/nathansobo/etch/blob/master/src/scheduler-assignment.js -[default-scheduler]: https://github.com/nathansobo/etch/blob/master/src/default-scheduler.js +[scheduler-assignment]: https://github.com/nathansobo/etch/blob/master/lib/scheduler-assignment.js +[default-scheduler]: https://github.com/nathansobo/etch/blob/master/lib/default-scheduler.js [dom-listener]: https://github.com/atom/dom-listener diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..52c711d --- /dev/null +++ b/TASKS.md @@ -0,0 +1,6 @@ +* Consolidate old tests with new tests... what can be simpler? +* Boolean attributes +* Props that shouldn't be converted to attributes +* Support for objects as the `style` prop +* SVG +* Benchmark diff --git a/src/component-helpers.js b/lib/component-helpers.js similarity index 76% rename from src/component-helpers.js rename to lib/component-helpers.js index f8f9a1c..b34af06 100644 --- a/src/component-helpers.js +++ b/lib/component-helpers.js @@ -1,23 +1,20 @@ -import createElement from 'virtual-dom/create-element' -import diff from 'virtual-dom/diff' -import patch from 'virtual-dom/patch' - -import refsStack from './refs-stack' -import {getScheduler} from './scheduler-assignment' +const render = require('./render') +const patch = require('./patch') +const {getScheduler} = require('./scheduler-assignment') const componentsWithPendingUpdates = new WeakSet() let syncUpdatesInProgressCounter = 0 let syncDestructionsInProgressCounter = 0 -function isValidVirtualElement (virtualElement) { - return virtualElement != null && virtualElement !== false +function isValidVirtualNode (virtualNode) { + return virtualNode != null && virtualNode !== false } // This function associates a component object with a DOM element by calling // the components `render` method, assigning an `.element` property on the // object and also returning the element. // -// It also assigns a `virtualElement` property based on the return value of the +// It also assigns a `virtualNode` property based on the return value of the // `render` method. This will be used later by `performElementUpdate` to diff // the new results of `render` with the previous results when updating the // component's element. @@ -27,21 +24,22 @@ function isValidVirtualElement (virtualElement) { // nodes of the `virtual-dom` tree. Before calling into `virtual-dom` to create // the DOM tree, it pushes this `refs` object to a shared stack so it can be // accessed by hooks during the creation of individual elements. -export function initialize (component) { +function initialize(component) { if (typeof component.update !== 'function') { throw new Error('Etch components must implement `update(props, children)`.') } - let virtualElement = component.render() - if (!isValidVirtualElement(virtualElement)) { + let virtualNode = component.render() + if (!isValidVirtualNode(virtualNode)) { let namePart = component.constructor && component.constructor.name ? ' in ' + component.constructor.name : '' - throw new Error('invalid falsy value ' + virtualElement + ' returned from render()' + namePart) + throw new Error('invalid falsy value ' + virtualNode + ' returned from render()' + namePart) } + component.refs = {} - component.virtualElement = virtualElement - refsStack.push(component.refs) - component.element = createElement(component.virtualElement) - refsStack.pop() + component.virtualNode = virtualNode + component.element = render(component.virtualNode, { + refs: component.refs, listenerContext: component + }) } // This function receives a component that has already been associated with an @@ -59,7 +57,7 @@ export function initialize (component) { // // Returns a promise that will resolve when the requested update has been // completed. -export function update (component, replaceNode = true) { +function update (component, replaceNode=true) { if (syncUpdatesInProgressCounter > 0) { updateSync(component, replaceNode) return Promise.resolve() @@ -79,7 +77,7 @@ export function update (component, replaceNode = true) { } // Synchronsly updates the DOM element associated with a component object. . -// This method assumes the presence of `.element` and `.virtualElement` +// This method assumes the presence of `.element` and `.virtualNode` // properties on the component, which are assigned in the `initialize` // function. // @@ -97,20 +95,21 @@ export function update (component, replaceNode = true) { // For now, etch does not allow the root tag of the `render` method to change // between invocations, because we want to preserve a one-to-one relationship // between component objects and DOM elements for simplicity. -export function updateSync (component, replaceNode = true) { - let newVirtualElement = component.render() - if (!isValidVirtualElement(newVirtualElement)) { +function updateSync (component, replaceNode=true) { + let newVirtualNode = component.render() + if (!isValidVirtualNode(newVirtualNode)) { let namePart = component.constructor && component.constructor.name ? ' in ' + component.constructor.name : '' - throw new Error('invalid falsy value ' + newVirtualElement + ' returned from render()' + namePart) + throw new Error('invalid falsy value ' + newVirtualNode + ' returned from render()' + namePart) } syncUpdatesInProgressCounter++ - let oldVirtualElement = component.virtualElement + let oldVirtualNode = component.virtualNode let oldDomNode = component.element - refsStack.push(component.refs) - let newDomNode = patch(component.element, diff(oldVirtualElement, newVirtualElement)) - refsStack.pop() - component.virtualElement = newVirtualElement + let newDomNode = patch(oldVirtualNode, newVirtualNode, { + refs: component.refs, + listenerContext: component + }) + component.virtualNode = newVirtualNode if (newDomNode !== oldDomNode && !replaceNode) { throw new Error('The root node type changed on update, but the update was performed with the replaceNode option set to false') } else { @@ -142,7 +141,7 @@ export function updateSync (component, replaceNode = true) { // If called as the result of destroying a component higher in the DOM, the // element is not removed to avoid redundant DOM manipulation. Returns a promise // that resolves when the destruction is completed. -export function destroy (component, removeNode = true) { +function destroy (component, removeNode=true) { if (syncUpdatesInProgressCounter > 0 || syncDestructionsInProgressCounter > 0) { destroySync(component, removeNode) return Promise.resolve() @@ -159,19 +158,23 @@ export function destroy (component, removeNode = true) { // // Note that we track whether `destroy` calls are in progress and only remove // the element if we are not a nested call. -export function destroySync (component, removeNode = true) { +function destroySync (component, removeNode=true) { syncDestructionsInProgressCounter++ - destroyChildComponents(component.virtualElement) + destroyChildComponents(component.virtualNode) if (syncDestructionsInProgressCounter === 1 && removeNode) component.element.remove() syncDestructionsInProgressCounter-- } -function destroyChildComponents (virtualNode) { - if (virtualNode.type === 'Widget') { - if (virtualNode.component && typeof virtualNode.component.destroy === 'function') { - virtualNode.component.destroy() - } +function destroyChildComponents(virtualNode) { + if (virtualNode.component && typeof virtualNode.component.destroy === 'function') { + virtualNode.component.destroy() } else if (virtualNode.children) { virtualNode.children.forEach(destroyChildComponents) } } + +module.exports = { + initialize, + update, updateSync, + destroy, destroySync +} diff --git a/src/default-scheduler.js b/lib/default-scheduler.js similarity index 98% rename from src/default-scheduler.js rename to lib/default-scheduler.js index 4c20a8a..f4cc8a7 100644 --- a/src/default-scheduler.js +++ b/lib/default-scheduler.js @@ -2,7 +2,7 @@ // this class will be used to schedule updates to the document. The // `updateDocument` method accepts functions to be run at some point in the // future, then runs them on the next animation frame. -export default class DefaultScheduler { +module.exports = class DefaultScheduler { constructor () { this.updateRequests = [] this.readRequests = [] diff --git a/lib/dom.js b/lib/dom.js new file mode 100644 index 0000000..4dab8ab --- /dev/null +++ b/lib/dom.js @@ -0,0 +1,63 @@ +const EVENT_LISTENER_PROPS = require('./event-listener-props') +const SVG_TAGS = require('./svg-tags') + +function dom (tag, props, ...children) { + for (let i = 0; i < children.length;) { + const child = children[i] + if (Array.isArray(child)) { + children.splice(i, 1, ...child) + } else if (typeof child === 'string' || typeof child === 'number') { + children.splice(i, 1, {text: child}) + i++ + } else { + i++ + } + } + + if (props) { + for (const propName in props) { + const eventName = EVENT_LISTENER_PROPS[propName] + if (eventName) { + if (!props.on) props.on = {} + props.on[eventName] = props[propName] + } + } + + if (props.class) { + props.className = props.class + } + } + + return {tag, props, children} +} + +const HTML_TAGS = [ + 'a', 'abbr', 'address', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', + 'blockquote', 'body', 'button', 'canvas', 'caption', 'cite', 'code', + 'colgroup', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', + 'dt', 'em', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', + 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'html', 'i', 'iframe', 'ins', 'kbd', + 'label', 'legend', 'li', 'main', 'map', 'mark', 'menu', 'meter', 'nav', + 'noscript', 'object', 'ol', 'optgroup', 'option', 'output', 'p', 'pre', + 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script', 'section', + 'select', 'small', 'span', 'strong', 'style', 'sub', 'summary', 'sup', + 'table', 'tbody', 'td', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', + 'tr', 'u', 'ul', 'var', 'video', 'area', 'base', 'br', 'col', 'command', + 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', + 'track', 'wbr' +] + +for (const tagName of HTML_TAGS) { + dom[tagName] = (props, ...children) => { + return dom(tagName, props, ...children) + } +} + +for (const tagName of SVG_TAGS) { + dom[tagName] = (props, ...children) => { + return dom(tagName, props, ...children) + } +} + + +module.exports = dom diff --git a/lib/event-listener-props.js b/lib/event-listener-props.js new file mode 100644 index 0000000..71d1e94 --- /dev/null +++ b/lib/event-listener-props.js @@ -0,0 +1,70 @@ +module.exports = { + onCopy: 'copy', + onCut: 'cut', + onPaste: 'paste', + onCompositionEnd: 'compositionend', + onCompositionStart: 'compositionstart', + onCompositionUpdate: 'compositionupdate', + onKeyDown: 'keydown', + onKeyPress: 'keypress', + onKeyUp: 'keyup', + onFocus: 'focus', + onBlur: 'blur', + onChange: 'change', + onInput: 'input', + onSubmit: 'submit', + onClick: 'click', + onContextMenu: 'contextmenu', + onDoubleClick: 'doubleclick', + onDrag: 'drag', + onDragEnd: 'dragend', + onDragEnter: 'dragenter', + onDragExit: 'dragexit', + onDragLeave: 'dragleave', + onDragOver: 'dragover', + onDragStart: 'dragstart', + onDrop: 'drop', + onMouseDown: 'mousedown', + onMouseEnter: 'mousenter', + onMouseLeave: 'mouseleave', + onMouseMove: 'mousemove', + onMouseOut: 'mouseout', + onMouseOver: 'mouseover', + onMouseUp: 'mouseup', + onSelect: 'select', + onTouchCancel: 'touchcancel', + onTouchEnd: 'touchend', + onTouchMove: 'touchmove', + onTouchStart: 'touchstart', + onScroll: 'scroll', + onWheel: 'wheel', + onAbort: 'abort', + onCanPlay: 'canplay', + onCanPlayThrough: 'canplaythrough', + onDurationChange: 'durationchange', + onEmptied: 'emptied', + onEncrypted: 'encrypted', + onEnded: 'ended', + onError: 'error', + onLoadedData: 'loadeddata', + onLoadedMetadata: 'loadedmetadat', + onLoadStart: 'loadstart', + onPause: 'pause', + onPlay: 'play', + onPlaying: 'playing', + onProgress: 'progress', + onRateChange: 'ratechange', + onSeeked: 'seeked', + onSeeking: 'seeking', + onStalled: 'stalled', + onSuspend: 'suspend', + onTimeUpdate: 'timeupdate', + onVolumeChange: 'volumechange', + onWaiting: 'waiting', + onLoad: 'load', + onError: 'error', + onAnimationStart: 'animationstart', + onAnimationEnd: 'animationend', + onAnimationIteration: 'animationiteration', + onTransitionEnd: 'transitionend' +} diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..34e268e --- /dev/null +++ b/lib/index.js @@ -0,0 +1,10 @@ +const dom = require('./dom') +const render = require('./render') +const {initialize, update, updateSync, destroy, destroySync} = require('./component-helpers') +const {setScheduler, getScheduler} = require('./scheduler-assignment') + +module.exports = { + dom, render, + initialize, update, updateSync, destroy, destroySync, + setScheduler, getScheduler +} diff --git a/lib/patch.js b/lib/patch.js new file mode 100644 index 0000000..a40d132 --- /dev/null +++ b/lib/patch.js @@ -0,0 +1,163 @@ +const render = require('./render') +const updateProps = require('./update-props') + +function patch (oldVirtualNode, newVirtualNode, options) { + const oldNode = oldVirtualNode.domNode + if (virtualNodesAreEqual(oldVirtualNode, newVirtualNode)) { + let newNode + if (newVirtualNode.text != null) { + oldNode.nodeValue = newVirtualNode.text + newNode = oldNode + } else { + if (typeof newVirtualNode.tag === 'function') { + newNode = updateComponent(oldVirtualNode, newVirtualNode, options) + } else { + updateChildren(oldNode, oldVirtualNode.children, newVirtualNode.children, options) + updateProps(oldNode, oldVirtualNode, newVirtualNode, options) + newNode = oldNode + } + } + newVirtualNode.domNode = oldNode + if (newNode !== oldNode && oldNode.parentNode) { + oldNode.parentNode.replaceChild(newNode, oldNode) + } + return newNode + } else { + const parentNode = oldNode.parentNode + const nextSibling = oldNode.nextSibling + removeVirtualNode(oldVirtualNode, options && options.refs) + const newNode = render(newVirtualNode, options) + if (parentNode) parentNode.insertBefore(newNode, nextSibling) + newVirtualNode.domNode = newNode + return newNode + } +} + +function updateComponent (oldVirtualNode, newVirtualNode, options) { + const {component, props: oldProps} = oldVirtualNode + let {props: newProps, children: newChildren} = newVirtualNode + newVirtualNode.component = component + if (options && options.refs) { + const refs = options.refs + const oldRefName = oldProps && oldProps.ref + const newRefName = newProps && newProps.ref + if (newRefName !== oldRefName) { + if (oldRefName && refs[oldRefName] === component) delete refs[oldRefName] + if (newRefName) refs[newRefName] = component + } + } + component.update(newProps || {}, newChildren) + return component.element +} + +function updateChildren (parentElement, oldChildren, newChildren, options) { + let oldStartIndex = 0 + let oldEndIndex = oldChildren.length - 1 + let oldStartChild = oldChildren[0] + let oldEndChild = oldChildren[oldEndIndex] + + let newStartIndex = 0 + let newEndIndex = newChildren.length - 1 + let newStartChild = newChildren[0] + let newEndChild = newChildren[newEndIndex] + + let oldIndicesByKey + + while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { + if (!oldStartChild) { + oldStartChild = oldChildren[++oldStartIndex] + } else if (!oldEndChild) { + oldEndChild = oldChildren[--oldEndIndex] + } else if (virtualNodesAreEqual(oldStartChild, newStartChild)) { + patch(oldStartChild, newStartChild, options) + oldStartChild = oldChildren[++oldStartIndex] + newStartChild = newChildren[++newStartIndex] + } else if (virtualNodesAreEqual(oldEndChild, newEndChild)) { + patch(oldEndChild, newEndChild, options) + oldEndChild = oldChildren[--oldEndIndex] + newEndChild = newChildren[--newEndIndex] + } else if (virtualNodesAreEqual(oldStartChild, newEndChild)) { + patch(oldStartChild, newEndChild, options) + parentElement.insertBefore(oldStartChild.domNode, oldEndChild.domNode.nextSibling) + oldStartChild = oldChildren[++oldStartIndex] + newEndChild = newChildren[--newEndIndex] + } else if (virtualNodesAreEqual(oldEndChild, newStartChild)) { + patch(oldEndChild, newStartChild, options) + parentElement.insertBefore(oldEndChild.domNode, oldStartChild.domNode); + oldEndChild = oldChildren[--oldEndIndex] + newStartChild = newChildren[++newStartIndex] + } else { + if (!oldIndicesByKey) oldIndicesByKey = mapOldKeysToIndices(oldChildren, oldStartIndex, oldEndIndex) + const key = getKey(newStartChild) + const oldIndex = key ? oldIndicesByKey[key] : null + if (oldIndex == null) { + parentElement.insertBefore(render(newStartChild, options), oldStartChild.domNode) + newStartChild = newChildren[++newStartIndex] + } else { + const oldChildToMove = oldChildren[oldIndex] + patch(oldChildToMove, newStartChild, options) + oldChildren[oldIndex] = undefined + parentElement.insertBefore(oldChildToMove.domNode, oldStartChild.domNode) + newStartChild = newChildren[++newStartIndex] + } + } + } + + if (oldStartIndex > oldEndIndex) { + const subsequentElement = newChildren[newEndIndex + 1] ? newChildren[newEndIndex + 1].domNode : null + for (let i = newStartIndex; i <= newEndIndex; i++) { + parentElement.insertBefore(render(newChildren[i], options), subsequentElement) + } + } else if (newStartIndex > newEndIndex) { + for (let i = oldStartIndex; i <= oldEndIndex; i++) { + const child = oldChildren[i] + if (child) removeVirtualNode(child, options && options.refs) + } + } +} + +function removeVirtualNode (virtualNode, refs, removeDOMNode = true) { + const {domNode, props, children, component} = virtualNode + const ref = props && props.ref + if (component) { + if (refs && ref && refs[ref] === component) delete refs[ref] + if (typeof component.destroy === 'function') component.destroy() + } else { + if (refs && ref && refs[ref] === domNode) delete refs[ref] + if (children) { + for (let i = 0; i < children.length; i++) { + removeVirtualNode(children[i], refs, false) + } + } + } + + if (removeDOMNode) domNode.remove() +} + +function removeRefs (virtualNode, refs) { + for (let i = 0; i < children.length; i++) { + removeRefs(children[i], refs) + } +} + +function virtualNodesAreEqual (oldVirtualNode, newVirtualNode) { + return ( + getKey(oldVirtualNode) === getKey(newVirtualNode) + && oldVirtualNode.tag === newVirtualNode.tag + ) +} + +function getKey (virtualNode) { + return virtualNode.props ? virtualNode.props.key : undefined +} + +function mapOldKeysToIndices (children, startIndex, endIndex) { + let oldIndicesByKey = {} + for (let i = startIndex; i <= endIndex; i++) { + const key = getKey(children[i]) + if (key) oldIndicesByKey[key] = i + } + return oldIndicesByKey; +} + +module.exports = patch diff --git a/lib/render.js b/lib/render.js new file mode 100644 index 0000000..938f1f7 --- /dev/null +++ b/lib/render.js @@ -0,0 +1,43 @@ +const updateProps = require('./update-props') +const SVG_TAGS = require('./svg-tags') + +function render (virtualNode, options) { + let domNode + if (virtualNode.text != null) { + domNode = document.createTextNode(virtualNode.text) + } else { + const {tag, children} = virtualNode + let {props} = virtualNode + + if (typeof tag === 'function') { + let ref + if (props && props.ref) { + ref = props.ref + } + const component = new tag(props || {}, children) + virtualNode.component = component + domNode = component.element + if (options && options.refs && ref) { + options.refs[ref] = component + } + } else if (SVG_TAGS.has(tag)) { + domNode = document.createElementNS("http://www.w3.org/2000/svg", tag); + if (children) addChildren(domNode, children, options) + if (props) updateProps(domNode, null, virtualNode, options) + } else { + domNode = document.createElement(tag) + if (children) addChildren(domNode, children, options) + if (props) updateProps(domNode, null, virtualNode, options) + } + } + virtualNode.domNode = domNode + return domNode +} + +function addChildren (parent, children, options) { + for (let i = 0; i < children.length; i++) { + parent.appendChild(render(children[i], options)) + } +} + +module.exports = render diff --git a/src/scheduler-assignment.js b/lib/scheduler-assignment.js similarity index 88% rename from src/scheduler-assignment.js rename to lib/scheduler-assignment.js index c6034bc..ac3b13a 100644 --- a/src/scheduler-assignment.js +++ b/lib/scheduler-assignment.js @@ -22,15 +22,15 @@ // associated functions repeatedly. Again, they should be scheduled in such a // way so as to avoid synchronous reflows. -import DefaultScheduler from './default-scheduler' +const DefaultScheduler = require('./default-scheduler') let scheduler = null -export function setScheduler (customScheduler) { +module.exports.setScheduler = function setScheduler (customScheduler) { scheduler = customScheduler } -export function getScheduler () { +module.exports.getScheduler = function getScheduler () { if (!scheduler) { scheduler = new DefaultScheduler() } diff --git a/lib/svg-attribute-translations.js b/lib/svg-attribute-translations.js new file mode 100644 index 0000000..c38b4b4 --- /dev/null +++ b/lib/svg-attribute-translations.js @@ -0,0 +1,74 @@ +// Based on https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute +module.exports = new Map([ + ['accentHeight', 'accent-height'], + ['alignmentBaseline', 'alignment-baseline'], + ['arabicForm', 'arabic-form'], + ['baselineShift', 'baseline-shift'], + ['capHeight', 'cap-height'], + ['className', 'class'], + ['clipPath', 'clip-path'], + ['clipRule', 'clip-rule'], + ['colorInterpolation', 'color-interpolation'], + ['colorInterpolationFilters', 'color-interpolation-filters'], + ['colorProfile', 'color-profile'], + ['colorRendering', 'color-rendering'], + ['dominantBaseline', 'dominant-baseline'], + ['enableBackground', 'enable-background'], + ['fillOpacity', 'fill-opacity'], + ['fillRule', 'fill-rule'], + ['floodColor', 'flood-color'], + ['floodOpacity', 'flood-opacity'], + ['fontFamily', 'font-family'], + ['fontSize', 'font-size'], + ['fontSizeAdjust', 'font-size-adjust'], + ['fontStretch', 'font-stretch'], + ['fontStyle', 'font-style'], + ['fontVariant', 'font-variant'], + ['fontWeight', 'font-weight'], + ['glyphName', 'glyph-name'], + ['glyphOrientationHorizontal', 'glyph-orientation-horizontal'], + ['glyphOrientationVertical', 'glyph-orientation-vertical'], + ['horizAdvX', 'horiz-adv-x'], + ['horizOriginX', 'horiz-origin-x'], + ['letterSpacing', 'letter-spacing'], + ['lightingColor', 'lighting-color'], + ['markerEnd', 'marker-end'], + ['markerMid', 'marker-mid'], + ['markerStart', 'marker-start'], + ['overlinePosition', 'overline-position'], + ['overlineThickness', 'overline-thickness'], + ['panose1', 'panose-1'], + ['paintOrder', 'paint-order'], + ['pointerEvents', 'pointer-events'], + ['renderingIntent', 'rendering-intent'], + ['shapeRendering', 'shape-rendering'], + ['stopColor', 'stop-color'], + ['stopOpacity', 'stop-opacity'], + ['strikethroughPosition', 'strikethrough-position'], + ['strikethroughThickness', 'strikethrough-thickness'], + ['strokeDasharray', 'stroke-dasharray'], + ['strokeDashoffset', 'stroke-dashoffset'], + ['strokeLinecap', 'stroke-linecap'], + ['strokeLinejoin', 'stroke-linejoin'], + ['strokeMiterlimit', 'stroke-miterlimit'], + ['strokeOpacity', 'stroke-opacity'], + ['strokeWidth', 'stroke-width'], + ['textAnchor', 'text-anchor'], + ['textDecoration', 'text-decoration'], + ['textRendering', 'text-rendering'], + ['underlinePosition', 'underline-position'], + ['underlineThickness', 'underline-thickness'], + ['unicodeBidi', 'unicode-bidi'], + ['unicodeRange', 'unicode-range'], + ['unitsPerEm', 'units-per-em'], + ['vAlphabetic', 'v-alphabetic'], + ['vHanging', 'v-hanging'], + ['vIdeographic', 'v-ideographic'], + ['vMathematical', 'v-mathematical'], + ['vertAdvY', 'vert-adv-y'], + ['vertOriginX', 'vert-origin-x'], + ['vertOriginY', 'vert-origin-y'], + ['wordSpacing', 'word-spacing'], + ['writingMode', 'writing-mode'], + ['xHeight', 'x-height'], +]) diff --git a/src/svg-tags.js b/lib/svg-tags.js similarity index 93% rename from src/svg-tags.js rename to lib/svg-tags.js index 8c23138..653ea19 100644 --- a/src/svg-tags.js +++ b/lib/svg-tags.js @@ -1,5 +1,5 @@ // taken from https://github.com/facebook/react/blob/67f8524e88abbf1ac0fd86d38a0477d11fbc7b3e/src/isomorphic/classic/element/ReactDOMFactories.js#L153 -export default new Set([ +module.exports = new Set([ 'circle', 'clipPath', 'defs', diff --git a/lib/update-props.js b/lib/update-props.js new file mode 100644 index 0000000..4a65e51 --- /dev/null +++ b/lib/update-props.js @@ -0,0 +1,118 @@ +const EVENT_LISTENER_PROPS = require('./event-listener-props') +const SVG_TAGS = require('./svg-tags') +const SVG_ATTRIBUTE_TRANSLATIONS = require('./svg-attribute-translations') +const EMPTY = '' + +module.exports = function (domNode, oldVirtualNode, newVirtualNode, options) { + const oldProps = oldVirtualNode && oldVirtualNode.props + const newProps = newVirtualNode.props + + let refs, listenerContext + if (options) { + refs = options.refs + listenerContext = options.listenerContext + } + updateProps(domNode, oldVirtualNode, oldProps, newVirtualNode, newProps) + if (refs) updateRef(domNode, oldProps && oldProps.ref, newProps && newProps.ref, refs) + updateEventListeners(domNode, oldVirtualNode, newVirtualNode, listenerContext) +} + +function updateProps (domNode, oldVirtualNode, oldProps, newVirtualNode, newProps) { + if (oldProps) { + const oldPropsNames = Object.keys(oldProps) + for (let i = 0; i < oldPropsNames.length; i++) { + const name = oldPropsNames[i] + if (name === 'ref' || name === 'on') continue + if (name in EVENT_LISTENER_PROPS) continue + if (!newProps || !(name in newProps)) { + if (name === 'dataset') { + updateProps(domNode.dataset, null, oldProps && oldProps.dataset, null, null) + } else if (name === 'style') { + updateProps(domNode.style, null, oldProps && oldProps.style, null, null) + } else if (name !== 'innerHTML' && oldVirtualNode && SVG_TAGS.has(oldVirtualNode.tag)) { + domNode.removeAttribute(SVG_ATTRIBUTE_TRANSLATIONS.get(name) || name) + } else { + // Clear property for objects that don't support deletion (e.g. style + // or className). If we used null instead of an empty string, the DOM + // could sometimes stringify the value and mistakenly assign 'null'. + domNode[name] = EMPTY + delete domNode[name] + } + } + } + } + + if (newProps) { + const newPropsNames = Object.keys(newProps) + for (let i = 0; i < newPropsNames.length; i++) { + const name = newPropsNames[i] + if (name === 'ref' || name === 'on') continue + if (name in EVENT_LISTENER_PROPS) continue + const oldValue = oldProps && oldProps[name] + const newValue = newProps[name] + if (name === 'dataset') { + updateProps(domNode.dataset, null, oldProps && oldProps.dataset, null, newProps.dataset) + } else if (name === 'style') { + updateProps(domNode.style, null, oldProps && oldProps.style, null, newProps.style) + } else { + if (newValue !== oldValue) { + if (name !== 'innerHTML' && newVirtualNode && SVG_TAGS.has(newVirtualNode.tag)) { + domNode.setAttribute(SVG_ATTRIBUTE_TRANSLATIONS.get(name) || name, newValue) + } else { + domNode[name] = newValue + } + } + } + } + } +} + +function updateRef (domNode, oldRefName, newRefName, refs) { + if (newRefName !== oldRefName) { + if (oldRefName && refs[oldRefName] === domNode) delete refs[oldRefName] + if (newRefName) refs[newRefName] = domNode + } +} + +function updateEventListeners (domNode, oldVirtualNode, newVirtualNode, listenerContext) { + const oldListeners = oldVirtualNode && oldVirtualNode.props && oldVirtualNode.props.on + const newListeners = newVirtualNode.props && newVirtualNode.props.on + + for (const eventName in oldListeners) { + if (!(newListeners && eventName in newListeners)) { + let listenerToRemove + if (oldVirtualNode && oldVirtualNode.boundListeners && oldVirtualNode.boundListeners[eventName]) { + listenerToRemove = oldVirtualNode.boundListeners[eventName] + } else { + listenerToRemove =oldListeners[eventName] + } + domNode.removeEventListener(eventName, listenerToRemove) + } + } + + for (const eventName in newListeners) { + const oldListener = oldListeners && oldListeners[eventName] + const newListener = newListeners[eventName] + + if (newListener !== oldListener) { + if (oldListener) { + let listenerToRemove + if (oldVirtualNode && oldVirtualNode.boundListeners && oldVirtualNode.boundListeners[eventName]) { + listenerToRemove = oldVirtualNode.boundListeners[eventName] + } else { + listenerToRemove = oldListener + } + domNode.removeEventListener(eventName, listenerToRemove) + } + let listenerToAdd + if (listenerContext) { + listenerToAdd = newListener.bind(listenerContext) + if (!newVirtualNode.boundListeners) newVirtualNode.boundListeners = {} + newVirtualNode.boundListeners[eventName] = listenerToAdd + } else { + listenerToAdd = newListener + } + domNode.addEventListener(eventName, listenerToAdd) + } + } +} diff --git a/package.json b/package.json index 30ef410..38d893d 100644 --- a/package.json +++ b/package.json @@ -2,15 +2,13 @@ "name": "etch", "version": "0.8.0", "description": "Perform virtual DOM updates based on changes to a data model.", - "main": "dist/index.js", + "main": "lib/index.js", "scripts": { "test": "npm run standard && npm run mocha", - "mocha": "devtool --quit --console node_modules/.bin/_mocha --colors --recursive ./test/helpers/register-babel test", + "mocha": "electron-mocha --renderer --recursive ./test/helpers/register-babel test", "tdd": "npm run standard && node_modules/.bin/electron-mocha --renderer --interactive --recursive ./test/helpers/register-babel test", - "prepublish": "npm run standard && npm run clean && npm run build", - "standard": "node_modules/.bin/standard --verbose --recursive", - "build": "node_modules/.bin/babel src --out-dir dist", - "clean": "rm -rf dist" + "prepublish": "npm run standard", + "standard": "node_modules/.bin/standard --recursive lib test && echo Linting passed" }, "repository": { "type": "git", @@ -29,16 +27,13 @@ "babel": "^5.8.33", "babel-eslint": "^4.1.6", "chai": "^3.5.0", - "devtool": "^2.3.0", - "electron-mocha": "git://github.com/nathansobo/electron-mocha.git#interactive-option", - "electron-prebuilt": "^0.37.8", + "electron": "1.3.13", + "electron-mocha": "3.3.0", "estraverse-fb": "^1.3.1", "mocha": "^3.1.2", + "random-seed": "^0.3.0", "standard": "^8.5.0" }, - "dependencies": { - "virtual-dom": "^2.1.1" - }, "standard": { "parser": "babel-eslint", "global": [ diff --git a/src/component-widget.js b/src/component-widget.js deleted file mode 100644 index 222ce31..0000000 --- a/src/component-widget.js +++ /dev/null @@ -1,104 +0,0 @@ -import refsStack from './refs-stack' - -// Instances of this class interface with an [extension mechanism](https://github.com/Matt-Esch/virtual-dom/blob/master/docs/widget.md) -// in the `virtual-dom` library that allows us to manually manage specific DOM -// nodes within a `virtual-dom` tree. It is designed to wrap a *component*, -// which is just a plain JavaScript object that implements a simple interface. -export default class ComponentWidget { - constructor (componentConstructor, properties, children) { - this.type = 'Widget' - this.componentConstructor = componentConstructor - this.properties = properties - this.children = children - - let sanitizedProperties - - if (this.properties && this.properties.ref) { - if (!sanitizedProperties) sanitizedProperties = Object.assign({}, this.properties) - this.ref = sanitizedProperties.ref - delete sanitizedProperties.ref - } - - if (this.properties && this.properties.key) { - if (!sanitizedProperties) sanitizedProperties = Object.assign({}, this.properties) - this.key = sanitizedProperties.key - delete sanitizedProperties.key - } - - if (sanitizedProperties) this.properties = sanitizedProperties - } - - // The `virtual-dom` library expects this method to return a DOM node. It - // calls this method when creating a DOM tree for the first time or when - // updating a DOM tree via patch. We create an instance of the component based - // on the constructor we saved when the widget was created, deal with the - // `ref` property if it is present, then return the component's associated DOM - // element. - init () { - this.component = new this.componentConstructor(this.properties, this.children) // eslint-disable-line new-cap - if (this.ref && refsStack.length > 0) { - refsStack[refsStack.length - 1][this.ref] = this.component - } - return this.component.element - } - - // The `virtual-dom` library calls this method when applying updates to the - // DOM by `diff`ing two virtual trees and then `patch`ing an element. - // - // If a widget with the same `componentConstructor` occurs at the same - // location of the tree, we attempt to update the underlying component by - // calling the `update` method with the component's new properties and - // children. If the component doesn't have an `update` method or if the - // widgets have *different* component constructors, we destroy the old - // component and build a new one in its place. - update (oldWidget, oldElement) { - const oldRef = oldWidget.ref - const newRef = this.ref - - if (this.componentConstructor === oldWidget.componentConstructor) { - this.component = oldWidget.component - - // If the ref properties have changed, delete the old reference and create - // a new reference to this widget's component. - if (newRef !== oldRef && refsStack.length > 0) { - delete refsStack[refsStack.length - 1][oldRef] - refsStack[refsStack.length - 1][newRef] = this.component - } - - this.component.update(this.properties, this.children) - } else { - // If `ref` properties are defined, delete the reference to the old - // component. Only do this if the reference name changed because otherwise - // the new reference (created in the call to `this.init`) will overwrite - // the old one anyway. - if (refsStack.length > 0) { - if (newRef !== oldRef) { - delete refsStack[refsStack.length - 1][oldRef] - } - } - - // Clean up the old component - oldWidget.destroy() - // Build a new component instance by calling `init`. - this.init() - } - - // Return the element so virtual-dom will replace the element if it changed - return this.component.element - } - - destroy () { - // Clean up the reference to this component if it is not now referencing a - // different component. - if (this.ref && refsStack.length > 0) { - const refs = refsStack[refsStack.length - 1] - if (refs[this.ref] === this.component) { - delete refs[this.ref] - } - } - - if (typeof this.component.destroy === 'function') { - this.component.destroy() - } - } -} diff --git a/src/dom.js b/src/dom.js deleted file mode 100644 index 337bb69..0000000 --- a/src/dom.js +++ /dev/null @@ -1,47 +0,0 @@ -import h from 'virtual-dom/h' -import svg from 'virtual-dom/virtual-hyperscript/svg' -import RefHook from './ref-hook' -import ComponentWidget from './component-widget' -import SVG_TAGS from './svg-tags' -import SVG_ATTRIBUTE_TRANSLATIONS from './svg-attribute-translations' - -// This function is invoked by JSX expressions to construct `virtual-dom` trees. -// -// For normal HTML tags (when the `tag` parameter is a string), we call through -// to `h`, the virtual-dom library's method for building virtual nodes. -// -// If the user passes a *constructor*, however, we build a special "widget" -// instance to manage the component. [Widgets](https://github.com/Matt-Esch/virtual-dom/blob/master/docs/widget.md) -// are an extension mechanism in the virtual-dom API that allows us to control -// a particular DOM element directly using native DOM APIs. This allows the -// component to manage its DOM element using whatever mechanism it desires, -// independent of the fact that its containing DOM tree is managed by this -// particular library. For more information, see `./component-widget.js`. -export default function dom (tag, properties, ...children) { - if (typeof tag === 'function') { - return new ComponentWidget(tag, properties || {}, children) - } else { - // Etch allows for a special `ref` property, which will automatically create - // named references to DOM elements containing the property. We implement - // this with virtual-dom's [hook system](https://github.com/Matt-Esch/virtual-dom/blob/master/docs/hooks.md), - // which allows a particular property to be associated with behavior when - // the element is created or destroyed. - if (properties && properties.ref) { - properties.ref = new RefHook(properties.ref) - } - - if (SVG_TAGS.has(tag)) { - if (properties) { - for (let property of Object.keys(properties)) { - if (SVG_ATTRIBUTE_TRANSLATIONS.hasOwnProperty(property)) { - properties[SVG_ATTRIBUTE_TRANSLATIONS[property]] = properties[property] - delete properties[property] - } - } - } - return svg(tag, properties, children) - } else { - return h(tag, properties, children) - } - } -} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 1a7f25b..0000000 --- a/src/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import dom from './dom' -import {initialize, update, updateSync, destroy, destroySync, onUpdate} from './component-helpers' -import {setScheduler, getScheduler} from './scheduler-assignment' - -let etch = { - dom, - initialize, - update, - updateSync, - destroy, - destroySync, - onUpdate, - setScheduler, - getScheduler -} - -export default etch diff --git a/src/ref-hook.js b/src/ref-hook.js deleted file mode 100644 index 9ad1049..0000000 --- a/src/ref-hook.js +++ /dev/null @@ -1,29 +0,0 @@ -import refsStack from './refs-stack' - -// Instances of this class interface with an [extension mechanism](https://github.com/Matt-Esch/virtual-dom/blob/master/docs/hooks.md) -// in the `virtual-dom` library called "hooks". When virtual nodes have -// properties that reference hook instances, `virtual-dom` will call `hook` -// and `unhook` on this object with the underlying DOM node when it is created -// and destroyed. -// -// We maintain a global stack of simple `Object` instances in which to store -// references. When creating or updating a component, we push its associated -// `refs` object to the top of the stack. This allows the hooks to assign -// references into the component whose element we are currently creating or -// updating. -export default class RefHook { - constructor (refName) { - this.refName = refName - } - - hook (node) { - refsStack[refsStack.length - 1][this.refName] = node - } - - unhook (node) { - const currentRefs = refsStack[refsStack.length - 1] - if (refsStack.length > 0 && currentRefs[this.refName] === node) { - delete currentRefs[this.refName] - } - } -} diff --git a/src/refs-stack.js b/src/refs-stack.js deleted file mode 100644 index cda59a1..0000000 --- a/src/refs-stack.js +++ /dev/null @@ -1,6 +0,0 @@ -// This stack is shared by `initialize`, `performElementUpdate`, -// `ComponentWidget`, and `RefHook`. The top of the stack will always contain -// the `refs` object of the component that is currently being created or -// updated, enabling widgets and hooks to create references to DOM nodes -// that are associated with `ref` properties. -export default [] diff --git a/src/svg-attribute-translations.js b/src/svg-attribute-translations.js deleted file mode 100644 index 52bcae1..0000000 --- a/src/svg-attribute-translations.js +++ /dev/null @@ -1,74 +0,0 @@ -// Based on https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute -export default { - accentHeight: 'accent-height', - alignmentBaseline: 'alignment-baseline', - arabicForm: 'arabic-form', - baselineShift: 'baseline-shift', - capHeight: 'cap-height', - className: 'class', - clipPath: 'clip-path', - clipRule: 'clip-rule', - colorInterpolation: 'color-interpolation', - colorInterpolationFilters: 'color-interpolation-filters', - colorProfile: 'color-profile', - colorRendering: 'color-rendering', - dominantBaseline: 'dominant-baseline', - enableBackground: 'enable-background', - fillOpacity: 'fill-opacity', - fillRule: 'fill-rule', - floodColor: 'flood-color', - floodOpacity: 'flood-opacity', - fontFamily: 'font-family', - fontSize: 'font-size', - fontSizeAdjust: 'font-size-adjust', - fontStretch: 'font-stretch', - fontStyle: 'font-style', - fontVariant: 'font-variant', - fontWeight: 'font-weight', - glyphName: 'glyph-name', - glyphOrientationHorizontal: 'glyph-orientation-horizontal', - glyphOrientationVertical: 'glyph-orientation-vertical', - horizAdvX: 'horiz-adv-x', - horizOriginX: 'horiz-origin-x', - letterSpacing: 'letter-spacing', - lightingColor: 'lighting-color', - markerEnd: 'marker-end', - markerMid: 'marker-mid', - markerStart: 'marker-start', - overlinePosition: 'overline-position', - overlineThickness: 'overline-thickness', - panose1: 'panose-1', - paintOrder: 'paint-order', - pointerEvents: 'pointer-events', - renderingIntent: 'rendering-intent', - shapeRendering: 'shape-rendering', - stopColor: 'stop-color', - stopOpacity: 'stop-opacity', - strikethroughPosition: 'strikethrough-position', - strikethroughThickness: 'strikethrough-thickness', - strokeDasharray: 'stroke-dasharray', - strokeDashoffset: 'stroke-dashoffset', - strokeLinecap: 'stroke-linecap', - strokeLinejoin: 'stroke-linejoin', - strokeMiterlimit: 'stroke-miterlimit', - strokeOpacity: 'stroke-opacity', - strokeWidth: 'stroke-width', - textAnchor: 'text-anchor', - textDecoration: 'text-decoration', - textRendering: 'text-rendering', - underlinePosition: 'underline-position', - underlineThickness: 'underline-thickness', - unicodeBidi: 'unicode-bidi', - unicodeRange: 'unicode-range', - unitsPerEm: 'units-per-em', - vAlphabetic: 'v-alphabetic', - vHanging: 'v-hanging', - vIdeographic: 'v-ideographic', - vMathematical: 'v-mathematical', - vertAdvY: 'vert-adv-y', - vertOriginX: 'vert-origin-x', - vertOriginY: 'vert-origin-y', - wordSpacing: 'word-spacing', - writingMode: 'writing-mode', - xHeight: 'x-height' -} diff --git a/test/helpers/setup.js b/test/helpers/setup.js index 4ea0650..da2d686 100644 --- a/test/helpers/setup.js +++ b/test/helpers/setup.js @@ -1,6 +1,6 @@ /* global beforeEach */ -import chai from 'chai' +const chai = require('chai') global.expect = chai.expect beforeEach(function () { diff --git a/test/unit/default-scheduler.test.js b/test/unit/default-scheduler.test.js index 09e028b..bf1053f 100644 --- a/test/unit/default-scheduler.test.js +++ b/test/unit/default-scheduler.test.js @@ -1,4 +1,4 @@ -import DefaultScheduler from '../../src/default-scheduler' +const DefaultScheduler = require('../../lib/default-scheduler') describe('DefaultScheduler', () => { let scheduler diff --git a/test/unit/destroy-sync.test.js b/test/unit/destroy-sync.test.js index 407a8a7..0a1e870 100644 --- a/test/unit/destroy-sync.test.js +++ b/test/unit/destroy-sync.test.js @@ -1,6 +1,6 @@ /** @jsx etch.dom */ -import etch from '../../src/index' +const etch = require('../../lib/index') describe('etch.destroySync(component)', () => { it('synchronously removes the component\'s element from the document and calls `destroy` on child components', () => { diff --git a/test/unit/destroy.test.js b/test/unit/destroy.test.js index d70c504..e7d8209 100644 --- a/test/unit/destroy.test.js +++ b/test/unit/destroy.test.js @@ -1,6 +1,6 @@ /** @jsx etch.dom */ -import etch from '../../src/index' +const etch = require('../../lib/index') describe('etch.destroy(component)', () => { it('removes the component\'s element from the document and calls `destroy` on child components', async () => { diff --git a/test/unit/dom.test.js b/test/unit/dom.test.js index 74eec61..8c03a67 100644 --- a/test/unit/dom.test.js +++ b/test/unit/dom.test.js @@ -1,6 +1,6 @@ /** @jsx etch.dom */ -import etch from '../../src/index' +const etch = require('../../lib/index') describe('etch.dom', () => { it('defaults properties to an empty object', () => { @@ -40,6 +40,18 @@ describe('etch.dom', () => { expect(component.element.outerHTML).to.equal('