diff --git a/bench/client.js b/bench/client.js index 7b3de5e..e7b6a18 100644 --- a/bench/client.js +++ b/bench/client.js @@ -1,13 +1,16 @@ var nanobench = require('nanobench') -var nanoHtml = require('../') +var { render, html } = require('../') var createApp = require('./fixtures/app') nanobench('nanohtml browser 10000 iterations', function (b) { - var app = createApp(nanoHtml) - for (var i = 0; i < 100; i++) app.render().toString() + var app = createApp(html) + var div = document.createElement('div') + + document.body.appendChild(div) + for (var i = 0; i < 100; i++) render(app.render(), div) b.start() - for (i = 0; i < 10000; i++) app.render().toString() + for (i = 0; i < 10000; i++) render(app.render(), div) b.end() }) diff --git a/component.js b/component.js new file mode 100644 index 0000000..c96ff58 --- /dev/null +++ b/component.js @@ -0,0 +1,225 @@ +var assert = require('nanoassert') +var { Partial } = require('./nanohtml') + +var loadid = `__onload-${Math.random().toString(36).substr(-4)}` +var identifier = Symbol('nanohtml/component') +var tracking = new WeakMap() +var windows = new WeakSet() +var stack = [] + +exports.memo = memo +exports.onload = onload +exports.onupdate = onupdate +exports.Component = Component +exports.identifier = identifier + +function Component (fn, key, args) { + if (this instanceof Component) { + this.beforeupdate = [] + this.afterupdate = [] + this.beforeload = [] + this.args = args + this.key = key + this.fn = fn + return this + } + + key = Symbol(fn.name || 'nanohtml/component') + return function (...args) { + return new Component(fn, key, args) + } +} + +Component.prototype = Object.create(Partial.prototype) +Component.prototype.constructor = Component + +Component.prototype.key = function key (key) { + this.key = key + return this +} + +Component.prototype.resolve = function (ctx) { + var cached = ctx ? ctx.state.get(identifier) : null + this.index = this.args.length + if (cached) { + this.args = cached.args.map((arg, i) => { + return typeof this.args[i] === 'undefined' ? arg : this.args[i] + }).concat(this.args.slice(cached.args.length)) + } + stack.unshift(this) + try { + const partial = this.fn(...this.args) + partial.key = this.key + return partial + } finally { + const component = stack.shift() + assert(component === this, 'nanohtml/component: stack out of sync') + } +} + +Component.prototype.render = function (oldNode) { + var partial = this.resolve() + var ctx = Partial.prototype.render.call(partial, oldNode) + ctx.state.set(identifier, this) + this.rendered = partial + return ctx +} + +Component.prototype.update = function (ctx) { + this.ctx = ctx // store context for async updates + var cached = ctx.state.get(identifier) + stack.unshift(this) + try { + const partial = this.rendered || this.resolve(ctx) + unwind(cached.beforeupdate, this.args) + Partial.prototype.update.call(partial, ctx) + unwind(this.afterupdate, this.args) + unwind(this.beforeload, [ctx.element]) + ctx.state.set(identifier, this) + } finally { + const component = stack.shift() + assert(component === this, 'nanohtml/component: stack out of sync') + } +} + +function unwind (arr, args) { + while (arr.length) { + const fn = arr.pop() + fn(...args) + } +} + +function onupdate (fn) { + assert(stack.length, 'nanohtml/component: cannot call onupdate outside component render cycle') + var component = stack[0] + if (typeof fn === 'function') { + component.afterupdate.push(function (...args) { + var res = fn(...args) + if (typeof res === 'function') { + component.beforeupdate.push(res) + } + }) + } + + return function (...args) { + assert(component.ctx, 'nanohtml/component: cannot update while rendering') + var next = new Component(component.fn, component.key, args) + next.update(component.ctx) + } +} + +function memo (initial) { + assert(stack.length, 'nanohtml/component: cannot call memo outside component render cycle') + var index = stack[0].index++ + var { args } = stack[0] + var value = args[index] + if (typeof value === 'undefined') { + if (typeof initial === 'function') value = initial(...args) + else value = initial + args[index] = value + } + return value +} + +/** + * An implementation of https://github.com/hyperdivision/fast-on-load + * + * Copyright 2020 Hyperdivision ApS (https://hyperdivision.dk) + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +function onload (fn) { + assert(stack.length, 'nanohtml/component: cannot call onload outside component render cycle') + stack[0].beforeload.push(function (el) { + el.classList.add(loadid) + + var entry + if (!tracking.has(el)) { + entry = { + on: [on], + off: [], + state: 2, + children: el.getElementsByClassName(loadid) + } + tracking.set(el, entry) + if (!windows.has(this)) createObserver(this) + } else { + // FIXME: this will reset the queue on every onload + entry = tracking.get(el) + entry.on = [on] + entry.off = [] + } + + function on (el) { + var res = fn(el) + if (typeof res === 'function') entry.off.push(res) + } + }) +} + +function createObserver (window) { + windows.add(window) + + const document = window.document + const observer = new window.MutationObserver(onchange) + + const isConnected = 'isConnected' in window.Node.prototype + ? node => node.isConnected + : node => document.documentElement.contains(node) + + observer.observe(document.documentElement, { + childList: true, + subtree: true + }) + + function callAll (nodes, idx) { + for (const node of nodes) { + if (!node.classList) continue + if (node.classList.contains(loadid)) call(node, idx) + const children = tracking.has(node) + ? tracking.get(node).children + : node.getElementsByClassName(loadid) + for (const child of children) { + call(child, idx) + } + } + } + + // State Enum + // 0: mounted + // 1: unmounted + // 2: undefined + function call (node, state) { + var entry = tracking.get(node) + if (!entry || entry.state === state) return + if (state === 0 && isConnected(node)) { + entry.state = 0 + for (const fn of entry.on) fn(node) + } else if (state === 1 && !isConnected(node)) { + entry.state = 1 + for (const fn of entry.off) fn(node) + } + } + + function onchange (mutations) { + for (const { addedNodes, removedNodes } of mutations) { + callAll(removedNodes, 1) + callAll(addedNodes, 0) + } + } +} diff --git a/dom.js b/dom.js deleted file mode 100644 index 2ac54d5..0000000 --- a/dom.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('./lib/dom') diff --git a/lazy.js b/lazy.js new file mode 100644 index 0000000..c5c27b2 --- /dev/null +++ b/lazy.js @@ -0,0 +1,90 @@ +const { Partial, Context } = require('./nanohtml') + +module.exports = Lazy + +function Lazy (primary, fallback) { + if (!(this instanceof Lazy)) { + primary = unwind(primary) + if (primary == null) return fallback() + if (!isPromise(primary)) return primary + return new Lazy(primary, fallback) + } + + fallback = fallback() + if (fallback instanceof Partial) { + this.key = fallback.key + this.partial = fallback + } else { + this.key = Symbol('nanohtml/lazy') + } + + this.primary = primary + this.fallback = fallback +} + +Lazy.prototype = Object.create(Partial.prototype) +Lazy.prototype.constructor = Lazy + +Lazy.prototype.render = function (oldNode) { + var { primary, fallback } = this + + var ctx + if (fallback instanceof Partial) { + ctx = fallback.render(oldNode) + oldNode = ctx.element + } else { + oldNode = toNode(fallback) + ctx = new Context({ + key: this.key, + element: oldNode, + editors: [], + bind (newNode) { + oldNode = newNode + } + }) + } + + ctx.queue(primary).then((res) => { + if (res instanceof Partial) { + var ctx = res.render(oldNode) + this.partial = res + this.key = res.key + res.update() + } else { + + } + }).catch((err) => { + var newNode = fallback(err) + oldNode.parentNode.replaceChild() + }) + + return ctx +} + +Lazy.prototype.update = function (ctx) { + if (this.partial) { + this.partial.update(ctx) + } +} + +function unwind (obj, value) { + if (isGenerator(obj)) { + const res = obj.next(value) + if (res.done) return res.value + if (isPromise(res.value)) { + return res.value.then(unwind).then((val) => unwind(obj, val)) + } + return unwind(obj, res.value) + } else if (isPromise(obj)) { + return obj.then(unwind) + } + return obj +} + +function isPromise (obj) { + return !!obj && (typeof obj === 'object' || typeof obj === 'function') && typeof obj.then === 'function' +} + +function isGenerator (obj) { + return obj && typeof obj.next === 'function' && typeof obj.throw === 'function' +} diff --git a/lib/bool-props.js b/lib/bool-props.js index 50bf4da..ee95b64 100644 --- a/lib/bool-props.js +++ b/lib/bool-props.js @@ -1,5 +1,3 @@ -'use strict' - module.exports = [ 'async', 'autofocus', 'autoplay', 'checked', 'controls', 'default', 'defaultchecked', 'defer', 'disabled', 'formnovalidate', 'hidden', diff --git a/lib/browser.js b/lib/browser.js index 5791aa4..b85eee0 100644 --- a/lib/browser.js +++ b/lib/browser.js @@ -1 +1,875 @@ -module.exports = require('./dom')(document) +'use strict' + +const hyperx = require('hyperx') +const morph = require('./morph') +const SVG_TAGS = require('./svg-tags') +const TEXT_TAGS = require('./text-tags') +const BOOL_PROPS = require('./bool-props') +const DIRECT_PROPS = require('./direct-props') +const VERBATIM_TAGS = require('./verbatim-tags') + +const cache = new WeakMap() +const pending = new WeakMap() +const templates = new WeakMap() + +const onlyWhiteSpaceRegexp = /^[\n\s]+$/ +const trailingNewlineRegex = /\n[\s]+$/ +const leadingNewlineRegex = /^\n[\s]+/ +const trailingSpaceRegex = /[\s]+$/ +const leadingSpaceRegex = /^[\s]+/ +const multiSpaceRegex = /[\n\s]+/g +const PLACEHOLDER_INDEX = /\0placeholder(\d+)\0/g +const XLINKNS = 'http://www.w3.org/1999/xlink' +const SVGNS = 'http://www.w3.org/2000/svg' +const FRAGMENT = Symbol('fragment') +const PENDING = Symbol('pending') +const REF = Symbol('ref') +const COMMENT_TAG = '!--' +const TEXT_NODE = 3 + +exports.Ref = Ref +exports.html = html +exports.cache = cache +exports.render = render +exports.Partial = Partial +exports.Context = Context + +function html (template, ...values) { + return new Partial({ template, values, key: template }) +} + +// Render partial, optionally mounting it on given node +// (Partial|Promise|Generator, Element) -> Element|Promise +function render (partial, oldNode) { + partial = unwind(partial) + + // handle async top level partial and pending promises + if (oldNode) pending.delete(oldNode) + if (isPromise(partial)) { + if (oldNode) pending.set(oldNode, partial) + return partial.then(function (res) { + if (!oldNode || pending.get(oldNode) === partial) { + return render(res, oldNode) + } + }) + } + + // Update tree in place + var ctx = oldNode && cache.get(oldNode) + if (ctx && ctx.key === partial.key) { + partial.update(ctx) + return oldNode + } + + // Render tree and mount in/replace old tree + ctx = partial.render(oldNode) + partial.update(ctx) + if (oldNode && !ctx.element.isSameNode(oldNode)) { + if (ctx.element instanceof window.DocumentFragment) { + removeChild(Array.from(oldNode.childNodes)) + oldNode.appendChild(ctx.element) + } else { + oldNode.replaceWith(ctx.element) + } + } + return ctx.element +} + +// Create a reference to an element, should be assigned as id attribute +// (String?) -> Ref +function Ref (uid = makeId()) { + if (!(this instanceof Ref)) return new Ref(uid) + if (typeof window === 'undefined') return uid + return new Proxy(this, { + get: (self, key, receiver) => { + var element = document.getElementById(uid) + switch (key) { + // Expose inernal uid to determine equality + case REF: return uid + // Expose underlying element to circumvent proxy + case 'element': return element + // Support all forms of serialization + case Symbol.toPrimitive: + case 'toString': + case 'toJSON': return () => uid + // Proxy everything else to the element + default: return Reflect.get(element || this, key) + } + }, + set (obj, key, value) { + // Allow overriding the uid to persist id attribute between updates + if (key === REF) return (uid = value[REF]) + var element = document.getElementById(uid) + if (element) return Reflect.set(element, key, value) + } + }) +} +if (typeof window !== 'undefined') { + // Augument the Element class to make the proxy as transparent as possible + Ref.prototype = Object.create(window.Element.prototype) + Ref.prototype.constructor = Ref +} + +// Element partial holding values and template +// ({ template: Array, values: Array, key: any }) -> Partial +function Partial ({ template, values, key }) { + this.template = template + this.values = values + this.key = key +} + +// Render a partial, optionally morphing it onto an existing node +// (Element?) -> Context +Partial.prototype.render = function render (oldNode) { + var { values, key } = this + var template = templates.get(this.template) + + if (!template) { + const placeholders = values.map(function toPlaceholder (_, index) { + return '\0placeholder' + index + '\0' + }) + + const parser = hyperx(createTemplate, { + comments: true, + createFragment (nodes) { + return createTemplate(FRAGMENT, {}, nodes) + } + }) + + template = parser.apply(undefined, [this.template].concat(placeholders)) + + if (typeof template === 'string') { + if (isPlaceholder(template)) { + // The only child is a partial, e.g. html`${html`

Hi

`}` or html`${'Hi'}` + template = createTemplate(FRAGMENT, {}, placeholders) + } else { + // The only child is text, e.g. html`Hi` + template = createTemplate(FRAGMENT, {}, [template]) + } + } + + templates.set(this.template, template) + } + + return template(values, key, oldNode) +} + +// Update element with partial values +// (Context) -> void +Partial.prototype.update = function update (ctx) { + ctx.state.get(PENDING).clear() + for (const { update, index } of ctx.editors) { + update(this.values[index], this.values) + } +} + +// Bindings for identifying and updating a rendered element +// ({ key: any, element: Node, editors: Array, bind: Function }) -> Context +function Context ({ key, element, editors, bind }) { + this.key = key + this.bind = bind + this.element = element + this.state = new Map([[PENDING, new Set()]]) + this.isPlaceholder = isPlaceholder(element.nodeValue) + this.editors = editors.slice().sort((a, b) => a.index - b.index) +} + +// Create a template for an element +// (String, Object, Array) -> Function +function createTemplate (tag, attrs, children = []) { + // If an svg tag, it needs a namespace + if (SVG_TAGS.indexOf(tag) !== -1) { + attrs.namespace = SVGNS + } + + // If we are using a namespace + var namespace = false + if (attrs.namespace) { + namespace = attrs.namespace + delete attrs.namespace + } + + // If we are extending a builtin element + var isCustomElement = false + if (attrs.is) { + isCustomElement = attrs.is + delete attrs.is + } + + return template + + // Render element with placeholders, optionally mounting onto an existing node + // (Array, any, Node) -> Context + function template (values, key, oldNode) { + var element + if (tag === FRAGMENT) { + element = document.createDocumentFragment() + } else if (oldNode && oldNode.tagName === tag.toUpperCase()) { + element = oldNode + for (const { name } of element.attributes) { + if (!(name in attrs)) element.removeAttribute(name) + } + } else { + element = createElement(tag, attrs, { namespace, isCustomElement }) + } + + var editors = [] + + Object.entries(attrs).forEach(function handleAttribute ([name, value]) { + if (tag === COMMENT_TAG) return + // Object as attribute (i.e. ) results in the + // attribute being formatted as such { placeholder0: 'placeholder0' } + if (isPlaceholder(name) && isPlaceholder(value)) { + var index = getPlaceholderIndex(name) + if (index === getPlaceholderIndex(value)) { + editors.push({ + index: index, + update (attrs) { + Object.entries(attrs).forEach(function ([name, value]) { + setAttribute(name, value) + }) + } + }) + } + } else if (isPlaceholder(name)) { + editors.push({ + index: getPlaceholderIndex(name), + update (name) { + setAttribute(name, value) + } + }) + } else if (isPlaceholder(value)) { + editors.push({ + index: getPlaceholderIndex(value), + update (value) { + if (name === 'id' && value instanceof Ref) { + var ctx = cache.get(element) + var ref = ctx.state.get(REF) + if (!ref) { + ctx.state.set(REF, value) + } else { + value[REF] = ref + value = ref + } + } + setAttribute(name, value) + } + }) + } else if (PLACEHOLDER_INDEX.test(name)) { + // Handle mixed attribute name
+ name.replace(PLACEHOLDER_INDEX, function placeholderAttr (match) { + editors.push({ + index: getPlaceholderIndex(match), + update (_, all) { + var next = name.replace( + PLACEHOLDER_INDEX, + function updateAttr (_, index) { + return all[index] + } + ) + setAttribute(next, value) + } + }) + }) + } else if (PLACEHOLDER_INDEX.test(value)) { + // Handle mixed attribute value
+ value.replace(PLACEHOLDER_INDEX, function placeholderAttr (match) { + editors.push({ + index: getPlaceholderIndex(match), + update (_, all) { + var next = value.replace( + PLACEHOLDER_INDEX, + function updateAttr (_, index) { + return all[index] + } + ) + setAttribute(name, next) + } + }) + }) + } else { + setAttribute(name, value) + } + }) + + // Handle children, adding placeholders for all partials + var oldChildren = oldNode && Array.from(oldNode.childNodes) + var newChildren = children.reduce(function eachChild (children, child, index, all) { + // Resolve inline templates + if (typeof child === 'function') child = child(values, key) + + if (isPlaceholder(child)) { + const placeholderIndex = getPlaceholderIndex(child) + child = values[placeholderIndex] + + if (child instanceof Partial) { + // If we know up front that there's to be a partial here, use it to + // determine if we can just reuse an existing child in `oldNode` + children[index] = appendChild(child, placeholderIndex) + } else { + // Append a placeholder and create an editor for updating it + child = document.createComment('placeholder') + children[index] = appendChild(child, placeholderIndex) + editors.push({ + index: placeholderIndex, + update: createUpdate(child) + }) + } + } else if (child instanceof Context) { + // Capture inline template child editors + children[index] = appendChild(child.element) + editors.push(...child.editors) + } else { + // Create a faux context for generic content (text nodes) so that they + // can be bound to an exiting text node in `oldChild` + children[index] = child = appendChild(child) + if (child != null) { + cache.set(child, new Context({ + key: Symbol(index), + element: child, + editors: [], + bind (newNode) { + children[index] = newNode + } + })) + } + } + + return children + + // Create an updater for given element + // (Node) -> Function + function createUpdate (oldChild) { + return function update (newChild) { + newChild = unwind(newChild) + + if (Array.isArray(newChild)) { + newChild = newChild.flat(Infinity) + oldChild = Array.isArray(oldChild) ? oldChild : [oldChild] + + // Handle array of children, match with an old element or render new + const newChildren = newChild.map(function childNode (child, index) { + child = unwind(child) + + if (isPromise(child)) { + // Defer update till after promise resolves + queue(child).then(function (child) { + child = childNode(child) + appendInPlace(child, index, newChildren) + newChildren[index] = child + }) + return null + } + + if (child instanceof Partial) { + // Try and match to existing element and update in place + for (let i = 0, len = oldChild.length; i < len; i++) { + const ctx = cache.get(oldChild[i]) + if (ctx && ctx.key === child.key) { + child.update(ctx) + return oldChild.splice(i, 1)[0] + } + } + // Render a new element + const res = child.render() + child.update(res) + return res.element + } + return toNode(child) + }) + + newChildren.forEach(appendInPlace) + + for (const el of oldChild) { + removeChild(el) + } + + children[index] = oldChild = newChildren + return + } + + if (isPromise(newChild)) { + // Defer update till after promise resolves + queue(newChild).then((newChild) => update(newChild)) + newChild = null + } else if (newChild instanceof Partial) { + // Try and match to existing element and update in place + let ctx = oldChild && cache.get(oldChild) + if (ctx && newChild.key === ctx.key && !ctx.isPlaceholder) { + newChild.update(ctx) + newChild = oldChild + } else { + // Render a new element + ctx = newChild.render(oldChild) + newChild.update(ctx) + newChild = ctx.element + } + } else { + newChild = toNode(newChild) + } + + // Insert element in place, replacing an old node if there is one + if (oldChild && oldChild.parentNode === element) { + replaceChild(newChild, oldChild) + } else { + appendInPlace(newChild) + } + + children[index] = oldChild = newChild + } + + // Insert a node in its proper place + // (Node, Number?, Array?) -> void + function appendInPlace (node, _index, _children) { + if (!node) return + var prev + if (typeof _index !== 'undefined') { + prev = getPrevSibling(_children, _index - 1) + } + if (!prev) prev = getPrevSibling(children, index - 1) + var next = prev && prev.nextSibling + if (next && next.isSameNode && next.isSameNode(node)) return + if (next) next.before(node) + else element.appendChild(node) + } + + // Find previous sibling in list of nodes + // (Array, Number?) -> Node? + function getPrevSibling (nodes, start = nodes.length - 1) { + for (let i = start; i >= 0; i--) { + let node = nodes[i] + if (Array.isArray(node)) node = getPrevSibling(node) + if (node != null && node.parentNode === element) return node + } + } + } + + // Append child to element + // (Node, Number?) -> Node? + function appendChild (child, placeholderIndex) { + var node + + // Normalize whitespace + if (typeof child === 'string') { + if (!VERBATIM_TAGS.includes(tag)) { + if (!TEXT_TAGS.includes(tag)) { + if (onlyWhiteSpaceRegexp.test(child)) { + const prev = children[index - 1] + if (!prev || !TEXT_TAGS.includes(prev.nodeName.toLowerCase())) { + return null + } + } + } + + const leader = index === 0 ? '' : ' ' + const tail = index === all.length - 1 ? '' : ' ' + child = child + .replace(leadingNewlineRegex, leader) + .replace(leadingSpaceRegex, ' ') + .replace(trailingSpaceRegex, tail) + .replace(trailingNewlineRegex, '') + .replace(multiSpaceRegex, ' ') + + if (child === '') { + return null + } + } + } + + // Try and find a compatible node in the oldNode tree + if (oldNode && element === oldNode) { + for (let i = 0; i < oldChildren.length; i++) { + let oldChild = oldChildren[i] + if (child instanceof Partial) { + node = node || document.createComment('placeholder') + const ctx = cache.get(oldChild) + if (ctx && ctx.key === child.key) { + oldChildren.splice(i, 1) + editors.push({ + index: placeholderIndex, + update: createUpdate(oldChild) + }) + return oldChild + } + } else { + node = node || toNode(child) + // Only morph compatible nodes that are not cached + if (!isEqual(node, oldChild) || cache.has(oldChild)) continue + if (node.nodeType === TEXT_NODE) { + oldChild.nodeValue = node.nodeValue + } else { + const ctx = cache.get(node) + if (ctx) ctx.bind(oldChild) + morph(node, oldChild) + updateChildren(node, oldChild) + } + oldChildren.splice(i, 1) + return oldChild + } + } + } + + if (child instanceof Partial) { + // Create a placeholder comment node + const value = '\0placeholder' + placeholderIndex + '\0' + node = document.createComment(value) + + // Have placeholder identify as compatible with any element created + // from the same template + node.isEqualNode = function isEqualNode (node) { + if (!cache.has(node)) return false + return cache.get(node).key === child.key + } + + const editor = { + index: placeholderIndex, + update: createUpdate(node) + } + const ctx = new Context({ + key: child.key, + element: node, + editors: [editor], + bind (newNode) { + // Replace update with updater scoped to the new node + editor.update = createUpdate(newNode) + } + }) + + editors.push(editor) + cache.set(node, ctx) + } + + node = node || toNode(child) + if (node) element.appendChild(node) + return node + } + + // Replace an existing node with a new node + // (Node?, Node?) -> void + function replaceChild (newChild, oldChild) { + newChild = toNode(newChild) + + if (newChild === oldChild) { + return newChild + } else if (newChild != null) { + if (oldChild != null) { + if (newChild.isSameNode) { + if (Array.isArray(oldChild)) { + insertBefore(newChild, oldChild) + removeChild(oldChild) + } else if (!newChild.isSameNode(oldChild)) { + oldChild.replaceWith(newChild) + } + } else { + insertBefore(newChild, oldChild) + removeChild(oldChild) + } + return newChild + } else { + let next = index + 1 + while (next < children.length && children[next] == null) next++ + if (children[next]) { + // TODO: add tests for update to/from array + insertBefore(newChild, children[next]) + } else { + element.appendChild(newChild) + } + } + return newChild + } else { + removeChild(oldChild) + return null + } + } + }, []) + + if (element === oldNode) { + for (const oldChild of oldChildren) { + removeChild(oldChild) + } + } + + // TODO: optimize, see createUpdate (appendInPlace) + for (const newChild of newChildren) { + if (Array.isArray(newChild)) { + for (const child of newChild) { + element.appendChild(child) + } + } else if (newChild) { + element.appendChild(newChild) + } + } + + var ctx = new Context({ key, element, editors, bind }) + + if (attrs.id && isPlaceholder(attrs.id)) { + // Save reference with element to preserve id in-between renders + ctx.state.set(REF, values[getPlaceholderIndex(attrs.id)]) + } + + // Cache context with element + cache.set(element, ctx) + + return ctx + + // Update internal element reference + // (Element) -> void + function bind (newElement) { + element = newElement + } + + // Insert node(s) before given node + // (Node|Array, Node) -> void + function insertBefore (newChild, oldChild) { + oldChild = Array.isArray(oldChild) ? oldChild[0] : oldChild + if (Array.isArray(newChild)) { + for (const child of newChild) { + oldChild.before(child) + } + } else { + oldChild.before(newChild) + } + } + + // Set attribute to element + // (String, String|Promise) -> void + function setAttribute (name, value) { + value = unwind(value) + + if (value == null) { + element.removeAttribute(name) + return + } + + if (isPromise(value)) { + queue(value).then((value) => setAttribute(name, value)) + return + } + + var key = name.toLowerCase() + + // Normalize className + if (key === 'classname') { + key = 'class' + name = 'class' + } + + // The for attribute gets transformed to htmlFor, but we just set as for + if (name === 'htmlFor') name = 'for' + + // If a property is boolean, set itself to the key + if (BOOL_PROPS.includes(key)) { + if (String(value) === 'true') value = key + else if (String(value) === 'false' || value == null) return + } + + // If a property prefers being set directly vs setAttribute + if (key.indexOf('on') === 0 || DIRECT_PROPS.includes(key)) { + element[name] = value + } else { + if (namespace) { + if (name === 'xlink:href') { + element.setAttributeNS(XLINKNS, name, value) + } else if (/^xmlns($|:)/i.test(name)) { + // skip xmlns definitions + } else { + element.setAttributeNS(null, name, value) + } + } else { + element.setAttribute(name, value) + } + } + } + + // Only resolve promise if it has not been cleared from element state + // (Promise) -> Promise + function queue (promise) { + var ctx = cache.get(element) + var pending = ctx.state.get(PENDING) + pending.add(promise) + return new Promise(function (resolve, reject) { + promise.then(function (value) { + if (pending.has(promise)) { + pending.delete(promise) + resolve(value) + } + }, reject) + }) + } + } +} + +// Resolve nested generator and promises +// (any, any?) -> any +function unwind (obj, value) { + if (isGenerator(obj)) { + const res = obj.next(value) + if (res.done) return res.value + if (isPromise(res.value)) { + return res.value.then(unwind).then((val) => unwind(obj, val)) + } + return unwind(obj, res.value) + } else if (isPromise(obj)) { + return obj.then(unwind) + } + return obj +} + +// Determin if object is promise +// (any) -> Boolean +function isPromise (obj) { + return !!obj && (typeof obj === 'object' || typeof obj === 'function') && typeof obj.then === 'function' +} + +// Determine if object is generator +// (any) -> Boolean +function isGenerator (obj) { + return obj && typeof obj.next === 'function' && typeof obj.throw === 'function' +} + +// Remove child +// (Node|Array) -> void +function removeChild (child) { + if (!child) return + if (Array.isArray(child)) { + for (const el of child) el.remove() + } else { + child.remove() + } +} + +// Morph children from one node onto another +// (Node, Node) -> void +function updateChildren (newNode, oldNode) { + var newChildren = Array.from(newNode.childNodes) + var oldChildren = Array.from(oldNode.childNodes) + + var prev + for (const newChild of newChildren) { + if (!newChild) continue + + let match + for (let i = 0, len = oldChildren.length; i < len; i++) { + if (isEqual(newChild, oldChildren[i])) { + match = oldChildren[i] + oldChildren.splice(i, 1) + break + } + } + + if (match) { + // Bind matching node to the new node + const ctx = cache.get(newChild) + if (ctx) ctx.bind(match) + + if (match.nodeType === TEXT_NODE) { + match.nodeValue = newChild.nodeValue + } else if (!ctx.isPlaceholder) { + morph(newChild, match) + updateChildren(newChild, match) + } + + if (prev) { + prev.after(match) + } else { + oldNode.appendChild(match) + } + prev = match + } else { + if (prev) { + prev.after(newChild) + } else { + oldNode.appendChild(newChild) + } + prev = newChild + } + } + + for (const oldChild of oldChildren) { + if (oldChild) removeChild(oldChild) + } +} + +// Create appropiate node +// (String, Object, { namespace: String, isCustomElement: Boolean }) -> Node +function createElement (tag, attrs, { namespace, isCustomElement }) { + if (namespace) { + if (isCustomElement) { + return document.createElementNS(namespace, tag, { is: isCustomElement }) + } else { + return document.createElementNS(namespace, tag) + } + } else if (tag === COMMENT_TAG) { + return document.createComment(attrs.comment) + } else if (isCustomElement) { + return document.createElement(tag, { is: isCustomElement }) + } else { + return document.createElement(tag) + } +} + +// Create document fragment and append children +// (Array) -> DocumentFragment +function createFragment (nodes) { + var fragment = document.createDocumentFragment() + for (const node of nodes) { + if (node == null) continue + fragment.appendChild(toNode(node)) + } + return fragment +} + +// Cast value to node +// (any) -> any +function toNode (value) { + var type = typeof value + + if (value == null) { + return null + } + + if (type === 'object' && value.nodeType) { + return value + } + + if (type === 'function' || type === 'string' || type === 'boolean' || + type === 'number' || value instanceof RegExp || value instanceof Date) { + value = value.toString() + } + + if (typeof value === 'string') { + return document.createTextNode(value) + } + + if (Array.isArray(value)) { + return createFragment(value) + } +} + +// Detemine if two nodes are equal and can be morphed +// (Node, Node) -> Boolean +function isEqual (a, b) { + if (a.id) return a.id === b.id + if (a.isEqualNode && a.isEqualNode(b)) return true + if (a.tagName && b.tagName && a.tagName === b.tagName) return true + if (a.nodeType === TEXT_NODE && b.nodeType === TEXT_NODE) return true + return false +} + +// Determine if value is a placeholder +// (any) -> Boolean +function isPlaceholder (value) { + return typeof value === 'string' && /^\0placeholder/.test(value) +} + +// Extract index from placeholder identifier +// (String) -> Number +function getPlaceholderIndex (placeholder) { + return parseInt(placeholder.slice('\0placeholder'.length), 10) +} + +// Generate a unique id +// () -> String +function makeId () { + return `__ref-${Math.random().toString(36).substr(-4)}` +} diff --git a/lib/direct-props.js b/lib/direct-props.js index 8fbe303..62a09b8 100644 --- a/lib/direct-props.js +++ b/lib/direct-props.js @@ -1,5 +1,3 @@ -'use strict' - module.exports = [ 'indeterminate' ] diff --git a/lib/dom.js b/lib/dom.js deleted file mode 100644 index 097bd05..0000000 --- a/lib/dom.js +++ /dev/null @@ -1,116 +0,0 @@ -'use strict' - -var hyperx = require('hyperx') -var appendChild = require('./append-child') -var SVG_TAGS = require('./svg-tags') -var BOOL_PROPS = require('./bool-props') -// Props that need to be set directly rather than with el.setAttribute() -var DIRECT_PROPS = require('./direct-props') - -var SVGNS = 'http://www.w3.org/2000/svg' -var XLINKNS = 'http://www.w3.org/1999/xlink' - -var COMMENT_TAG = '!--' - -module.exports = function (document) { - function nanoHtmlCreateElement (tag, props, children) { - var el - - // If an svg tag, it needs a namespace - if (SVG_TAGS.indexOf(tag) !== -1) { - props.namespace = SVGNS - } - - // If we are using a namespace - var ns = false - if (props.namespace) { - ns = props.namespace - delete props.namespace - } - - // If we are extending a builtin element - var isCustomElement = false - if (props.is) { - isCustomElement = props.is - delete props.is - } - - // Create the element - if (ns) { - if (isCustomElement) { - el = document.createElementNS(ns, tag, { is: isCustomElement }) - } else { - el = document.createElementNS(ns, tag) - } - } else if (tag === COMMENT_TAG) { - return document.createComment(props.comment) - } else if (isCustomElement) { - el = document.createElement(tag, { is: isCustomElement }) - } else { - el = document.createElement(tag) - } - - // Create the properties - for (var p in props) { - if (props.hasOwnProperty(p)) { - var key = p.toLowerCase() - var val = props[p] - // Normalize className - if (key === 'classname') { - key = 'class' - p = 'class' - } - // The for attribute gets transformed to htmlFor, but we just set as for - if (p === 'htmlFor') { - p = 'for' - } - // If a property is boolean, set itself to the key - if (BOOL_PROPS.indexOf(key) !== -1) { - if (String(val) === 'true') val = key - else if (String(val) === 'false') continue - } - // If a property prefers being set directly vs setAttribute - if (key.slice(0, 2) === 'on' || DIRECT_PROPS.indexOf(key) !== -1) { - el[p] = val - } else { - if (ns) { - if (p === 'xlink:href') { - el.setAttributeNS(XLINKNS, p, val) - } else if (/^xmlns($|:)/i.test(p)) { - // skip xmlns definitions - } else { - el.setAttributeNS(null, p, val) - } - } else { - el.setAttribute(p, val) - } - } - } - } - - appendChild(el, children) - return el - } - - function createFragment (nodes) { - var fragment = document.createDocumentFragment() - for (var i = 0; i < nodes.length; i++) { - if (nodes[i] == null) continue - if (Array.isArray(nodes[i])) { - fragment.appendChild(createFragment(nodes[i])) - } else { - if (typeof nodes[i] === 'string') nodes[i] = document.createTextNode(nodes[i]) - fragment.appendChild(nodes[i]) - } - } - return fragment - } - - var exports = hyperx(nanoHtmlCreateElement, { - comments: true, - createFragment: createFragment - }) - exports.default = exports - exports.createComment = nanoHtmlCreateElement - return exports -} diff --git a/lib/events.js b/lib/events.js new file mode 100644 index 0000000..dea06fe --- /dev/null +++ b/lib/events.js @@ -0,0 +1,42 @@ +module.exports = [ + // attribute events (can be set with attributes) + 'onclick', + 'ondblclick', + 'onmousedown', + 'onmouseup', + 'onmouseover', + 'onmousemove', + 'onmouseout', + 'onmouseenter', + 'onmouseleave', + 'ontouchcancel', + 'ontouchend', + 'ontouchmove', + 'ontouchstart', + 'ondragstart', + 'ondrag', + 'ondragenter', + 'ondragleave', + 'ondragover', + 'ondrop', + 'ondragend', + 'onkeydown', + 'onkeypress', + 'onkeyup', + 'onunload', + 'onabort', + 'onerror', + 'onresize', + 'onscroll', + 'onselect', + 'onchange', + 'onsubmit', + 'onreset', + 'onfocus', + 'onblur', + 'oninput', + // other common events + 'oncontextmenu', + 'onfocusin', + 'onfocusout' +] diff --git a/lib/morph.js b/lib/morph.js new file mode 100644 index 0000000..ccc20cf --- /dev/null +++ b/lib/morph.js @@ -0,0 +1,164 @@ +var events = require('./events') +var eventsLength = events.length + +var ELEMENT_NODE = 1 +var TEXT_NODE = 3 +var COMMENT_NODE = 8 + +module.exports = morph + +// diff elements and apply the resulting patch to the old node +// (obj, obj) -> null +function morph (newNode, oldNode) { + var nodeType = newNode.nodeType + var nodeName = newNode.nodeName + + if (nodeType === ELEMENT_NODE) { + copyAttrs(newNode, oldNode) + } + + if (nodeType === TEXT_NODE || nodeType === COMMENT_NODE) { + if (oldNode.nodeValue !== newNode.nodeValue) { + oldNode.nodeValue = newNode.nodeValue + } + } + + // Some DOM nodes are weird + // https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js + if (nodeName === 'INPUT') updateInput(newNode, oldNode) + else if (nodeName === 'OPTION') updateOption(newNode, oldNode) + else if (nodeName === 'TEXTAREA') updateTextarea(newNode, oldNode) + + copyEvents(newNode, oldNode) +} + +function copyAttrs (newNode, oldNode) { + var oldAttrs = oldNode.attributes + var newAttrs = newNode.attributes + var attrNamespaceURI = null + var attrValue = null + var fromValue = null + var attrName = null + var attr = null + + for (var i = newAttrs.length - 1; i >= 0; --i) { + attr = newAttrs[i] + attrName = attr.name + attrNamespaceURI = attr.namespaceURI + attrValue = attr.value + if (attrNamespaceURI) { + attrName = attr.localName || attrName + fromValue = oldNode.getAttributeNS(attrNamespaceURI, attrName) + if (fromValue !== attrValue) { + oldNode.setAttributeNS(attrNamespaceURI, attrName, attrValue) + } + } else { + if (!oldNode.hasAttribute(attrName)) { + oldNode.setAttribute(attrName, attrValue) + } else { + fromValue = oldNode.getAttribute(attrName) + if (fromValue !== attrValue) { + // apparently values are always cast to strings, ah well + if (attrValue === 'null' || attrValue === 'undefined') { + oldNode.removeAttribute(attrName) + } else { + oldNode.setAttribute(attrName, attrValue) + } + } + } + } + } + + // Remove any extra attributes found on the original DOM element that + // weren't found on the target element. + for (var j = oldAttrs.length - 1; j >= 0; --j) { + attr = oldAttrs[j] + if (attr.specified !== false) { + attrName = attr.name + attrNamespaceURI = attr.namespaceURI + + if (attrNamespaceURI) { + attrName = attr.localName || attrName + if (!newNode.hasAttributeNS(attrNamespaceURI, attrName)) { + oldNode.removeAttributeNS(attrNamespaceURI, attrName) + } + } else { + if (!newNode.hasAttributeNS(null, attrName)) { + oldNode.removeAttribute(attrName) + } + } + } + } +} + +function copyEvents (newNode, oldNode) { + for (var i = 0; i < eventsLength; i++) { + var ev = events[i] + if (newNode[ev]) { // if new element has a whitelisted attribute + oldNode[ev] = newNode[ev] // update existing element + } else if (oldNode[ev]) { // if existing element has it and new one doesnt + oldNode[ev] = undefined // remove it from existing element + } + } +} + +function updateOption (newNode, oldNode) { + updateAttribute(newNode, oldNode, 'selected') +} + +// The "value" attribute is special for the element since it sets the +// initial value. Changing the "value" attribute without changing the "value" +// property will have no effect since it is only used to the set the initial +// value. Similar for the "checked" attribute, and "disabled". +function updateInput (newNode, oldNode) { + var newValue = newNode.value + var oldValue = oldNode.value + + updateAttribute(newNode, oldNode, 'checked') + updateAttribute(newNode, oldNode, 'disabled') + + if (newValue !== oldValue) { + oldNode.setAttribute('value', newValue) + oldNode.value = newValue + } + + if (newValue === 'null') { + oldNode.value = '' + oldNode.removeAttribute('value') + } + + if (!newNode.hasAttributeNS(null, 'value')) { + oldNode.removeAttribute('value') + } else if (oldNode.type === 'range') { + // this is so elements like slider move their UI thingy + oldNode.value = newValue + } +} + +function updateTextarea (newNode, oldNode) { + var newValue = newNode.value + if (newValue !== oldNode.value) { + oldNode.value = newValue + } + + if (oldNode.firstChild && oldNode.firstChild.nodeValue !== newValue) { + // Needed for IE. Apparently IE sets the placeholder as the + // node value and vise versa. This ignores an empty update. + if (newValue === '' && oldNode.firstChild.nodeValue === oldNode.placeholder) { + return + } + + oldNode.firstChild.nodeValue = newValue + } +} + +function updateAttribute (newNode, oldNode, name) { + if (newNode[name] !== oldNode[name]) { + oldNode[name] = newNode[name] + if (newNode[name]) { + oldNode.setAttribute(name, '') + } else { + oldNode.removeAttribute(name) + } + } +} diff --git a/lib/raw-browser.js b/lib/raw-browser.js index a46aeaa..bf46074 100644 --- a/lib/raw-browser.js +++ b/lib/raw-browser.js @@ -3,11 +3,7 @@ function nanohtmlRawBrowser (tag) { var el = document.createElement('div') el.innerHTML = tag - return toArray(el.childNodes) -} - -function toArray (arr) { - return Array.isArray(arr) ? arr : [].slice.call(arr) + return Array.from(el.childNodes) } module.exports = nanohtmlRawBrowser diff --git a/lib/server.js b/lib/server.js index b80432e..4bf54dd 100644 --- a/lib/server.js +++ b/lib/server.js @@ -5,10 +5,10 @@ var BOOL_PROPS = require('./bool-props') var boolPropRx = new RegExp('([^-a-z](' + BOOL_PROPS.join('|') + '))=["\']?$', 'i') var query = /(?:="|&)[^"]*=$/ -module.exports = nanothtmlServer +module.exports = nanohtmlServer module.exports.default = module.exports -function nanothtmlServer (src, filename, options, done) { +function nanohtmlServer (src, filename, options, done) { if (typeof src === 'string' && !/\n/.test(src) && filename && filename._flags) { var args = Array.prototype.slice.apply(arguments) return require('./browserify-transform.js').apply(this, args) @@ -19,19 +19,31 @@ function nanothtmlServer (src, filename, options, done) { var boolMatch var pieces = arguments[0] + var values = Array.from(arguments).slice(1).map(unwind) + var hasPromise = values.some(function wrap (value) { + if (Array.isArray(value)) return value.some(wrap) + return isPromise(value) + }, []) + + if (hasPromise) { + return Promise.all(values.flat(Infinity)).then(function (values) { + return nanohtmlServer(pieces, ...values) + }) + } + var output = '' for (var i = 0; i < pieces.length; i++) { var piece = pieces[i] if (i < pieces.length - 1) { if ((boolMatch = boolPropRx.exec(piece))) { output += piece.slice(0, boolMatch.index) - if (arguments[i + 1]) { + if (values[i]) { output += boolMatch[1] + '="' + boolMatch[2] + '"' } continue } - var value = handleValue(arguments[i + 1]) + var value = handleValue(values[i]) if (piece[piece.length - 1] === '=' && !query.test(piece)) { output += piece + '"' + value + '"' } else { @@ -85,3 +97,25 @@ function handleValue (value) { .replace(/"/g, '"') .replace(/'/g, ''') } + +function unwind (obj, value) { + if (isGenerator(obj)) { + const res = obj.next(value) + if (res.done) return res.value + if (isPromise(res.value)) { + return res.value.then(unwind).then((val) => unwind(obj, val)) + } + return unwind(obj, res.value) + } else if (isPromise(obj)) { + return obj.then(unwind) + } + return obj +} + +function isPromise (obj) { + return !!obj && (typeof obj === 'object' || typeof obj === 'function') && typeof obj.then === 'function' +} + +function isGenerator (obj) { + return obj && typeof obj.next === 'function' && typeof obj.throw === 'function' +} diff --git a/lib/set-attribute.js b/lib/set-attribute.js deleted file mode 100644 index 1fe6f81..0000000 --- a/lib/set-attribute.js +++ /dev/null @@ -1,25 +0,0 @@ -'use strict' - -var DIRECT_PROPS = require('./direct-props') - -module.exports = function nanohtmlSetAttribute (el, attr, value) { - if (typeof attr === 'object') { - for (var i in attr) { - if (attr.hasOwnProperty(i)) { - nanohtmlSetAttribute(el, i, attr[i]) - } - } - return - } - if (!attr) return - if (attr === 'className') attr = 'class' - if (attr === 'htmlFor') attr = 'for' - if (attr.slice(0, 2) === 'on' || DIRECT_PROPS.indexOf(attr) !== -1) { - el[attr] = value - } else { - // assume a boolean attribute if the value === true or false - if (value === true) value = attr - if (value === false) return - el.setAttribute(attr, value) - } -} diff --git a/lib/svg-tags.js b/lib/svg-tags.js index 078598a..b9e1d83 100644 --- a/lib/svg-tags.js +++ b/lib/svg-tags.js @@ -1,5 +1,3 @@ -'use strict' - module.exports = [ 'svg', 'altGlyph', 'altGlyphDef', 'altGlyphItem', 'animate', 'animateColor', 'animateMotion', 'animateTransform', 'circle', 'clipPath', 'color-profile', diff --git a/lib/text-tags.js b/lib/text-tags.js new file mode 100644 index 0000000..b62d6ea --- /dev/null +++ b/lib/text-tags.js @@ -0,0 +1,5 @@ +module.exports = [ + 'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'data', 'dfn', 'em', 'i', + 'kbd', 'mark', 'q', 'rp', 'rt', 'rtc', 'ruby', 's', 'amp', 'small', 'span', + 'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr' +] diff --git a/lib/verbatim-tags.js b/lib/verbatim-tags.js new file mode 100644 index 0000000..324fdda --- /dev/null +++ b/lib/verbatim-tags.js @@ -0,0 +1,3 @@ +module.exports = [ + 'code', 'pre', 'textarea' +] diff --git a/tests/browser/api.js b/tests/browser/api.js index 5552940..f97f682 100644 --- a/tests/browser/api.js +++ b/tests/browser/api.js @@ -1,40 +1,471 @@ var test = require('tape') -if (typeof window !== 'undefined') { - var html = require('../../') -} else { - html = require('./html').html -} +var { html, render, Ref } = require('../../') + +test('renders html', function (t) { + var el = render(html`Hello ${'planet'}!`) + t.ok(el instanceof window.Element, 'renders an element') + t.equal(el.innerText, 'Hello planet!', 'renders partials') + t.end() +}) + +test('renders plain text', function (t) { + var div = document.createElement('div') + var el = render(html`Hello world!`) + div.appendChild(el) + t.equal(div.innerText, 'Hello world!', 'text rendered') + t.end() +}) + +test('renders anything', function (t) { + var div = document.createElement('div') + var el = render(html`${['Hello world!']}`) + div.appendChild(el) + t.equal(div.innerText, 'Hello world!', 'text rendered') + t.end() +}) + +test('renders nested html', function (t) { + var el = render(html`Hello ${html`planet`}!`) + t.equal(el.childElementCount, 1, 'has children') + t.equal(el.firstElementChild.innerText, 'planet', 'children rendered') + t.end() +}) -test('creates an element', function (t) { - t.plan(3) - var button = html` - - ` - - var result = html` - - ` - - function onselected (result) { - t.equal(result, 'success') +test('renders array of children', function (t) { + var el = render(html`Hello ${[html`planet`]}!`) + t.equal(el.childElementCount, 1, 'has children') + t.equal(el.firstElementChild.innerText, 'planet', 'children rendered') + t.end() +}) + +test('fragments', function (t) { + t.test('top level fragment', function (t) { + var div = document.createElement('div') + var el = render(html`Hello ${'world'}!`) + div.appendChild(el) + t.equal(div.childElementCount, 2, 'has children') + t.equal(div.innerText, 'Hello world!', 'children rendered') + t.end() + }) + + t.test('nested fragment', function (t) { + var div = document.createElement('div') + var el = render(html`
${html`Hello ${'world'}!`}
`) + div.appendChild(el) + t.equal(div.firstElementChild.childElementCount, 2, 'has nested children') + t.equal(div.innerText, 'Hello world!', 'nested children rendered') + t.end() + }) + + t.test('fragment with top level partial', function (t) { + var div = document.createElement('div') + var el = render(html`Hello ${html`${'world'}!`}`) + div.appendChild(el) + t.equal(div.childElementCount, 2, 'has children') + t.equal(div.innerText, 'Hello world!', 'children rendered') t.end() + }) + + t.test('fragment with only partial', function (t) { + var div = document.createElement('div') + var el = render(html`${html`Hello ${'world'}!`}`) + div.appendChild(el) + t.equal(div.childElementCount, 2, 'has children') + t.equal(div.innerText, 'Hello world!', 'children rendered') + t.end() + }) + + t.test('fragment with mixed content', function (t) { + var arr = [html`
  • Helsinki
  • `, null, html`
  • Stockholm
  • `] + var multiple = render(html`
  • Hamburg
  • ${arr}
  • Berlin
  • `) + + var list = document.createElement('ul') + list.appendChild(multiple) + t.equal(list.children.length, 4, '4 children') + t.equal(list.children[0].tagName, 'LI', 'list tag name') + t.equal(list.children[0].textContent, 'Hamburg', 'first child in place') + t.equal(list.children[1].textContent, 'Helsinki', 'second child in place') + t.equal(list.children[2].textContent, 'Stockholm', 'third child in place') + t.equal(list.children[3].textContent, 'Berlin', 'fourth child in place') + t.end() + }) +}) + +test('can mount in DOM', function (t) { + var id = makeId() + var div = document.createElement('div') + div.innerHTML = 'Hi world!' + var firstChild = div.firstElementChild + document.body.appendChild(div) + + render(html`
    Hello ${html`planet`}!
    `, div) + var res = document.getElementById(id) + t.ok(res, 'element was mounted') + t.ok(res.isSameNode(div), 'morphed onto existing node') + t.ok(firstChild.isSameNode(res.firstElementChild), 'children morphed too') + t.equal(res.innerText, 'Hello planet!', 'content match') + document.body.removeChild(div) + t.end() +}) + +test('can mount fragments', function (t) { + var div = document.createElement('div') + render(html`Hello world!`, div) + t.equal(div.childElementCount, 2, 'has children') + t.equal(div.innerText, 'Hello world!', 'children rendered') + t.end() +}) + +test('tolerates DOM changes in-between renders', function (t) { + var ol = document.createElement('ol') + var two = html`
  • 2
  • ` + render(main(), ol) + ol.children.two.remove() + ol.children.three.remove() + ol.children.five.remove() + t.equal(ol.textContent, '14', 'Children removed') + two = '2' + render(main(), ol) + t.equal(ol.textContent, '12345', 'All children added back') + t.end() + + function main () { + return html` +
      + ${html`
    1. 1
    2. `} + ${two} + ${[ + html`
    3. 3
    4. `, + html`
    5. 4
    6. ` + ]} + ${html`
    7. 5
    8. `} +
    + ` + } +}) + +test('updates in place', function (t) { + var id = makeId() + var div = document.createElement('div') + div.innerHTML = 'Hi world!' + document.body.appendChild(div) + + render(main('planet'), div) + var res = document.getElementById(id) + t.equal(res.innerText, 'planet', 'content mounted') + render(main('world'), div) + t.equal(res.innerText, 'world', 'content updated') + document.body.removeChild(div) + t.end() + + function main (text) { + return html`
    Hello ${html`${text}`}!
    ` + } +}) + +test('persists in DOM between mounts', function (t) { + var id = makeId() + var div = document.createElement('div') + div.innerHTML = 'Hi world!' + document.body.appendChild(div) + + render(foo('planet'), div) + var res = document.getElementById(id) + t.equal(res.innerText, 'planet', 'did mount') + + render(bar('world'), div) + var updated = document.getElementById(id) + t.ok(res.isSameNode(updated), 'same node') + t.equal(updated.innerText, 'world', 'content updated') + document.body.removeChild(div) + t.end() + + function foo (text) { + return html`
    Hello ${name(text)}!
    ` + } + + function bar (text) { + return html`
    Hello ${name(text)}!
    ` } - t.equal(result.tagName, 'UL') - t.equal(result.querySelector('button').textContent, 'click me') + function name (text) { + return html`${text}` + } +}) + +test('updating with null', function (t) { + var id = makeId() + var div = document.createElement('div') + div.innerHTML = 'Hi world!' + document.body.appendChild(div) + + render(main('planet'), div) + var res = document.getElementById(id) + t.equal(res.innerHTML, 'Hello planet!', 'did mount') + render(main(false), div) + t.equal(res.innerHTML, 'Hello !', 'node was removed') + render(main('world'), div) + t.equal(res.innerHTML, 'Hello world!', 'node added back') + document.body.removeChild(div) + t.end() + + function main (text) { + return html`
    Hello ${text || null}!
    ` + } +}) + +test('updating from null', function (t) { + var id = makeId() + var div = document.createElement('div') + div.innerHTML = 'Hi world!' + document.body.appendChild(div) + + render(main(false), div) + var res = document.getElementById(id) + t.equal(res.innerHTML, 'Hello !', 'node was removed') + render(main('planet'), div) + t.equal(res.innerHTML, 'Hello planet!', 'did mount') + render(main('world'), div) + t.equal(res.innerHTML, 'Hello world!', 'node added back') + document.body.removeChild(div) + t.end() + + function main (text) { + return html`
    Hello ${text || null}!
    ` + } +}) + +test('updating with array', function (t) { + var div = document.createElement('div') + div.innerHTML = 'Hi world!' + document.body.appendChild(div) + + var children = [ + html`planet`, + html`world` + ] + render(main(children[0]), div) + var firstChild = div.firstElementChild + t.equal(div.innerText, 'Hello planet!', 'child mounted') + render(main(children), div) + t.equal(div.innerText, 'Hello planetworld!', 'all children mounted') + t.equal(div.firstElementChild, firstChild, 'child remained in place') + document.body.removeChild(div) + t.end() - button.click() + function main (children) { + return html`
    Hello ${children}!
    ` + } }) -test('using class and className', function (t) { - t.plan(2) - var result = html`
    ` - t.equal(result.className, 'test1') - result = html`
    ` - t.equal(result.className, 'test2 another') +test('alternating partials', function (t) { + var id = makeId() + var world = html`world` + var planet = html`planet` + var div = document.createElement('div') + div.innerHTML = 'Hi world!' + document.body.appendChild(div) + + render(main(planet), div) + var res = document.getElementById(id) + t.equal(res.innerText, 'Hello planet!', 'did mount') + render(main(world), div) + t.equal(res.innerText, 'Hello world!', 'did update') + document.body.removeChild(div) t.end() + + function main (child) { + return html`
    Hello ${child}!
    ` + } }) + +test('reordering children', function (t) { + var ids = [makeId(), makeId()] + var children = [ + html`world`, + html`planet` + ] + var div = document.createElement('div') + div.innerHTML = 'Hi world!' + document.body.appendChild(div) + + render(main(children), div) + var world = document.getElementById(ids[0]) + var planet = document.getElementById(ids[1]) + t.equal(world.nextSibling, planet, 'mounted in order') + t.equal(div.innerText, 'Hello worldplanet!', 'children in order') + render(main(children.reverse()), div) + world = document.getElementById(ids[0]) + planet = document.getElementById(ids[1]) + t.equal(planet.nextElementSibling, world, 'children reordered') + t.equal(div.innerText, 'Hello planetworld!', 'children in (reversed) order') + document.body.removeChild(div) + t.end() + + function main (children) { + return html`
    Hello ${children}!
    ` + } +}) + +test('async partials', function (t) { + t.test('resolves generators', function (t) { + t.plan(5) + var div = document.createElement('div') + render(html`
    Hello ${outer('world')}!
    `, div) + t.equal(div.innerText, 'Hello world!', 'content rendered') + t.equal(div.dataset.test, 'test', 'attribute resolved') + + function * outer (text) { + var res = yield * echo(text) + t.equal(res, text, 'nested generators are unwound') + return html`${res}` + } + + function * echo (arg) { + var res = yield 'foo' + t.equal(res, 'foo', 'yielded values are returned') + return arg + } + }) + + t.test('resolves promises', function (t) { + t.plan(4) + var div = document.createElement('div') + render(html`
    Hello ${Promise.resolve('world')}!
    `, div) + t.equal(div.innerText, 'Hello !', 'promise partial left blank') + t.equal(div.dataset.test, undefined, 'attribute left blank') + window.requestAnimationFrame(function () { + t.equal(div.innerText, 'Hello world!', 'content updated once resolved') + t.equal(div.dataset.test, 'test', 'attribute updated once resolved') + }) + }) + + t.test('recursively resolves nested generators and promises', function (t) { + t.plan(4) + var div = document.createElement('div') + render(html`
    Hello ${outer('world')}!
    `, div) + t.equal(div.innerText, 'Hello !', 'promise partial left blank') + window.requestAnimationFrame(function () { + t.equal(div.innerText, 'Hello world!', 'content updated once resolved') + t.equal(div.firstElementChild.dataset.test, 'test', 'attribute updated once resolved') + }) + + function * outer (text) { + var res = yield Promise.resolve(inner(text)) + t.equal(res, text, 'yielded promises are resolved') + return html`${res}` + + function * inner (text) { + var res = yield text + return res + } + } + }) + + t.test('top level async generators resolve', function (t) { + t.plan(3) + var div = document.createElement('div') + var res = render(main('world'), div) + t.ok(res instanceof Promise, 'returns promise') + t.equal(div.innerText, '', 'nothing renderd async') + res.then(function () { + t.equal(div.innerText, 'Hello world!', 'content updated once resolved') + }) + + function * main (text) { + var res = yield Promise.resolve(text) + return html`
    Hello ${res}!
    ` + } + }) + + t.test('does not succomb to race condition', function (t) { + t.plan(4) + var div = document.createElement('div') + render(Promise.resolve(main(Promise.resolve('world'))), div) + render(main('planet'), div) + t.equal(div.innerText, 'Hello planet!', 'sync content rendered') + t.equal(div.dataset.test, 'planet', 'sync attribute rendered') + window.requestAnimationFrame(function () { + t.equal(div.innerText, 'Hello planet!', 'sync content persisted') + t.equal(div.dataset.test, 'planet', 'sync attribute persisted') + }) + + function main (val) { + return html`
    Hello ${val}!
    ` + } + }) + + t.test('does not affect ordering', function (t) { + t.plan(2) + var state = 0 + var list = render(main()) + t.equal(list.textContent, '1246', 'sync content rendered') + render(main(), list) + list.children.four.remove() + window.requestAnimationFrame(function () { + t.equal(list.textContent, '1356', 'async content in place') + }) + + function main (val) { + return html` + + ` + } + }) +}) + +test('can access elements with ref', function (t) { + var ref1 = new Ref('test') + var ref2 + + t.throws(function () { + ref1.className // eslint-disable-line + }, 'throws when accessing Element prototype property before render') + t.doesNotThrow(function () { + ref1.foo // eslint-disable-line + }, 'does not throw when accessing arbitrary prop') + t.throws(function () { + 'use strict' + ref1.className = 'foo' + }, 'mutating ref before render in strict mode throws') + t.doesNotThrow(function () { + ref1.className = 'foo' + }, 'mutation fails silently in non-strict mode') + + var div = document.createElement('div') + document.body.appendChild(div) + render(main(ref1), div) + + t.equal(div.id, 'test', 'custom ref id assigned') + t.equal(ref1.element, div, 'ref.element references element') + t.equal(ref1.className, 'test1', 'properties are read from element') + ref1.className = 'test2' + t.equal(div.className, 'test2', 'properties are set to element') + var id = ref2.id + render(main(ref1), div) + t.equal(id, ref2.id, 'id persist between renders') + document.body.removeChild(div) + t.end() + + function main () { + ref2 = new Ref() + return html` +
    + Hello world! +
    + ` + } +}) + +function makeId () { + return 'uid-' + Math.random().toString(36).substr(-4) +} diff --git a/tests/browser/component.js b/tests/browser/component.js new file mode 100644 index 0000000..f868825 --- /dev/null +++ b/tests/browser/component.js @@ -0,0 +1,154 @@ +var test = require('tape') +var { html, render } = require('../../') +var { Component, memo, onupdate, onload } = require('../../component') + +// TODO: test alternating return value (null/array/partial) + +test('component can render', function (t) { + var id = makeId() + var div = document.createElement('div') + div.innerHTML = 'Hi world!' + document.body.appendChild(div) + + var Greeting = Component(function Greeting (text) { + return html`Hello ${text}!` + }) + + render(main('planet'), div) + var res = document.getElementById(id) + t.equal(res.innerText, 'planet', 'content mounted') + render(main('world'), div) + t.equal(res.innerText, 'world', 'content updated') + document.body.removeChild(div) + t.end() + + function main (text) { + return html`
    ${Greeting(text)}
    ` + } +}) + +test('component can render fragment', function (t) { + var update + var Greeting = Component(function Greeting (text) { + update = onupdate() + return html`Hello ${text}!` + }) + var div = document.createElement('div') + render(html`
    ${Greeting('world')}
    `, div) + t.equal(div.innerText, 'Hello world!', 'children rendered') + update('planet') + t.equal(div.innerText, 'Hello planet!', 'children updated') + t.end() +}) + +test('component can update', function (t) { + t.plan(6) + + var id = makeId() + var div = document.createElement('div') + div.innerHTML = 'Hi world!' + document.body.appendChild(div) + + var update + var BEFORE_UPDATE = 0 + var AFTER_UPDATE = 1 + var state = BEFORE_UPDATE + var Greeting = Component(function Greeting (value) { + update = onupdate(function afterupdate (value) { + t.equal(value, state, 'afterupdate called with latest argument') + return function beforeupdate (value) { + t.equal(value, state, 'beforeupdate called with latest argument') + } + }) + return html`Hello ${value}!` + }) + + render(main(state), div) + var res = document.getElementById(id) + t.equal(res.innerText, BEFORE_UPDATE.toString(), 'content mounted') + t.equal(typeof update, 'function', 'onupdate returns function') + update(++state) + t.equal(res.innerText, AFTER_UPDATE.toString(), 'content updated') + document.body.removeChild(div) + + function main (value) { + return html`
    ${Greeting(value)}
    ` + } +}) + +test('component can memoize arguments', function (t) { + t.plan(12) + + var id = makeId() + var div = document.createElement('div') + div.innerHTML = 'Hi world!' + document.body.appendChild(div) + + var update + var BEFORE_UPDATE = [0, 0, 0] + var AFTER_UPDATE = [1, 1, 1] + var state = BEFORE_UPDATE + var Greeting = Component(function Greeting (a, b = memo(state[1]), c = memo(init)) { + t.deepEqual([a, b, c], state, 'all arguments in place') + update = onupdate(function afterupdate (a, b, c, d = memo(1)) { + t.deepEqual([a, b, c], state, 'afterupdate called with latest argument') + t.equal(d, 1, 'afterupdate can add more args') + return function beforeupdate (a, b, c, d = memo()) { + t.deepEqual([a, b, c], state, 'beforeupdate called with latest argument') + t.equal(d, 1, 'beforeupdate received additional arg') + } + }) + return html`Hello ${[a, b, c].join('')}!` + }) + + render(main(state[0]), div) + var res = document.getElementById(id) + t.equal(res.innerText, BEFORE_UPDATE.join(''), 'content mounted') + state = AFTER_UPDATE + update(...state) + t.equal(res.innerText, AFTER_UPDATE.join(''), 'content updated') + document.body.removeChild(div) + + function init (a, b) { + t.equal(a, state[0], 'static arg forwarded to init') + t.equal(b, state[1], 'dynamic arg forwarded to init') + return state[2] + } + + function main (value) { + return html`
    ${Greeting(value)}
    ` + } +}) + +test('component is notified of being added to the DOM', function (t) { + t.plan(4) + + var id = makeId() + var div = document.createElement('div') + div.innerHTML = 'Hi world!' + document.body.appendChild(div) + + var Greeting = Component(function Greeting () { + onload(function (element) { + var el = document.getElementById(id) + t.pass('onload handler called') + t.equal(element, el, 'onload handler called with root element') + document.body.removeChild(div) + return function (value) { + t.pass('onload callback called') + t.equal(element, el, 'onload callback called with root element') + } + }) + return html`Hello planet!` + }) + + render(main(), div) + + function main (value) { + return html`
    ${Greeting(value)}
    ` + } +}) + +function makeId () { + return 'uid-' + Math.random().toString(36).substr(-4) +} diff --git a/tests/browser/elements.js b/tests/browser/elements.js index 408a722..c4095b2 100644 --- a/tests/browser/elements.js +++ b/tests/browser/elements.js @@ -1,86 +1,20 @@ var test = require('tape') -if (typeof window !== 'undefined') { - var document = window.document - var html = require('../../') -} else { - var nano = require('./html') - document = nano.document - html = nano.html -} +var { html, render } = require('../../') test('create inputs', function (t) { - t.plan(7) - var expected = 'testing' - var result = html`` + var result = render(html``) t.equal(result.tagName, 'INPUT', 'created an input') t.equal(result.value, expected, 'set the value of an input') - - result = html`` - t.equal(result.getAttribute('type'), 'checkbox', 'created a checkbox') - t.equal(result.getAttribute('checked'), 'checked', 'set the checked attribute') - t.equal(result.getAttribute('disabled'), null, 'should not have set the disabled attribute') - t.equal(result.indeterminate, true, 'should have set indeterminate property') - - result = html`` - t.equal(result.indeterminate, true, 'should have set indeterminate property') - t.end() }) -test('create inputs with object spread', function (t) { - t.plan(7) - - var expected = 'testing' - var props = { type: 'text', value: expected } - var result = html`` - t.equal(result.tagName, 'INPUT', 'created an input') - t.equal(result.value, expected, 'set the value of an input') - - props = { type: 'checkbox', checked: true, disabled: false, indeterminate: true } - result = html`` - t.equal(result.getAttribute('type'), 'checkbox', 'created a checkbox') - t.equal(result.getAttribute('checked'), 'checked', 'set the checked attribute') - t.equal(result.getAttribute('disabled'), null, 'should not have set the disabled attribute') - t.equal(result.indeterminate, true, 'should have set indeterminate property') - - props = { indeterminate: true } - result = html`` - t.equal(result.indeterminate, true, 'should have set indeterminate property') - - t.end() -}) - -test('can update and submit inputs', function (t) { - t.plan(2) - document.body.innerHTML = '' - var expected = 'testing' - function render (data, onsubmit) { - var input = html`` - return html`
    - ${input} - -
    ` - } - var result = render(expected, function onsubmit (newvalue) { - t.equal(newvalue, 'changed') - document.body.innerHTML = '' - t.end() - }) - document.body.appendChild(result) - t.equal(document.querySelector('input').value, expected, 'set the input correctly') - document.querySelector('input').value = 'changed' - document.querySelector('button').click() -}) - test('svg', function (t) { t.plan(4) - var result = html` + var result = render(html` - ` + `) t.equal(result.tagName, 'svg', 'create svg tag') t.equal(result.childNodes[0].tagName, 'rect', 'created child rect tag') t.equal(result.childNodes[1].getAttribute('xlink:href'), '#test', 'created child use tag with xlink:href') @@ -92,9 +26,9 @@ test('svg with namespace', function (t) { t.plan(3) var result function create () { - result = html` + result = render(html` - ` + `) } t.doesNotThrow(create) t.equal(result.tagName, 'svg', 'create svg tag') @@ -105,9 +39,9 @@ test('svg with xmlns:svg', function (t) { t.plan(3) var result function create () { - result = html` + result = render(html` - ` + `) } t.doesNotThrow(create) t.equal(result.tagName, 'svg', 'create svg tag') @@ -115,7 +49,7 @@ test('svg with xmlns:svg', function (t) { }) test('comments', function (t) { - var result = html`
    ` + var result = render(html`
    `) t.equal(result.outerHTML, '
    ', 'created comment') t.end() }) @@ -123,7 +57,7 @@ test('comments', function (t) { test('style', function (t) { t.plan(2) var name = 'test' - var result = html`

    Hey ${name.toUpperCase()}, This is a card!!!

    ` + var result = render(html`

    Hey ${name.toUpperCase()}, This is a card!!!

    `) t.equal(result.style.color, 'red', 'set style color on parent') t.equal(result.querySelector('span').style.color, 'blue', 'set style color on child') t.end() @@ -133,114 +67,141 @@ test('adjacent text nodes', function (t) { t.plan(2) var who = 'world' var exclamation = ['!', ' :)'] - var result = html`
    hello ${who}${exclamation}
    ` - t.equal(result.childNodes.length, 1, 'should be merged') + var result = render(html`
    + hello + ${who} + ${exclamation} +
    `) + t.equal(result.childNodes.length, 4, 'should preserve partial') t.equal(result.outerHTML, '
    hello world! :)
    ', 'should have correct output') t.end() }) test('space in only-child text nodes', function (t) { t.plan(1) - var result = html` + var result = render(html` surrounding newlines - ` + `) t.equal(result.outerHTML, 'surrounding newlines', 'should remove extra space') t.end() }) test('space between text and non-text nodes', function (t) { t.plan(1) - var result = html` + var result = render(html`

    whitespace is empty

    - ` + `) t.equal(result.outerHTML, '

    whitespace is empty

    ', 'should have correct output') t.end() }) test('space between text followed by non-text nodes', function (t) { t.plan(1) - var result = html` + var result = render(html`

    whitespace is strong

    - ` + `) t.equal(result.outerHTML, '

    whitespace is strong

    ', 'should have correct output') t.end() }) test('space around text surrounded by non-text nodes', function (t) { t.plan(1) - var result = html` + var result = render(html`

    I agree whitespace is strong

    - ` + `) t.equal(result.outerHTML, '

    I agree whitespace is strong

    ', 'should have correct output') t.end() }) test('space between non-text nodes', function (t) { t.plan(1) - var result = html` + var result = render(html`

    whitespace is beautiful

    - ` + `) t.equal(result.outerHTML, '

    whitespace is beautiful

    ', 'should have correct output') t.end() }) test('space in
    ', function (t) {
       t.plan(1)
    -  var result = html`
    +  var result = render(html`
         
           whitespace is empty
         
    - ` + `) t.equal(result.outerHTML, '
    \n      whitespace is empty\n    
    ', 'should preserve space') t.end() }) test('space in - ` + `) t.equal(result.outerHTML, '', 'should preserve space') t.end() }) +test('class is set correctly', function (t) { + var div = render(html`
    `) + t.equal(div.className, 'test', 'class attribute set') + t.end() +}) + +test('boolean and direct props are set correctly', function (t) { + var input = render(html``) + t.equal(input.getAttribute('hidden'), null, 'false bool prop not set') + t.equal(input.getAttribute('disabled'), 'disabled', 'bool prop set to key') + t.equal(input.getAttribute('indeterminate'), null, 'direct prop is not an attribute') + t.ok(input.indeterminate, 'direct prop is set') + t.equal(input.required, false, 'does not assign null to attributes') + t.end() +}) + test('for attribute is set correctly', function (t) { t.plan(1) - var result = html`
    + var result = render(html`
    -
    ` +
    `) t.ok(result.outerHTML.indexOf('') !== -1, 'contains for="heyo"') t.end() }) test('allow objects to be passed', function (t) { t.plan(1) - var result = html`
    + var result = render(html`
    hey
    -
    ` +
    `) t.ok(result.outerHTML.indexOf('
    hey
    ') !== -1, 'contains foo="bar"') t.end() }) +test('allow mixed partial attribute names', function (t) { + t.plan(1) + var result = render(html`
    hey
    `) + t.equal(result.outerHTML, '
    hey
    ', 'data attribute set') + t.end() +}) + test('supports extended build-in elements', function (t) { t.plan(1) @@ -255,7 +216,7 @@ test('supports extended build-in elements', function (t) { } })() - ;html`
    ` + ;render(html`
    `) t.ok(typeof optionsArg === 'object' && optionsArg.is === 'my-div', 'properly passes optional extends object') diff --git a/tests/browser/events.js b/tests/browser/events.js index ed99a83..ebabe47 100644 --- a/tests/browser/events.js +++ b/tests/browser/events.js @@ -1,12 +1,5 @@ let test = require('tape') -if (typeof window !== 'undefined') { - var document = window.document - var html = require('../../') -} else { - var nano = require('./html') - document = nano.document - html = nano.html -} +var { html, render } = require('../../') /* Note: Failing tests have been commented. They include the following: @@ -28,7 +21,7 @@ function raiseEvent (element, eventName) { test('should have onabort events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'abort') @@ -42,7 +35,7 @@ test('should have onabort events(html attribute) ', function (t) { test('should have onblur events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'blur') @@ -56,7 +49,7 @@ test('should have onblur events(html attribute) ', function (t) { test('should have onchange events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'change') @@ -70,7 +63,7 @@ test('should have onchange events(html attribute) ', function (t) { test('should have onclick events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'click') @@ -84,7 +77,7 @@ test('should have onclick events(html attribute) ', function (t) { test('should have oncontextmenu events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'contextmenu') @@ -98,7 +91,7 @@ test('should have oncontextmenu events(html attribute) ', function (t) { test('should have ondblclick events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'dblclick') @@ -112,7 +105,7 @@ test('should have ondblclick events(html attribute) ', function (t) { test('should have ondrag events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'drag') @@ -126,7 +119,7 @@ test('should have ondrag events(html attribute) ', function (t) { test('should have ondragend events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'dragend') @@ -140,7 +133,7 @@ test('should have ondragend events(html attribute) ', function (t) { test('should have ondragenter events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'dragenter') @@ -154,7 +147,7 @@ test('should have ondragenter events(html attribute) ', function (t) { test('should have ondragleave events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'dragleave') @@ -168,7 +161,7 @@ test('should have ondragleave events(html attribute) ', function (t) { test('should have ondragover events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'dragover') @@ -182,7 +175,7 @@ test('should have ondragover events(html attribute) ', function (t) { test('should have ondragstart events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'dragstart') @@ -196,7 +189,7 @@ test('should have ondragstart events(html attribute) ', function (t) { test('should have ondrop events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'drop') @@ -210,7 +203,7 @@ test('should have ondrop events(html attribute) ', function (t) { test('should have onerror events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'error') @@ -224,7 +217,7 @@ test('should have onerror events(html attribute) ', function (t) { test('should have onfocus events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'focus') @@ -238,7 +231,7 @@ test('should have onfocus events(html attribute) ', function (t) { /* test('should have onfocusin events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'focusin') @@ -252,7 +245,7 @@ test('should have onfocus events(html attribute) ', function (t) { /* test('should have onfocusout events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'focusout') @@ -266,7 +259,7 @@ test('should have onfocus events(html attribute) ', function (t) { test('should have oninput events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'input') @@ -280,7 +273,7 @@ test('should have oninput events(html attribute) ', function (t) { test('should have onkeydown events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'keydown') @@ -294,7 +287,7 @@ test('should have onkeydown events(html attribute) ', function (t) { test('should have onkeypress events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'keypress') @@ -308,7 +301,7 @@ test('should have onkeypress events(html attribute) ', function (t) { test('should have onkeyup events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'keyup') @@ -322,7 +315,7 @@ test('should have onkeyup events(html attribute) ', function (t) { test('should have onmousedown events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'mousedown') @@ -336,7 +329,7 @@ test('should have onmousedown events(html attribute) ', function (t) { test('should have onmouseenter events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'mouseenter') @@ -350,7 +343,7 @@ test('should have onmouseenter events(html attribute) ', function (t) { test('should have onmouseleave events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'mouseleave') @@ -364,7 +357,7 @@ test('should have onmouseleave events(html attribute) ', function (t) { test('should have onmousemove events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'mousemove') @@ -378,7 +371,7 @@ test('should have onmousemove events(html attribute) ', function (t) { test('should have onmouseout events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'mouseout') @@ -392,7 +385,7 @@ test('should have onmouseout events(html attribute) ', function (t) { test('should have onmouseover events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'mouseover') @@ -406,7 +399,7 @@ test('should have onmouseover events(html attribute) ', function (t) { test('should have onmouseup events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'mouseup') @@ -420,7 +413,7 @@ test('should have onmouseup events(html attribute) ', function (t) { test('should have onreset events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'reset') @@ -434,7 +427,7 @@ test('should have onreset events(html attribute) ', function (t) { test('should have onresize events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'resize') @@ -448,7 +441,7 @@ test('should have onresize events(html attribute) ', function (t) { test('should have onscroll events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'scroll') @@ -462,7 +455,7 @@ test('should have onscroll events(html attribute) ', function (t) { test('should have onselect events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'select') @@ -476,7 +469,7 @@ test('should have onselect events(html attribute) ', function (t) { test('should have onsubmit events(html attribute) ', function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'submit') @@ -490,7 +483,7 @@ test('should have onsubmit events(html attribute) ', function (t) { test('should have ontouchcancel events(html attribute) ', { skip: true }, function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'touchcancel') @@ -504,7 +497,7 @@ test('should have ontouchcancel events(html attribute) ', { skip: true }, functi test('should have ontouchend events(html attribute) ', { skip: true }, function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'touchend') @@ -518,7 +511,7 @@ test('should have ontouchend events(html attribute) ', { skip: true }, function test('should have ontouchmove events(html attribute) ', { skip: true }, function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'touchmove') @@ -532,7 +525,7 @@ test('should have ontouchmove events(html attribute) ', { skip: true }, function test('should have ontouchstart events(html attribute) ', { skip: true }, function (t) { t.plan(1) let expectationMet = false - let res = html`` + let res = render(html``) raiseEvent(res, 'touchstart') diff --git a/tests/browser/html.js b/tests/browser/html.js deleted file mode 100644 index d62c21b..0000000 --- a/tests/browser/html.js +++ /dev/null @@ -1,4 +0,0 @@ -var doc = new (require('js' + 'dom').JSDOM)().window.document - -module.exports.document = doc -module.exports.html = require('../../dom')(doc) diff --git a/tests/browser/index.js b/tests/browser/index.js index 422f7dd..6cb2f2b 100644 --- a/tests/browser/index.js +++ b/tests/browser/index.js @@ -2,4 +2,3 @@ require('./api.js') require('./elements.js') require('./raw.js') require('./events.js') -require('./multiple.js') diff --git a/tests/browser/lazy.js b/tests/browser/lazy.js new file mode 100644 index 0000000..88876b9 --- /dev/null +++ b/tests/browser/lazy.js @@ -0,0 +1,92 @@ +var test = require('tape') +var { html, render } = require('../../') + +// TODO: test alternating return value (null/array/partial) + +test('fallback is required', function (t) { + t.throws(function () { + lazy(html`Hello planet!`) + }, 'throws when missing fallback') + t.end() +}) + +test('renders any type of mixed content', function (t) { + var noop = Function.prototype + var div = render(html` +
    + ${lazy('Hello', noop)} ${lazy([html`world`, '!'], noop)} +
    + `) + t.equal(div.innerText.trim(), 'Hello world!', 'content rendered') + t.end() +}) + +test('renders fallback in place of null', function (t) { + var div = render(html` +
    + ${lazy(null, function () { + return html`Hello world!` + })} +
    + `) + t.equal(div.innerText.trim(), 'Hello world!', 'fallback rendered') + t.end() +}) + +test('renders async partial', function (t) { + t.plan(2) + var div = render(html` +
    + ${lazy(Promise.resolve(html`Hello planet!`), function () { + return html`Hello world!` + })} +
    + `) + t.equal(div.innerText.trim(), 'Hello world!', 'fallback rendered') + window.requestAnimationFrame(function () { + t.equal(div.innerText.trim(), 'Hello planet!', 'async content rendered') + }) +}) + +test('forwards error to fallback', function (t) { + t.plan(3) + var state = 0 + var div = render(html` +
    + ${lazy(Promise.reject(new Error('test')), function (err) { + if (state++) t.equal(err.message, 'test', 'rejection is forwarded') + return html`Hello world!` + })} +
    + `) + t.equal(div.innerText.trim(), 'Hello world!', 'fallback rendered') + window.requestAnimationFrame(function () { + t.equal(div.innerText.trim(), 'Hello world!', 'fallback persisted') + }) +}) + +test('does not affect ordering', function (t) { + t.plan(2) + var state = 0 + var list = render(main()) + t.equal(list.innerText.replace(/\s/g, ''), '1246', 'sync content rendered') + render(main(), list) + window.requestAnimationFrame(function () { + t.equal(list.innerText.replace(/\s/g, ''), '13456', 'async content in place') + }) + + function main (val) { + return html` +
      +
    • 1
    • + ${state++ ? null : html`
    • 2
    • `} + ${Promise.resolve(html`
    • 3
    • `)} + ${[ + html`
    • 4
    • `, + Promise.resolve(html`
    • 5
    • `), + html`
    • 6
    • ` + ]} +
    + ` + } +}) diff --git a/tests/browser/multiple.js b/tests/browser/multiple.js deleted file mode 100644 index 39e9b02..0000000 --- a/tests/browser/multiple.js +++ /dev/null @@ -1,49 +0,0 @@ -var test = require('tape') -if (typeof window !== 'undefined') { - var document = window.document - var html = require('../../') -} else { - var nano = require('./html') - document = nano.document - html = nano.html -} - -test('multiple elements', function (t) { - var multiple = html`
  • Hamburg
  • Helsinki
  • haha
  • Berlin
    test
  • ` - - var list = document.createElement('ul') - list.appendChild(multiple) - t.equal(list.children.length, 3, '3 children') - t.equal(list.childNodes.length, 4, '4 childNodes') - t.equal(list.children[0].tagName, 'LI', 'list tag name') - t.equal(list.children[0].textContent, 'Hamburg') - t.equal(list.children[1].textContent, 'Helsinki') - t.equal(list.children[2].textContent, 'Berlintest') - t.equal(list.querySelector('div').textContent, 'test', 'created sub-element') - t.equal(list.childNodes[2].nodeValue, 'haha') - t.end() -}) - -test('nested fragments', function (t) { - var fragments = html`
    1
    ab${html`cd
    2
    between
    3
    `}
    4
    ` - t.equals(fragments.textContent, '1abcd2between34') - t.equals(fragments.children.length, 4) - t.equals(fragments.childNodes[4].textContent, 'between') - t.equals(fragments.childNodes.length, 7) - t.end() -}) - -test('multiple elements mixed with array', function (t) { - var arr = [html`
  • Helsinki
  • `, null, html`
  • Stockholm
  • `] - var multiple = html`
  • Hamburg
  • ${arr}
  • Berlin
  • ` - - var list = document.createElement('ul') - list.appendChild(multiple) - t.equal(list.children.length, 4, '4 children') - t.equal(list.children[0].tagName, 'LI', 'list tag name') - t.equal(list.children[0].textContent, 'Hamburg') - t.equal(list.children[1].textContent, 'Helsinki') - t.equal(list.children[2].textContent, 'Stockholm') - t.equal(list.children[3].textContent, 'Berlin') - t.end() -}) diff --git a/tests/browser/raw.js b/tests/browser/raw.js index 097c3b6..860b12f 100644 --- a/tests/browser/raw.js +++ b/tests/browser/raw.js @@ -1,12 +1,12 @@ var test = require('tape') -var html = require('../../') var raw = require('../../raw') +var { html, render } = require('../../') if (typeof window !== 'undefined') { test('unescape html', function (t) { t.plan(1) - var expected = html`Hello there`.toString() + var expected = render(html`Hello there`).toString() var result = raw('Hello there').toString() t.equal(expected, result) diff --git a/tests/index.js b/tests/index.js index 80fd953..2ea96c4 100644 --- a/tests/index.js +++ b/tests/index.js @@ -1,13 +1,7 @@ -function getNodeMajor () { - return process.version.split('.')[0].slice(1) -} - -if (typeof process === 'undefined' || getNodeMajor() >= 8) { - require('./browser') -} - if (typeof window === 'undefined') { require('./server') - require('./transform') - require('./babel') + // require('./transform') + // require('./babel') +} else { + require('./browser') } diff --git a/tests/server/index.js b/tests/server/index.js index 22e417c..97121be 100644 --- a/tests/server/index.js +++ b/tests/server/index.js @@ -68,8 +68,8 @@ test('respect query parameters', function (t) { t.plan(1) var param = 'planet' - var expected = 'Hello planet' - var result = html`Hello planet`.toString() + var expected = 'Hello planet' + var result = html`Hello ${param}`.toString() t.equal(result, expected) t.end() @@ -128,3 +128,40 @@ test('nested multiple root elements', function (t) { t.equal(expected, result) t.end() }) + +test('resolves generators', function (t) { + var expected = '
    Hello planet
    ' + var result = html`
    Hello ${html`${child()}`}
    ` + t.equal(result.toString(), expected) + t.end() + + function * child () { + var value = yield 'planet' + return value + } +}) + +test('resolves promises', function (t) { + t.plan(2) + var expected = '
    Hello planet
    ' + var result = html`
    Hello ${Promise.resolve(html`${[Promise.resolve('planet')]}`)}
    ` + t.ok(result instanceof Promise, 'returns promise') + result.then(function (result) { + t.equal(result.toString(), expected) + }) +}) + +test('resolves generators with promises', function (t) { + t.plan(2) + var expected = '
    Hello planet
    ' + var result = html`
    Hello ${html`${child()}`}
    ` + t.ok(result instanceof Promise, 'returns promise') + result.then(function (result) { + t.equal(result.toString(), expected) + }) + + function * child () { + var value = yield Promise.resolve('planet') + return value + } +})