Skip to content
This repository has been archived by the owner on Feb 25, 2021. It is now read-only.

Commit

Permalink
Improve JS-side event handling code. Fixes #433
Browse files Browse the repository at this point in the history
  • Loading branch information
SteveSandersonMS committed Apr 10, 2018
1 parent a20e6c2 commit 3bbe9f5
Show file tree
Hide file tree
Showing 13 changed files with 320 additions and 70 deletions.
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { invokeWithJsonMarshalling } from './InvokeWithJsonMarshalling';
import { attachComponentToElement, renderBatch } from '../Rendering/Renderer';
import { attachRootComponentToElement, renderBatch } from '../Rendering/Renderer';

/**
* The definitive list of internal functions invokable from .NET code.
* These function names are treated as 'reserved' and cannot be passed to registerFunction.
*/
export const internalRegisteredFunctions = {
attachComponentToElement,
attachRootComponentToElement,
invokeWithJsonMarshalling,
renderBatch,
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@
import { getRenderTreeEditPtr, renderTreeEdit, RenderTreeEditPointer, EditType } from './RenderTreeEdit';
import { getTreeFramePtr, renderTreeFrame, FrameType, RenderTreeFramePointer } from './RenderTreeFrame';
import { platform } from '../Environment';
import { EventDelegator } from './EventDelegator';
import { EventForDotNet, UIEventArgs } from './EventForDotNet';
const selectValuePropname = '_blazorSelectValue';
let raiseEventMethod: MethodHandle;
let renderComponentMethod: MethodHandle;

export class BrowserRenderer {
private eventDelegator: EventDelegator;
private childComponentLocations: { [componentId: number]: Element } = {};

constructor(private browserRendererId: number) {
this.eventDelegator = new EventDelegator((event, componentId, eventHandlerId, eventArgs) => {
raiseEvent(event, this.browserRendererId, componentId, eventHandlerId, eventArgs);
});
}

public attachComponentToElement(componentId: number, element: Element) {
this.childComponentLocations[componentId] = element;
public attachRootComponentToElement(componentId: number, element: Element) {
this.attachComponentToElement(componentId, element);
}

public updateComponent(componentId: number, edits: System_Array<RenderTreeEditPointer>, editsOffset: number, editsLength: number, referenceFrames: System_Array<RenderTreeFramePointer>) {
Expand All @@ -29,7 +35,15 @@ export class BrowserRenderer {
delete this.childComponentLocations[componentId];
}

applyEdits(componentId: number, parent: Element, childIndex: number, edits: System_Array<RenderTreeEditPointer>, editsOffset: number, editsLength: number, referenceFrames: System_Array<RenderTreeFramePointer>) {
public disposeEventHandler(eventHandlerId: number) {
this.eventDelegator.removeListener(eventHandlerId);
}

private attachComponentToElement(componentId: number, element: Element) {
this.childComponentLocations[componentId] = element;
}

private applyEdits(componentId: number, parent: Element, childIndex: number, edits: System_Array<RenderTreeEditPointer>, editsOffset: number, editsLength: number, referenceFrames: System_Array<RenderTreeFramePointer>) {
let currentDepth = 0;
let childIndexAtCurrentDepth = childIndex;
const maxEditIndexExcl = editsOffset + editsLength;
Expand Down Expand Up @@ -58,6 +72,8 @@ export class BrowserRenderer {
break;
}
case EditType.removeAttribute: {
// Note that we don't have to dispose the info we track about event handlers here, because the
// disposed event handler IDs are delivered separately (in the 'disposedEventHandlerIds' array)
const siblingIndex = renderTreeEdit.siblingIndex(edit);
removeAttributeFromDOM(parent, childIndexAtCurrentDepth + siblingIndex, renderTreeEdit.removedAttributeName(edit)!);
break;
Expand Down Expand Up @@ -91,7 +107,7 @@ export class BrowserRenderer {
}
}

insertFrame(componentId: number, parent: Element, childIndex: number, frames: System_Array<RenderTreeFramePointer>, frame: RenderTreeFramePointer, frameIndex: number): number {
private insertFrame(componentId: number, parent: Element, childIndex: number, frames: System_Array<RenderTreeFramePointer>, frame: RenderTreeFramePointer, frameIndex: number): number {
const frameType = renderTreeFrame.frameType(frame);
switch (frameType) {
case FrameType.element:
Expand All @@ -113,7 +129,7 @@ export class BrowserRenderer {
}
}

insertElement(componentId: number, parent: Element, childIndex: number, frames: System_Array<RenderTreeFramePointer>, frame: RenderTreeFramePointer, frameIndex: number) {
private insertElement(componentId: number, parent: Element, childIndex: number, frames: System_Array<RenderTreeFramePointer>, frame: RenderTreeFramePointer, frameIndex: number) {
const tagName = renderTreeFrame.elementName(frame)!;
const newDomElement = tagName === 'svg' || parent.namespaceURI === 'http://www.w3.org/2000/svg' ?
document.createElementNS('http://www.w3.org/2000/svg', tagName) :
Expand All @@ -135,7 +151,7 @@ export class BrowserRenderer {
}
}

insertComponent(parent: Element, childIndex: number, frame: RenderTreeFramePointer) {
private insertComponent(parent: Element, childIndex: number, frame: RenderTreeFramePointer) {
// Currently, to support O(1) lookups from render tree frames to DOM nodes, we rely on
// each child component existing as a single top-level element in the DOM. To guarantee
// that, we wrap child components in these 'blazor-component' wrappers.
Expand All @@ -162,68 +178,41 @@ export class BrowserRenderer {
this.attachComponentToElement(childComponentId, containerElement);
}

insertText(parent: Element, childIndex: number, textFrame: RenderTreeFramePointer) {
private insertText(parent: Element, childIndex: number, textFrame: RenderTreeFramePointer) {
const textContent = renderTreeFrame.textContent(textFrame)!;
const newDomTextNode = document.createTextNode(textContent);
insertNodeIntoDOM(newDomTextNode, parent, childIndex);
}

applyAttribute(componentId: number, toDomElement: Element, attributeFrame: RenderTreeFramePointer) {
private applyAttribute(componentId: number, toDomElement: Element, attributeFrame: RenderTreeFramePointer) {
const attributeName = renderTreeFrame.attributeName(attributeFrame)!;
const browserRendererId = this.browserRendererId;
const eventHandlerId = renderTreeFrame.attributeEventHandlerId(attributeFrame);

if (eventHandlerId) {
const firstTwoChars = attributeName.substring(0, 2);
const eventName = attributeName.substring(2);
if (firstTwoChars !== 'on' || !eventName) {
throw new Error(`Attribute has nonzero event handler ID, but attribute name '${attributeName}' does not start with 'on'.`);
}
this.eventDelegator.setListener(toDomElement, eventName, componentId, eventHandlerId);
return;
}

if (attributeName === 'value') {
if (this.tryApplyValueProperty(toDomElement, renderTreeFrame.attributeValue(attributeFrame))) {
return; // If this DOM element type has special 'value' handling, don't also write it as an attribute
}
}

// 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 = evt => raiseEvent(evt, browserRendererId, componentId, eventHandlerId, 'mouse', { Type: 'click' });
toDomElement['_blazorClickListener'] = listener;
toDomElement.addEventListener('click', listener);
break;
}
case 'onchange': {
toDomElement.removeEventListener('change', toDomElement['_blazorChangeListener']);
const targetIsCheckbox = isCheckbox(toDomElement);
const listener = evt => {
const newValue = targetIsCheckbox ? evt.target.checked : evt.target.value;
raiseEvent(evt, browserRendererId, componentId, eventHandlerId, 'change', { Type: 'change', Value: newValue });
};
toDomElement['_blazorChangeListener'] = listener;
toDomElement.addEventListener('change', 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(evt, browserRendererId, componentId, eventHandlerId, '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,
renderTreeFrame.attributeValue(attributeFrame)!
);
break;
}
// Treat as a regular string-valued attribute
toDomElement.setAttribute(
attributeName,
renderTreeFrame.attributeValue(attributeFrame)!
);
}

tryApplyValueProperty(element: Element, value: string | null) {
private tryApplyValueProperty(element: Element, value: string | null) {
// Certain elements have built-in behaviour for their 'value' property
switch (element.tagName) {
case 'INPUT':
Expand Down Expand Up @@ -257,7 +246,7 @@ export class BrowserRenderer {
}
}

insertFrameRange(componentId: number, parent: Element, childIndex: number, frames: System_Array<RenderTreeFramePointer>, startIndex: number, endIndexExcl: number): number {
private insertFrameRange(componentId: number, parent: Element, childIndex: number, frames: System_Array<RenderTreeFramePointer>, startIndex: number, endIndexExcl: number): number {
const origChildIndex = childIndex;
for (let index = startIndex; index < endIndexExcl; index++) {
const frame = getTreeFramePtr(frames, index);
Expand Down Expand Up @@ -296,7 +285,7 @@ function removeAttributeFromDOM(parent: Element, childIndex: number, attributeNa
element.removeAttribute(attributeName);
}

function raiseEvent(event: Event, browserRendererId: number, componentId: number, eventHandlerId: number, eventInfoType: EventInfoType, eventInfo: any) {
function raiseEvent(event: Event, browserRendererId: number, componentId: number, eventHandlerId: number, eventArgs: EventForDotNet<UIEventArgs>) {
event.preventDefault();

if (!raiseEventMethod) {
Expand All @@ -309,13 +298,11 @@ function raiseEvent(event: Event, browserRendererId: number, componentId: number
BrowserRendererId: browserRendererId,
ComponentId: componentId,
EventHandlerId: eventHandlerId,
EventArgsType: eventInfoType
EventArgsType: eventArgs.type
};

platform.callMethod(raiseEventMethod, null, [
platform.toDotNetString(JSON.stringify(eventDescriptor)),
platform.toDotNetString(JSON.stringify(eventInfo))
platform.toDotNetString(JSON.stringify(eventArgs.data))
]);
}

type EventInfoType = 'mouse' | 'keyboard' | 'change';
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { EventForDotNet, UIEventArgs } from './EventForDotNet';

export interface OnEventCallback {
(event: Event, componentId: number, eventHandlerId: number, eventArgs: EventForDotNet<UIEventArgs>): void;
}

// Responsible for adding/removing the eventInfo on an expando property on DOM elements, and
// calling an EventInfoStore that deals with registering/unregistering the underlying delegated
// event listeners as required (and also maps actual events back to the given callback).
export class EventDelegator {
private static nextEventDelegatorId = 0;
private eventsCollectionKey: string;
private eventInfoStore: EventInfoStore;

constructor(private onEvent: OnEventCallback) {
const eventDelegatorId = ++EventDelegator.nextEventDelegatorId;
this.eventsCollectionKey = `_blazorEvents_${eventDelegatorId}`;
this.eventInfoStore = new EventInfoStore(this.onGlobalEvent.bind(this));
}

public setListener(element: Element, eventName: string, componentId: number, eventHandlerId: number) {
// Ensure we have a place to store event info for this element
let infoForElement: EventHandlerInfosForElement = element[this.eventsCollectionKey];
if (!infoForElement) {
infoForElement = element[this.eventsCollectionKey] = {};
}

if (infoForElement.hasOwnProperty(eventName)) {
// We can cheaply update the info on the existing object and don't need any other housekeeping
const oldInfo = infoForElement[eventName];
this.eventInfoStore.update(oldInfo.eventHandlerId, eventHandlerId);
} else {
// Go through the whole flow which might involve registering a new global handler
const newInfo = { element, eventName, componentId, eventHandlerId };
this.eventInfoStore.add(newInfo);
infoForElement[eventName] = newInfo;
}
}

public removeListener(eventHandlerId: number) {
// This method gets called whenever the .NET-side code reports that a certain event handler
// has been disposed. However we will already have disposed the info about that handler if
// the eventHandlerId for the (element,eventName) pair was replaced during diff application.
const info = this.eventInfoStore.remove(eventHandlerId);
if (info) {
// Looks like this event handler wasn't already disposed
// Remove the associated data from the DOM element
const element = info.element;
if (element.hasOwnProperty(this.eventsCollectionKey)) {
const elementEventInfos: EventHandlerInfosForElement = element[this.eventsCollectionKey];
delete elementEventInfos[info.eventName];
if (Object.getOwnPropertyNames(elementEventInfos).length === 0) {
delete element[this.eventsCollectionKey];
}
}
}
}

private onGlobalEvent(evt: Event) {
if (!(evt.target instanceof Element)) {
return;
}

// Scan up the element hierarchy, looking for any matching registered event handlers
let candidateElement = evt.target as Element | null;
let eventArgs: EventForDotNet<UIEventArgs> | null = null; // Populate lazily
while (candidateElement) {
if (candidateElement.hasOwnProperty(this.eventsCollectionKey)) {
const handlerInfos = candidateElement[this.eventsCollectionKey];
if (handlerInfos.hasOwnProperty(evt.type)) {
// We are going to raise an event for this element, so prepare info needed by the .NET code
if (!eventArgs) {
eventArgs = EventForDotNet.fromDOMEvent(evt);
}

const handlerInfo = handlerInfos[evt.type];
this.onEvent(evt, handlerInfo.componentId, handlerInfo.eventHandlerId, eventArgs);
}
}

candidateElement = candidateElement.parentElement;
}
}
}

// Responsible for adding and removing the global listener when the number of listeners
// for a given event name changes between zero and nonzero
class EventInfoStore {
private infosByEventHandlerId: { [eventHandlerId: number]: EventHandlerInfo } = {};
private countByEventName: { [eventName: string]: number } = {};

constructor(private globalListener: EventListener) {
}

public add(info: EventHandlerInfo) {
if (this.infosByEventHandlerId[info.eventHandlerId]) {
// Should never happen, but we want to know if it does
throw new Error(`Event ${info.eventHandlerId} is already tracked`);
}

this.infosByEventHandlerId[info.eventHandlerId] = info;

const eventName = info.eventName;
if (this.countByEventName.hasOwnProperty(eventName)) {
this.countByEventName[eventName]++;
} else {
this.countByEventName[eventName] = 1;
document.addEventListener(eventName, this.globalListener);
}
}

public update(oldEventHandlerId: number, newEventHandlerId: number) {
if (this.infosByEventHandlerId.hasOwnProperty(newEventHandlerId)) {
// Should never happen, but we want to know if it does
throw new Error(`Event ${newEventHandlerId} is already tracked`);
}

// Since we're just updating the event handler ID, there's no need to update the global counts
const info = this.infosByEventHandlerId[oldEventHandlerId];
delete this.infosByEventHandlerId[oldEventHandlerId];
info.eventHandlerId = newEventHandlerId;
this.infosByEventHandlerId[newEventHandlerId] = info;
}

public remove(eventHandlerId: number): EventHandlerInfo {
const info = this.infosByEventHandlerId[eventHandlerId];
if (info) {
delete this.infosByEventHandlerId[eventHandlerId];

const eventName = info.eventName;
if (--this.countByEventName[eventName] === 0) {
delete this.countByEventName[eventName];
document.removeEventListener(eventName, this.globalListener);
}
}

return info;
}
}

interface EventHandlerInfosForElement {
// Although we *could* track multiple event handlers per (element, eventName) pair
// (since they have distinct eventHandlerId values), there's no point doing so because
// our programming model is that you declare event handlers as attributes. An element
// can only have one attribute with a given name, hence only one event handler with
// that name at any one time.
// So to keep things simple, only track one EventHandlerInfo per (element, eventName)
[eventName: string]: EventHandlerInfo
}

interface EventHandlerInfo {
element: Element;
eventName: string;
componentId: number;
eventHandlerId: number;
}
Loading

0 comments on commit 3bbe9f5

Please sign in to comment.