@dependable/view
Version:
View layer for @dependable/state
855 lines (725 loc) • 22 kB
JavaScript
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 };