synks
Version:
Asynchronous view renderer
538 lines (518 loc) • 19.4 kB
JavaScript
const SCOPE = Symbol();
const SCOPE_CHILDREN = Symbol();
const UPDATE_CONTEXT = Symbol();
class Context {
constructor() {
const keys = Object.getOwnPropertyNames(Object.getPrototypeOf(this));
keys.forEach((key) => {
const value = this[key];
if (value instanceof Function) {
this[key] = (...args) => {
const output = value.apply(this, args);
this[UPDATE_CONTEXT]();
return output;
};
}
});
}
[UPDATE_CONTEXT]() { }
}
function h(type, props, ...children) {
const key = props && props.key;
return {
type,
props,
children,
key: key === undefined ? null : key,
};
}
function arrayUnique(array) {
const a = array.slice();
for (let i = 0; i < a.length; ++i) {
for (let j = i + 1; j < a.length; ++j) {
if (a[i] === a[j]) {
a.splice(j--, 1);
}
}
}
return a;
}
function getChildNodes(node) {
return Array.prototype.slice.call(node.childNodes);
}
async function build(currentNode, previousNode = {}, container) {
if (currentNode.target) {
return currentNode.target;
}
const underSameParent = container
? !!getChildNodes(container)
.find((child) => child === previousNode.target)
: false;
// Text node
if (currentNode.type === '') {
const p = currentNode.props;
if (p === undefined || p === null || typeof p === 'boolean') {
currentNode.props = '';
}
// Patch text node
if (underSameParent && previousNode && previousNode.type === '' && previousNode.target) {
// Patch node content
if (currentNode.props !== previousNode.props) {
previousNode.target.nodeValue = String(currentNode.props);
}
return previousNode.target;
}
// New text node
return new Text(String(currentNode.props));
}
if (!underSameParent) {
previousNode.props = {};
previousNode.key = null;
}
// Patch node
if (underSameParent && previousNode && previousNode.target && currentNode.type === previousNode.type) {
return previousNode.target;
}
const type = String(currentNode.type);
if (currentNode.type === 'svg' || container instanceof SVGElement) {
return document.createElementNS('http://www.w3.org/2000/svg', type);
}
// New node
return document.createElement(String(type));
}
function isContext(value) {
return !!value && Object.getPrototypeOf(value) === Context;
}
async function handleContext(generator, scope, context, node, contextRoot) {
if (!isContext(contextRoot))
return contextRoot;
const contextName = contextRoot && contextRoot.name;
const currentContext = context[contextName];
if (!currentContext) {
throw new Error(`${contextName} was called in <${node.type.name}> before it was defined`);
}
if (currentContext[1].indexOf(scope) === -1) {
currentContext[1].push(scope);
}
return (await generator.next(currentContext[0])).value;
}
function isGenerator(value) {
return !!value && typeof value.next === 'function' && typeof value.throw === 'function';
}
async function handleHooks(generator, scope, context, node, hook) {
if (!isGenerator(hook))
return hook;
let value = await hook.next();
while (isContext(value.value) || value.value === SCOPE) {
// Allows context in hooks
if (isContext(value.value)) {
const contextName = value.value && value.value.name;
const currentContext = context[contextName];
if (!currentContext) {
throw new Error(`${contextName} was called from hook in <${node.type.name}> before it was defined`);
}
if (currentContext[1].indexOf(scope) === -1) {
currentContext[1].push(scope);
}
value = await hook.next(currentContext[0]);
}
// Returns current component scope in hook
if (value.value === SCOPE) {
value = await hook.next({
...scope,
async next() {
if (value !== undefined) {
Object.assign(value, await hook.next());
}
return await scope.next();
},
});
}
}
return (await generator.next(value)).value;
}
async function handleCustomYields(generator, output, scope, node, context) {
output = (await generator.next()).value;
while (isGenerator(output) || isContext(output)) {
output = await handleHooks(generator, scope, context, node, output);
output = await handleContext(generator, scope, context, node, output);
}
return output;
}
var PROPS;
(function (PROPS) {
PROPS["innerHTML"] = "innerHTML";
PROPS["style"] = "style";
})(PROPS || (PROPS = {}));
function patchStyle(currentNode, previousNode) {
const target = currentNode.target;
if (!currentNode.type || (target instanceof Text) || !target)
return;
const styleA = (currentNode.props || {}).style || {};
const styleB = (previousNode.props || {}).style || {};
Object.keys(styleA).forEach(name => {
const value = styleA[name];
const oldValue = styleB[name];
if (value === oldValue)
return;
if (value === null || value === false || value === undefined) {
return delete target[name];
}
return target.style[name] = String(value);
});
if (!previousNode)
return;
Object.keys(styleB).forEach(name => {
if (styleA && (styleA[name] !== null
&& styleA[name] !== false
&& styleA[name] !== undefined))
return;
return delete target.style[name];
});
}
function patchProps(currentNode, previousNode) {
const target = currentNode.target;
if (!currentNode.type || (target instanceof Text) || !target)
return;
const propsA = currentNode.props || {};
const propsB = previousNode.props || {};
Object.keys(propsA).forEach(name => {
if (name === PROPS.style)
return;
const value = propsA[name];
const oldValue = propsB[name];
if (value === oldValue)
return;
if (value instanceof Function) {
if (value === target[name])
return;
return (target[name] = value);
}
else {
if (value === null || value === false || value === undefined) {
return target.removeAttribute(name);
}
const newValue = String(value);
if (name === PROPS.innerHTML) {
return target[name] = newValue;
}
return target.setAttribute(name, newValue);
}
});
if (!previousNode)
return;
Object.keys(propsB).forEach(name => {
if (name === PROPS.style)
return;
if (propsA && (propsA[name] !== null
&& propsA[name] !== false
&& propsA[name] !== undefined))
return;
if (name === PROPS.innerHTML) {
return delete target[name];
}
return target.removeAttribute(name);
});
patchStyle(currentNode, previousNode);
}
function checkEquality(a, b, keys) {
for (const key of keys) {
if (typeof a[key] === 'function' && String(a[key]) !== String(b[key])) {
return false;
}
if (JSON.stringify(a[key]) !== JSON.stringify(b[key]) && a[key] !== b[key]) {
return false;
}
}
return true;
}
function quickEqual(a, b) {
if (a === b)
return true;
if (!(a instanceof Object && a))
return false;
if (!(b instanceof Object && b))
return false;
const ai = Object.keys(a);
const bi = Object.keys(b);
if (ai.length !== bi.length)
return false;
return checkEquality(a, b, ai);
}
function removeNode(node) {
if (node instanceof Array) {
node.map(removeNode);
return;
}
if (!(node && node.target && node.target.parentNode))
return;
node.target.parentNode.removeChild(node.target);
if (node.scope) {
node.scope.destroy();
}
}
function removeStranglers(previous) {
if (previous instanceof Array) {
previous.forEach((prev) => {
const instance = (prev.instance || prev);
if (instance.target !== null) {
removeNode(prev);
}
});
}
}
async function asyncMap(value, fn) {
const output = [];
for (const i in value) {
output.push(await fn(value[i], parseInt(i, 10)));
}
return output;
}
async function renderChildren(currentChildren, previousChildren, container, parentIndex, context) {
let offset = parentIndex;
const renderedData = await asyncMap(currentChildren, async (child, index) => {
const previousNode = previousChildren && previousChildren[index];
if (child instanceof Array) {
const renderedData = await renderChildren(child, previousNode, container, offset + parseInt(index, 10), context);
removeStranglers(previousNode);
offset += Math.max([].concat(...renderedData).length - 1, 0);
return renderedData;
}
// Handle keyed children
if (child && child.key !== null && child.key !== undefined && previousNode && previousChildren instanceof Array) {
if (previousNode.key === child.key) {
child.target = previousNode.target;
previousNode.target = null;
patchProps(child, previousNode);
return child;
}
const keyElement = previousChildren.find((p) => p.key === child.key);
if (previousNode.key !== child.key && keyElement) {
child.target = keyElement.target;
keyElement.target = null;
}
}
const output = await render(child, previousNode, container, parseInt(index, 10) + offset, context);
if (output instanceof Array) {
offset += Math.max([].concat(...output).length - 1, 0);
}
if (!(output instanceof Array) && output.instance instanceof Array) {
offset += Math.max([].concat(...output.instance).length - 1, 0);
}
return output;
});
removeStranglers(previousChildren);
return renderedData;
}
function isVnode(value) {
return value && value instanceof Object;
}
function transformNode(node) {
if (node instanceof Array) {
return Object.assign(node, node.map(transformNode));
}
if (isVnode(node)) {
if (node.children instanceof Array) {
node.children = node.children.map(transformNode);
}
return Object.assign(node, {
type: node.type,
props: node.props,
key: node.key === undefined ? null : node.key,
children: node.children,
target: null,
instance: null,
scope: null,
});
}
return {
type: '',
props: node,
key: null,
children: undefined,
};
}
let updateQueue = [];
let cleanupQueue = [];
function startCleanup() {
const cleanup = cleanupQueue.slice();
cleanupQueue = [];
cleanup.forEach((d) => d());
}
async function render(currentNode, previousNode = currentNode && currentNode.constructor(), container, childIndex, context) {
if (currentNode === undefined || currentNode === null) {
return transformNode();
}
if (currentNode instanceof Array) {
const prevNodes = previousNode instanceof Array ? previousNode : [previousNode];
return await renderChildren(currentNode.map(transformNode), prevNodes, container, childIndex, context);
}
currentNode = transformNode(currentNode);
if (previousNode && previousNode.type instanceof Function && currentNode && currentNode.type !== previousNode.type) {
const cachedTarget = previousNode && Object.assign({}, previousNode.instance);
if (previousNode.scope) {
await previousNode.scope.destroy();
}
if (cachedTarget) {
cleanupQueue.push(() => {
removeNode(cachedTarget);
});
}
}
// Component
if (currentNode.type instanceof Function) {
const fn = currentNode.type;
// Context
if (isContext(fn)) {
const currentContext = new fn(currentNode.props);
const name = fn.name;
const events = [];
const nextFn = async (newContext) => {
updateQueue = arrayUnique(events.concat(updateQueue));
// Update context state
Object.assign(currentContext, newContext);
const cache = updateQueue.slice().filter((d) => d.mounted && !d.rendering);
updateQueue = [];
// Update subscribed components
for (const event of cache) {
if (event.mounted && !event.rendering) {
await event.next();
}
}
};
currentContext[UPDATE_CONTEXT] = async () => await nextFn.call(currentContext);
context[name] = [currentContext, events];
// Render children
return await render(currentNode.children, previousNode.children, container, childIndex, context);
}
// Component update
if (currentNode.type === previousNode.type && !!previousNode.instance) {
const props1 = Object.assign({}, currentNode.props, {
children: currentNode.children,
});
const props2 = Object.assign({}, previousNode.props, {
children: previousNode.children,
});
const key1 = currentNode.key;
const key2 = previousNode.key;
// Don't update component as props are the same (in shallow level)
if (quickEqual(props1, props2) || (key1 !== null && key1 === key2)) {
return Object.assign(currentNode, previousNode);
}
}
// Regular component
let output;
const originalProps = Object.assign({}, currentNode.props, {
children: currentNode.children,
});
const scope = {
mounted: true,
rendering: false,
[SCOPE_CHILDREN]: [],
async onMount() { },
async onDestroy() { },
async next() {
if (!scope.mounted || scope.rendering) {
return;
}
await this.nextProps(originalProps);
},
async nextProps(props) {
if (!scope.mounted || scope.rendering) {
return;
}
scope.rendering = true;
// eslint-disable-next-line @typescript-eslint/no-use-before-define
await renderSelf(currentNode.instance, props);
scope.rendering = false;
},
async destroy() {
if (!scope.mounted) {
return;
}
scope.mounted = false;
await scope.onDestroy();
scope[SCOPE_CHILDREN].forEach((s) => {
s.destroy();
});
scope[SCOPE_CHILDREN] = [];
const parentScopes = context.scope && context.scope[SCOPE_CHILDREN];
if (parentScopes) {
context.scope[SCOPE_CHILDREN] = parentScopes.filter((s) => s.mounted);
}
},
};
if (context.scope) {
context.scope[SCOPE_CHILDREN].push(scope);
}
const newContext = Object.assign({}, context, {
scope,
});
currentNode.scope = scope;
let generator;
const renderSelf = async (previousTree, props) => {
Object.assign(originalProps, props);
if (typeof fn === 'function') {
output = await fn.call(scope, props);
// Generator component
if (isGenerator(output)) {
if (!generator) {
generator = output;
}
output = await handleCustomYields(generator, output, scope, currentNode, context);
}
}
const rendered = await render(output, previousTree, container, childIndex, newContext);
startCleanup();
if (!(currentNode instanceof Array)) {
currentNode.instance = output;
}
Object.assign(previousNode, currentNode);
if (previousTree) {
Object.assign(previousTree, rendered);
}
previousNode.target = null;
return rendered;
};
await renderSelf(previousNode, originalProps);
scope.onMount();
return currentNode;
}
const isKeyMoved = currentNode.target && previousNode.key !== undefined && previousNode.key !== null;
currentNode.target = await build(currentNode, previousNode, container);
// Patch props
patchProps(currentNode, previousNode);
if (isKeyMoved) {
container.insertBefore(currentNode.target, container.childNodes[childIndex + 1]);
return currentNode;
}
if (currentNode.target === previousNode.target || (previousNode.instance && previousNode.target === container)) {
previousNode.target = null;
}
// Patch children
if (currentNode.children instanceof Array) {
await renderChildren(currentNode.children, previousNode.children, currentNode.target, 0, context);
}
// Remove stranglers
if (previousNode.target && previousNode.target.parentNode && previousNode.target !== currentNode.target && previousNode.target !== container) {
removeNode(previousNode);
}
if (previousNode && previousNode.children) {
removeStranglers(previousNode.children);
}
// Add element to dom
if (!currentNode.target.parentNode) {
container.insertBefore(currentNode.target, container.childNodes[childIndex]);
}
return currentNode;
}
async function mount(node, container = document.body, previousNode = node.constructor()) {
if (!(container instanceof HTMLElement)) {
throw new Error('[Synks] Container should be valid HTMLElement');
}
if (!(node instanceof Array)) {
node = [node];
}
return await render(node, previousNode, container, 0, {});
}
export { Context, SCOPE, h, mount };