Apply render tree diffs on JS side

This commit is contained in:
Steve Sanderson 2018-01-22 16:02:52 -08:00
parent 584e363110
commit 2c3925a05c
2 changed files with 261 additions and 146 deletions

View File

@ -0,0 +1,251 @@
import { System_Array, MethodHandle } from '../Platform/Platform';
import { getRenderTreeEditPtr, renderTreeEdit, EditType } from './RenderTreeEdit';
import { getTreeNodePtr, renderTreeNode, NodeType, RenderTreeNodePointer } from './RenderTreeNode';
import { platform } from '../Environment';
let raiseEventMethod: MethodHandle;
let renderComponentMethod: MethodHandle;
export class BrowserRenderer {
// TODO: Instead of associating components to parent elements, associate them with a
// start/end node, so that components don't have to be enclosed in a wrapper
// TODO: To avoid leaking memory, automatically remove entries from this dict as soon
// as the corresponding DOM nodes are removed (or maybe when the associated component
// is disposed, assuming we can guarantee that always happens).
private childComponentLocations: { [componentId: number]: Element } = {};
constructor(private browserRendererId: number) {
}
public attachComponentToElement(componentId: number, element: Element) {
this.childComponentLocations[componentId] = element;
}
public updateComponent(componentId: number, edits: System_Array, editsLength: number, referenceTree: System_Array) {
const element = this.childComponentLocations[componentId];
if (!element) {
throw new Error(`No element is currently associated with component ${componentId}`);
}
this.applyEdits(componentId, { parent: element, childIndex: 0 }, edits, editsLength, referenceTree);
}
applyEdits(componentId: number, location: DOMLocation, edits: System_Array, editsLength: number, referenceTree: System_Array) {
for (let editIndex = 0; editIndex < editsLength; editIndex++) {
const edit = getRenderTreeEditPtr(edits, editIndex);
const editType = renderTreeEdit.type(edit);
switch (editType) {
case EditType.continue: {
location.childIndex++;
break;
}
case EditType.prependNode: {
const nodeIndex = renderTreeEdit.newTreeIndex(edit);
const node = getTreeNodePtr(referenceTree, nodeIndex);
this.insertNode(componentId, location, referenceTree, node, nodeIndex);
break;
}
case EditType.removeNode: {
removeNodeFromDOM(location);
break;
}
case EditType.setAttribute: {
const nodeIndex = renderTreeEdit.newTreeIndex(edit);
const node = getTreeNodePtr(referenceTree, nodeIndex);
const element = location.parent.childNodes[location.childIndex] as HTMLElement;
this.applyAttribute(componentId, element, node, nodeIndex);
break;
}
case EditType.removeAttribute: {
removeAttributeFromDOM(location, renderTreeEdit.removedAttributeName(edit)!);
break;
}
case EditType.updateText: {
const nodeIndex = renderTreeEdit.newTreeIndex(edit);
const node = getTreeNodePtr(referenceTree, nodeIndex);
const domTextNode = location.parent.childNodes[location.childIndex] as Text;
domTextNode.textContent = renderTreeNode.textContent(node);
break;
}
case EditType.stepIn: {
location.parent = location.parent.childNodes[location.childIndex] as HTMLElement;
location.childIndex = 0;
break;
}
case EditType.stepOut: {
// To avoid the indexOf, consider maintaining a stack of locations
const targetElement = location.parent;
location.parent = location.parent.parentElement!;
location.childIndex = Array.prototype.indexOf.call(location.parent.childNodes, targetElement) + 1;
break;
}
default: {
const unknownType: never = editType; // Compile-time verification that the switch was exhaustive
throw new Error(`Unknown edit type: ${unknownType}`);
}
}
}
}
insertNode(componentId: number, location: DOMLocation, nodes: System_Array, node: RenderTreeNodePointer, nodeIndex: number) {
const nodeType = renderTreeNode.nodeType(node);
switch (nodeType) {
case NodeType.element:
this.insertElement(componentId, location, nodes, node, nodeIndex);
break;
case NodeType.text:
this.insertText(location, node);
break;
case NodeType.attribute:
throw new Error('Attribute nodes should only be present as leading children of element nodes.');
case NodeType.component:
this.insertComponent(location, node);
break;
default:
const unknownType: never = nodeType; // Compile-time verification that the switch was exhaustive
throw new Error(`Unknown node type: ${unknownType}`);
}
}
insertElement(componentId: number, location: DOMLocation, nodes: System_Array, node: RenderTreeNodePointer, nodeIndex: number) {
const tagName = renderTreeNode.elementName(node)!;
const newDomElement = document.createElement(tagName);
insertNodeIntoDOM(newDomElement, location);
// Apply attributes
const descendantsEndIndex = renderTreeNode.descendantsEndIndex(node);
for (let descendantIndex = nodeIndex + 1; descendantIndex <= descendantsEndIndex; descendantIndex++) {
const descendantNode = getTreeNodePtr(nodes, descendantIndex);
if (renderTreeNode.nodeType(descendantNode) === NodeType.attribute) {
this.applyAttribute(componentId, newDomElement, descendantNode, descendantIndex);
} else {
// As soon as we see a non-attribute child, all the subsequent child nodes are
// not attributes, so bail out and insert the remnants recursively
this.insertNodeRange(componentId, { parent: newDomElement, childIndex: 0 }, nodes, descendantIndex, descendantsEndIndex);
break;
}
}
}
insertComponent(location: DOMLocation, node: RenderTreeNodePointer) {
const containerElement = document.createElement('blazor-component');
insertNodeIntoDOM(containerElement, location);
const childComponentId = renderTreeNode.componentId(node);
this.attachComponentToElement(childComponentId, containerElement);
if (!renderComponentMethod) {
renderComponentMethod = platform.findMethod(
'Microsoft.Blazor.Browser', 'Microsoft.Blazor.Browser.Rendering', 'BrowserRendererEventDispatcher', 'RenderChildComponent'
);
}
// TODO: Consider caching the .NET string instance for this.browserRendererId
platform.callMethod(renderComponentMethod, null, [
platform.toDotNetString(this.browserRendererId.toString()),
platform.toDotNetString(childComponentId.toString())
]);
}
insertText(location: DOMLocation, textNode: RenderTreeNodePointer) {
const textContent = renderTreeNode.textContent(textNode)!;
const newDomTextNode = document.createTextNode(textContent);
insertNodeIntoDOM(newDomTextNode, location);
}
applyAttribute(componentId: number, toDomElement: Element, attributeNode: RenderTreeNodePointer, attributeNodeIndex: number) {
const attributeName = renderTreeNode.attributeName(attributeNode)!;
const browserRendererId = this.browserRendererId;
// TODO: Instead of applying separate event listeners to each DOM element, use event delegation
// and remove all the _blazor*Listener hacks
switch (attributeName) {
case 'onclick': {
toDomElement.removeEventListener('click', toDomElement['_blazorClickListener']);
const listener = () => raiseEvent(browserRendererId, componentId, attributeNodeIndex, 'mouse', { Type: 'click' });
toDomElement['_blazorClickListener'] = listener;
toDomElement.addEventListener('click', listener);
break;
}
case 'onkeypress': {
toDomElement.removeEventListener('keypress', toDomElement['_blazorKeypressListener']);
const listener = evt => {
// This does not account for special keys nor cross-browser differences. So far it's
// just to establish that we can pass parameters when raising events.
// We use C#-style PascalCase on the eventInfo to simplify deserialization, but this could
// change if we introduced a richer JSON library on the .NET side.
raiseEvent(browserRendererId, componentId, attributeNodeIndex, 'keyboard', { Type: evt.type, Key: (evt as any).key });
};
toDomElement['_blazorKeypressListener'] = listener;
toDomElement.addEventListener('keypress', listener);
break;
}
default:
// Treat as a regular string-valued attribute
toDomElement.setAttribute(
attributeName,
renderTreeNode.attributeValue(attributeNode)!
);
break;
}
}
insertNodeRange(componentId: number, location: DOMLocation, nodes: System_Array, startIndex: number, endIndex: number) {
for (let index = startIndex; index <= endIndex; index++) {
const node = getTreeNodePtr(nodes, index);
this.insertNode(componentId, location, nodes, node, index);
// Skip over any descendants, since they are already dealt with recursively
const descendantsEndIndex = renderTreeNode.descendantsEndIndex(node);
if (descendantsEndIndex > 0) {
index = descendantsEndIndex;
}
}
}
}
export interface DOMLocation {
parent: Element;
childIndex: number;
}
function insertNodeIntoDOM(node: Node, location: DOMLocation) {
const parent = location.parent;
if (location.childIndex >= parent.childNodes.length) {
parent.appendChild(node);
} else {
parent.insertBefore(node, parent.childNodes[location.childIndex]);
}
location.childIndex++;
}
function removeNodeFromDOM(location: DOMLocation) {
const parent = location.parent;
parent.removeChild(parent.childNodes[location.childIndex]);
}
function removeAttributeFromDOM(location: DOMLocation, attributeName: string) {
const element = location.parent.childNodes[location.childIndex] as HTMLElement;
element.removeAttribute(attributeName);
}
function raiseEvent(browserRendererId: number, componentId: number, renderTreeNodeIndex: number, eventInfoType: EventInfoType, eventInfo: any) {
if (!raiseEventMethod) {
raiseEventMethod = platform.findMethod(
'Microsoft.Blazor.Browser', 'Microsoft.Blazor.Browser.Rendering', 'BrowserRendererEventDispatcher', 'DispatchEvent'
);
}
const eventDescriptor = {
BrowserRendererId: browserRendererId,
ComponentId: componentId,
RenderTreeNodeIndex: renderTreeNodeIndex,
EventArgsType: eventInfoType
};
platform.callMethod(raiseEventMethod, null, [
platform.toDotNetString(JSON.stringify(eventDescriptor)),
platform.toDotNetString(JSON.stringify(eventInfo))
]);
}
type EventInfoType = 'mouse' | 'keyboard';

View File

@ -1,18 +1,11 @@
import { System_Object, System_String, System_Array, MethodHandle, Pointer } from '../Platform/Platform';
import { platform } from '../Environment';
import { getTreeNodePtr, renderTreeNode, NodeType, RenderTreeNodePointer } from './RenderTreeNode';
import { getRenderTreeEditPtr, renderTreeEdit, EditType, RenderTreeEditPointer } from './RenderTreeEdit';
import { RenderTreeEditPointer } from './RenderTreeEdit';
import { renderComponentArgs, RenderComponentArgsPointer } from './RenderComponentArgs';
let raiseEventMethod: MethodHandle;
let renderComponentMethod: MethodHandle;
import { BrowserRenderer } from './BrowserRenderer';
// TODO: Instead of associating components to parent elements, associate them with a
// start/end node, so that components don't have to be enclosed in a wrapper
// TODO: To avoid leaking memory, automatically remove entries from this dict as soon
// as the corresponding DOM nodes are removed (or maybe when the associated component
// is disposed, assuming we can guarantee that always happens).
type ComponentIdToParentElement = { [componentId: number]: Element };
type BrowserRendererRegistry = { [browserRendererId: number]: ComponentIdToParentElement };
type BrowserRendererRegistry = { [browserRendererId: number]: BrowserRenderer };
const browserRenderers: BrowserRendererRegistry = {};
export function attachComponentToElement(browserRendererId: number, elementSelector: System_String, componentId: number) {
@ -22,8 +15,12 @@ export function attachComponentToElement(browserRendererId: number, elementSelec
throw new Error(`Could not find any element matching selector '${elementSelectorJs}'.`);
}
browserRenderers[browserRendererId] = browserRenderers[browserRendererId] || {};
browserRenderers[browserRendererId][componentId] = element;
let browserRenderer = browserRenderers[browserRendererId];
if (!browserRenderer) {
browserRenderer = browserRenderers[browserRendererId] = new BrowserRenderer(browserRendererId);
}
browserRenderer.attachComponentToElement(componentId, element);
clearElement(element);
}
export function renderRenderTree(args: RenderComponentArgsPointer) {
@ -34,142 +31,11 @@ export function renderRenderTree(args: RenderComponentArgsPointer) {
}
const componentId = renderComponentArgs.componentId(args);
const element = browserRenderer[componentId];
if (!element) {
throw new Error(`No element is currently associated with component ${componentId}`);
}
clearElement(element);
const edits = renderComponentArgs.renderTreeEdits(args);
const editsLength = renderComponentArgs.renderTreeEditsLength(args);
const tree = renderComponentArgs.renderTree(args);
const treeLength = renderComponentArgs.renderTreeLength(args);
insertNodeRange(browserRendererId, componentId, element, tree, 0, treeLength - 1);
}
function insertNodeRange(browserRendererId: number, componentId: number, intoDomElement: Element, tree: System_Array, startIndex: number, endIndex: number) {
for (let index = startIndex; index <= endIndex; index++) {
const node = getTreeNodePtr(tree, index);
insertNode(browserRendererId, componentId, intoDomElement, tree, node, index);
// Skip over any descendants, since they are already dealt with recursively
const descendantsEndIndex = renderTreeNode.descendantsEndIndex(node);
if (descendantsEndIndex > 0) {
index = descendantsEndIndex;
}
}
}
function insertNode(browserRendererId: number, componentId: number, intoDomElement: Element, tree: System_Array, node: RenderTreeNodePointer, nodeIndex: number) {
const nodeType = renderTreeNode.nodeType(node);
switch (nodeType) {
case NodeType.element:
insertElement(browserRendererId, componentId, intoDomElement, tree, node, nodeIndex);
break;
case NodeType.text:
insertText(intoDomElement, node);
break;
case NodeType.attribute:
throw new Error('Attribute nodes should only be present as leading children of element nodes.');
case NodeType.component:
insertComponent(browserRendererId, intoDomElement, node);
break;
default:
const unknownType: never = nodeType; // Compile-time verification that the switch was exhaustive
throw new Error(`Unknown node type: ${ unknownType }`);
}
}
function insertComponent(browserRendererId: number, intoDomElement: Element, node: RenderTreeNodePointer) {
const containerElement = document.createElement('blazor-component');
intoDomElement.appendChild(containerElement);
var childComponentId = renderTreeNode.componentId(node);
browserRenderers[browserRendererId][childComponentId] = containerElement;
if (!renderComponentMethod) {
renderComponentMethod = platform.findMethod(
'Microsoft.Blazor.Browser', 'Microsoft.Blazor.Browser.Rendering', 'BrowserRendererEventDispatcher', 'RenderChildComponent'
);
}
platform.callMethod(renderComponentMethod, null, [
platform.toDotNetString(browserRendererId.toString()),
platform.toDotNetString(childComponentId.toString())
]);
}
function insertElement(browserRendererId: number, componentId: number, intoDomElement: Element, tree: System_Array, elementNode: RenderTreeNodePointer, elementNodeIndex: number) {
const tagName = renderTreeNode.elementName(elementNode)!;
const newDomElement = document.createElement(tagName);
intoDomElement.appendChild(newDomElement);
// Apply attributes
const descendantsEndIndex = renderTreeNode.descendantsEndIndex(elementNode);
for (let descendantIndex = elementNodeIndex + 1; descendantIndex <= descendantsEndIndex; descendantIndex++) {
const descendantNode = getTreeNodePtr(tree, descendantIndex);
if (renderTreeNode.nodeType(descendantNode) === NodeType.attribute) {
applyAttribute(browserRendererId, componentId, newDomElement, descendantNode, descendantIndex);
} else {
// As soon as we see a non-attribute child, all the subsequent child nodes are
// not attributes, so bail out and insert the remnants recursively
insertNodeRange(browserRendererId, componentId, newDomElement, tree, descendantIndex, descendantsEndIndex);
break;
}
}
}
function applyAttribute(browserRendererId: number, componentId: number, toDomElement: Element, attributeNode: RenderTreeNodePointer, attributeNodeIndex: number) {
const attributeName = renderTreeNode.attributeName(attributeNode)!;
switch (attributeName) {
case 'onclick':
toDomElement.addEventListener('click', () => raiseEvent(browserRendererId, componentId, attributeNodeIndex, 'mouse', { Type: 'click' }));
break;
case 'onkeypress':
toDomElement.addEventListener('keypress', evt => {
// This does not account for special keys nor cross-browser differences. So far it's
// just to establish that we can pass parameters when raising events.
// We use C#-style PascalCase on the eventInfo to simplify deserialization, but this could
// change if we introduced a richer JSON library on the .NET side.
raiseEvent(browserRendererId, componentId, attributeNodeIndex, 'keyboard', { Type: evt.type, Key: (evt as any).key });
});
break;
default:
// Treat as a regular string-valued attribute
toDomElement.setAttribute(
attributeName,
renderTreeNode.attributeValue(attributeNode)!
);
break;
}
}
function raiseEvent(browserRendererId: number, componentId: number, renderTreeNodeIndex: number, eventInfoType: EventInfoType, eventInfo: any) {
if (!raiseEventMethod) {
raiseEventMethod = platform.findMethod(
'Microsoft.Blazor.Browser', 'Microsoft.Blazor.Browser.Rendering', 'BrowserRendererEventDispatcher', 'DispatchEvent'
);
}
const eventDescriptor = {
BrowserRendererId: browserRendererId,
ComponentId: componentId,
RenderTreeNodeIndex: renderTreeNodeIndex,
EventArgsType: eventInfoType
};
platform.callMethod(raiseEventMethod, null, [
platform.toDotNetString(JSON.stringify(eventDescriptor)),
platform.toDotNetString(JSON.stringify(eventInfo))
]);
}
function insertText(intoDomElement: Element, textNode: RenderTreeNodePointer) {
const textContent = renderTreeNode.textContent(textNode)!;
const newDomTextNode = document.createTextNode(textContent);
intoDomElement.appendChild(newDomTextNode);
browserRenderer.updateComponent(componentId, edits, editsLength, tree);
}
function clearElement(element: Element) {
@ -178,5 +44,3 @@ function clearElement(element: Element) {
element.removeChild(childNode);
}
}
type EventInfoType = 'mouse' | 'keyboard';