diff --git a/samples/HostedInAspNet.Client/Program.cs b/samples/HostedInAspNet.Client/Program.cs index 3b148be37a..c5ae7fe9b2 100644 --- a/samples/HostedInAspNet.Client/Program.cs +++ b/samples/HostedInAspNet.Client/Program.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.Blazor.Browser; +using Microsoft.Blazor.Browser.Rendering; using Microsoft.Blazor.Components; using Microsoft.Blazor.RenderTree; @@ -13,7 +14,7 @@ namespace HostedInAspNet.Client { // Temporarily render this test component until there's a proper mechanism // for testing this. - DOM.AttachComponent("app", new MyComponent()); + new BrowserRenderer().AddComponent("app", new MyComponent()); } } diff --git a/src/Microsoft.Blazor.Browser.JS/src/Platform/Mono/MonoPlatform.ts b/src/Microsoft.Blazor.Browser.JS/src/Platform/Mono/MonoPlatform.ts index 428d88a59c..90bb87b8ed 100644 --- a/src/Microsoft.Blazor.Browser.JS/src/Platform/Mono/MonoPlatform.ts +++ b/src/Microsoft.Blazor.Browser.JS/src/Platform/Mono/MonoPlatform.ts @@ -53,6 +53,11 @@ export const monoPlatform: Platform = { }, callMethod: function callMethod(method: MethodHandle, target: System_Object, args: System_Object[]): System_Object { + if (args.length > 4) { + // Hopefully this restriction can be eased soon, but for now make it clear what's going on + throw new Error(`Currently, MonoPlatform supports passing a maximum of 4 arguments from JS to .NET. You tried to pass ${args.length}.`); + } + const stack = Module.Runtime.stackSave(); try { diff --git a/src/Microsoft.Blazor.Browser.JS/src/Rendering/RenderTreeNode.ts b/src/Microsoft.Blazor.Browser.JS/src/Rendering/RenderTreeNode.ts index 377954ed11..b6375efb77 100644 --- a/src/Microsoft.Blazor.Browser.JS/src/Rendering/RenderTreeNode.ts +++ b/src/Microsoft.Blazor.Browser.JS/src/Rendering/RenderTreeNode.ts @@ -1,6 +1,6 @@ import { System_String, System_Array, Pointer } from '../Platform/Platform'; import { platform } from '../Environment'; -const renderTreeNodeStructLength = 32; +const renderTreeNodeStructLength = 36; // 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 @@ -18,6 +18,9 @@ export const renderTreeNode = { textContent: (node: RenderTreeNodePointer) => _readStringProperty(node, 12), attributeName: (node: RenderTreeNodePointer) => _readStringProperty(node, 16), attributeValue: (node: RenderTreeNodePointer) => _readStringProperty(node, 20), + attributeEventHandlerValue: (node: RenderTreeNodePointer) => _readObjectProperty(node, 24), + componentId: (node: RenderTreeNodePointer) => _readInt32Property(node, 28), + component: (node: RenderTreeNodePointer) => _readObjectProperty(node, 32), }; export enum NodeType { @@ -32,6 +35,10 @@ function _readInt32Property(baseAddress: Pointer, offsetBytes: number) { return platform.readHeapInt32(baseAddress, offsetBytes); } +function _readObjectProperty(baseAddress: Pointer, offsetBytes: number) { + return platform.readHeapObject(baseAddress, offsetBytes); +} + function _readStringProperty(baseAddress: Pointer, offsetBytes: number) { var managedString = platform.readHeapObject(baseAddress, offsetBytes) as System_String; return platform.toJavaScriptString(managedString); diff --git a/src/Microsoft.Blazor.Browser.JS/src/Rendering/Renderer.ts b/src/Microsoft.Blazor.Browser.JS/src/Rendering/Renderer.ts index 048ba3f7a5..fa4b1fa9e1 100644 --- a/src/Microsoft.Blazor.Browser.JS/src/Rendering/Renderer.ts +++ b/src/Microsoft.Blazor.Browser.JS/src/Rendering/Renderer.ts @@ -1,46 +1,57 @@ import { registerFunction } from '../RegisteredFunction'; -import { System_Object, System_String, System_Array, MethodHandle } from '../Platform/Platform'; +import { System_Object, System_String, System_Array, MethodHandle, Pointer } from '../Platform/Platform'; import { platform } from '../Environment'; import { getTreeNodePtr, renderTreeNode, NodeType, RenderTreeNodePointer } from './RenderTreeNode'; let raiseEventMethod: MethodHandle; -let getComponentRenderInfoMethod: MethodHandle; +let renderComponentMethod: 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 // 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). -const componentIdToParentElement: { [componentId: string]: Element } = {}; +type ComponentIdToParentElement = { [componentId: number]: Element }; +type BrowserRendererRegistry = { [browserRendererId: number]: ComponentIdToParentElement }; +const browserRenderers: BrowserRendererRegistry = {}; registerFunction('_blazorAttachComponentToElement', attachComponentToElement); registerFunction('_blazorRender', renderRenderTree); -function attachComponentToElement(elementSelector: System_String, componentId: System_String) { +function attachComponentToElement(browserRendererId: number, elementSelector: System_String, componentId: number) { const elementSelectorJs = platform.toJavaScriptString(elementSelector); const element = document.querySelector(elementSelectorJs); if (!element) { throw new Error(`Could not find any element matching selector '${elementSelectorJs}'.`); } - const componentIdJs = platform.toJavaScriptString(componentId); - componentIdToParentElement[componentIdJs] = element; + browserRenderers[browserRendererId] = browserRenderers[browserRendererId] || {}; + browserRenderers[browserRendererId][componentId] = element; } -function renderRenderTree(componentId: System_String, tree: System_Array, treeLength: number) { - const componentIdJs = platform.toJavaScriptString(componentId); - const element = componentIdToParentElement[componentIdJs]; +function renderRenderTree(renderComponentArgs: Pointer) { + const browserRendererId = platform.readHeapInt32(renderComponentArgs, 0); + const browserRenderer = browserRenderers[browserRendererId]; + if (!browserRenderer) { + throw new Error(`There is no browser renderer with ID ${browserRendererId}.`); + } + + const componentId = platform.readHeapInt32(renderComponentArgs, 4); + const element = browserRenderer[componentId]; if (!element) { - throw new Error(`No element is currently associated with component ${componentIdJs}`); + throw new Error(`No element is currently associated with component ${componentId}`); } clearElement(element); - insertNodeRange(componentIdJs, element, tree, 0, treeLength - 1); + + const tree = platform.readHeapObject(renderComponentArgs, 8) as System_Array; + const treeLength = platform.readHeapInt32(renderComponentArgs, 12); + insertNodeRange(browserRendererId, componentId, element, tree, 0, treeLength - 1); } -function insertNodeRange(componentId: string, intoDomElement: Element, tree: System_Array, startIndex: number, endIndex: number) { +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(componentId, intoDomElement, tree, node, index); + insertNode(browserRendererId, componentId, intoDomElement, tree, node, index); // Skip over any descendants, since they are already dealt with recursively const descendantsEndIndex = renderTreeNode.descendantsEndIndex(node); @@ -50,11 +61,11 @@ function insertNodeRange(componentId: string, intoDomElement: Element, tree: Sys } } -function insertNode(componentId: string, intoDomElement: Element, tree: System_Array, node: RenderTreeNodePointer, nodeIndex: number) { +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(componentId, intoDomElement, tree, node, nodeIndex); + insertElement(browserRendererId, componentId, intoDomElement, tree, node, nodeIndex); break; case NodeType.text: insertText(intoDomElement, node); @@ -62,7 +73,7 @@ function insertNode(componentId: string, intoDomElement: Element, tree: System_A case NodeType.attribute: throw new Error('Attribute nodes should only be present as leading children of element nodes.'); case NodeType.component: - insertComponent(intoDomElement, componentId, nodeIndex); + insertComponent(browserRendererId, intoDomElement, node); break; default: const unknownType: never = nodeType; // Compile-time verification that the switch was exhaustive @@ -70,31 +81,26 @@ function insertNode(componentId: string, intoDomElement: Element, tree: System_A } } -function insertComponent(intoDomElement: Element, parentComponentId: string, componentNodeIndex: number) { - if (!getComponentRenderInfoMethod) { - getComponentRenderInfoMethod = platform.findMethod( - 'Microsoft.Blazor.Browser', 'Microsoft.Blazor.Browser', 'DOMComponentRenderState', 'GetComponentRenderInfo' +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' ); } - // Currently, platform.callMethod always returns a heap object. If the target method - // tries to return a value, it gets boxed before return. - const renderInfoBoxed = platform.callMethod(getComponentRenderInfoMethod, null, [ - platform.toDotNetString(parentComponentId), - platform.toDotNetString(componentNodeIndex.toString()) + platform.callMethod(renderComponentMethod, null, [ + platform.toDotNetString(browserRendererId.toString()), + platform.toDotNetString(childComponentId.toString()) ]); - const renderInfoFields = platform.getHeapObjectFieldsPtr(renderInfoBoxed); - const componentId = platform.toJavaScriptString(platform.readHeapObject(renderInfoFields, 0) as System_String); - const componentTree = platform.readHeapObject(renderInfoFields, 4) as System_Array; - const componentTreeLength = platform.readHeapInt32(renderInfoFields, 8); - - const containerElement = document.createElement('blazor-component'); - intoDomElement.appendChild(containerElement); - componentIdToParentElement[componentId] = containerElement; - insertNodeRange(componentId, containerElement, componentTree, 0, componentTreeLength - 1); } -function insertElement(componentId: string, intoDomElement: Element, tree: System_Array, elementNode: RenderTreeNodePointer, elementNodeIndex: number) { +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); @@ -104,22 +110,22 @@ function insertElement(componentId: string, intoDomElement: Element, tree: Syste for (let descendantIndex = elementNodeIndex + 1; descendantIndex <= descendantsEndIndex; descendantIndex++) { const descendantNode = getTreeNodePtr(tree, descendantIndex); if (renderTreeNode.nodeType(descendantNode) === NodeType.attribute) { - applyAttribute(componentId, newDomElement, descendantNode, descendantIndex); + 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(componentId, newDomElement, tree, descendantIndex, descendantsEndIndex); + insertNodeRange(browserRendererId, componentId, newDomElement, tree, descendantIndex, descendantsEndIndex); break; } } } -function applyAttribute(componentId: string, toDomElement: Element, attributeNode: RenderTreeNodePointer, attributeNodeIndex: number) { +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(componentId, attributeNodeIndex, 'mouse', { Type: 'click' })); + toDomElement.addEventListener('click', () => raiseEvent(browserRendererId, componentId, attributeNodeIndex, 'mouse', { Type: 'click' })); break; case 'onkeypress': toDomElement.addEventListener('keypress', evt => { @@ -127,7 +133,7 @@ function applyAttribute(componentId: string, toDomElement: Element, attributeNod // 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(componentId, attributeNodeIndex, 'keyboard', { Type: evt.type, Key: (evt as any).key }); + raiseEvent(browserRendererId, componentId, attributeNodeIndex, 'keyboard', { Type: evt.type, Key: (evt as any).key }); }); break; default: @@ -140,19 +146,22 @@ function applyAttribute(componentId: string, toDomElement: Element, attributeNod } } -function raiseEvent(componentId: string, renderTreeNodeIndex: number, eventInfoType: EventInfoType, eventInfo: any) { +function raiseEvent(browserRendererId: number, componentId: number, renderTreeNodeIndex: number, eventInfoType: EventInfoType, eventInfo: any) { if (!raiseEventMethod) { raiseEventMethod = platform.findMethod( - 'Microsoft.Blazor.Browser', 'Microsoft.Blazor.Browser', 'Events', 'RaiseEvent' + 'Microsoft.Blazor.Browser', 'Microsoft.Blazor.Browser.Rendering', 'BrowserRendererEventDispatcher', 'DispatchEvent' ); } - // TODO: Find a way of passing the renderTreeNodeIndex as a System.Int32, possibly boxing - // it first if necessary. Until then we have to send it as a string. + const eventDescriptor = { + BrowserRendererId: browserRendererId, + ComponentId: componentId, + RenderTreeNodeIndex: renderTreeNodeIndex, + EventArgsType: eventInfoType + }; + platform.callMethod(raiseEventMethod, null, [ - platform.toDotNetString(componentId), - platform.toDotNetString(renderTreeNodeIndex.toString()), - platform.toDotNetString(eventInfoType), + platform.toDotNetString(JSON.stringify(eventDescriptor)), platform.toDotNetString(JSON.stringify(eventInfo)) ]); } diff --git a/src/Microsoft.Blazor.Browser/DOM.cs b/src/Microsoft.Blazor.Browser/DOM.cs deleted file mode 100644 index 5e27c40640..0000000000 --- a/src/Microsoft.Blazor.Browser/DOM.cs +++ /dev/null @@ -1,31 +0,0 @@ -// 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; - -namespace Microsoft.Blazor.Browser -{ - /// - /// Provides mechanisms for displaying Blazor components in a browser Document - /// Object Model (DOM). - /// - public static class DOM - { - /// - /// Associates the specified component with the specified DOM element, causing the - /// component to be displayed there. - /// - /// A CSS selector that identifies a unique DOM element. - /// The component to be displayed in the DOM element. - public static void AttachComponent(string elementSelector, IComponent component) - { - var renderState = DOMComponentRenderState.GetOrCreate(component); - - RegisteredFunction.InvokeUnmarshalled( - "_blazorAttachComponentToElement", elementSelector, renderState.DOMComponentId); - - renderState.RenderToDOM(); - } - } -} diff --git a/src/Microsoft.Blazor.Browser/DOMComponentRenderState.cs b/src/Microsoft.Blazor.Browser/DOMComponentRenderState.cs deleted file mode 100644 index f1bffb15b8..0000000000 --- a/src/Microsoft.Blazor.Browser/DOMComponentRenderState.cs +++ /dev/null @@ -1,131 +0,0 @@ -// 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.RenderTree; -using System; -using System.Runtime.CompilerServices; - -namespace Microsoft.Blazor.Browser -{ - /// - /// Tracks the rendering state associated with an that is - /// being displayed in the DOM. - /// - internal class DOMComponentRenderState - { - // 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 RenderTreeBuilder _uITreeBuilder; // TODO: Maintain two, so we can diff successive renders - - public string DOMComponentId { get; } - - public IComponent Component { get; } - - private DOMComponentRenderState(string componentId, IComponent component) - { - DOMComponentId = componentId; - Component = component; - _uITreeBuilder = new RenderTreeBuilder(); - } - - public static DOMComponentRenderState GetOrCreate(IComponent component) - { - lock (_renderStatesByComponent) - { - if (_renderStatesByComponent.TryGetValue(component, out var existingState)) - { - return existingState; - } - else - { - var newId = (_nextDOMComponentId++).ToString(); - var newState = new DOMComponentRenderState(newId, component); - _renderStatesByComponent.Add(component, newState); - _renderStatesByComponentId.Add(newId, newState); - return newState; - } - } - } - - 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.BuildRenderTree(_uITreeBuilder); - - // TODO: Change this to return a diff between the previous render result and this new one - return _uITreeBuilder.GetNodes(); - } - - public void RaiseEvent(int uiTreeNodeIndex, UIEventArgs eventInfo) - { - 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(RenderTreeNode)} at index {uiTreeNodeIndex} does not have any {nameof(RenderTreeNode.AttributeEventHandlerValue)}."); - } - - eventHandler.Invoke(eventInfo); - RenderToDOM(); - } - - public void RenderToDOM() - { - var tree = UpdateRender(); - RegisteredFunction.InvokeUnmarshalled( - "_blazorRender", - DOMComponentId, - tree.Array, - tree.Count); - } - - public static ComponentRenderInfo GetComponentRenderInfo(string parentComponentId, string componentNodeIndexString) - { - var parentComponentRenderState = FindByDOMComponentID(parentComponentId); - var parentComponentNodes = parentComponentRenderState._uITreeBuilder.GetNodes(); - var component = parentComponentNodes - .Array[int.Parse(componentNodeIndexString)] - .Component; - if (component == null ) - { - throw new ArgumentException($"The tree entry at position {componentNodeIndexString} does not refer to a component."); - } - - var componentRenderState = GetOrCreate(component); - - // Don't necessarily need to re-render the child at this point. Review when the - // component lifecycle has more details (e.g., async init method) - var componentNodes = componentRenderState.UpdateRender(); - return new ComponentRenderInfo - { - ComponentId = componentRenderState.DOMComponentId, - RenderTree = componentNodes.Array, - RenderTreeLength = componentNodes.Count - }; - } - - public struct ComponentRenderInfo - { - public string ComponentId; - public RenderTreeNode[] RenderTree; - public int RenderTreeLength; - } - } -} diff --git a/src/Microsoft.Blazor.Browser/Events.cs b/src/Microsoft.Blazor.Browser/Events.cs deleted file mode 100644 index b60bf254a9..0000000000 --- a/src/Microsoft.Blazor.Browser/Events.cs +++ /dev/null @@ -1,36 +0,0 @@ -// 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.RenderTree; -using System; - -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, string eventInfoType, string eventInfoJson) - { - // 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); - var eventInfo = ParseEventInfo(eventInfoType, eventInfoJson); - renderState.RaiseEvent(int.Parse(uiTreeNodeIndex), eventInfo); - } - - private static UIEventArgs ParseEventInfo(string eventInfoType, string eventInfoJson) - { - switch (eventInfoType) - { - case "mouse": - return Json.Deserialize(eventInfoJson); - case "keyboard": - return Json.Deserialize(eventInfoJson); - default: - throw new ArgumentException($"Unsupported value '{eventInfoType}'.", nameof(eventInfoType)); - } - } - } -} diff --git a/src/Microsoft.Blazor.Browser/Rendering/BrowserRenderer.cs b/src/Microsoft.Blazor.Browser/Rendering/BrowserRenderer.cs new file mode 100644 index 0000000000..367dc46f07 --- /dev/null +++ b/src/Microsoft.Blazor.Browser/Rendering/BrowserRenderer.cs @@ -0,0 +1,87 @@ +// 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.Rendering; +using Microsoft.Blazor.RenderTree; +using System; +using System.Collections.Generic; + +namespace Microsoft.Blazor.Browser.Rendering +{ + public class BrowserRenderer : Renderer, IDisposable + { + private readonly int _browserRendererId; + + // Ensures the explicitly-added components aren't GCed, because the browser + // will still send events referencing them by ID. We only need to store the + // top-level components, because the associated ComponentState will reference + // all the reachable descendant components of each. + private IList _rootComponents = new List(); + + /// + /// Constructs an instance of . + /// + public BrowserRenderer() + { + _browserRendererId = BrowserRendererRegistry.Add(this); + } + + internal void DispatchBrowserEvent(int componentId, int renderTreeIndex, UIEventArgs eventArgs) + => DispatchEvent(componentId, renderTreeIndex, eventArgs); + + internal void RenderComponentInternal(int componentId) + => RenderComponent(componentId); + + /// + /// Associates the with the , + /// causing it to be displayed in the specified DOM element. + /// + /// A CSS selector that uniquely identifies a DOM element. + /// The . + public void AddComponent(string domElementSelector, IComponent component) + { + var componentId = AssignComponentId(component); + RegisteredFunction.InvokeUnmarshalled( + "_blazorAttachComponentToElement", + _browserRendererId, + domElementSelector, + componentId); + _rootComponents.Add(component); + + RenderComponent(component); + } + + /// + public void Dispose() + { + BrowserRendererRegistry.TryRemove(_browserRendererId); + } + + /// + protected override void UpdateDisplay( + int componentId, + ArraySegment renderTree) + { + RegisteredFunction.InvokeUnmarshalled( + "_blazorRender", + new RenderComponentArgs + { + BrowserRendererId = _browserRendererId, + ComponentId = componentId, + RenderTree = renderTree.Array, + RenderTreeLength = renderTree.Count + }); + } + + // Encapsulates the data we pass to the JS rendering function + private struct RenderComponentArgs + { + public int BrowserRendererId; + public int ComponentId; + public RenderTreeNode[] RenderTree; + public int RenderTreeLength; + } + } +} diff --git a/src/Microsoft.Blazor.Browser/Rendering/BrowserRendererEventDispatcher.cs b/src/Microsoft.Blazor.Browser/Rendering/BrowserRendererEventDispatcher.cs new file mode 100644 index 0000000000..20a92cc732 --- /dev/null +++ b/src/Microsoft.Blazor.Browser/Rendering/BrowserRendererEventDispatcher.cs @@ -0,0 +1,63 @@ +// 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.RenderTree; +using System; + +namespace Microsoft.Blazor.Browser.Rendering +{ + /// + /// Provides mechanisms for dispatching events to components in a . + /// This is marked 'internal' because it only gets invoked from JS code. + /// + internal static class BrowserRendererEventDispatcher + { + // We receive the information as JSON strings because of current interop limitations: + // - Can't pass unboxed value types from JS to .NET (yet all the IDs are ints) + // - Can't pass more than 4 args from JS to .NET + // This can be simplified in the future when the Mono WASM runtime is enhanced. + public static void DispatchEvent(string eventDescriptorJson, string eventArgsJson) + { + var eventDescriptor = Json.Deserialize(eventDescriptorJson); + var eventArgs = ParseEventArgsJson(eventDescriptor.EventArgsType, eventArgsJson); + var browserRenderer = BrowserRendererRegistry.Find(eventDescriptor.BrowserRendererId); + browserRenderer.DispatchBrowserEvent( + eventDescriptor.ComponentId, + eventDescriptor.RenderTreeNodeIndex, + eventArgs); + } + + // Again, the params are received as strings for the same reason as above and + // can be simplified once runtime support improves. + public static void RenderChildComponent( + string browserRendererId, + string componentId) + { + var browserRenderer = BrowserRendererRegistry.Find(int.Parse(browserRendererId)); + browserRenderer.RenderComponentInternal( + int.Parse(componentId)); + } + + private static UIEventArgs ParseEventArgsJson(string eventArgsType, string eventArgsJson) + { + switch (eventArgsType) + { + case "mouse": + return Json.Deserialize(eventArgsJson); + case "keyboard": + return Json.Deserialize(eventArgsJson); + default: + throw new ArgumentException($"Unsupported value '{eventArgsType}'.", nameof(eventArgsType)); + } + } + + private class BrowserEventDescriptor + { + public int BrowserRendererId { get; set; } + public int ComponentId { get; set; } + public int RenderTreeNodeIndex { get; set; } + public string EventArgsType { get; set; } + } + } +} diff --git a/src/Microsoft.Blazor.Browser/Rendering/BrowserRendererRegistry.cs b/src/Microsoft.Blazor.Browser/Rendering/BrowserRendererRegistry.cs new file mode 100644 index 0000000000..0e784cd043 --- /dev/null +++ b/src/Microsoft.Blazor.Browser/Rendering/BrowserRendererRegistry.cs @@ -0,0 +1,71 @@ +// 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.Collections.Generic; + +namespace Microsoft.Blazor.Browser.Rendering +{ + /// + /// Provides mechanisms for locating instances + /// by ID. This is used when receiving incoming events from the browser. It + /// implictly ensures that the instances and + /// their associated component instances aren't GCed when events may still + /// be received for them. + /// + internal static class BrowserRendererRegistry + { + private static int _nextId; + private static IDictionary _browserRenderers + = new Dictionary(); + + /// + /// Adds the and gets a unique identifier for it. + /// + /// + /// A unique identifier for the . + public static int Add(BrowserRenderer browserRenderer) + { + lock (_browserRenderers) + { + var id = _nextId++; + _browserRenderers.Add(id, browserRenderer); + return id; + } + } + + /// + /// Gets the with the specified + /// . + /// + /// The identifier of the instance to be returned. + /// The corresponding instance. + public static BrowserRenderer Find(int browserRendererId) + { + lock (_browserRenderers) + { + return _browserRenderers[browserRendererId]; + } + } + + /// + /// Removes the with the specified identifier, if present. + /// + /// The identifier of the to remove. + /// if the was present; otherwise . + public static bool TryRemove(int browserRendererId) + { + lock (_browserRenderers) + { + if (_browserRenderers.ContainsKey(browserRendererId)) + { + _browserRenderers.Remove(browserRendererId); + return true; + } + else + { + return false; + } + } + } + } +} diff --git a/src/Microsoft.Blazor/RenderTree/RenderTreeBuilder.cs b/src/Microsoft.Blazor/RenderTree/RenderTreeBuilder.cs index a6dce7911f..70df197b7b 100644 --- a/src/Microsoft.Blazor/RenderTree/RenderTreeBuilder.cs +++ b/src/Microsoft.Blazor/RenderTree/RenderTreeBuilder.cs @@ -2,6 +2,7 @@ // 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.Rendering; using System; using System.Collections.Generic; @@ -13,11 +14,21 @@ namespace Microsoft.Blazor.RenderTree public class RenderTreeBuilder { private const int MinBufferLength = 10; + private readonly Renderer _renderer; private RenderTreeNode[] _entries = new RenderTreeNode[100]; private int _entriesInUse = 0; - private Stack _openElementIndices = new Stack(); + private readonly Stack _openElementIndices = new Stack(); private RenderTreeNodeType? _lastNonAttributeNodeType; + /// + /// Constructs an instance of . + /// + /// The associated . + public RenderTreeBuilder(Renderer renderer) + { + _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); + } + /// /// Appends a node representing an element, i.e., a container for other nodes. /// In order for the state to be valid, you must @@ -84,7 +95,8 @@ namespace Microsoft.Blazor.RenderTree // previous tree, we'll either instantiate a new component or reuse the // existing instance (and notify it about changes to parameters). var instance = Activator.CreateInstance(); - Append(RenderTreeNode.ChildComponent(instance)); + var instanceId = _renderer.AssignComponentId(instance); + Append(RenderTreeNode.ChildComponent(instanceId, instance)); } private void AssertCanAddAttribute() diff --git a/src/Microsoft.Blazor/RenderTree/RenderTreeNode.cs b/src/Microsoft.Blazor/RenderTree/RenderTreeNode.cs index c628903120..c27819c3a8 100644 --- a/src/Microsoft.Blazor/RenderTree/RenderTreeNode.cs +++ b/src/Microsoft.Blazor/RenderTree/RenderTreeNode.cs @@ -56,6 +56,12 @@ namespace Microsoft.Blazor.RenderTree /// public UIEventHandler AttributeEventHandlerValue { get; private set; } + /// + /// If the property equals , + /// gets the child component instance identifier. + /// + public int ComponentId { get; private set; } + /// /// If the property equals , /// gets the child component instance. Otherwise, the value is . @@ -88,9 +94,10 @@ namespace Microsoft.Blazor.RenderTree AttributeEventHandlerValue = value }; - internal static RenderTreeNode ChildComponent(IComponent component) => new RenderTreeNode + internal static RenderTreeNode ChildComponent(int componentId, IComponent component) => new RenderTreeNode { NodeType = RenderTreeNodeType.Component, + ComponentId = componentId, Component = component }; diff --git a/src/Microsoft.Blazor/Rendering/ComponentState.cs b/src/Microsoft.Blazor/Rendering/ComponentState.cs new file mode 100644 index 0000000000..e5826d8180 --- /dev/null +++ b/src/Microsoft.Blazor/Rendering/ComponentState.cs @@ -0,0 +1,75 @@ +// 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.RenderTree; +using System; + +namespace Microsoft.Blazor.Rendering +{ + /// + /// Tracks the rendering state associated with an instance + /// within the context of a . This is an internal implementation + /// detail of . + /// + internal class ComponentState + { + private readonly int _componentId; // TODO: Change the type to 'long' when the Mono runtime has more complete support for passing longs in .NET->JS calls + private readonly IComponent _component; + private readonly Renderer _renderer; + private readonly RenderTreeBuilder _renderTreeBuilder; + + /// + /// Constructs an instance of . + /// + /// The with which the new instance should be associated. + /// The externally visible identifer for the . The identifier must be unique in the context of the . + /// The whose state is being tracked. + public ComponentState(Renderer renderer, int componentId, IComponent component) + { + _componentId = componentId; + _component = component ?? throw new ArgumentNullException(nameof(component)); + _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); + _renderTreeBuilder = new RenderTreeBuilder(renderer); + } + + /// + /// Regenerates the and notifies the + /// to update the visible UI state. + /// + public void Render() + { + _renderTreeBuilder.Clear(); + _component.BuildRenderTree(_renderTreeBuilder); + + var renderTree = _renderTreeBuilder.GetNodes(); + _renderer.UpdateDisplay(_componentId, renderTree); + } + + /// + /// Invokes the handler corresponding to an event. + /// + /// The index of the current render tree node that holds the event handler to be invoked. + /// Arguments to be passed to the event handler. + public void DispatchEvent(int renderTreeIndex, UIEventArgs eventArgs) + { + if (eventArgs == null) + { + throw new ArgumentNullException(nameof(eventArgs)); + } + + var nodes = _renderTreeBuilder.GetNodes(); + var eventHandler = nodes.Array[renderTreeIndex].AttributeEventHandlerValue; + if (eventHandler == null) + { + throw new ArgumentException($"The render tree node at index {renderTreeIndex} has a null value for {nameof(RenderTreeNode.AttributeEventHandlerValue)}."); + } + + eventHandler.Invoke(eventArgs); + + // After any event, we synchronously re-render. Most of the time this means that + // developers don't need to call Render() on their components explicitly. + Render(); + } + } +} diff --git a/src/Microsoft.Blazor/Rendering/Renderer.cs b/src/Microsoft.Blazor/Rendering/Renderer.cs new file mode 100644 index 0000000000..d9f6ebf621 --- /dev/null +++ b/src/Microsoft.Blazor/Rendering/Renderer.cs @@ -0,0 +1,97 @@ +// 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.RenderTree; +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace Microsoft.Blazor.Rendering +{ + /// + /// Provides mechanisms for rendering hierarchies of instances, + /// dispatching events to them, and notifying when the user interface is being updated. + /// + public abstract class Renderer + { + // Methods for tracking associations between component IDs, instances, and states, + // without pinning any of them in memory here. The explictly GC rooted items are the + // components explicitly added to the renderer (i.e., top-level components). In turn + // these reference descendant components and associated ComponentState instances. + private readonly WeakValueDictionary _componentStateById + = new WeakValueDictionary(); + private readonly ConditionalWeakTable _componentStateByComponent + = new ConditionalWeakTable(); + private int _nextComponentId = 0; // TODO: change to 'long' when Mono .NET->JS interop supports it + + // Ensure that explictly-added components (and transitively, their current descendants) + // aren't GCed. If we don't do this then the display layer (e.g., browser) might send + // us events for component IDs where the corresponding state has already been collected. + private readonly IList _topLevelComponents = new List(); + + /// + /// Associates the with the , assigning + /// an identifier that is unique within the scope of the . + /// + /// The . + /// The assigned identifier for the . + protected internal int AssignComponentId(IComponent component) + { + lock (_componentStateByComponent) + { + if (_componentStateByComponent.TryGetValue(component, out _)) + { + throw new ArgumentException("The component was already associated with the renderer."); + } + + var componentId = _nextComponentId++; + var componentState = new ComponentState(this, componentId, component); + _componentStateById.Add(componentId, componentState); + _componentStateByComponent.Add(component, componentState); + return componentId; + } + } + + /// + /// Updates the visible UI to display the supplied + /// at the location corresponding to the . + /// + /// The identifier for the updated . + /// The updated render tree to be displayed. + internal protected abstract void UpdateDisplay(int componentId, ArraySegment renderTree); + + /// + /// Updates the rendered state of the specified . + /// + /// The . + protected void RenderComponent(IComponent component) + => GetRequiredComponentState(component).Render(); + + /// + /// Updates the rendered state of the specified . + /// + /// The identifier of the to render. + protected void RenderComponent(int componentId) + => GetRequiredComponentState(componentId).Render(); + + /// + /// Notifies the specified component that an event has occurred. + /// + /// The unique identifier for the component within the scope of this . + /// The index into the component's current render tree that specifies which event handler to invoke. + /// Arguments to be passed to the event handler. + protected void DispatchEvent(int componentId, int renderTreeIndex, UIEventArgs eventArgs) + => GetRequiredComponentState(componentId).DispatchEvent(renderTreeIndex, eventArgs); + + private ComponentState GetRequiredComponentState(int componentId) + => _componentStateById.TryGetValue(componentId, out var componentState) + ? componentState + : throw new ArgumentException($"The renderer does not have a component with ID {componentId}."); + + private ComponentState GetRequiredComponentState(IComponent component) + => _componentStateByComponent.TryGetValue(component, out var componentState) + ? componentState + : throw new ArgumentException("The component is not associated with the renderer."); + } +} diff --git a/src/Microsoft.Blazor.Browser/WeakValueDictionary.cs b/src/Microsoft.Blazor/Rendering/WeakValueDictionary.cs similarity index 98% rename from src/Microsoft.Blazor.Browser/WeakValueDictionary.cs rename to src/Microsoft.Blazor/Rendering/WeakValueDictionary.cs index c98d3f7087..43303b2f2f 100644 --- a/src/Microsoft.Blazor.Browser/WeakValueDictionary.cs +++ b/src/Microsoft.Blazor/Rendering/WeakValueDictionary.cs @@ -5,7 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; -namespace Microsoft.Blazor.Browser +namespace Microsoft.Blazor.Rendering { internal class WeakValueDictionary where TValue : class { diff --git a/test/Microsoft.Blazor.Test/UITreeBuilderTest.cs b/test/Microsoft.Blazor.Test/RenderTreeBuilderTest.cs similarity index 90% rename from test/Microsoft.Blazor.Test/UITreeBuilderTest.cs rename to test/Microsoft.Blazor.Test/RenderTreeBuilderTest.cs index b8e89e9885..1243462771 100644 --- a/test/Microsoft.Blazor.Test/UITreeBuilderTest.cs +++ b/test/Microsoft.Blazor.Test/RenderTreeBuilderTest.cs @@ -2,6 +2,7 @@ // 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.Rendering; using Microsoft.Blazor.RenderTree; using System; using System.Linq; @@ -15,7 +16,7 @@ namespace Microsoft.Blazor.Test public void StartsEmpty() { // Arrange - var builder = new RenderTreeBuilder(); + var builder = new RenderTreeBuilder(new TestRenderer()); // Assert var nodes = builder.GetNodes(); @@ -28,7 +29,7 @@ namespace Microsoft.Blazor.Test public void CanAddText() { // Arrange - var builder = new RenderTreeBuilder(); + var builder = new RenderTreeBuilder(new TestRenderer()); // Act builder.AddText("First item"); @@ -46,7 +47,7 @@ namespace Microsoft.Blazor.Test public void UnclosedElementsHaveNoEndDescendantIndex() { // Arrange - var builder = new RenderTreeBuilder(); + var builder = new RenderTreeBuilder(new TestRenderer()); // Act builder.OpenElement("my element"); @@ -60,7 +61,7 @@ namespace Microsoft.Blazor.Test public void ClosedEmptyElementsHaveSelfAsEndDescendantIndex() { // Arrange - var builder = new RenderTreeBuilder(); + var builder = new RenderTreeBuilder(new TestRenderer()); // Act builder.AddText("some node so that the element isn't at position zero"); @@ -77,7 +78,7 @@ namespace Microsoft.Blazor.Test public void ClosedElementsHaveCorrectEndDescendantIndex() { // Arrange - var builder = new RenderTreeBuilder(); + var builder = new RenderTreeBuilder(new TestRenderer()); // Act builder.OpenElement("my element"); @@ -96,7 +97,7 @@ namespace Microsoft.Blazor.Test public void CanNestElements() { // Arrange - var builder = new RenderTreeBuilder(); + var builder = new RenderTreeBuilder(new TestRenderer()); // Act builder.AddText("standalone text 1"); // 0: standalone text 1 @@ -136,7 +137,7 @@ namespace Microsoft.Blazor.Test public void CanAddAttributes() { // Arrange - var builder = new RenderTreeBuilder(); + var builder = new RenderTreeBuilder(new TestRenderer()); UIEventHandler eventHandler = eventInfo => { }; // Act @@ -163,7 +164,7 @@ namespace Microsoft.Blazor.Test public void CannotAddAttributeAtRoot() { // Arrange - var builder = new RenderTreeBuilder(); + var builder = new RenderTreeBuilder(new TestRenderer()); // Act/Assert Assert.Throws(() => @@ -176,7 +177,7 @@ namespace Microsoft.Blazor.Test public void CannotAddEventHandlerAttributeAtRoot() { // Arrange - var builder = new RenderTreeBuilder(); + var builder = new RenderTreeBuilder(new TestRenderer()); // Act/Assert Assert.Throws(() => @@ -189,7 +190,7 @@ namespace Microsoft.Blazor.Test public void CannotAddAttributeToText() { // Arrange - var builder = new RenderTreeBuilder(); + var builder = new RenderTreeBuilder(new TestRenderer()); // Act/Assert Assert.Throws(() => @@ -204,7 +205,7 @@ namespace Microsoft.Blazor.Test public void CannotAddEventHandlerAttributeToText() { // Arrange - var builder = new RenderTreeBuilder(); + var builder = new RenderTreeBuilder(new TestRenderer()); // Act/Assert Assert.Throws(() => @@ -219,7 +220,7 @@ namespace Microsoft.Blazor.Test public void CanAddChildComponents() { // Arrange - var builder = new RenderTreeBuilder(); + var builder = new RenderTreeBuilder(new TestRenderer()); // Act builder.OpenElement("parent"); // 0: @@ -244,7 +245,7 @@ namespace Microsoft.Blazor.Test public void CanClear() { // Arrange - var builder = new RenderTreeBuilder(); + var builder = new RenderTreeBuilder(new TestRenderer()); // Act builder.AddText("some text"); @@ -306,5 +307,11 @@ namespace Microsoft.Blazor.Test public void BuildRenderTree(RenderTreeBuilder builder) => throw new NotImplementedException(); } + + private class TestRenderer : Renderer + { + protected override void UpdateDisplay(int componentId, ArraySegment renderTree) + => throw new NotImplementedException(); + } } } diff --git a/test/testapps/BasicTestApp/Program.cs b/test/testapps/BasicTestApp/Program.cs index 623ef67260..ee3ebc1a50 100644 --- a/test/testapps/BasicTestApp/Program.cs +++ b/test/testapps/BasicTestApp/Program.cs @@ -1,8 +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 Microsoft.Blazor.Browser; using Microsoft.Blazor.Browser.Interop; +using Microsoft.Blazor.Browser.Rendering; using Microsoft.Blazor.Components; using System; @@ -20,7 +20,7 @@ namespace BasicTestApp { var componentType = Type.GetType(componentTypeName); var componentInstance = (IComponent)Activator.CreateInstance(componentType); - DOM.AttachComponent("app", componentInstance); + new BrowserRenderer().AddComponent("app", componentInstance); } } }