cascade
Version:
A modern library for creating user interfaces.
596 lines (537 loc) • 24.5 kB
text/typescript
import Cascade from '../cascade/Cascade';
import Computed from '../graph/Computed';
import VirtualNode from './VirtualNode';
import ComponentNode from './ComponentNode';
import Fragment from './Fragment';
import { IVirtualNode, IVirtualNodeProps } from './IVirtualNode';
import Diff, { DiffOperation } from '../util/Diff';
// Store ComponentContext in window to prevent multiple Cascade instance problem.
export interface IComponentContext {
componentContexts: ComponentNode<IVirtualNodeProps>[][];
context: ComponentNode<IVirtualNodeProps>[];
}
var componentContext: IComponentContext = window['$_cascade_component_context'] || {};
window['$_cascade_component_context'] = componentContext;
componentContext.componentContexts = componentContext.componentContexts || [];
componentContext.context = componentContext.context || undefined;
export abstract class Component<T> implements IVirtualNode<T> {
// TODO: Remove unused uniqueId?
uniqueId: number = Math.floor(Math.random() * 1000000);
props: T & IVirtualNodeProps;
prevProps: T & IVirtualNodeProps;
children: any;
key: string | number;
root: any;
element: Node;
context: ComponentNode<any>[];
oldContext: ComponentNode<any>[];
mounted: boolean = false;
rendered: boolean = false;
portal: boolean = false;
constructor(props?: T & IVirtualNodeProps, children?: any[]) {
this.storeProps(props, children);
}
storeProps(props?: T & IVirtualNodeProps, children?: any[]) {
this.prevProps = this.props;
this.props = props || ({} as any);
this.key = this.props.key;
// TODO: Remove key and ref?
// if (this.props.key) {
// delete this.props.key;
// }
this.children = children;
this.afterProps(this.mounted);
}
build() {
// Store old context
this.oldContext = this.context;
// Create a new context
Component.pushContext();
// Render
var root = this.render();
// Store the new context
this.context = Component.popContext();
return root;
}
init() {
// This should subscribe to all observables used by render.
Cascade.createComputed(this, 'root', () => {
Cascade.wrapContext(() => {
this.beforeRender(this.mounted);
});
return this.build();
});
// Only update if we are re-rendering
Cascade.subscribe(this, 'root', (root: any, oldRoot: any) => {
if (this.rendered) {
var element = this.element;
// Get namespace from current element
// If element is an svg, use undefined, as it may change
// Otherwise, assume the namespace comes from parent
let namespace;
if (element && element.nodeName !== 'svg') {
let namespaceURI = element.namespaceURI;
namespace = (namespaceURI && namespaceURI.endsWith('svg')) ? namespaceURI : undefined;
}
this.toNode(namespace, oldRoot);
// Dispose of old context
this.disposeContext();
if (element !== this.element) {
if (element) {
var parentNode = element.parentNode;
if (parentNode) {
if (this.element) {
parentNode.replaceChild(this.element, element);
} else {
parentNode.removeChild(element);
}
}
}
}
} else {
this.rendered = true
}
});
}
update(props?: T & IVirtualNodeProps, children?: any[]) {
this.storeProps(props, children);
this.rendered = false;
return Cascade.update(this, 'root');
}
abstract render(): any;
toNode(namespace?: string, oldRoot?: any): Node {
// Get root
var root = Cascade.peek(this, 'root') as any;
// Store old element
var oldElement = this.element;
var element: Node;
var rootType = typeof root;
var noDispose: boolean = false;
// Test if we must diff.
// If we have an old root, we may need to diff.
if (oldRoot && typeof oldRoot === rootType) {
switch (rootType) {
case 'string':
if (root !== oldRoot) {
element = document.createTextNode(root as string);
} else {
element = oldElement;
}
break;
case 'object':
if (root) {
if (root instanceof ComponentNode) {
// Root is a Component
if (root.componentConstructor === oldRoot.componentConstructor && root.key === oldRoot.key) {
// Root and OldRoot are both the same Component - Diff this case
if (oldRoot.component instanceof Fragment) {
element = this.diffFragments(root, oldRoot, oldElement, namespace);
} else {
element = this.diffComponents(root, oldRoot, namespace);
swapChildren(element, oldElement);
}
noDispose = true;
} else {
// Root is a different Component
element = root.toNode(namespace);
}
} else if (root.type) {
// Root is a VirtualNode
if (root.type === oldRoot.type && root.key === oldRoot.key) {
// Root and OldRoot are both the same VirtualNode - Diff this case
root.element = oldElement;
element = this.diffVirtualNodes(root, oldRoot, oldElement as HTMLElement, namespace);
} else {
// Root is a different VirtualNode
element = root.toNode(namespace);
}
} else {
// Root is an Object
element = document.createTextNode(root.toString());
}
// Ignore Null
}
break;
case 'undefined':
// Ignore Undefined
break;
// Number and anything else
// case 'number':
default:
if (root !== oldRoot) {
// Render to a string
element = document.createTextNode(root.toString());
} else {
// Root and OldRoot are the same
element = oldElement;
}
break;
}
} else {
switch (rootType) {
case 'string':
element = document.createTextNode(root as string);
break;
case 'object':
if (root) {
if (root.toNode) {
element = root.toNode(namespace);
} else {
element = document.createTextNode(root.toString());
}
}
// Ignore Null
break;
case 'undefined':
// Ignore Undefined
break;
// Number and anything else
// case 'number':
default:
element = document.createTextNode(root.toString());
break;
}
}
if (this.props && this.props.ref) {
if (typeof this.props.ref === 'function') {
this.props.ref(element);
} else {
this.props.ref.current = element;
}
}
if (!noDispose && oldRoot && oldRoot instanceof ComponentNode) {
//oldRoot.dispose();
}
this.afterRender(element, this.mounted);
if (!element) {
element = document.createComment('Empty Component');
}
this.element = element;
this.rendered = true;
this.mounted = true;
if (this.portal) {
element = document.createComment('Empty Component');
}
return element;
}
dispose() {
var computed = Cascade.getObservable(this, 'root') as Computed<any>;
computed.dispose();
if (this.context) {
for (var index = 0, length = this.context.length; index < length; index++) {
let component = this.context[index];
component.dispose();
}
}
this.afterDispose(this.element);
}
disposeContext() {
if (this.oldContext) {
for (var index = 0, length = this.oldContext.length; index < length; index++) {
let component = this.oldContext[index];
component.dispose();
}
}
}
afterProps(updating: boolean) {
}
beforeRender(updating: boolean) {
}
afterRender(node: Node, updating: boolean) {
}
afterDispose(node: Node) {
}
diffFragments(newRootComponentNode: ComponentNode<IVirtualNodeProps>, oldRootComponentNode: ComponentNode<IVirtualNodeProps>, oldElement: Node, namespace: string, offsetIndex: number = 0) {
var output: Node;
let oldRoot: Fragment = oldRootComponentNode.component as any;
oldRootComponentNode.component = undefined;
newRootComponentNode.component = oldRoot as any;
oldRoot.update(newRootComponentNode.props, newRootComponentNode.children);
this.diffVirtualNodes(oldRoot as any, oldRoot as any, oldElement as any, namespace, offsetIndex);
return oldElement;
}
diffComponents(newRootComponentNode: ComponentNode<IVirtualNodeProps>, oldRootComponentNode: ComponentNode<IVirtualNodeProps>, namespace: string) {
// No diff necessary. We have the exact same Components
if (newRootComponentNode === oldRootComponentNode) {
return oldRootComponentNode.component.element;
}
// We have already rendered newRootComponentNode. It is likely a child Component.
if (newRootComponentNode.component) {
return newRootComponentNode.component.element;
}
var output: Node;
let oldRoot = oldRootComponentNode.component;
let oldElement = oldRoot.element;
// This should never happen
if (!oldRoot) {
throw 'Old Component has never been rendered. Replacing with new Component.';
}
oldRootComponentNode.component = undefined;
newRootComponentNode.component = oldRoot;
let innerOldRoot = Cascade.peekDirty(oldRoot, 'root');
let innerRoot = oldRoot.update(newRootComponentNode.props, newRootComponentNode.children);
if (!innerOldRoot) {
// We are replacing
switch (typeof innerRoot) {
case 'object':
if (innerRoot) {
if (innerRoot.toNode) {
output = innerRoot.toNode(namespace);
} else {
output = document.createTextNode(innerRoot.toString());
}
}
break;
case 'undefined':
break;
default:
output = document.createTextNode(innerRoot.toString());
}
} else {
switch (typeof innerRoot) {
case 'object':
if (innerRoot) {
// InnerRoot is not Null
if (innerRoot instanceof ComponentNode) {
// InnerRoot is a Component
if (innerOldRoot instanceof ComponentNode && innerRoot.componentConstructor === innerOldRoot.componentConstructor && innerRoot.key === innerOldRoot.key) {
// InnerRoot is the same Component as InnerOldRoot - Diff this case
if (innerOldRoot.component instanceof Fragment) {
output = this.diffFragments(innerRoot, innerOldRoot, oldElement, namespace);
} else {
output = this.diffComponents(innerRoot, innerOldRoot, namespace);
swapChildren(output, oldElement);
}
} else {
// Replace
output = innerRoot.toNode(namespace);
// Dispose innerOldRoot
innerOldRoot.dispose();
}
} else if (innerRoot instanceof VirtualNode) {
if (innerOldRoot instanceof VirtualNode && innerRoot.type === innerOldRoot.type && innerRoot.key === innerOldRoot.key) {
// InnerRoot is the same VirtualNode as InnerOldRoot - Diff this case
output = this.diffVirtualNodes(innerRoot, innerOldRoot, oldElement as HTMLElement, namespace);
} else {
// Replace
output = innerRoot.toNode(namespace);
}
}
}
// Ignore Null
break;
case 'undefined':
// Ignore Undefined
break;
case 'string':
if (innerRoot === innerOldRoot) {
// InnerRoot is the same as InnerOldRoot
output = oldElement;
} else {
// Replace
output = document.createTextNode(innerRoot);
}
break;
default:
if (innerRoot === innerOldRoot) {
// InnerRoot is the same as InnerOldRoot
output = oldElement;
} else {
// Replace
output = document.createTextNode(innerRoot.toString());
}
}
}
// Call the ref for oldRoot
if (oldRoot.props.ref) {
if (typeof oldRoot.props.ref === 'function') {
oldRoot.props.ref(output);
} else {
oldRoot.props.ref.current = output;
}
}
if (oldRoot.afterRender) {
oldRoot.afterRender(output, true);
}
if (!output) {
output = document.createComment('Empty Component');
}
oldRoot.element = output;
if (oldRoot.portal) {
output = document.createComment('Empty Component');
}
return output;
}
diffVirtualNodes(newRoot: VirtualNode<IVirtualNodeProps>, oldRoot: VirtualNode<IVirtualNodeProps>, oldElement: HTMLElement, namespace: string, offsetIndex?: number) {
// TODO: This case should not happen.
if (!oldRoot || oldRoot.type !== newRoot.type) {
// We are cleanly replacing
oldElement = newRoot.toNode(namespace);
} else if (newRoot === oldRoot) {
// No diff necessary. We have the exact same VirtualNodes
} else {
// Old and New Roots match
var diff = Diff.compare(oldRoot.children, newRoot.children, compareVirtualNodes);
var propertyDiff = Diff.compareHash(oldRoot.props, newRoot.props);
namespace = namespace || ((oldElement && oldElement.namespaceURI && oldElement.namespaceURI.endsWith('svg')) ? oldElement.namespaceURI : undefined)
var childIndex = oldRoot.children.length - 1 + (offsetIndex || 0);
for (var index = 0, length = diff.length; index < length; index++) {
var diffItem = diff[index];
switch (diffItem.operation) {
case DiffOperation.REMOVE:
var oldChild = diffItem.item;
// We need to know if oldChild is a Fragment or a Component with a root Fragment
if (oldChild.component && oldChild.component.element.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
let fragmentLength = oldChild.component.getChildLength();
let fragmentIndexLength = fragmentLength + childIndex;
for (let fragmentIndex = childIndex; fragmentIndex < fragmentIndexLength; fragmentIndex++) {
oldElement.removeChild(oldElement.childNodes[fragmentIndex]);
}
childIndex -= fragmentLength;
} else {
oldElement.removeChild(oldElement.childNodes[childIndex]);
childIndex--;
}
break;
case DiffOperation.NONE:
var newChild = diffItem.itemB;
var oldChild = diffItem.item;
// Diff recursively
// TODO: Remove extra casts
if (typeof newChild === 'object') {
if (newChild instanceof ComponentNode) {
if (oldChild.component instanceof Fragment) {
this.diffVirtualNodes(newChild as any, oldChild, oldElement.childNodes[childIndex] as HTMLElement, namespace, childIndex);
} else {
let newNode = this.diffComponents(newChild, oldChild, namespace);
swapChildren(newNode, oldElement.childNodes[childIndex] as HTMLElement, oldElement, childIndex);
}
} else if (newChild instanceof VirtualNode) {
// TODO: This is the only case where we don't know if oldChild exists and has the same type as newChild.
// Perhaps we should figure that out here intead of inside diffVirtualNodes.
this.diffVirtualNodes(newChild as any, oldChild as any, oldElement.childNodes[childIndex] as HTMLElement, namespace);
}
}
childIndex--;
break;
case DiffOperation.ADD:
var newChild = diffItem.item;
var newElement;
// TODO: Null and Undefined should never happen
switch (typeof newChild) {
case 'string':
newElement = document.createTextNode(newChild as string);
oldElement.insertBefore(newElement, oldElement.childNodes[childIndex + 1]);
break;
case 'object':
if (newChild) {
if ((newChild as any).toNode) {
newElement = (newChild as any).toNode(namespace);
} else {
newElement = document.createTextNode(newChild.toString());
}
oldElement.insertBefore(newElement, oldElement.childNodes[childIndex + 1]);
}
break;
// case 'undefined':
// break;
// Number and anything else
// case 'number':
default:
newElement = document.createTextNode(newChild.toString());
oldElement.insertBefore(newElement, oldElement.childNodes[childIndex + 1]);
break;
}
break;
}
}
for (var name in propertyDiff) {
if (propertyDiff.hasOwnProperty(name)) {
var property = propertyDiff[name];
if (property === null) {
VirtualNode.removeAttribute(oldElement, name, namespace);
} else {
VirtualNode.setAttribute(oldElement, name, property, namespace);
}
}
}
// Call the ref for newRoot
if (newRoot.props.ref) {
if (typeof newRoot.props.ref === 'function') {
newRoot.props.ref(oldElement);
} else {
newRoot.props.ref.current = oldElement;
}
}
}
return oldElement;
}
getChildLength() {
let root = Cascade.peekDirty(this, 'root') as any;
if (root instanceof ComponentNode && root.component.getChildLength) {
return root.component.getChildLength();
} else {
return 1;
}
}
static getContext() {
return componentContext.context;
}
static pushContext() {
componentContext.context = [];
componentContext.componentContexts.unshift(componentContext.context);
return componentContext.context;
}
static popContext() {
var oldContext = componentContext.componentContexts.shift();
componentContext.context = componentContext.componentContexts[0];
return oldContext;
}
}
function compareVirtualNodes(nodeA: any, nodeB: any) {
var typeA = typeof nodeA;
var typeB = typeof nodeB;
if (typeA === typeB) {
// The two are of the same type
switch (typeA) {
case 'object':
// If nodeA and nodeB are both IVirtualNodes
if (nodeA && nodeB && (nodeA as any).toNode && (nodeB as any).toNode) {
if ((nodeA as IVirtualNode<IVirtualNodeProps>).key === (nodeB as IVirtualNode<IVirtualNodeProps>).key) {
if (nodeA instanceof ComponentNode) {
return nodeA.componentConstructor === nodeB.componentConstructor;
} else {
return (nodeA as VirtualNode<IVirtualNodeProps>).type === (nodeB as VirtualNode<IVirtualNodeProps>).type;
}
} else {
return false;
}
} else {
// This covers null
return nodeA === nodeB;
}
// case 'undefined';
// case 'string':
// case 'number':
default:
return nodeA === nodeB;
}
} else {
return false;
}
}
function swapChildren(newNode: Node, oldNode: Node, parent?: HTMLElement, index?: number) {
if (newNode) {
if (newNode !== oldNode) {
if (oldNode && oldNode.parentNode) {
oldNode.parentNode.replaceChild(newNode, oldNode);
} else if (parent) {
parent.insertBefore(newNode, parent.childNodes[index + 1]);
}
}
} else {
if (oldNode && oldNode.parentNode) {
oldNode.parentNode.removeChild(oldNode);
}
}
}