Fixed a bug relating to custom-elements's first vnode where render(){ return <div class="example" /> } would set the example-class on the custom-element itself.

Added support for className and style similar to react
Cleaned up some comments
Reworked how tests are built in order to add a new test "pdf" which was a small side-project where previous mentioned bug showed up, it's an example using HTML to create a PDF for printing
This commit is contained in:
2019-12-21 21:48:38 +01:00
parent 93fb6927ca
commit 4ca54727f1
56 changed files with 2397 additions and 381 deletions

View File

@@ -28,6 +28,7 @@
"rollup-plugin-commonjs": "latest",
"rollup-plugin-terser": "latest",
"rollup-plugin-json": "latest",
"rollup-plugin-postcss": "latest",
"npm-run-all": "latest"
},
"dependencies":{

View File

@@ -1,4 +1,4 @@
import {render} from "../vdom";
import {render, Host} from "../vdom";
// TODO the custom-element class could be removed, and its functionality implemented through the @defineElement decorator
// Currently there are issues: if a custom-element reimplements the connectedCallback, they need to magically know that
@@ -23,6 +23,9 @@ export class CustomElement extends HTMLElement {
update(){
if (this.render) {
let newVNode = this.render();
if(newVNode.type !== Host){
newVNode = {type: Host, children: newVNode instanceof Array? newVNode : [newVNode]};
}
render(newVNode, {
host: this,
old: this.#renderedVNode,

View File

@@ -6,25 +6,23 @@ import {
NodeTreeRenderer, NativeRenderer
} from "./renderers";
// TODO Element renderer (for things that are already DOM-elements)
export function getNodeMeta(vnode){
if(vnode===undefined||vnode===null) return undefined; // Indicate it shouldn't render
if(vnode instanceof Element) return {renderer: NativeRenderer, normedType: Element};
export function getNodeMeta(vnode) {
if (vnode === undefined || vnode === null) return undefined; // Indicate it shouldn't render
if (vnode instanceof Element) return { renderer: NativeRenderer, normedType: Element };
let type = vnode?.type;
if(!type) return {renderer: PrimitiveRenderer, normedType: Primitive};
else if(type===Host) return {renderer: HostNodeRenderer, normedType: Host};
else if(type===ShadowDOM) return {renderer: ShadowNodeRenderer, normedType: ShadowDOM};
else return {renderer: NodeTreeRenderer, normedType: window.customElements?.get(type)??type};
if (!type) return { renderer: PrimitiveRenderer, normedType: Primitive };
else if (type === Host) return { renderer: HostNodeRenderer, normedType: Host };
else if (type === ShadowDOM) return { renderer: ShadowNodeRenderer, normedType: ShadowDOM };
else return { renderer: NodeTreeRenderer, normedType: window.customElements?.get(type) ?? type };
}
/**
* @typedef {Object} RenderOptions
* @category VDOM
* @property {Element} [host] - A host element to update to the specified VDOM
* @property {Element} [host] - The element to update to the specified VDOM
* @property {VNode} [old] - Old VNode representation of rendered host
* @property {Document} [document] - The document we're rendering to
* @property {Element} [parent] - The parent element (TODO not sure what this will do when specified; Insert it as child element of the parent where?)
* @property {Element} [parent] - The parent element (TODO not sure what this will do when specified; Insert it as child element of the parent where?)
*/
/**
@@ -35,9 +33,12 @@ export function getNodeMeta(vnode){
* @return {Element}
*/
export function render(vnode, opts = {}) {
// TODO innerHTML, innerText and other tags/props that are trickyer then just mapping value to attribute (see NodeTreeRenderer)
// TODO ref-prop (should it only return once all child els are created and appended to the child?!)
// TODO this code could use restructuring when opts.host and vnode.type are incompatible (non updatable type), the host element should be replaced
// with a newly created element, like it does with all child-elements..
// General flow of this code is to process the hierarchy using a queue (so no recursion is used)
// on each node of the hierarchy a renderer is determined which is compared to the renderer of the previous version of this vnode-hierarchy
// to determine if these nodes can be updated (e.g updating a div, or textnode) and if they behave as a child-node (e.g. shows up in childNodes)
// or are some other special type of node (like Host or ShadowDOM)
/**
*
* @type {VRenderState}
@@ -48,7 +49,7 @@ export function render(vnode, opts = {}) {
queue: [{
// Start item
item: {
document: opts.document||document,
document: opts.document || document,
host: opts.host,
parent: opts.parent,
old: opts.old,
@@ -59,23 +60,23 @@ export function render(vnode, opts = {}) {
};
let newRoot = undefined;
while(state.queue.length>0){
let {item, meta, previous} = state.queue.splice(0,1)[0];
while (state.queue.length > 0) {
let { item, meta, previous } = state.queue.splice(0, 1)[ 0 ];
let renderer = meta.renderer;
if(!renderer) throw new Error("No renderer for vnode", item.vnode);
if (!renderer) throw new Error("No renderer for vnode", item.vnode);
// SVG handling..
if(!item.inSvg && item.vnode?.type === 'svg') item.inSvg = true;
else if(item.inSvg && item.vnode?.type === 'foreignObject') item.inSvg = false;
if (!item.inSvg && item.vnode?.type === 'svg') item.inSvg = true;
else if (item.inSvg && item.vnode?.type === 'foreignObject') item.inSvg = false;
// Create the element if no matching existing element was set
let newlyCreated = false;
if (!item.host) {
item.host = renderer.create(item, meta);
newlyCreated = true;
if(item.vnode?.props?.ref){// If props specify a ref-function, queue it to be called at the end of the render
state.refs.push([item.vnode.props.ref,item.host]);
if (item.vnode?.props?.ref) {// If props specify a ref-function, queue it to be called at the end of the render
state.refs.push([item.vnode.props.ref, item.host]);
}
}
@@ -83,7 +84,7 @@ export function render(vnode, opts = {}) {
renderer.update(item, meta);
// Update children
if(meta.normedType!==Element && (item.vnode?.children || item.old?.children)) {
if (meta.normedType !== Element && (item.vnode?.children || item.old?.children)) {
let childTypes = new Set();
// Flatten and organize new vNode-children (this could be a separate function, or implemented using a helper function (because mucht of the code is similar between old/new vnodes)
@@ -91,19 +92,19 @@ export function render(vnode, opts = {}) {
* @type { Object.<VNodeType, Array.<VRenderQueueItem>> }
*/
let vChildren = {};
let queue = (item.vnode?.children||[]).slice();
while(queue.length>0){
let next = queue.splice(0,1)[0];
if(next instanceof Array) queue.splice(0,0,...next);
else{
let queue = (item.vnode?.children || []).slice();
while (queue.length > 0) {
let next = queue.splice(0, 1)[ 0 ];
if (next instanceof Array) queue.splice(0, 0, ...next);
else {
let meta = getNodeMeta(next);
if(meta && meta.renderer) {
if (meta && meta.renderer) {
// Only items with a renderer are tracked (any other are undefined or null and shoulnd't be rendered at all)
let childType = meta.normedType;
if(!meta.renderer.remove) childType = 'node'; // Treat anything that doesnt have a special remove-function as ChildNode-type (e.g. it shows up in Element.childNodes)
if (!meta.renderer.remove) childType = 'node'; // Treat anything that doesnt have a special remove-function as ChildNode-type (e.g. it shows up in Element.childNodes)
childTypes.add(childType);// Track that children of this type exist and should be iterated later
vChildren[childType] = vChildren[childType] || []; // Make sure the array exists
vChildren[childType].push({
vChildren[ childType ] = vChildren[ childType ] || []; // Make sure the array exists
vChildren[ childType ].push({
item: {
...item,
old: undefined,
@@ -121,64 +122,64 @@ export function render(vnode, opts = {}) {
/**
* @type { Object.<VNodeType, Array.<VOldQueueItem>> }
*/
let oldVChildren = { };
let oldVChildren = {};
let curElement = item.host.firstChild;
queue = (item.old?.children || []).slice();
while(queue.length>0){
let next = queue.splice(0,1)[0];
if(next instanceof Array) queue.splice(0,0,...next);
else{
while (queue.length > 0) {
let next = queue.splice(0, 1)[ 0 ];
if (next instanceof Array) queue.splice(0, 0, ...next);
else {
let meta = getNodeMeta(next);
if(meta && meta.renderer) {
if (meta && meta.renderer) {
// Only items with a renderer are tracked (any other are undefined or null and shoulnd't be rendered at all)
let childType = meta.normedType;
let childElement;
if(!meta.renderer.remove){
if (!meta.renderer.remove) {
childType = 'node';// Treat anything that doesnt have a special remove-function as ChildNode-type (e.g. it shows up in Element.childNodes)
if(curElement){
if (curElement) {
childElement = curElement;
curElement = curElement.nextSibling;
}
}
childTypes.add(childType);// Track that children of this type exist and should be iterated later
oldVChildren[childType] = oldVChildren[childType] || []; // Make sure the array exists
oldVChildren[ childType ] = oldVChildren[ childType ] || []; // Make sure the array exists
let oldItem = {
vnode: next,
element: childElement,
meta: meta
};
oldVChildren[childType].push(oldItem);
if(next.props?.key){
state.keyedElements.set(next.key,oldItem);
oldVChildren[ childType ].push(oldItem);
if (next.props?.key) {
state.keyedElements.set(next.key, oldItem);
}
}
}
}
let sortedChildTypes = Array.from(childTypes).sort((a,b)=>a==='node'?1:-1); // Always do ChildNode-types last
let sortedChildTypes = Array.from(childTypes).sort((a, b) => a === 'node' ? 1 : -1); // Always do ChildNode-types last
let queuedItems = [];
/**@type {VRenderQueueItem}*/ let previous = null;
for(let childType of sortedChildTypes){
let newChildren = vChildren[childType];
let oldChildren = oldVChildren[childType];
for (let childType of sortedChildTypes) {
let newChildren = vChildren[ childType ];
let oldChildren = oldVChildren[ childType ];
while(newChildren && newChildren.length){
let child = newChildren.splice(0,1)[0];
while (newChildren && newChildren.length) {
let child = newChildren.splice(0, 1)[ 0 ];
// Key handling
let childKey = child.item.vnode.props?.key;
/**@type {VOldQueueItem}*/ let oldChild;
if(childKey){
if (childKey) {
oldChild = state.keyedElements.get(childKey);
if(oldChild) {
if (oldChild) {
if (oldChildren && oldChildren[ 0 ] === oldChild) {
// Old keyed child already in the right place (just clear it from the queue);
oldChildren.splice(0, 1);
} else {
// Old keyed child not already in the right place
let indexOfKeyed = oldChildren.indexOf(oldChild);
if(indexOfKeyed) {
if (indexOfKeyed) {
oldChildren.splice(indexOfKeyed, 1);
item.host.removeChild(oldChild.element);
}
@@ -190,32 +191,33 @@ export function render(vnode, opts = {}) {
}
}
}
if(!oldChild) oldChild = oldChildren && oldChildren.splice(0,1)[0];
if (!oldChild) oldChild = oldChildren && oldChildren.splice(0, 1)[ 0 ];
child.previous = previous;
if(oldChild && child.meta.normedType === oldChild.meta.normedType && childKey===oldChild.vnode.props?.key){
if (oldChild && child.meta.normedType === oldChild.meta.normedType && childKey === oldChild.vnode.props?.key
&& (child.meta.normedType !== Element || child.item.vnode === oldChild.vnode)) {
// Update old-child
child.item.host = oldChild.element;
child.item.old = oldChild.vnode;
queuedItems.push(child);
}else{
} else {
// New child
if(oldChild){
if(oldChild.meta.renderer.remove)
if (oldChild) {
if (oldChild.meta.renderer.remove)
oldChild.meta.renderer.remove({ ...item, parent: item.host, host: oldChild.element });
else
item.host.removeChild(oldChild.element);
}
queuedItems.push(child);
}
if(!child.meta.renderer.remove){
if (!child.meta.renderer.remove) {
// If child is a node-type item track it as the previous (so we can insert next node-type items after it as intended)
previous = child.item;
}
}
while(oldChildren && oldChildren.length){
let oldChild = oldChildren.splice(0,1)[0];
if(oldChild.meta.renderer.remove)
while (oldChildren && oldChildren.length) {
let oldChild = oldChildren.splice(0, 1)[ 0 ];
if (oldChild.meta.renderer.remove)
oldChild.meta.renderer.remove({ ...item, parent: item.host, host: oldChild.element });
else
item.host.removeChild(oldChild.element);
@@ -225,23 +227,23 @@ export function render(vnode, opts = {}) {
state.queue.splice(0, 0, ...queuedItems);
}
if(newlyCreated){
if(!meta.renderer.remove){
if(item.parent){
if(!previous){
if (newlyCreated) {
if (!meta.renderer.remove) {
if (item.parent) {
if (!previous) {
// First child
item.parent.prepend(item.host);
}else{
} else {
// Subsequent child
previous.host.after(item.host);
}
}
}
if(!item.parent) newRoot = item.host;
if (!item.parent) newRoot = item.host;
}
}
for(let [refCb, refItem] of state.refs){
for (let [refCb, refItem] of state.refs) {
refCb(refItem);
}
return newRoot;

View File

@@ -1,26 +1,9 @@
import '../types';
// Keys of a Element to be set directly rather than using setAttribute
const VNODEPROP_DIRECT = {
//['checked']: true NOT NEEDED!
};
const VNODEPROP_EXCLUDE_DIRECT = {
['style']: true,
['class']: true,
};
const VNODEPROP_IGNORE = {
['key']: true,
['ref']: true
};
const VNODE_SPECIAL_PROPS = {
['key']: false,
['ref']: false,
// TODO: className, (style), event/events, (see react-docs)
}
let namespace = {
svg: "http://www.w3.org/2000/svg"
}
// Plain HTML doens't need a namespace, if it did it would've shown up here..
};
/**
* Takes care of rendering a typical VNode (like div, span or any custom-element)
@@ -90,11 +73,14 @@ export const NodeTreeRenderer = {
// Now apply each
for(let [key, newVal, oldVal] of propDiffs){
let special = VNODE_SPECIAL_PROPS[key];
if(special === false){
if(special === false) {
// Ignore the prop
}else if(vtype.props?.has && vtype.props.has(key)){
// Registered prop of the Custom-Element
host[key] = newVal;
}else if(special instanceof Function){
// Special prop
special({host, newVal, oldVal, key});
}else if(key.slice(0,2)==='on' && key[2]>='A' && key[2]<='Z'){
// Event-prop
// Convert event name from camelCase to dash-case (this means that this on<EvenName> syntax might not be able to cover all custom-events)
@@ -117,3 +103,58 @@ export const NodeTreeRenderer = {
}
}
};
// Keys of a Element that need special handling rather than simply using setAttribute
const VNODE_SPECIAL_PROPS = {
['key']: false,
['ref']: false,
['className']: ({host, newVal, oldVal, key})=>{
let oldClasses = new Set();
let newClasses = new Set();
if(typeof(oldVal) === 'string'){
oldVal = oldVal.split(' ').map(x=>x.trim()).filter(x=>x);
for(let className of oldVal){ oldClasses.add(className); }
}else if(typeof(oldVal)==='object'){
for(let key in oldVal){
if(oldVal[key]) oldClasses.add(key);
}
}
if(typeof(newVal) === 'string'){
newVal = newVal.split(' ').map(x=>x.trim()).filter(x=>x);
for(let className of newVal){ newClasses.add(className); }
}else if(typeof(newVal)==='object'){
for(let key in newVal){
if(newVal[key]) newClasses.add(key);
}
}
host.classList.remove(...Array.from(oldClasses));
host.classList.add(...Array.from(newClasses));
},
['style']: ({host, newVal, oldVal, key})=>{
if(typeof(newVal) === 'string'){
host.setAttribute('style', newVal);
}else if(typeof(newVal) ==='object'){
if(oldVal){
for(let key in oldVal){
if(!newVal[key]){
host.style[key]=null;
}
}
for(let key in newVal){
host.style[key] = newVal[key];
}
}
}else if(!newVal){
host.removeAttribute('style');
}
},
['events']: ({host, newVal, oldVal, key})=>{
//TODO!!
throw new Error("We're still planning to implement this but it hasn't been done yet!");
},
};