File: Infrastructure\TestDocument.cs
Web Access
Project: src\src\Components\WebView\WebView\test\Microsoft.AspNetCore.Components.WebView.Test.csproj (Microsoft.AspNetCore.Components.WebView.Test)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Components.RenderTree;
 
namespace Microsoft.AspNetCore.Components.WebView.Document;
 
public class TestDocument
{
    private const string SelectValuePropname = "_blazorSelectValue";
 
    private readonly Dictionary<long, ComponentNode> _componentsById = new();
 
    public void AddRootComponent(int componentId, string selector)
    {
        if (_componentsById.ContainsKey(componentId))
        {
            throw new InvalidOperationException($"Component with Id '{componentId}' already exists.");
        }
 
        _componentsById.Add(componentId, new RootComponentNode(componentId, selector));
    }
 
    public void ApplyChanges(RenderBatch batch)
    {
        for (var i = 0; i < batch.UpdatedComponents.Count; i++)
        {
            var diff = batch.UpdatedComponents.Array[i];
            var componentId = diff.ComponentId;
            var edits = diff.Edits;
            UpdateComponent(batch, componentId, edits);
        }
 
        for (var i = 0; i < batch.DisposedComponentIDs.Count; i++)
        {
            DisposeComponent(batch.DisposedComponentIDs.Array[i]);
        }
 
        for (var i = 0; i < batch.DisposedEventHandlerIDs.Count; i++)
        {
            DisposeEventHandler(batch.DisposedEventHandlerIDs.Array[i]);
        }
    }
 
    private void UpdateComponent(RenderBatch batch, int componentId, ArrayBuilderSegment<RenderTreeEdit> edits)
    {
        if (!_componentsById.TryGetValue(componentId, out var component))
        {
            component = new ComponentNode(componentId);
            _componentsById.Add(componentId, component);
        }
 
        ApplyEdits(batch, component, 0, edits);
    }
 
    private void DisposeComponent(int componentId)
    {
 
    }
 
    private void DisposeEventHandler(ulong eventHandlerId)
    {
 
    }
 
    private void ApplyEdits(RenderBatch batch, ContainerNode parent, int childIndex, ArrayBuilderSegment<RenderTreeEdit> edits)
    {
        var currentDepth = 0;
        var childIndexAtCurrentDepth = childIndex;
        var permutations = new List<PermutationListEntry>();
 
        for (var editIndex = edits.Offset; editIndex < edits.Offset + edits.Count; editIndex++)
        {
            var edit = edits.Array[editIndex];
            switch (edit.Type)
            {
                case RenderTreeEditType.PrependFrame:
                    {
                        var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex];
                        var siblingIndex = edit.SiblingIndex;
                        InsertFrame(batch, parent, childIndexAtCurrentDepth + siblingIndex, batch.ReferenceFrames.Array, frame, edit.ReferenceFrameIndex);
                        break;
                    }
 
                case RenderTreeEditType.RemoveFrame:
                    {
                        var siblingIndex = edit.SiblingIndex;
                        parent.RemoveLogicalChild(childIndexAtCurrentDepth + siblingIndex);
                        break;
                    }
 
                case RenderTreeEditType.SetAttribute:
                    {
                        var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex];
                        var siblingIndex = edit.SiblingIndex;
                        var node = parent.Children[childIndexAtCurrentDepth + siblingIndex];
                        if (node is ElementNode element)
                        {
                            ApplyAttribute(batch, element, frame);
                        }
                        else
                        {
                            throw new Exception("Cannot set attribute on non-element child");
                        }
                        break;
                    }
 
                case RenderTreeEditType.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)
                        var siblingIndex = edit.SiblingIndex;
                        var node = parent.Children[childIndexAtCurrentDepth + siblingIndex];
                        if (node is ElementNode element)
                        {
                            var attributeName = edit.RemovedAttributeName;
 
                            // First try to remove any special property we use for this attribute
                            if (!TryApplySpecialProperty(batch, element, attributeName, default))
                            {
                                // If that's not applicable, it's a regular DOM attribute so remove that
                                element.RemoveAttribute(attributeName);
                            }
                        }
                        else
                        {
                            throw new Exception("Cannot remove attribute from non-element child");
                        }
                        break;
                    }
 
                case RenderTreeEditType.UpdateText:
                    {
                        var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex];
                        var siblingIndex = edit.SiblingIndex;
                        var node = parent.Children[childIndexAtCurrentDepth + siblingIndex];
                        if (node is TextNode textNode)
                        {
                            textNode.Text = frame.TextContent;
                        }
                        else
                        {
                            throw new Exception("Cannot set text content on non-text child");
                        }
                        break;
                    }
 
                case RenderTreeEditType.UpdateMarkup:
                    {
                        var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex];
                        var siblingIndex = edit.SiblingIndex;
                        parent.RemoveLogicalChild(childIndexAtCurrentDepth + siblingIndex);
                        InsertMarkup(parent, childIndexAtCurrentDepth + siblingIndex, frame);
                        break;
                    }
 
                case RenderTreeEditType.StepIn:
                    {
                        var siblingIndex = edit.SiblingIndex;
                        parent = (ContainerNode)parent.Children[childIndexAtCurrentDepth + siblingIndex];
                        currentDepth++;
                        childIndexAtCurrentDepth = 0;
                        break;
                    }
 
                case RenderTreeEditType.StepOut:
                    {
                        parent = parent.Parent ?? throw new InvalidOperationException($"Cannot step out of {parent}");
                        currentDepth--;
                        childIndexAtCurrentDepth = currentDepth == 0 ? childIndex : 0; // The childIndex is only ever nonzero at zero depth
                        break;
                    }
 
                case RenderTreeEditType.PermutationListEntry:
                    {
                        permutations.Add(new PermutationListEntry(childIndexAtCurrentDepth + edit.SiblingIndex, childIndexAtCurrentDepth + edit.MoveToSiblingIndex));
                        break;
                    }
 
                case RenderTreeEditType.PermutationListEnd:
                    {
                        throw new NotSupportedException();
                        //permuteLogicalChildren(parent, permutations!);
                        //permutations.Clear();
                        //break;
                    }
 
                default:
                    {
                        throw new Exception($"Unknown edit type: '{edit.Type}'");
                    }
            }
        }
    }
 
    private int InsertFrame(RenderBatch batch, ContainerNode parent, int childIndex, ArraySegment<RenderTreeFrame> frames, RenderTreeFrame frame, int frameIndex)
    {
        switch (frame.FrameType)
        {
            case RenderTreeFrameType.Element:
                {
                    InsertElement(batch, parent, childIndex, frames, frame, frameIndex);
                    return 1;
                }
 
            case RenderTreeFrameType.Text:
                {
                    InsertText(parent, childIndex, frame);
                    return 1;
                }
 
            case RenderTreeFrameType.Attribute:
                {
                    throw new Exception("Attribute frames should only be present as leading children of element frames.");
                }
 
            case RenderTreeFrameType.Component:
                {
                    InsertComponent(parent, childIndex, frame);
                    return 1;
                }
 
            case RenderTreeFrameType.Region:
                {
                    return InsertFrameRange(batch, parent, childIndex, frames, frameIndex + 1, frameIndex + frame.RegionSubtreeLength);
                }
 
            case RenderTreeFrameType.ElementReferenceCapture:
                {
                    if (parent is ElementNode)
                    {
                        return 0; // A "capture" is a child in the diff, but has no node in the DOM
                    }
                    else
                    {
                        throw new Exception("Reference capture frames can only be children of element frames.");
                    }
                }
 
            case RenderTreeFrameType.Markup:
                {
                    InsertMarkup(parent, childIndex, frame);
                    return 1;
                }
 
        }
 
        throw new Exception($"Unknown frame type: {frame.FrameType}");
    }
 
    private void InsertText(ContainerNode parent, int childIndex, RenderTreeFrame frame)
    {
        var textContent = frame.TextContent;
        var newTextNode = new TextNode(textContent);
        parent.InsertLogicalChild(newTextNode, childIndex);
    }
 
    private void InsertComponent(ContainerNode parent, int childIndex, RenderTreeFrame frame)
    {
        // All we have to do is associate the child component ID with its location. We don't actually
        // do any rendering here, because the diff for the child will appear later in the render batch.
        var childComponentId = frame.ComponentId;
        var containerElement = parent.CreateAndInsertComponent(childComponentId, childIndex);
 
        _componentsById[childComponentId] = containerElement;
    }
 
    private int InsertFrameRange(RenderBatch batch, ContainerNode parent, int childIndex, ArraySegment<RenderTreeFrame> frames, int startIndex, int endIndexExcl)
    {
        var origChildIndex = childIndex;
        for (var index = startIndex; index < endIndexExcl; index++)
        {
            var frame = batch.ReferenceFrames.Array[index];
            var numChildrenInserted = InsertFrame(batch, parent, childIndex, frames, frame, index);
            childIndex += numChildrenInserted;
 
            // Skip over any descendants, since they are already dealt with recursively
            index += CountDescendantFrames(frame);
        }
 
        return childIndex - origChildIndex; // Total number of children inserted
    }
 
    private void InsertElement(RenderBatch batch, ContainerNode parent, int childIndex, ArraySegment<RenderTreeFrame> frames, RenderTreeFrame frame, int frameIndex)
    {
        // Note: we don't handle SVG here
        var newElement = new ElementNode(frame.ElementName);
 
        var inserted = false;
 
        // Apply attributes
        for (var i = frameIndex + 1; i < frameIndex + frame.ElementSubtreeLength; i++)
        {
            var descendantFrame = batch.ReferenceFrames.Array[i];
            if (descendantFrame.FrameType == RenderTreeFrameType.Attribute)
            {
                ApplyAttribute(batch, newElement, descendantFrame);
            }
            else
            {
                parent.InsertLogicalChild(newElement, childIndex);
                inserted = true;
 
                // As soon as we see a non-attribute child, all the subsequent child frames are
                // not attributes, so bail out and insert the remnants recursively
                InsertFrameRange(batch, newElement, 0, frames, i, frameIndex + frame.ElementSubtreeLength);
                break;
            }
        }
 
        // this element did not have any children, so it's not inserted yet.
        if (!inserted)
        {
            parent.InsertLogicalChild(newElement, childIndex);
        }
    }
 
    private void ApplyAttribute(RenderBatch batch, ElementNode elementNode, RenderTreeFrame attributeFrame)
    {
        var attributeName = attributeFrame.AttributeName;
        var eventHandlerId = attributeFrame.AttributeEventHandlerId;
 
        if (eventHandlerId != 0)
        {
            var firstTwoChars = attributeName.Substring(0, 2);
            var eventName = attributeName.Substring(2);
            if (firstTwoChars != "on" || string.IsNullOrEmpty(eventName))
            {
                throw new InvalidOperationException($"Attribute has nonzero event handler ID, but attribute name '${attributeName}' does not start with 'on'.");
            }
            var descriptor = new ElementNode.ElementEventDescriptor(eventName, eventHandlerId);
            elementNode.SetEvent(eventName, descriptor);
 
            return;
        }
 
        // First see if we have special handling for this attribute
        if (!TryApplySpecialProperty(batch, elementNode, attributeName, attributeFrame))
        {
            // If not, treat it as a regular string-valued attribute
            elementNode.SetAttribute(
              attributeName,
              attributeFrame.AttributeValue);
        }
    }
 
    private bool TryApplySpecialProperty(RenderBatch batch, ElementNode element, string attributeName, RenderTreeFrame attributeFrame)
    {
        switch (attributeName)
        {
            case "value":
                return TryApplyValueProperty(element, attributeFrame);
            case "checked":
                return TryApplyCheckedProperty(element, attributeFrame);
            default:
                return false;
        }
    }
 
    private bool TryApplyValueProperty(ElementNode element, RenderTreeFrame attributeFrame)
    {
        // Certain elements have built-in behaviour for their 'value' property
        switch (element.TagName)
        {
            case "INPUT":
            case "SELECT":
            case "TEXTAREA":
                {
                    var value = attributeFrame.AttributeValue;
                    element.SetProperty("value", value);
 
                    if (element.TagName == "SELECT")
                    {
                        // <select> is special, in that anything we write to .value will be lost if there
                        // isn't yet a matching <option>. To maintain the expected behavior no matter the
                        // element insertion/update order, preserve the desired value separately so
                        // we can recover it when inserting any matching <option>.
                        element.SetProperty(SelectValuePropname, value);
                    }
                    return true;
                }
            case "OPTION":
                {
                    var value = attributeFrame.AttributeValue;
                    if (value != null)
                    {
                        element.SetAttribute("value", value);
                    }
                    else
                    {
                        element.RemoveAttribute("value");
                    }
                    return true;
                }
            default:
                return false;
        }
    }
 
    private bool TryApplyCheckedProperty(ElementNode element, RenderTreeFrame attributeFrame)
    {
        // Certain elements have built-in behaviour for their 'checked' property
        if (element.TagName == "INPUT")
        {
            var value = attributeFrame.AttributeValue;
            element.SetProperty("checked", value);
            return true;
        }
 
        return false;
    }
 
    private void InsertMarkup(ContainerNode parent, int childIndex, RenderTreeFrame markupFrame)
    {
        var markupContainer = parent.CreateAndInsertContainer(childIndex);
        var markupContent = markupFrame.MarkupContent;
        var markupNode = new MarkupNode(markupContent);
        markupContainer.InsertLogicalChild(markupNode, childIndex);
    }
 
    private int CountDescendantFrames(RenderTreeFrame frame)
    {
        switch (frame.FrameType)
        {
            // The following frame types have a subtree length. Other frames may use that memory slot
            // to mean something else, so we must not read it. We should consider having nominal subtypes
            // of RenderTreeFramePointer that prevent access to non-applicable fields.
            case RenderTreeFrameType.Component:
                return frame.ComponentSubtreeLength - 1;
            case RenderTreeFrameType.Element:
                return frame.ElementSubtreeLength - 1;
            case RenderTreeFrameType.Region:
                return frame.RegionSubtreeLength - 1;
            default:
                return 0;
        }
    }
 
    public string GetHtml()
    {
        var builder = new StringBuilder();
        foreach (var root in _componentsById.Values.OfType<RootComponentNode>())
        {
            Render(root, builder);
        }
 
        return builder.ToString();
    }
 
    private void Render(TestNode node, StringBuilder builder)
    {
        if (node is TextNode t)
        {
            builder.Append(HtmlEncoder.Default.Encode(t.Text));
        }
        else if (node is MarkupNode m)
        {
            builder.Append(m.Content);
        }
        else if (node is ElementNode e)
        {
            builder.Append("<");
            builder.Append(e.TagName);
            foreach (var (name, value) in e.Attributes)
            {
                builder.Append(" ");
                builder.Append(name);
                builder.Append("=");
                builder.Append(HtmlEncoder.Default.Encode(value.ToString()));
            }
            builder.Append(">");
            RenderDescendants(e, builder);
            if (e.Children.Count > 0)
            {
                builder.Append("</");
                builder.Append(e.TagName);
                builder.Append(">");
            }
        }
        else if (node is ContainerNode c)
        {
            RenderDescendants(c, builder);
        }
 
        void RenderDescendants(ContainerNode e, StringBuilder builder)
        {
            foreach (var child in e.Children)
            {
                Render(child, builder);
            }
        }
    }
 
    private readonly struct PermutationListEntry
    {
        public readonly int From;
        public readonly int To;
 
        public PermutationListEntry(int from, int to)
        {
            From = from;
            To = to;
        }
    }
}