diff --git a/src/Microsoft.Blazor.Browser.JS/src/Rendering/Renderer.ts b/src/Microsoft.Blazor.Browser.JS/src/Rendering/Renderer.ts index 2f8dea2e49..562e52cd82 100644 --- a/src/Microsoft.Blazor.Browser.JS/src/Rendering/Renderer.ts +++ b/src/Microsoft.Blazor.Browser.JS/src/Rendering/Renderer.ts @@ -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) { diff --git a/src/Microsoft.Blazor.Browser.JS/src/Rendering/UITreeNode.ts b/src/Microsoft.Blazor.Browser.JS/src/Rendering/UITreeNode.ts index 4cdbf46c40..25b3fb9849 100644 --- a/src/Microsoft.Blazor.Browser.JS/src/Rendering/UITreeNode.ts +++ b/src/Microsoft.Blazor.Browser.JS/src/Rendering/UITreeNode.ts @@ -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 diff --git a/src/Microsoft.Blazor.Browser/DOM.cs b/src/Microsoft.Blazor.Browser/DOM.cs index c355928172..5e27c40640 100644 --- a/src/Microsoft.Blazor.Browser/DOM.cs +++ b/src/Microsoft.Blazor.Browser/DOM.cs @@ -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( "_blazorAttachComponentToElement", elementSelector, renderState.DOMComponentId); - RefreshComponentInDOM(renderState); - } - - private static void RefreshComponentInDOM(DOMComponentRenderState renderState) - { - var tree = renderState.UpdateRender(); - RegisteredFunction.InvokeUnmarshalled( - "_blazorRender", - renderState.DOMComponentId, - tree.Array, - tree.Count); + renderState.RenderToDOM(); } } } diff --git a/src/Microsoft.Blazor.Browser/DOMComponentRenderState.cs b/src/Microsoft.Blazor.Browser/DOMComponentRenderState.cs index 970f2f2c1b..eb1b4ba916 100644 --- a/src/Microsoft.Blazor.Browser/DOMComponentRenderState.cs +++ b/src/Microsoft.Blazor.Browser/DOMComponentRenderState.cs @@ -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 /// internal class DOMComponentRenderState { - private static ConditionalWeakTable _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 _renderStatesByComponent = new ConditionalWeakTable(); + private static WeakValueDictionary _renderStatesByComponentId + = new WeakValueDictionary(); 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 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 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( + "_blazorRender", + DOMComponentId, + tree.Array, + tree.Count); + } } } diff --git a/src/Microsoft.Blazor.Browser/Events.cs b/src/Microsoft.Blazor.Browser/Events.cs new file mode 100644 index 0000000000..34a33652fa --- /dev/null +++ b/src/Microsoft.Blazor.Browser/Events.cs @@ -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)); + } + } +} diff --git a/src/Microsoft.Blazor.Browser/WeakValueDictionary.cs b/src/Microsoft.Blazor.Browser/WeakValueDictionary.cs new file mode 100644 index 0000000000..c98d3f7087 --- /dev/null +++ b/src/Microsoft.Blazor.Browser/WeakValueDictionary.cs @@ -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 where TValue : class + { + private IDictionary> _store + = new Dictionary>(); + 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)}. Key: {key}"); + } + + _store[key] = new WeakReference(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; + } + } + } + } +} diff --git a/src/Microsoft.Blazor/UITree/UIEventHandler.cs b/src/Microsoft.Blazor/UITree/UIEventHandler.cs new file mode 100644 index 0000000000..35a645c652 --- /dev/null +++ b/src/Microsoft.Blazor/UITree/UIEventHandler.cs @@ -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 +{ + /// + /// Handles an event raised for a . + /// + public delegate void UIEventHandler(); +} diff --git a/src/Microsoft.Blazor/UITree/UITreeBuilder.cs b/src/Microsoft.Blazor/UITree/UITreeBuilder.cs index 7b812009a9..32f2fdae4d 100644 --- a/src/Microsoft.Blazor/UITree/UITreeBuilder.cs +++ b/src/Microsoft.Blazor/UITree/UITreeBuilder.cs @@ -48,18 +48,32 @@ namespace Microsoft.Blazor.UITree => Append(UITreeNode.Text(textContent)); /// - /// 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. /// /// The name of the attribute. /// The value of the attribute. public void AddAttribute(string name, string value) { - if (_lastNonAttributeNodeType == UITreeNodeType.Element) - { - Append(UITreeNode.Attribute(name, value)); - } - else + AssertCanAddAttribute(); + Append(UITreeNode.Attribute(name, value)); + } + + /// + /// Appends a node representing an -valued attribute. + /// The attribute is associated with the most recently added element. + /// + /// The name of the attribute. + /// The value of the attribute. + 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}"); } diff --git a/src/Microsoft.Blazor/UITree/UITreeNode.cs b/src/Microsoft.Blazor/UITree/UITreeNode.cs index 71a917a8a9..54ac7edfcc 100644 --- a/src/Microsoft.Blazor/UITree/UITreeNode.cs +++ b/src/Microsoft.Blazor/UITree/UITreeNode.cs @@ -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 /// public string AttributeValue { get; private set; } + /// + /// If the property equals , + /// gets the attribute's event handle, if any. Otherwise, the value is . + /// + 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; diff --git a/test/Microsoft.Blazor.E2ETest/Tests/ComponentRenderingTest.cs b/test/Microsoft.Blazor.E2ETest/Tests/ComponentRenderingTest.cs index 5982691853..6474384330 100644 --- a/test/Microsoft.Blazor.E2ETest/Tests/ComponentRenderingTest.cs +++ b/test/Microsoft.Blazor.E2ETest/Tests/ComponentRenderingTest.cs @@ -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 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(); 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(); 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(); + + 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() where TComponent: IComponent + { + var componentTypeName = typeof(TComponent).FullName; WaitUntilDotNetRunningInBrowser(); ((IJavaScriptExecutor)Browser).ExecuteScript( $"mountTestComponent('{componentTypeName}')"); + return Browser.FindElement(By.TagName("app")); } private void WaitUntilDotNetRunningInBrowser() diff --git a/test/Microsoft.Blazor.Test/UITreeBuilderTest.cs b/test/Microsoft.Blazor.Test/UITreeBuilderTest.cs index c902eb1b59..c4fe80a9b1 100644 --- a/test/Microsoft.Blazor.Test/UITreeBuilderTest.cs +++ b/test/Microsoft.Blazor.Test/UITreeBuilderTest.cs @@ -136,13 +136,14 @@ namespace Microsoft.Blazor.Test { // Arrange var builder = new UITreeBuilder(); + UIEventHandler eventHandler = () => { }; // Act builder.OpenElement("myelement"); // 0: builder.OpenElement("child"); // 3: + builder.AddAttribute("childevent", eventHandler); // 4: childevent=eventHandler> builder.AddText("some text"); // 5: some text builder.CloseElement(); // builder.CloseElement(); // @@ -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(() => + { + 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(() => + { + 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); + } } } diff --git a/test/testapps/BasicTestApp/CounterComponent.cs b/test/testapps/BasicTestApp/CounterComponent.cs new file mode 100644 index 0000000000..cbb1446275 --- /dev/null +++ b/test/testapps/BasicTestApp/CounterComponent.cs @@ -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++; + } + } +}