Prop-decorators and supporting DOMElements as VNodes in the renderer

This commit is contained in:
2019-11-29 23:06:05 +01:00
parent 698656c8f6
commit e4eef2cc1a
20 changed files with 332 additions and 6663 deletions

View File

@@ -36,7 +36,7 @@
"scripts": {
"build": "rollup -c",
"watch": "rollup -c -w",
"npm-publish": "npm run build && npm publish --registry https://npm.cerxes.net"
"npm-publish": "npm run build && npm publish --registry https://npm.cerxes.net --tag latest"
},
"module": "./src/index.js",
"main": "./dist/index.js"

View File

@@ -35,7 +35,7 @@
"watch-cjs": "rollup -c -w",
"build-es6": "npx babel ./src --out-dir=lib",
"watch-es6": "npx babel ./src --out-dir=lib -w",
"npm-publish": "npm run build && npm publish --registry https://npm.cerxes.net"
"npm-publish": "npm run build && npm publish --registry https://npm.cerxes.net --tag latest"
},
"module": "./lib/index.js",
"main": "./dist/index.js"

View File

@@ -1,62 +1,8 @@
import {render} from "../vdom";
/** Helper class to mark an initializer-value to be used on first get of a value**/
class InitializerValue{ constructor(value){ this.value = value; } }
/**
* The decorators proposal has changed since @babel implemented it. This code will need to change at some point...
*/
export function State() {
return function decorator(target, key, descriptor){
let {get: oldGet, set: oldSet} = descriptor;
let valueKey='__'+key;
// Rewrite the property as if using getters and setters (if needed)
descriptor.get = oldGet = oldGet || function(){
let val = this[valueKey];
if(val instanceof InitializerValue){
this[valueKey] = val = val.value.call(this);
}
return val;
};
oldSet = oldSet || function(newVal){
this[valueKey]=newVal;
return newVal;
};
// Overwrite the setter to call markDirty whenever it is used
descriptor.set = function(newValue){
let result = oldSet.call(this, newValue);
this.markDirty && this.markDirty();
return result;
};
// Update the descriptor to match with using getters and setters
target.kind = 'method'; // update to get and set if need be..
delete descriptor.writable;
// Catch usage of initial value or initalizers
if(descriptor.value){
Object.defineProperty(target, valueKey, {
writable: true,
value: descriptor.value
});
delete descriptor.value;
}else if(descriptor.initializer){
Object.defineProperty(target, valueKey, {
writable: true,
value: new InitializerValue(descriptor.initializer)
});
delete descriptor.initializer;
}
return descriptor;
}
}
// TODO a proper Prop-decorator
export {State as Prop};
// 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
// they should run the super.connectCallback(). to make sure it actually gets rendered on mounting to the DOM...
/**
* This CustomElement class is to avoid having to do an ugly workaround in every custom-element:

View File

@@ -1,2 +1,4 @@
export * from './define-element';
export * from './custom-element';
export * from './custom-element';
export * from "./prop";
export * from "./state";

View File

@@ -0,0 +1,62 @@
import { trackValue } from "../decorator-utils/track-value";
/**
* Decorate a variable as a property of the custom-element.
* @param {Object} opts
* @param {boolean} opts.attr - Update the property when an attribute with specified name is set. Defaults to the property-name
* @param {boolean} opts.reflect - Reflect the property back to attributes when the property is set
*/
export function Prop(opts) {
opts = opts || {};
return function decorator(target, key, descriptor) {
// Dev-note: Tis is run for every instance made of the decorated class ...
// console.log("Prop " + key, target);
// TODO could check the prototype if a markDirty function exists, throw an error if it doesn't
// Register this prop on the class, so the VDOM-renderer can find this and set the prop instead of the attribute (if @meta is every introduced as a built-in decorator, this would be the place to use it)
if (!target.constructor.props) {
target.constructor.props = new Set();
}
target.constructor.props.add(key);
let attrName = opts.attr !== false && typeof (opts.attr) === "string" ? opts.attr : key;
// Prop behavior
let trackedDecorator = trackValue({ target, key, descriptor }, function (value) {
//console.log(`Prop ${key} was set: ` + JSON.stringify(value));
if (opts.reflect) {
if ((value ?? false) === false) {
this.removeAttribute(attrName);
} else if (value === true) {
this.setAttribute(attrName, "");
} else {
this.setAttribute(attrName, value);
}
}
this.markDirty && this.markDirty();
});
// Registering as attr and subscribing to relevant callbacks
if (opts.attr !== false || opts.attr === undefined) {
let oldCallback = target.attributeChangedCallback;
target.attributeChangedCallback = function (...args) {
let [name, oldValue, newValue] = args;
if (name === attrName) {
// This is our tracked prop, pipe the attribute-value the the property-setter
//console.log(`Attribute ${attrName} was set: ` + JSON.stringify(newValue));
trackedDecorator.set.call(this, newValue);
}
if (oldCallback) oldCallback.call(this, ...args);
};
let observedAttrs = Array.from(new Set([attrName, ...(target.constructor.observedAttributes || [])]));
Object.defineProperty(target.constructor, 'observedAttributes', {
get: () => observedAttrs,
configurable: true
});
}
return trackedDecorator
}
}

View File

@@ -0,0 +1,13 @@
import {trackValue} from "../decorator-utils/track-value";
/**
* Decorate a variable as part of the component-state, and will thus trigger a re-render whenever it is changed
*/
export function State() {
return function decorator(target, key, descriptor){
// Dev-note: Tis is run for every instance made of the decorated class ...
// console.log("State " + key, target);
return trackValue({target, key, descriptor}, function(value){ this.markDirty && this.markDirty() });
}
}

View File

@@ -0,0 +1,72 @@
/** Helper class to mark an initializer-value to be used on first get of a value**/
class InitializerValue{ constructor(value){ this.value = value; } }
/**
* This callback type is called `requestCallback` and is displayed as a global symbol.
*
* @callback trackValueCallback
* @param {any} value
* @param {PropertyKey} prop
*/
/**
* This is an implementation of https://github.com/tc39/proposal-decorators/blob/master/NEXTBUILTINS.md#tracked
* to use until support for the new decorators proposal lands in @babel
*
* @param {Object} decoratorArgs
* @param {any} decoratorArgs.target
* @param {PropertyKey} decoratorArgs.key
* @param {PropertyDescriptor} decoratorArgs.descriptor
* @param {trackValueCallback} cb
* @return {PropertyDescriptor}
*/
export function trackValue({target, key, descriptor}, cb){
let {get: oldGet, set: oldSet} = descriptor;
let valueKey='__'+key;
if(!cb) throw Error("No callback given to track property. No point in decorating the prop");
// TODO the story here of handling an initializer value is a bit dirty... Ideally, we'd want to run the value initializer before the class-constructor is called..
// Rewrite the property as if using getters and setters (if needed)
descriptor.get = oldGet = oldGet || function(){
let val = this[valueKey];
if(val instanceof InitializerValue){
this[valueKey] = val = val.value.call(this);
}
return val;
};
oldSet = oldSet || function(newVal){
this[valueKey]=newVal;
return newVal;
};
// Overwrite the setter to call markDirty whenever it is used
descriptor.set = function(newValue){
let oldvalue = oldGet.call(this);
let result = oldSet.call(this, newValue);
if(oldvalue!==newValue) cb.call(this,newValue, key);
return result;
};
// Update the descriptor to match with using getters and setters
target.kind = 'method'; // update to get and set if need be..
delete descriptor.writable;
// Catch usage of initial-value or value-initalizers and handle'em
if(descriptor.value){
Object.defineProperty(target, valueKey, {
writable: true,
value: descriptor.value
});
delete descriptor.value;
}else if(descriptor.initializer){
Object.defineProperty(target, valueKey, {
writable: true,
value: new InitializerValue(descriptor.initializer)
});
delete descriptor.initializer;
}
return descriptor;
}

View File

@@ -3,17 +3,18 @@ import {
HostNodeRenderer, Host,
ShadowNodeRenderer, ShadowDOM,
PrimitiveRenderer, Primitive,
NodeTreeRenderer
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};
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: type};
else return {renderer: NodeTreeRenderer, normedType: window.customElements?.get(type)??type};
}
@@ -70,7 +71,7 @@ export function render(vnode, opts = {}) {
// Create the element if no matching existing element was set
let newlyCreated = false;
if (!item.host) {
item.host = renderer.create(item);
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
@@ -79,10 +80,10 @@ export function render(vnode, opts = {}) {
}
// Update the element
renderer.update(item);
renderer.update(item, meta);
// Update children
if(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)

View File

@@ -1,4 +1,5 @@
export * from "./hostnode";
export * from "./nodetree";
export * from "./nodeprimitive";
export * from "./shadownode";
export * from "./shadownode";
export * from "./nativeelement"

View File

@@ -0,0 +1,23 @@
import '../types';
/**
* Takes care of rendering a Native DOM-Element
*
* @class
* @implements {VNodeRenderer}
*/
export const NativeRenderer = {
/**
* @param {VRenderItem} item
*/
create(item){
return item.vnode;
},
/**
* @param {VRenderItem} item
*/
update(item){
return;// NO-OP
}
};

View File

@@ -12,6 +12,11 @@ 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"
@@ -48,8 +53,10 @@ export const NodeTreeRenderer = {
/**
* @param {VRenderItem} item
*/
update(item){
update(item, meta){
let vnode = item.vnode;
let vtype = meta?.normedType||item.vnode?.type;
/**
* @type {VNodeProps}
*/
@@ -82,10 +89,11 @@ export const NodeTreeRenderer = {
// Now apply each
for(let [key, newVal, oldVal] of propDiffs){
if(VNODEPROP_IGNORE[key]){
// Prop to be ignored (like 'key')
}else if(VNODEPROP_DIRECT[key]){
// Direct-value prop only (e.g. checked attribute of checkbox will reflect in attributes automatically, no need to set the attribute..)
let special = VNODE_SPECIAL_PROPS[key];
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(key.slice(0,2)==='on' && key[2]>='A' && key[2]<='Z'){
// Event-prop
@@ -96,13 +104,8 @@ export const NodeTreeRenderer = {
}else{
host.addEventListener(eventName, newVal);
}
// TODO might want to support objects for defining events, so we can specifiy passive or not, and other event options
}else{
if(!VNODEPROP_EXCLUDE_DIRECT[key] && !item.inSvg){
// TODO there are many properties we do not want to be setting directly.. (transform attr on svg's is a good example...)
// Unless otherwise excluded, set the prop directly on the Element as well (because this is what we'd typically want to do passing complex objects into custom-elements)
host[key] = newVal;
}
// Assumed to be just an attribute
if(newVal===undefined || newVal === false || newVal===null || newVal===''){
host.removeAttribute(key);
}else if(newVal === true){

File diff suppressed because it is too large Load Diff