UNPKG

@dependable/view

Version:
855 lines (725 loc) 22 kB
import { observable, computed, track, flush as flush$1 } from '@dependable/state'; // Fork of https://github.com/derbyjs/arraydiff // // Based on some rough benchmarking, this algorithm is about O(2n) worst case, // and it can compute diffs on random arrays of length 1024 in about 34ms, // though just a few changes on an array of length 1024 takes about 0.5ms function InsertDiff(index, values) { this._index = index; this._values = values; } function RemoveDiff(index, howMany) { this._index = index; this._howMany = howMany; } function MoveDiff(from, to, howMany) { this._from = from; this._to = to; this._howMany = howMany; } /** @internal */ function arrayDiff(before, after, equalFn) { // Find all items in both the before and after array, and represent them // as moves. Many of these "moves" may end up being discarded in the last // pass if they are from an index to the same index, but we don't know this // up front, since we haven't yet offset the indices. // // Also keep a map of all the indices accounted for in the before and after // arrays. These maps are used next to create insert and remove diffs. const beforeLength = before.length; const afterLength = after.length; const moves = []; const beforeMarked = {}; const afterMarked = {}; for (let beforeIndex = 0; beforeIndex < beforeLength; beforeIndex++) { const beforeItem = before[beforeIndex]; for (let afterIndex = 0; afterIndex < afterLength; afterIndex++) { if (afterMarked[afterIndex]) continue; if (!equalFn(beforeItem, after[afterIndex])) continue; const from = beforeIndex; const to = afterIndex; let howMany = 0; do { beforeMarked[beforeIndex++] = afterMarked[afterIndex++] = true; howMany++; } while ( beforeIndex < beforeLength && afterIndex < afterLength && equalFn(before[beforeIndex], after[afterIndex]) && !afterMarked[afterIndex] ); moves.push(new MoveDiff(from, to, howMany)); beforeIndex--; break; } } // Create a remove for all of the items in the before array that were // not marked as being matched in the after array as well const removes = []; for (let beforeIndex = 0; beforeIndex < beforeLength; ) { if (beforeMarked[beforeIndex]) { beforeIndex++; continue; } const index = beforeIndex; let howMany = 0; while (beforeIndex < beforeLength && !beforeMarked[beforeIndex++]) { howMany++; } removes.push(new RemoveDiff(index, howMany)); } // Create an insert for all of the items in the after array that were // not marked as being matched in the before array as well const inserts = []; for (let afterIndex = 0; afterIndex < afterLength; ) { if (afterMarked[afterIndex]) { afterIndex++; continue; } const index = afterIndex; let howMany = 0; while (afterIndex < afterLength && !afterMarked[afterIndex++]) { howMany++; } const values = after.slice(index, index + howMany); inserts.push(new InsertDiff(index, values)); } const insertsLength = inserts.length; const removesLength = removes.length; const movesLength = moves.length; let i, j; // Offset subsequent removes and moves by removes let count = 0; for (i = 0; i < removesLength; i++) { const remove = removes[i]; remove._index -= count; count += remove._howMany; for (j = 0; j < movesLength; j++) { const move = moves[j]; if (move._from >= remove._index) move._from -= remove._howMany; } } // Offset moves by inserts for (i = insertsLength; i--; ) { const insert = inserts[i]; const howMany = insert._values.length; for (j = movesLength; j--; ) { const move = moves[j]; if (move._to >= insert._index) move._to -= howMany; } } // Offset the to of moves by later moves for (i = movesLength; i-- > 1; ) { const move = moves[i]; if (move._to === move._from) continue; for (j = i; j--; ) { const earlier = moves[j]; if (earlier._to >= move._to) earlier._to -= move._howMany; if (earlier._to >= move._from) earlier._to += move._howMany; } } // Only output moves that end up having an effect after offsetting const outputMoves = []; // Offset the from of moves by earlier moves for (i = 0; i < movesLength; i++) { const move = moves[i]; if (move._to === move._from) continue; outputMoves.push(move); for (j = i + 1; j < movesLength; j++) { const later = moves[j]; if (later._from >= move._from) later._from -= move._howMany; if (later._from >= move._to) later._from += move._howMany; } } return removes.concat(outputMoves, inserts); } const isArray = (v) => Array.isArray(v); const getAnchor = (dom) => (isArray(dom) ? getAnchor(dom[0]) : dom); const removeChildren = (container) => { while (container.firstChild) { container.removeChild(container.firstChild); } }; const mount = (vdom) => { if (isArray(vdom)) { return vdom.flatMap(mount); } else { return vdom._mount(); } }; const flush = (vdom) => { if (isArray(vdom)) { return vdom.flatMap(flush); } else { return vdom && vdom._flush && vdom._flush(); } }; const unmount = (vdom) => { if (isArray(vdom)) { vdom.map(unmount); } else if (vdom) { vdom._unmount(); } }; const appendChildren = (container, children) => { if (isArray(children)) { children.forEach((child) => { appendChildren(container, child); }); } else { container.appendChild(children); } }; const insertBefore = (dom, referenceNode) => { if (isArray(dom)) { dom.forEach((node) => { insertBefore(node, referenceNode); }); } else { referenceNode.parentNode.insertBefore(dom, referenceNode); } }; const getDom = (vdom) => (isArray(vdom) ? vdom.map((c) => c._dom) : vdom._dom); function shallowEqual(a, b) { const prevKeys = Object.keys(a); const keys = Object.keys(b); if (prevKeys.length !== keys.length) return false; for (let i = 0; i < prevKeys.length; i++) { const key = prevKeys[i]; if (b[key] !== a[key]) return false; } return true; } class UserComponent { constructor({ type, props, children }, context) { const Constructor = type; this._type = type; this._props = observable(props); this._children = observable(children); this._defaultProps = (Constructor.defaultProps || (() => ({})))(); this._instanceProps = computed(() => ({ ...this._defaultProps, ...this._props(), children: this._children(), })); const instanceProps = this._instanceProps(); const instance = new Constructor(instanceProps, context._userContext); this._instance = instance; this._render = this._render.bind(this); this._mounted = false; this._dependencies = new Set(); instance.context = context._userContext; instance.props = instanceProps; if (instance.didCatch) { instance.didCatch = instance.didCatch.bind(instance); } this._context = { ...context, _priority: context._priority + 1, _errorHandler: instance.didCatch || context._errorHandler, }; } get _dom() { return getDom(this._vdom); } _update(tree) { if (!shallowEqual(this._props(), tree.props)) { this._props(tree.props); } if (this._children() !== tree.children) { this._children(tree.children); } } _renderVDom() { try { let result; const nextProps = this._instanceProps(); this._instance.props = nextProps; const capturedDependencies = track(() => { result = this._instance.render(nextProps, this._context._userContext); }); for (const dependency of this._dependencies) { if (!capturedDependencies.has(dependency)) { dependency.unsubscribe(this._render); } } for (const dependency of capturedDependencies) { dependency.subscribe(this._render, this._context._priority); } this._dependencies = capturedDependencies; return result; } catch (e) { this._context._errorHandler(e); } } _render() { // It is possible to have a pending render, that is cancelled by unmounting // the component, so we need to not execute that render. if (this._mounted) { const instance = this._instance; try { this._vdom = update(this._renderVDom(), this._vdom, this._context); instance.didUpdate && instance.didUpdate(); instance.didRender && instance.didRender(); } catch (e) { this._context._errorHandler(e); } } } _mount() { try { const instance = this._instance; if (instance.willMount) { instance.willMount(); flush$1(); } this._instanceProps.subscribe(this._render, this._context._priority); this._vdom = create(this._renderVDom(), this._context); const dom = mount(this._vdom); return dom; } catch (e) { this._context._errorHandler(e); this._vdom = new Hidden(); return mount(this._vdom); } } _insertBefore(dom) { getAnchor(this._vdom)._insertBefore(dom); } _unmount() { const instance = this._instance; this._instanceProps.unsubscribe(this._render); for (const dependency of this._dependencies) { dependency.unsubscribe(this._render); } try { instance.willUnmount && instance.willUnmount(); } catch (e) { this._context._errorHandler(e); } unmount(this._vdom); this._mounted = false; } _flush() { try { flush(this._vdom); this._mounted = true; this._instance.didMount && this._instance.didMount(); this._instance.didRender && this._instance.didRender(); } catch (e) { this._context._errorHandler(e); } } } const propWithoutDot = (p) => p.slice(1); const setStyles = (style, value, prevValue) => { if (typeof value === "string") { style.cssText = value; } else { const prevValueWasString = typeof prevValue === "string"; const hasPrevValue = !prevValueWasString && prevValue; if (prevValueWasString) { style.cssText = ""; } for (const name in value) { if (!hasPrevValue || value[name] !== prevValue[name]) { style.setProperty(name, value[name]); } } } }; const captureRegex = /Capture$/; const eventPropRegex = /^on/; const isCapturePhase = (name) => captureRegex.test(name); const isEventHandlerProp = (name) => eventPropRegex.test(name); const eventHandlerPropToEventName = (name) => { const eventName = name.replace(eventPropRegex, "").replace(captureRegex, ""); const loweredEventName = eventName.toLowerCase(); return `on${loweredEventName}` in document ? loweredEventName : eventName; }; const mapPropName = (name) => (name === "className" ? "class" : name); const addEventListener = (dom, name, listener) => { if (listener) { dom.addEventListener( eventHandlerPropToEventName(name), listener, isCapturePhase(name) ); } }; const removeEventListener = (dom, name, listener) => { dom.removeEventListener( eventHandlerPropToEventName(name), listener, isCapturePhase(name) ); }; class PrimitiveComponent { constructor({ type, props, children }, context) { this._type = type; this._props = props; this._context = type === "svg" ? { ...context, _isSvg: true } : context; this._children = children && create(children, this._context); } _update(tree) { const props = tree.props; for (const p in this._props) { if (p !== "key" && p !== "ref" && !(p in props)) { const value = this._props[p]; if (isEventHandlerProp(p)) { removeEventListener(this._dom, p, value); } else if (p[0] !== ".") { if (p === "style") { this._dom.style.cssText = ""; } this._dom.removeAttribute(mapPropName(p)); } } } for (const p in props) { const prevValue = this._props[p]; const value = props[p]; if (p !== "key" && p !== "ref" && prevValue !== value) { if (isEventHandlerProp(p)) { removeEventListener(this._dom, p, prevValue); addEventListener(this._dom, p, value); } else if (p[0] === ".") { this._dom[propWithoutDot(p)] = value; } else if (p === "style") { setStyles(this._dom.style, value, prevValue); } else if (value === true) { this._dom.setAttribute(mapPropName(p), ""); } else if (!value) { this._dom.removeAttribute(mapPropName(p)); } else { this._dom.setAttribute(mapPropName(p), value); } } } if (props.ref && this._props.ref !== props.ref) { props.ref(this._dom); } this._props = props; const children = tree.children; if (this._children !== children) { if (children === null) { unmount(this._children); this._children = children; } else if (this._children === null) { this._children = create(children, this._context); appendChildren(this._dom, mount(this._children)); flush(this._children); } else { this._children = update(children, this._children, this._context); } } } _mount() { if (this._context._isSvg) { this._dom = document.createElementNS( "http://www.w3.org/2000/svg", this._type ); } else { this._dom = document.createElement(this._type); } for (const p in this._props) { if (p !== "key" && p !== "ref") { const value = this._props[p]; if (isEventHandlerProp(p)) { addEventListener(this._dom, p, value); } else if (p[0] === ".") { this._dom[propWithoutDot(p)] = value; } else if (p === "style") { setStyles(this._dom.style, value); } else if (value === true) { this._dom.setAttribute(mapPropName(p), ""); } else if (value) { this._dom.setAttribute(mapPropName(p), value); } } } if (this._children) { appendChildren(this._dom, mount(this._children)); } if (this._props.ref) { this._props.ref(this._dom); } return this._dom; } _insertBefore(dom) { insertBefore(dom, this._dom); } _unmount() { unmount(this._children); this._dom.remove(); } _flush() { flush(this._children); } } class Text { constructor(value) { this._type = "text"; this._value = value; } _mount() { this._dom = document.createTextNode(this._value); return this._dom; } _updateText(value) { if (this._value !== value) { this._dom.textContent = value; this._value = value; } } _insertBefore(dom) { insertBefore(dom, this._dom); } _unmount() { this._dom.remove(); } } class Hidden { constructor() { this._type = "hidden"; } _mount() { this._dom = document.createComment("hidden"); return this._dom; } _insertBefore(dom) { insertBefore(dom, this._dom); } _unmount() { this._dom.remove(); } } class ContextUserComponent { render({ children }) { return children; } } class ContextComponent extends UserComponent { constructor({ type, props, children }, context) { super( { type: ContextUserComponent, props, children }, { ...context, _userContext: Object.freeze({ ...context._userContext, ...props }), } ); this._type = type; } } class PortalComponent extends Hidden { constructor( { type, props: { target = document.body } = {}, children }, context ) { super(); this._type = type; this._context = context; this._children = children && create(children, context); this._target = target; } _update(tree) { const target = tree.props.target || document.body; if (this._target !== target) { // Move DOM tree this._target = target; appendChildren(target, getDom(this._children)); } this._children = update(tree.children, this._children, this._context); } _mount() { if (this._children) { appendChildren(this._target, mount(this._children)); flush(this._children); } return super._mount(); } _unmount() { unmount(this._children); super._unmount(); } _flush() { flush(this._children); } } const isHidden = (value) => value == null || value === false || (isArray(value) && !value.length); const create = (value, context) => { if (isHidden(value)) { return new Hidden(); } if (isArray(value)) { return value.map((item) => create(item, context)); } if (typeof value.type === "function") { try { return new UserComponent(value, context); } catch (e) { context._errorHandler(e); return new Hidden(); } } if (typeof value === "object") { if (value.type === "Context") { return new ContextComponent(value, context); } if (value.type === "Portal") { return new PortalComponent(value, context); } return new PrimitiveComponent(value, context); } return new Text(String(value)); }; const getKey = (props) => props && (typeof props === "function" ? props().key : props.key); const hasKey = (value) => value && typeof getKey(value.props || value._props) !== "undefined"; const similar = (a, b) => a._type === b.type && getKey(a._props) === getKey(b.props); const updateKeyedArray = (updatedTree, vdom, context) => { const updatedByKey = new Map(); updatedTree.forEach((child) => { updatedByKey.set(getKey(child.props), child); }); vdom.forEach((oldChild, i) => { const key = getKey(oldChild._props); if (updatedByKey.has(key)) { const newChild = updatedByKey.get(key); update(newChild, oldChild, context); } }); const diff = arrayDiff(vdom, updatedTree, similar); const container = getAnchor(vdom[0]._dom).parentNode; const insertBefore = (dom, anchor) => { if (anchor) { anchor._insertBefore(dom); } else { appendChildren(container, dom); } }; if (!diff.length) { return vdom; } diff.forEach((update) => { if (update instanceof InsertDiff) { const anchor = vdom[update._index]; const newValues = update._values.map((child) => create(child, context)); const dom = mount(newValues); insertBefore(dom, anchor); vdom.splice(update._index, 0, ...newValues); flush(newValues); } else if (update instanceof RemoveDiff) { const candidates = vdom.splice(update._index, update._howMany); unmount(candidates); } else if (update instanceof MoveDiff) { const anchor = vdom[update._to]; const candidates = vdom.splice(update._from, update._howMany); const dom = candidates.map((c) => c._dom); insertBefore(dom, anchor); vdom.splice(update._to, 0, ...candidates); } }); return vdom; }; const updateArray = (updatedTree, vdom, context) => { if (hasKey(updatedTree[0]) && hasKey(vdom[0])) { return updateKeyedArray(updatedTree, vdom, context); } if (updatedTree.length && updatedTree.length === vdom.length) { for (let i = 0; i < updatedTree.length; i++) { vdom[i] = update(updatedTree[i], vdom[i], context); } return vdom; } const newVdom = create(updatedTree, context); getAnchor(vdom)._insertBefore(mount(newVdom)); unmount(vdom); flush(newVdom); return newVdom; }; const update = (updatedTree, vdom, context) => { if (vdom._type === "hidden" && isHidden(updatedTree)) { return vdom; } if ( vdom._type === "text" && (typeof updatedTree === "string" || typeof updatedTree === "number") ) { vdom._updateText(updatedTree); return vdom; } if (updatedTree && updatedTree.type && updatedTree.type === vdom._type) { vdom._update(updatedTree); return vdom; } if (isArray(updatedTree) && updatedTree.length && isArray(vdom)) { return updateArray(updatedTree, vdom, context); } const newVdom = create(updatedTree, context); getAnchor(vdom)._insertBefore(mount(newVdom)); unmount(vdom); flush(newVdom); return newVdom; }; const reThrow = (e) => { throw e; }; /** * Renders a virtual DOM into a container. * * @function * * @param {import('./shared').VNodes} vnodes the virtual DOM to render. * @param {import('./shared').Container} container the container to render into. * @param {import('./shared').Context} context the rendering context */ const render = (vnodes, container = document.body, context = {}) => { container._unmount?.(); removeChildren(container); const vdom = create(vnodes, { _userContext: Object.freeze(context), _errorHandler: reThrow, _isSvg: false, _priority: 0, }); appendChildren(container, mount(vdom)); flush(vdom); container._unmount = () => unmount(vdom); }; /** * Clone a {@link VElement} with new properties and children * * @function * * @param {import('./shared').VElement} element the virual nodes to clone * @param {import('./shared').VElementOverrides} overrides the overrides for the virtual element * @return {import('./shared').VNode} the cloned virtual node */ const clone = (element, overrides) => ({ type: element.type, props: { ...element.props, ...overrides?.props, }, children: overrides?.children || element.children, }); /** * Create a virtual DOM node. * * @function * @param {import('./shared').VElement["type"]} type The type of the virtual DOM node * @param {Record<string, any>} props The props of the virtual DOM node * @param {any[]} ...children The children of the virtual DOM node * @returns {import('./shared').VNode} VNode A virtual DOM node */ const h = (type, props, ...children) => ({ type: type, props: props || {}, children: children.length ? children.flat() : null, }); export { clone, h, render };