Basic parameterless events support (e.g., button click)

This commit is contained in:
Steve Sanderson 2018-01-05 22:09:05 +00:00
parent f7cb54121b
commit 23f7120b75
12 changed files with 315 additions and 57 deletions

View File

@ -1,7 +1,8 @@
import { registerFunction } from '../RegisteredFunction';
import { System_String, System_Array } from '../Platform/Platform';
import { System_Object, System_String, System_Array, MethodHandle } from '../Platform/Platform';
import { platform } from '../Environment';
import { getTreeNodePtr, uiTreeNode, NodeType, UITreeNodePointer } from './UITreeNode';
let raiseEventMethod: MethodHandle;
// 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
@ -20,8 +21,6 @@ function attachComponentToElement(elementSelector: System_String, componentId: S
throw new Error(`Could not find any element matching selector '${elementSelectorJs}'.`);
}
clearElement(element);
const componentIdJs = platform.toJavaScriptString(componentId);
componentIdToParentElement[componentIdJs] = element;
}
@ -33,13 +32,14 @@ function renderUITree(componentId: System_String, tree: System_Array, treeLength
throw new Error(`No element is currently associated with component ${componentIdJs}`);
}
insertNodeRange(element, tree, 0, treeLength - 1);
clearElement(element);
insertNodeRange(componentIdJs, element, tree, 0, treeLength - 1);
}
function insertNodeRange(intoDomElement: Element, tree: System_Array, startIndex: number, endIndex: number) {
function insertNodeRange(componentId: string, intoDomElement: Element, tree: System_Array, startIndex: number, endIndex: number) {
for (let index = startIndex; index <= endIndex; index++) {
const node = getTreeNodePtr(tree, index);
insertNode(intoDomElement, tree, node, index);
insertNode(componentId, intoDomElement, tree, node, index);
// Skip over any descendants, since they are already dealt with recursively
const descendantsEndIndex = uiTreeNode.descendantsEndIndex(node);
@ -49,11 +49,11 @@ function insertNodeRange(intoDomElement: Element, tree: System_Array, startIndex
}
}
function insertNode(intoDomElement: Element, tree: System_Array, node: UITreeNodePointer, nodeIndex: number) {
function insertNode(componentId: string, intoDomElement: Element, tree: System_Array, node: UITreeNodePointer, nodeIndex: number) {
const nodeType = uiTreeNode.nodeType(node);
switch (nodeType) {
case NodeType.element:
insertElement(intoDomElement, tree, node, nodeIndex);
insertElement(componentId, intoDomElement, tree, node, nodeIndex);
break;
case NodeType.text:
insertText(intoDomElement, node);
@ -66,7 +66,7 @@ function insertNode(intoDomElement: Element, tree: System_Array, node: UITreeNod
}
}
function insertElement(intoDomElement: Element, tree: System_Array, elementNode: UITreeNodePointer, elementNodeIndex: number) {
function insertElement(componentId: string, intoDomElement: Element, tree: System_Array, elementNode: UITreeNodePointer, elementNodeIndex: number) {
const tagName = uiTreeNode.elementName(elementNode);
const newDomElement = document.createElement(tagName);
intoDomElement.appendChild(newDomElement);
@ -76,21 +76,46 @@ function insertElement(intoDomElement: Element, tree: System_Array, elementNode:
for (let descendantIndex = elementNodeIndex + 1; descendantIndex <= descendantsEndIndex; descendantIndex++) {
const descendantNode = getTreeNodePtr(tree, descendantIndex);
if (uiTreeNode.nodeType(descendantNode) === NodeType.attribute) {
applyAttribute(newDomElement, descendantNode);
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
insertNodeRange(newDomElement, tree, descendantIndex, descendantsEndIndex);
insertNodeRange(componentId, newDomElement, tree, descendantIndex, descendantsEndIndex);
break;
}
}
}
function applyAttribute(toDomElement: Element, attributeNode: UITreeNodePointer) {
toDomElement.setAttribute(
uiTreeNode.attributeName(attributeNode),
uiTreeNode.attributeValue(attributeNode)
);
function applyAttribute(componentId: string, toDomElement: Element, attributeNode: UITreeNodePointer, attributeNodeIndex: number) {
const attributeName = uiTreeNode.attributeName(attributeNode);
switch (attributeName) {
case 'onclick':
toDomElement.addEventListener('click', () => raiseEvent(componentId, attributeNodeIndex, 'click'));
break;
default:
// Treat as a regular string-valued attribute
toDomElement.setAttribute(
attributeName,
uiTreeNode.attributeValue(attributeNode)
);
break;
}
}
function raiseEvent(componentId: string, uiTreeNodeIndex: number, eventName: string) {
if (!raiseEventMethod) {
raiseEventMethod = platform.findMethod(
'Microsoft.Blazor.Browser', 'Microsoft.Blazor.Browser', 'Events', 'RaiseEvent'
);
}
// TODO: Find a way of passing the uiTreeNodeIndex as a System.Int32, possibly boxing
// it first if necessary. Until then we have to send it as a string.
platform.callMethod(raiseEventMethod, null, [
platform.toDotNetString(componentId),
platform.toDotNetString(uiTreeNodeIndex.toString())
]);
}
function insertText(intoDomElement: Element, textNode: UITreeNodePointer) {

View File

@ -1,6 +1,6 @@
import { System_String, System_Array, Pointer } from '../Platform/Platform';
import { platform } from '../Environment';
const uiTreeNodeStructLength = 24;
const uiTreeNodeStructLength = 28;
// To minimise GC pressure, instead of instantiating a JS object to represent each tree node,
// we work in terms of pointers to the structs on the .NET heap, and use static functions that

View File

@ -3,7 +3,6 @@
using Microsoft.Blazor.Browser.Interop;
using Microsoft.Blazor.Components;
using Microsoft.Blazor.UITree;
namespace Microsoft.Blazor.Browser
{
@ -26,17 +25,7 @@ namespace Microsoft.Blazor.Browser
RegisteredFunction.InvokeUnmarshalled<string, string, object>(
"_blazorAttachComponentToElement", elementSelector, renderState.DOMComponentId);
RefreshComponentInDOM(renderState);
}
private static void RefreshComponentInDOM(DOMComponentRenderState renderState)
{
var tree = renderState.UpdateRender();
RegisteredFunction.InvokeUnmarshalled<string, UITreeNode[], int, object>(
"_blazorRender",
renderState.DOMComponentId,
tree.Array,
tree.Count);
renderState.RenderToDOM();
}
}
}

View File

@ -1,6 +1,7 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.Blazor.Browser.Interop;
using Microsoft.Blazor.Components;
using Microsoft.Blazor.UITree;
using System;
@ -14,8 +15,16 @@ namespace Microsoft.Blazor.Browser
/// </summary>
internal class DOMComponentRenderState
{
private static ConditionalWeakTable<IComponent, DOMComponentRenderState> _stateInstances
// Track the associations between component IDs, IComponent instances, and
// DOMComponentRenderState instances, but without pinning any IComponent instances
// in memory.
// TODO: Instead of storing these as statics, have some kind of RenderContext instance
// that holds them. It can also hold a reference to the root component, since otherwise
// there isn't anything stopping the whole hierarchy of components from being GCed.
private static ConditionalWeakTable<IComponent, DOMComponentRenderState> _renderStatesByComponent
= new ConditionalWeakTable<IComponent, DOMComponentRenderState>();
private static WeakValueDictionary<string, DOMComponentRenderState> _renderStatesByComponentId
= new WeakValueDictionary<string, DOMComponentRenderState>();
private static long _nextDOMComponentId = 0;
private readonly UITreeBuilder _uITreeBuilder; // TODO: Maintain two, so we can diff successive renders
@ -26,16 +35,16 @@ namespace Microsoft.Blazor.Browser
private DOMComponentRenderState(string componentId, IComponent component)
{
DOMComponentId = DOMComponentId;
DOMComponentId = componentId;
Component = component;
_uITreeBuilder = new UITreeBuilder();
}
public static DOMComponentRenderState GetOrCreate(IComponent component)
{
lock (_stateInstances)
lock (_renderStatesByComponent)
{
if (_stateInstances.TryGetValue(component, out var existingState))
if (_renderStatesByComponent.TryGetValue(component, out var existingState))
{
return existingState;
}
@ -43,13 +52,19 @@ namespace Microsoft.Blazor.Browser
{
var newId = (_nextDOMComponentId++).ToString();
var newState = new DOMComponentRenderState(newId, component);
_stateInstances.Add(component, newState);
_renderStatesByComponent.Add(component, newState);
_renderStatesByComponentId.Add(newId, newState);
return newState;
}
}
}
public ArraySegment<UITreeNode> UpdateRender()
public static DOMComponentRenderState FindByDOMComponentID(string id)
=> _renderStatesByComponentId.TryGetValue(id, out var result)
? result
: throw new ArgumentException($"No component was found with ID {id}");
private ArraySegment<UITreeNode> UpdateRender()
{
_uITreeBuilder.Clear();
Component.BuildUITree(_uITreeBuilder);
@ -57,5 +72,28 @@ namespace Microsoft.Blazor.Browser
// TODO: Change this to return a diff between the previous render result and this new one
return _uITreeBuilder.GetNodes();
}
public void RaiseEvent(int uiTreeNodeIndex)
{
var nodes = _uITreeBuilder.GetNodes();
var eventHandler = nodes.Array[nodes.Offset + uiTreeNodeIndex].AttributeEventHandlerValue;
if (eventHandler == null)
{
throw new ArgumentException($"Cannot raise event because the specified {nameof(UITreeNode)} at index {uiTreeNodeIndex} does not have any {nameof(UITreeNode.AttributeEventHandlerValue)}.");
}
eventHandler.Invoke();
RenderToDOM();
}
public void RenderToDOM()
{
var tree = UpdateRender();
RegisteredFunction.InvokeUnmarshalled<string, UITreeNode[], int, object>(
"_blazorRender",
DOMComponentId,
tree.Array,
tree.Count);
}
}
}

View File

@ -0,0 +1,18 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
namespace Microsoft.Blazor.Browser
{
// Invoked by the Microsoft.Blazor.Browser.JS code when a DOM event occurs
internal static class Events
{
public static void RaiseEvent(string domComponentID, string uiTreeNodeIndex)
{
// We're receiving the uiTreeNodeIndex as a string only because there's not
// yet a way to pass ints (or construct boxed ones) from JS with the current Mono
// runtime. When there's a supported way to do that, this can be simplified.
var renderState = DOMComponentRenderState.FindByDOMComponentID(domComponentID);
renderState.RaiseEvent(int.Parse(uiTreeNodeIndex));
}
}
}

View File

@ -0,0 +1,61 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Linq;
namespace Microsoft.Blazor.Browser
{
internal class WeakValueDictionary<TKey, TValue> where TValue : class
{
private IDictionary<TKey, WeakReference<TValue>> _store
= new Dictionary<TKey, WeakReference<TValue>>();
private int _cullThreshold = 10;
public bool TryGetValue(TKey key, out TValue value)
{
if (_store.TryGetValue(key, out var existingWeakRef))
{
if (existingWeakRef.TryGetTarget(out value))
{
return true;
}
// Since we know it's not there, we might as well drop the entry now
_store.Remove(key);
}
value = default(TValue);
return false;
}
public void Add(TKey key, TValue value)
{
if (_store.TryGetValue(key, out _))
{
throw new ArgumentException($"The given key was already present in the {nameof(WeakValueDictionary<TKey, TValue>)}. Key: {key}");
}
_store[key] = new WeakReference<TValue>(value);
CullIfApplicable();
}
private void CullIfApplicable()
{
if (_store.Count > _cullThreshold)
{
var itemsToRemove = _store.Where(x => !x.Value.TryGetTarget(out _)).ToList();
foreach (var itemToRemove in itemsToRemove)
{
_store.Remove(itemToRemove.Key);
}
if (_store.Count > (_cullThreshold / 2))
{
_cullThreshold *= 2;
}
}
}
}
}

View File

@ -0,0 +1,10 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
namespace Microsoft.Blazor.UITree
{
/// <summary>
/// Handles an event raised for a <see cref="UITreeNode"/>.
/// </summary>
public delegate void UIEventHandler();
}

View File

@ -48,18 +48,32 @@ namespace Microsoft.Blazor.UITree
=> Append(UITreeNode.Text(textContent));
/// <summary>
/// Appends a node representing an attribute. The attribute is associated
/// with the most recently added element.
/// Appends a node representing a string-valued attribute.
/// The attribute is associated with the most recently added element.
/// </summary>
/// <param name="name">The name of the attribute.</param>
/// <param name="value">The value of the attribute.</param>
public void AddAttribute(string name, string value)
{
if (_lastNonAttributeNodeType == UITreeNodeType.Element)
{
Append(UITreeNode.Attribute(name, value));
}
else
AssertCanAddAttribute();
Append(UITreeNode.Attribute(name, value));
}
/// <summary>
/// Appends a node representing an <see cref="UIEventHandler"/>-valued attribute.
/// The attribute is associated with the most recently added element.
/// </summary>
/// <param name="name">The name of the attribute.</param>
/// <param name="value">The value of the attribute.</param>
public void AddAttribute(string name, UIEventHandler value)
{
AssertCanAddAttribute();
Append(UITreeNode.Attribute(name, value));
}
private void AssertCanAddAttribute()
{
if (_lastNonAttributeNodeType != UITreeNodeType.Element)
{
throw new InvalidOperationException($"Attributes may only be added immediately after nodes of type {UITreeNodeType.Element}");
}

View File

@ -1,6 +1,8 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
namespace Microsoft.Blazor.UITree
{
// TODO: Consider coalescing properties of compatible types that don't need to be
@ -48,6 +50,12 @@ namespace Microsoft.Blazor.UITree
/// </summary>
public string AttributeValue { get; private set; }
/// <summary>
/// If the <see cref="NodeType"/> property equals <see cref="UITreeNodeType.Attribute"/>,
/// gets the attribute's event handle, if any. Otherwise, the value is <see langword="null"/>.
/// </summary>
public UIEventHandler AttributeEventHandlerValue { get; private set; }
internal static UITreeNode Element(string elementName) => new UITreeNode
{
NodeType = UITreeNodeType.Element,
@ -67,6 +75,13 @@ namespace Microsoft.Blazor.UITree
AttributeValue = value
};
internal static UITreeNode Attribute(string name, UIEventHandler value) => new UITreeNode
{
NodeType = UITreeNodeType.Attribute,
AttributeName = name,
AttributeEventHandlerValue = value
};
internal void CloseElement(int descendantsEndIndex)
{
ElementDescendantsEndIndex = descendantsEndIndex;

View File

@ -3,6 +3,7 @@
using System;
using BasicTestApp;
using Microsoft.Blazor.Components;
using Microsoft.Blazor.E2ETest.Infrastructure;
using Microsoft.Blazor.E2ETest.Infrastructure.ServerFixtures;
using OpenQA.Selenium;
@ -17,43 +18,55 @@ namespace Microsoft.Blazor.E2ETest.Tests
public ComponentRenderingTest(BrowserFixture browserFixture, DevHostServerFixture<Program> serverFixture)
: base(browserFixture, serverFixture)
{
Navigate("/", noReload: true);
}
[Fact]
public void BasicTestAppCanBeServed()
{
Navigate("/", noReload: true);
Assert.Equal("Basic test app", Browser.Title);
}
[Fact]
public void CanRenderTextOnlyComponent()
{
Navigate("/", noReload: true);
MountTestComponent("BasicTestApp.TextOnlyComponent");
var appElement = Browser.FindElement(By.TagName("app"));
var appElement = MountTestComponent<TextOnlyComponent>();
Assert.Equal("Hello from TextOnlyComponent", appElement.Text);
}
[Fact]
public void CanRenderComponentWithAttributes()
{
Navigate("/", noReload: true);
MountTestComponent("BasicTestApp.RedTextComponent");
var appElement = Browser.FindElement(By.TagName("app"));
var appElement = MountTestComponent<RedTextComponent>();
var styledElement = appElement.FindElement(By.TagName("h1"));
Assert.Equal("Hello, world!", styledElement.Text);
Assert.Equal("color: red;", styledElement.GetAttribute("style"));
Assert.Equal("somevalue", styledElement.GetAttribute("customattribute"));
}
private void MountTestComponent(string componentTypeName)
[Fact]
public void CanTriggerEvents()
{
var appElement = MountTestComponent<CounterComponent>();
Assert.Equal(
"Current count: 0",
appElement.FindElement(By.TagName("p")).Text);
appElement.FindElement(By.TagName("button")).Click();
Assert.Equal(
"Current count: 1",
appElement.FindElement(By.TagName("p")).Text);
}
private IWebElement MountTestComponent<TComponent>() where TComponent: IComponent
{
var componentTypeName = typeof(TComponent).FullName;
WaitUntilDotNetRunningInBrowser();
((IJavaScriptExecutor)Browser).ExecuteScript(
$"mountTestComponent('{componentTypeName}')");
return Browser.FindElement(By.TagName("app"));
}
private void WaitUntilDotNetRunningInBrowser()

View File

@ -136,13 +136,14 @@ namespace Microsoft.Blazor.Test
{
// Arrange
var builder = new UITreeBuilder();
UIEventHandler eventHandler = () => { };
// Act
builder.OpenElement("myelement"); // 0: <myelement
builder.AddAttribute("attribute1", "value 1"); // 1: attribute1="value 1"
builder.AddAttribute("attribute2", "value 2"); // 2: attribute2="value 2">
builder.OpenElement("child"); // 3: <child
builder.AddAttribute("attribute1", "child value"); // 4: attribute1="child value">
builder.AddAttribute("childevent", eventHandler); // 4: childevent=eventHandler>
builder.AddText("some text"); // 5: some text
builder.CloseElement(); // </child>
builder.CloseElement(); // </myelement>
@ -153,12 +154,12 @@ namespace Microsoft.Blazor.Test
node => AssertAttribute(node, "attribute1", "value 1"),
node => AssertAttribute(node, "attribute2", "value 2"),
node => AssertElement(node, "child", 5),
node => AssertAttribute(node, "attribute1", "child value"),
node => AssertAttribute(node, "childevent", eventHandler),
node => AssertText(node, "some text"));
}
[Fact]
public void CannotAddAttributesAtRoot()
public void CannotAddAttributeAtRoot()
{
// Arrange
var builder = new UITreeBuilder();
@ -171,7 +172,20 @@ namespace Microsoft.Blazor.Test
}
[Fact]
public void CannotAddAttributesToText()
public void CannotAddEventHandlerAttributeAtRoot()
{
// Arrange
var builder = new UITreeBuilder();
// Act/Assert
Assert.Throws<InvalidOperationException>(() =>
{
builder.AddAttribute("name", () => { });
});
}
[Fact]
public void CannotAddAttributeToText()
{
// Arrange
var builder = new UITreeBuilder();
@ -185,6 +199,21 @@ namespace Microsoft.Blazor.Test
});
}
[Fact]
public void CannotAddEventHandlerAttributeToText()
{
// Arrange
var builder = new UITreeBuilder();
// Act/Assert
Assert.Throws<InvalidOperationException>(() =>
{
builder.OpenElement("some element");
builder.AddText("hello");
builder.AddAttribute("name", () => { });
});
}
[Fact]
public void CanClear()
{
@ -216,11 +245,22 @@ namespace Microsoft.Blazor.Test
Assert.Equal(descendantsEndIndex, node.ElementDescendantsEndIndex);
}
void AssertAttribute(UITreeNode node, string attributeName, string attributeValue)
void AssertAttribute(UITreeNode node, string attributeName)
{
Assert.Equal(UITreeNodeType.Attribute, node.NodeType);
Assert.Equal(attributeName, node.AttributeName);
}
void AssertAttribute(UITreeNode node, string attributeName, string attributeValue)
{
AssertAttribute(node, attributeName);
Assert.Equal(attributeValue, node.AttributeValue);
}
void AssertAttribute(UITreeNode node, string attributeName, UIEventHandler attributeEventHandlerValue)
{
AssertAttribute(node, attributeName);
Assert.Equal(attributeEventHandlerValue, node.AttributeEventHandlerValue);
}
}
}

View File

@ -0,0 +1,35 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.Blazor.Components;
using Microsoft.Blazor.UITree;
namespace BasicTestApp
{
public class CounterComponent : IComponent
{
private int currentCount = 0;
public void BuildUITree(UITreeBuilder builder)
{
builder.OpenElement("h1");
builder.AddText("Counter");
builder.CloseElement();
builder.OpenElement("p");
builder.AddText("Current count: ");
builder.AddText(currentCount.ToString());
builder.CloseElement();
builder.OpenElement("button");
builder.AddAttribute("onclick", OnButtonClicked);
builder.AddText("Click me");
builder.CloseElement();
}
private void OnButtonClicked()
{
currentCount++;
}
}
}