File: Language\Components\ComponentMarkupBlockPass.cs
Web Access
Project: src\src\Razor\src\Compiler\Microsoft.CodeAnalysis.Razor.Compiler\src\Microsoft.CodeAnalysis.Razor.Compiler.csproj (Microsoft.CodeAnalysis.Razor.Compiler)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using Microsoft.AspNetCore.Razor.Language.Intermediate;
 
namespace Microsoft.AspNetCore.Razor.Language.Components;
 
// Rewrites contiguous subtrees of HTML into a special node type to reduce the
// size of the Render tree.
//
// Does not preserve insignificant details of the HTML, like tag closing style
// or quote style.
internal sealed class ComponentMarkupBlockPass : ComponentIntermediateNodePassBase, IRazorOptimizationPass
{
    private readonly RazorLanguageVersion _version;
 
    public ComponentMarkupBlockPass(RazorLanguageVersion version)
    {
        _version = version;
    }
 
    // Runs LATE because we want to destroy structure.
    //
    // We also need to run after ComponentMarkupDiagnosticPass to avoid destroying diagnostics
    // added in that pass.
    public override int Order => ComponentMarkupDiagnosticPass.DefaultOrder + 10;
 
    protected override void ExecuteCore(
        RazorCodeDocument codeDocument,
        DocumentIntermediateNode documentNode,
        CancellationToken cancellationToken)
    {
        if (!IsComponentDocument(documentNode))
        {
            return;
        }
 
        if (documentNode.Options.DesignTime)
        {
            // Nothing to do during design time.
            return;
        }
 
        var findVisitor = new FindHtmlTreeVisitor(_version);
        findVisitor.Visit(documentNode);
 
        var trees = findVisitor.Trees;
        var rewriteVisitor = new RewriteVisitor(trees);
        while (trees.Count > 0)
        {
            // Walk backwards since we did a postorder traversal.
            var reference = trees[trees.Count - 1];
 
            // Forcibly remove a node to prevent infinite loops.
            trees.RemoveAt(trees.Count - 1);
 
            // We want to fold together siblings where possible. To do this, first we find
            // the index of the node we're looking at now - then we need to walk backwards
            // and identify a set of contiguous nodes we can merge.
            var start = reference.Parent.Children.Count - 1;
            for (; start >= 0; start--)
            {
                if (ReferenceEquals(reference.Node, reference.Parent.Children[start]))
                {
                    break;
                }
            }
 
            // This is the current node. Check if the left sibling is always a candidate
            // for rewriting. Due to the order we processed the nodes, we know that the
            // left sibling is next in the list to process if it's a candidate.
            var end = start;
            while (start - 1 >= 0)
            {
                var candidate = reference.Parent.Children[start - 1];
                if (trees.Count == 0 || !ReferenceEquals(trees[trees.Count - 1].Node, candidate))
                {
                    // This means the we're out of nodes, or the left sibling is not in the list.
                    break;
                }
 
                // This means that the left sibling is valid to merge.
                start--;
 
                // Remove this since we're combining it.
                trees.RemoveAt(trees.Count - 1);
            }
 
            // As a degenerate case, don't bother rewriting an single HtmlContent node
            // It doesn't add any value.
            if (end - start == 0 && reference.Node is HtmlContentIntermediateNode)
            {
                continue;
            }
 
            // Now we know the range of nodes to rewrite (end is inclusive)
            var length = end + 1 - start;
            while (length > 0)
            {
                // Keep using start since we're removing nodes.
                var node = reference.Parent.Children[start];
                reference.Parent.Children.RemoveAt(start);
 
                rewriteVisitor.Visit(node);
 
                length--;
            }
 
            reference.Parent.Children.Insert(start, new MarkupBlockIntermediateNode()
            {
                Content = rewriteVisitor.Builder.ToString(),
            });
 
            rewriteVisitor.Builder.Clear();
        }
    }
 
    // Finds HTML-blocks using a postorder traversal. We store nodes in an
    // ordered list so we can avoid redundant rewrites.
    //
    // Consider a case like:
    //  <div>
    //    <a href="...">click me</a>
    //  </div>
    //
    // We would store both the div and a tag in a list, but make sure to visit
    // the div first. Then when we process the div (recursively), we would remove
    // the a from the list.
    private class FindHtmlTreeVisitor : IntermediateNodeWalker
    {
        private readonly RazorLanguageVersion _version;
 
        public FindHtmlTreeVisitor(RazorLanguageVersion version)
        {
            _version = version;
        }
 
        private bool _foundNonHtml;
 
        public List<IntermediateNodeReference> Trees { get; } = new List<IntermediateNodeReference>();
 
        public override void VisitDefault(IntermediateNode node)
        {
            // If we get here, we found a non-HTML node. Keep traversing.
            _foundNonHtml = true;
            base.VisitDefault(node);
        }
 
        public override void VisitMarkupElement(MarkupElementIntermediateNode node)
        {
            // We need to restore the state after processing this node.
            // We might have found a leaf-block of HTML, but that shouldn't
            // affect our parent's state.
            var originalState = _foundNonHtml;
 
            _foundNonHtml = false;
 
            if (node.HasDiagnostics)
            {
                // Treat node with errors as non-HTML - don't let the parent rewrite this either.
                _foundNonHtml = true;
            }
 
            if (_version <= RazorLanguageVersion.Version_7_0 &&
                string.Equals("script", node.TagName, StringComparison.OrdinalIgnoreCase))
            {
                // Treat script tags as non-HTML in .NET 7 and earlier.
                _foundNonHtml = true;
            }
            else if (string.Equals("option", node.TagName, StringComparison.OrdinalIgnoreCase))
            {
                // Also, treat <option>...</option> as non-HTML - we don't want it to be coalesced so that we can support setting "selected" attribute on it.
                // We only care about option tags that are nested under a select tag.
                foreach (var ancestor in Ancestors)
                {
                    if (ancestor is MarkupElementIntermediateNode element &&
                        string.Equals("select", element.TagName, StringComparison.OrdinalIgnoreCase))
                    {
                        _foundNonHtml = true;
                        break;
                    }
                }
            }
 
            base.VisitDefault(node);
 
            if (!_foundNonHtml)
            {
                Trees.Add(new IntermediateNodeReference(node, Parent!));
            }
 
            _foundNonHtml = originalState |= _foundNonHtml;
        }
 
        public override void VisitHtmlAttribute(HtmlAttributeIntermediateNode node)
        {
            if (node.HasDiagnostics)
            {
                // Treat node with errors as non-HTML
                _foundNonHtml = true;
            }
 
            // Visit Children
            base.VisitDefault(node);
        }
 
        public override void VisitHtmlAttributeValue(HtmlAttributeValueIntermediateNode node)
        {
            if (node.HasDiagnostics)
            {
                // Treat node with errors as non-HTML
                _foundNonHtml = true;
            }
 
            // Visit Children
            base.VisitDefault(node);
        }
 
        public override void VisitHtml(HtmlContentIntermediateNode node)
        {
            // We need to restore the state after processing this node.
            // We might have found a leaf-block of HTML, but that shouldn't
            // affect our parent's state.
            var originalState = _foundNonHtml;
 
            _foundNonHtml = false;
 
            if (node.HasDiagnostics)
            {
                // Treat node with errors as non-HTML
                _foundNonHtml = true;
            }
 
            // Visit Children
            base.VisitDefault(node);
 
            if (!_foundNonHtml)
            {
                Trees.Add(new IntermediateNodeReference(node, Parent!));
            }
 
            _foundNonHtml = originalState |= _foundNonHtml;
        }
 
        public override void VisitToken(IntermediateToken node)
        {
            if (node.HasDiagnostics)
            {
                // Treat node with errors as non-HTML
                _foundNonHtml = true;
            }
 
            if (node is CSharpIntermediateToken)
            {
                _foundNonHtml = true;
            }
        }
    }
 
    private class RewriteVisitor : IntermediateNodeWalker
    {
        private readonly List<IntermediateNodeReference> _trees;
 
        public RewriteVisitor(List<IntermediateNodeReference> trees)
        {
            _trees = trees;
        }
 
        public StringBuilder Builder { get; } = new StringBuilder();
 
        public override void VisitMarkupElement(MarkupElementIntermediateNode node)
        {
            for (var i = 0; i < _trees.Count; i++)
            {
                // Remove this node if it's in the list. This ensures that we don't
                // do redundant operations.
                if (ReferenceEquals(_trees[i].Node, node))
                {
                    _trees.RemoveAt(i);
                    break;
                }
            }
 
            var isVoid = Legacy.ParserHelpers.VoidElements.Contains(node.TagName);
            var hasBodyContent = node.Body.Any();
 
            Builder.Append('<');
            Builder.Append(node.TagName);
 
            foreach (var attribute in node.Attributes)
            {
                Visit(attribute);
            }
 
            // If for some reason a void element contains body, then treat it as a
            // start/end tag.
            if (!hasBodyContent && isVoid)
            {
                // void
                Builder.Append('>');
                return;
            }
            else if (!hasBodyContent)
            {
                // In HTML5, we can't have self-closing non-void elements, so explicitly
                // add a close tag
                Builder.Append("></");
                Builder.Append(node.TagName);
                Builder.Append('>');
                return;
            }
 
            // start/end tag with body.
            Builder.Append('>');
 
            foreach (var item in node.Body)
            {
                Visit(item);
            }
 
            Builder.Append("</");
            Builder.Append(node.TagName);
            Builder.Append('>');
        }
 
        public override void VisitHtmlAttribute(HtmlAttributeIntermediateNode node)
        {
            Builder.Append(' ');
            Builder.Append(node.AttributeName);
 
            if (node.Children.Count == 0)
            {
                // Minimized attribute
                return;
            }
 
            // We examine the node.Prefix (e.g. " onfocus='" or " on focus=\"")
            // to preserve the quote type that is used in the original markup.
            var quoteType = node.Prefix.EndsWith("'", StringComparison.Ordinal) ? "'" : "\"";
 
            Builder.Append('=');
            Builder.Append(quoteType);
 
            // Visit Children
            base.VisitDefault(node);
 
            Builder.Append(quoteType);
        }
 
        public override void VisitHtmlAttributeValue(HtmlAttributeValueIntermediateNode node)
        {
            for (var i = 0; i < node.Children.Count; i++)
            {
                Builder.Append(node.Prefix);
 
                if (node.Children[i] is IntermediateToken token)
                {
                    Builder.Append(token.Content);
                }
            }
        }
 
        public override void VisitHtml(HtmlContentIntermediateNode node)
        {
            for (var i = 0; i < node.Children.Count; i++)
            {
                if (node.Children[i] is IntermediateToken token)
                {
                    Builder.Append(token.Content);
                }
            }
        }
    }
}