Skip to content
This repository was archived by the owner on Dec 15, 2022. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
4c3165c
Implement render for custom virtual DOM implementation
Oct 3, 2016
f2f7ff2
Implement attribute patching
Oct 9, 2016
acf5ea5
Start on patchChildren
Oct 9, 2016
7f21c80
Complete coverage for patching keyed children
Oct 9, 2016
87e1ad5
Fix key handling
Oct 9, 2016
4a477c4
Add tests for patching unkeyed children and implement text node patching
Oct 9, 2016
e1ff837
Pass old virtual node to patch to eliminate global weak map
Oct 9, 2016
e29cbb6
Compare outerHTML instead of deep-comparing DOM trees
Oct 9, 2016
a8c62d8
Handle patching with a different root node type
Oct 9, 2016
1a0338f
Rename element to domNode in multiple places to be more precise
Oct 9, 2016
03e0f00
Start on components; call destroy when removing
Oct 9, 2016
b64510b
:art:
Oct 10, 2016
595224b
:art:
Oct 10, 2016
c1292cd
Implement refs to DOM nodes
Oct 15, 2016
42aa16d
Call update and destroy on child components
Oct 15, 2016
ca98d73
Handle situations where virtual node props are null
Oct 15, 2016
75b6e0b
Bind event handlers via `on` prop
Oct 16, 2016
1ec44e2
Add listenerContext option to bind event listeners in a specific context
Oct 16, 2016
716d532
:art:
Oct 16, 2016
82c783b
Support standard events via camel-cased props
Oct 16, 2016
3f58b1e
Convert component helpers to use custom virtual DOM implementation
Nov 11, 2016
54a52e1
Replace devtool w/ electron-mocha
Feb 20, 2017
3ec8026
Assign properties to DOM elements instead of attributes
Feb 21, 2017
97112fc
Handle text children that are empty
Feb 21, 2017
3444b6c
Don't use `for...of` and `for...in` loops in hot code paths
Feb 21, 2017
50b4a69
Add support for SVG elements
Feb 22, 2017
ca8d2c7
Support assigning `innerHTML` to svg tags
Feb 22, 2017
de67ebd
Remove babel constructs from production code
Feb 22, 2017
47997a5
Create a text node for children that are numbers
Feb 22, 2017
c248122
Add functions to create SVG tags to `etch.dom`
Feb 22, 2017
0b6ebad
Fix updating the `style` property on a DOM node
Feb 22, 2017
6128e32
Export render
Feb 23, 2017
65da030
Don’t run tests interactively
Feb 23, 2017
afd2837
Merge branch 'master' into custom-virtual-dom
Feb 23, 2017
2831c10
Don't pass the component key as a prop
Feb 23, 2017
8a96251
Use a newer electron version and the newest electron-mocha
Feb 23, 2017
19475eb
Replace src with lib
Feb 23, 2017
827f311
Update README to describe the new event handling facility
Feb 23, 2017
9761d7f
Small README tweaks
Feb 23, 2017
bff2ffc
Revert "Don't pass the component key as a prop"
Feb 23, 2017
dc91dee
Assign an empty string instead of null to clear out DOM properties
Feb 24, 2017
accde4d
Pass ref and key to child components to avoid copying objects
Feb 24, 2017
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .npmignore
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
test
src
42 changes: 32 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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...
}
}
```
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 () {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 <div on={click: this.didClick, focus: this.didFocus} />
}

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
6 changes: 6 additions & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
@@ -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
75 changes: 39 additions & 36 deletions src/component-helpers.js → lib/component-helpers.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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.
//
Expand All @@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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
}
2 changes: 1 addition & 1 deletion src/default-scheduler.js → lib/default-scheduler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
63 changes: 63 additions & 0 deletions lib/dom.js
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions lib/event-listener-props.js
Original file line number Diff line number Diff line change
@@ -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'
}
Loading