From c5a5dff27c86864fdbf36f22e1d8beab497e9872 Mon Sep 17 00:00:00 2001 From: Dustin Campbell Date: Tue, 25 Mar 2025 16:05:24 -0700 Subject: [PATCH 01/13] TagHelperBinding: Don't expose Mappings dictionary Don't expose the Mappings dictionary. Instead, expose a GetBoundRules(...) method and update each access. --- .../test/DefaultRazorTagHelperBinderPhaseTest.cs | 4 ++-- .../test/TagHelperBinderTest.cs | 10 +++++----- .../src/Language/Legacy/TagHelperBlockRewriter.cs | 2 +- .../Language/Legacy/TagHelperParseTreeRewriter.cs | 4 ++-- .../src/Language/TagHelperBinding.cs | 14 ++++++++++---- .../RazorWrapperFactory.TagHelperBindingWrapper.cs | 2 +- .../AutoClosingTagOnAutoInsertProvider.cs | 3 +-- .../TagHelperFactsTest.cs | 2 +- 8 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/DefaultRazorTagHelperBinderPhaseTest.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/DefaultRazorTagHelperBinderPhaseTest.cs index 9a539690d29..dd3802efad4 100644 --- a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/DefaultRazorTagHelperBinderPhaseTest.cs +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/DefaultRazorTagHelperBinderPhaseTest.cs @@ -295,7 +295,7 @@ public void Execute_DirectiveWithoutQuotes_RewritesTagHelpers_TagHelperMatchesEl var formTagHelper = Assert.Single(tagHelperNodes); Assert.Equal("form", formTagHelper.TagHelperInfo.TagName); - Assert.Equal(2, formTagHelper.TagHelperInfo.BindingResult.Mappings[tagHelper].Length); + Assert.Equal(2, formTagHelper.TagHelperInfo.BindingResult.GetBoundRules(tagHelper).Length); } [Fact] @@ -340,7 +340,7 @@ public void Execute_DirectiveWithQuotes_RewritesTagHelpers_TagHelperMatchesEleme var formTagHelper = Assert.Single(tagHelperNodes); Assert.Equal("form", formTagHelper.TagHelperInfo.TagName); - Assert.Equal(2, formTagHelper.TagHelperInfo.BindingResult.Mappings[tagHelper].Length); + Assert.Equal(2, formTagHelper.TagHelperInfo.BindingResult.GetBoundRules(tagHelper).Length); } [Fact] diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TagHelperBinderTest.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TagHelperBinderTest.cs index c413db5a176..dcf47aaf0c6 100644 --- a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TagHelperBinderTest.cs +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TagHelperBinderTest.cs @@ -39,7 +39,7 @@ public void GetBinding_ReturnsBindingWithInformation() Assert.Equal("body", bindingResult.ParentTagName); Assert.Equal>(expectedAttributes, bindingResult.Attributes); Assert.Equal("th:", bindingResult.TagHelperPrefix); - Assert.Equal(divTagHelper.TagMatchingRules, bindingResult.Mappings[divTagHelper]); + Assert.Equal(divTagHelper.TagMatchingRules, bindingResult.GetBoundRules(divTagHelper)); } [Fact] @@ -81,7 +81,7 @@ void TestTagName(string tagName, TagMatchingRuleDescriptor expectedBindingResult Assert.Equal(expectedDescriptors, bindingResult.Descriptors); Assert.Equal(tagName, bindingResult.TagName); - var mapping = Assert.Single(bindingResult.Mappings[multiTagHelper]); + var mapping = Assert.Single(bindingResult.GetBoundRules(multiTagHelper)); Assert.Equal(expectedBindingResult, mapping); } } @@ -136,7 +136,7 @@ void TestTagName(string tagName, TagHelperDescriptor[] expectedDescriptors, TagM for (int i = 0; i < expectedDescriptors.Length; i++) { - var mapping = Assert.Single(bindingResult.Mappings[expectedDescriptors[i]]); + var mapping = Assert.Single(bindingResult.GetBoundRules(expectedDescriptors[i])); Assert.Equal(expectedBindingResults[i], mapping); } } @@ -598,7 +598,7 @@ public void GetBinding_DescriptorWithMultipleRules_CorrectlySelectsMatchingRules // Assert var boundDescriptor = Assert.Single(binding.Descriptors); Assert.Same(multiRuleDescriptor, boundDescriptor); - var boundRules = binding.Mappings[boundDescriptor]; + var boundRules = binding.GetBoundRules(boundDescriptor); var boundRule = Assert.Single(boundRules); Assert.Equal("div", boundRule.TagName); } @@ -626,7 +626,7 @@ public void GetBinding_PrefixedParent_ReturnsBinding() // Assert var boundDescriptor = Assert.Single(bindingResult.Descriptors); Assert.Same(divDescriptor, boundDescriptor); - var boundRules = bindingResult.Mappings[boundDescriptor]; + var boundRules = bindingResult.GetBoundRules(boundDescriptor); var boundRule = Assert.Single(boundRules); Assert.Equal("div", boundRule.TagName); Assert.Equal("p", boundRule.ParentTag); diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperBlockRewriter.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperBlockRewriter.cs index 3d5afbce23d..5a364468e50 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperBlockRewriter.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperBlockRewriter.cs @@ -29,7 +29,7 @@ public static TagMode GetTagMode( var hasDirectiveAttribute = false; foreach (var descriptor in bindingResult.Descriptors) { - var boundRules = bindingResult.Mappings[descriptor]; + var boundRules = bindingResult.GetBoundRules(descriptor); var nonDefaultRule = boundRules.FirstOrDefault(static rule => rule.TagStructure != TagStructure.Unspecified); if (nonDefaultRule?.TagStructure == TagStructure.WithoutEndTag) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperParseTreeRewriter.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperParseTreeRewriter.cs index 5e1a323a65e..2c456bfd4a0 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperParseTreeRewriter.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperParseTreeRewriter.cs @@ -349,7 +349,7 @@ private bool TryRewriteTagHelperEnd( foreach (var descriptor in tagHelperBinding.Descriptors) { - var boundRules = tagHelperBinding.Mappings[descriptor]; + var boundRules = tagHelperBinding.GetBoundRules(descriptor); var invalidRule = boundRules.FirstOrDefault(static rule => rule.TagStructure == TagStructure.WithoutEndTag); if (invalidRule != null) @@ -464,7 +464,7 @@ private void ValidateBinding( foreach (var descriptor in bindingResult.Descriptors) { - var boundRules = bindingResult.Mappings[descriptor]; + var boundRules = bindingResult.GetBoundRules(descriptor); foreach (var rule in boundRules) { if (rule.TagStructure != TagStructure.Unspecified) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinding.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinding.cs index 378586eb70e..5d05dd747b6 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinding.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinding.cs @@ -10,13 +10,14 @@ namespace Microsoft.AspNetCore.Razor.Language; internal sealed class TagHelperBinding { + private readonly FrozenDictionary> _mappings; + public string TagName { get; } public string? ParentTagName { get; } public ImmutableArray> Attributes { get; } - public FrozenDictionary> Mappings { get; } public string? TagHelperPrefix { get; } - public ImmutableArray Descriptors => Mappings.Keys; + public ImmutableArray Descriptors => _mappings.Keys; internal TagHelperBinding( string tagName, @@ -28,10 +29,15 @@ internal TagHelperBinding( TagName = tagName; Attributes = attributes; ParentTagName = parentTagName; - Mappings = mappings; + _mappings = mappings; TagHelperPrefix = tagHelperPrefix; } + public ImmutableArray GetBoundRules(TagHelperDescriptor descriptor) + { + return _mappings[descriptor]; + } + /// /// Gets a value that indicates whether the the binding matched on attributes only. /// @@ -44,7 +50,7 @@ public bool IsAttributeMatch { get { - foreach (var descriptor in Mappings.Keys) + foreach (var descriptor in _mappings.Keys) { if (!descriptor.Metadata.TryGetValue(TagHelperMetadata.Common.ClassifyAttributesOnly, out var value) || !string.Equals(value, bool.TrueString, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.LegacyEditor/RazorWrapperFactory.TagHelperBindingWrapper.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.LegacyEditor/RazorWrapperFactory.TagHelperBindingWrapper.cs index d96f0d34e8e..34aa49599f3 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.LegacyEditor/RazorWrapperFactory.TagHelperBindingWrapper.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.LegacyEditor/RazorWrapperFactory.TagHelperBindingWrapper.cs @@ -17,7 +17,7 @@ public ImmutableArray Descriptors public ImmutableArray GetBoundRules(IRazorTagHelperDescriptor descriptor) { - return WrapAll(Object.Mappings[Unwrap(descriptor)], Wrap); + return WrapAll(Object.GetBoundRules(Unwrap(descriptor)), Wrap); } } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/AutoInsert/AutoClosingTagOnAutoInsertProvider.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/AutoInsert/AutoClosingTagOnAutoInsertProvider.cs index c532fa188c6..376da13dcc8 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/AutoInsert/AutoClosingTagOnAutoInsertProvider.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/AutoInsert/AutoClosingTagOnAutoInsertProvider.cs @@ -155,8 +155,7 @@ private static bool TryGetTagHelperAutoClosingBehavior(TagHelperBinding bindingR foreach (var descriptor in bindingResult.Descriptors) { - var tagMatchingRules = bindingResult.Mappings[descriptor]; - foreach (var tagMatchingRule in tagMatchingRules) + foreach (var tagMatchingRule in bindingResult.GetBoundRules(descriptor)) { if (tagMatchingRule.TagStructure == TagStructure.Unspecified) { diff --git a/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/TagHelperFactsTest.cs b/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/TagHelperFactsTest.cs index a1b00abdcbc..5f9a5ab4151 100644 --- a/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/TagHelperFactsTest.cs +++ b/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/TagHelperFactsTest.cs @@ -77,7 +77,7 @@ public void GetTagHelperBinding_WorksAsExpected() // Assert var descriptor = Assert.Single(binding.Descriptors); Assert.Equal(documentDescriptors[0], descriptor); - var boundRule = Assert.Single(binding.Mappings[descriptor]); + var boundRule = Assert.Single(binding.GetBoundRules(descriptor)); Assert.Equal(documentDescriptors[0].TagMatchingRules.First(), boundRule); } From 4ea942ccdbd5c3135bbf9904b1d21a4bdaa075d9 Mon Sep 17 00:00:00 2001 From: Dustin Campbell Date: Tue, 25 Mar 2025 16:14:12 -0700 Subject: [PATCH 02/13] TagHelperBinder: Don't create HashSet for each rule This change uses a single HashSet to ensure that there are no duplicates. This avoids needing to use a HashSet per rule. --- .../src/Language/TagHelperBinder.cs | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs index 24d3b5a09a2..3ce168ee232 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs @@ -14,7 +14,7 @@ namespace Microsoft.AspNetCore.Razor.Language; /// internal sealed class TagHelperBinder { - private readonly Dictionary> _registrations; + private readonly Dictionary> _registrations; public string? TagHelperPrefix { get; } public ImmutableArray TagHelpers { get; } @@ -31,12 +31,34 @@ public TagHelperBinder(string? tagHelperPrefix, ImmutableArray>(tagHelpers.Length, StringComparer.OrdinalIgnoreCase); + _registrations = new Dictionary>(tagHelpers.Length, StringComparer.OrdinalIgnoreCase); + + using var pooledSet = HashSetPool.GetPooledObject(out var processedDescriptors); // Populate our registrations foreach (var descriptor in tagHelpers) { - Register(descriptor); + if (!processedDescriptors.Add(descriptor)) + { + // We're already seen this descriptor, skip it. + continue; + } + + foreach (var rule in descriptor.TagMatchingRules) + { + var registrationKey = rule.TagName == TagHelperMatchingConventions.ElementCatchAllName + ? TagHelperMatchingConventions.ElementCatchAllName + : TagHelperPrefix + rule.TagName; + + // Ensure there's a HashSet to add the descriptor to. + if (!_registrations.TryGetValue(registrationKey, out var descriptorList)) + { + descriptorList = []; + _registrations[registrationKey] = descriptorList; + } + + descriptorList.Add(descriptor); + } } } @@ -104,7 +126,7 @@ public TagHelperBinder(string? tagHelperPrefix, ImmutableArray descriptors, + List descriptors, ReadOnlySpan tagNameWithoutPrefix, ReadOnlySpan parentTagNameWithoutPrefix, ImmutableArray> attributes, @@ -131,23 +153,4 @@ static void FindApplicableDescriptors( } } } - - private void Register(TagHelperDescriptor descriptor) - { - foreach (var rule in descriptor.TagMatchingRules) - { - var registrationKey = rule.TagName == TagHelperMatchingConventions.ElementCatchAllName - ? TagHelperMatchingConventions.ElementCatchAllName - : TagHelperPrefix + rule.TagName; - - // Ensure there's a HashSet to add the descriptor to. - if (!_registrations.TryGetValue(registrationKey, out var descriptorSet)) - { - descriptorSet = []; - _registrations[registrationKey] = descriptorSet; - } - - descriptorSet.Add(descriptor); - } - } } From f9a92efaf8348e00ca491d098d5ba05334fc3eb8 Mon Sep 17 00:00:00 2001 From: Dustin Campbell Date: Wed, 26 Mar 2025 09:51:09 -0700 Subject: [PATCH 03/13] Use a pooled dictionary to build tagNameToDescriptorsMap (registrations) Instead of creating a new Dictionary with an arbitrary capacity, use a pooled Dictionary to build the initial map and copy the results to a new Dictionary with the precise capacity. --- .../src/Language/TagHelperBinder.cs | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs index 3ce168ee232..81e1c413b84 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs @@ -14,7 +14,7 @@ namespace Microsoft.AspNetCore.Razor.Language; /// internal sealed class TagHelperBinder { - private readonly Dictionary> _registrations; + private readonly Dictionary> _tagNameToDescriptorsMap; public string? TagHelperPrefix { get; } public ImmutableArray TagHelpers { get; } @@ -30,12 +30,10 @@ public TagHelperBinder(string? tagHelperPrefix, ImmutableArray>(tagHelpers.Length, StringComparer.OrdinalIgnoreCase); - + using var pooledMap = StringDictionaryPool.Builder>.OrdinalIgnoreCase.GetPooledObject(out var mapBuilder); using var pooledSet = HashSetPool.GetPooledObject(out var processedDescriptors); - // Populate our registrations + // Build a map of tag name -> tag helpers. foreach (var descriptor in tagHelpers) { if (!processedDescriptors.Add(descriptor)) @@ -46,20 +44,23 @@ public TagHelperBinder(string? tagHelperPrefix, ImmutableArray ImmutableArray.CreateBuilder()); - descriptorList.Add(descriptor); + builder.Add(descriptor); } } + + // Build the final dictionary with immutable arrays. + _tagNameToDescriptorsMap = new(capacity: mapBuilder.Count, StringComparer.OrdinalIgnoreCase); + + foreach (var (key, value) in mapBuilder) + { + _tagNameToDescriptorsMap.Add(key, value.DrainToImmutable()); + } } /// @@ -102,13 +103,13 @@ public TagHelperBinder(string? tagHelperPrefix, ImmutableArray>.GetPooledObject(out var applicableDescriptors); // First, try any tag helpers with this tag name. - if (_registrations.TryGetValue(tagName, out var matchingDescriptors)) + if (_tagNameToDescriptorsMap.TryGetValue(tagName, out var matchingDescriptors)) { FindApplicableDescriptors(matchingDescriptors, tagNameWithoutPrefix, parentTagNameWithoutPrefix, attributes, applicableDescriptors); } // Next, try any "catch all" descriptors. - if (_registrations.TryGetValue(TagHelperMatchingConventions.ElementCatchAllName, out var catchAllDescriptors)) + if (_tagNameToDescriptorsMap.TryGetValue(TagHelperMatchingConventions.ElementCatchAllName, out var catchAllDescriptors)) { FindApplicableDescriptors(catchAllDescriptors, tagNameWithoutPrefix, parentTagNameWithoutPrefix, attributes, applicableDescriptors); } @@ -126,7 +127,7 @@ public TagHelperBinder(string? tagHelperPrefix, ImmutableArray descriptors, + ImmutableArray descriptors, ReadOnlySpan tagNameWithoutPrefix, ReadOnlySpan parentTagNameWithoutPrefix, ImmutableArray> attributes, From 76214f51bc651cefc56cbdb1c8faf1e8897fe1b9 Mon Sep 17 00:00:00 2001 From: Dustin Campbell Date: Wed, 26 Mar 2025 09:59:57 -0700 Subject: [PATCH 04/13] Store catch all descriptors in a separate array rather than in the map This avoids an extra Dictionary lookup for '*' during binding. --- .../src/Language/TagHelperBinder.cs | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs index 81e1c413b84..c2f3b5a6c18 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs @@ -14,6 +14,7 @@ namespace Microsoft.AspNetCore.Razor.Language; /// internal sealed class TagHelperBinder { + private readonly ImmutableArray _catchAllDescriptors; private readonly Dictionary> _tagNameToDescriptorsMap; public string? TagHelperPrefix { get; } @@ -30,6 +31,7 @@ public TagHelperBinder(string? tagHelperPrefix, ImmutableArray(); using var pooledMap = StringDictionaryPool.Builder>.OrdinalIgnoreCase.GetPooledObject(out var mapBuilder); using var pooledSet = HashSetPool.GetPooledObject(out var processedDescriptors); @@ -44,9 +46,16 @@ public TagHelperBinder(string? tagHelperPrefix, ImmutableArray ImmutableArray.CreateBuilder()); @@ -61,6 +70,9 @@ public TagHelperBinder(string? tagHelperPrefix, ImmutableArray @@ -109,10 +121,7 @@ public TagHelperBinder(string? tagHelperPrefix, ImmutableArray Date: Wed, 26 Mar 2025 10:20:47 -0700 Subject: [PATCH 05/13] Avoid FrozenDictionary in TagHelperBinding Frozen dictionaries are expensive to construct and should be avoided in binding. Instead, this change updates TagHelperBinding to store all bound rules as an array of TagHelperBoundRulesInfo, which is a struct containing the mapping of TagHelperDescriptor to bound rules. --- .../src/Language/TagHelperBinder.cs | 57 ++++++++++++------- .../src/Language/TagHelperBinding.cs | 27 ++++++--- .../src/Language/TagHelperBoundRulesInfo.cs | 14 +++++ 3 files changed, 68 insertions(+), 30 deletions(-) create mode 100644 src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBoundRulesInfo.cs diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs index c2f3b5a6c18..fa8c7f8f26a 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs @@ -2,9 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Frozen; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics; using Microsoft.AspNetCore.Razor.PooledObjects; namespace Microsoft.AspNetCore.Razor.Language; @@ -99,31 +99,39 @@ public TagHelperBinder(string? tagHelperPrefix, ImmutableArray 0 }) { - tagNameWithoutPrefix = tagNameWithoutPrefix[length..]; + tagNameSpan = tagNameSpan[length..]; if (parentIsTagHelper) { - parentTagNameWithoutPrefix = parentTagNameWithoutPrefix[length..]; + parentTagNameSpan = parentTagNameSpan[length..]; } } - using var _ = DictionaryPool>.GetPooledObject(out var applicableDescriptors); + using var pooledSet = HashSetPool.GetPooledObject(out var distinctSet); + using var resultsBuilder = new PooledArrayBuilder(); + using var tempRulesBuilder = new PooledArrayBuilder(); // First, try any tag helpers with this tag name. if (_tagNameToDescriptorsMap.TryGetValue(tagName, out var matchingDescriptors)) { - FindApplicableDescriptors(matchingDescriptors, tagNameWithoutPrefix, parentTagNameWithoutPrefix, attributes, applicableDescriptors); + CollectBoundRulesInfo( + matchingDescriptors, + tagNameSpan, parentTagNameSpan, attributes, + distinctSet, ref resultsBuilder.AsRef(), ref tempRulesBuilder.AsRef()); } // Next, try any "catch all" descriptors. - FindApplicableDescriptors(_catchAllDescriptors, tagNameWithoutPrefix, parentTagNameWithoutPrefix, attributes, applicableDescriptors); + CollectBoundRulesInfo( + _catchAllDescriptors, + tagNameSpan, parentTagNameSpan, attributes, + distinctSet, ref resultsBuilder.AsRef(), ref tempRulesBuilder.AsRef()); - if (applicableDescriptors.Count == 0) + if (resultsBuilder.Count == 0) { return null; } @@ -132,34 +140,41 @@ public TagHelperBinder(string? tagHelperPrefix, ImmutableArray descriptors, - ReadOnlySpan tagNameWithoutPrefix, - ReadOnlySpan parentTagNameWithoutPrefix, + ReadOnlySpan tagName, + ReadOnlySpan parentTagName, ImmutableArray> attributes, - Dictionary> applicableDescriptors) + HashSet distinctSet, + ref PooledArrayBuilder resultsBuilder, + ref PooledArrayBuilder tempRulesBuilder) { - using var applicableRules = new PooledArrayBuilder(); - foreach (var descriptor in descriptors) { + if (!distinctSet.Add(descriptor)) + { + continue; // We've already seen this descriptor. + } + + Debug.Assert(tempRulesBuilder.Count == 0); + foreach (var rule in descriptor.TagMatchingRules) { - if (TagHelperMatchingConventions.SatisfiesRule(rule, tagNameWithoutPrefix, parentTagNameWithoutPrefix, attributes)) + if (TagHelperMatchingConventions.SatisfiesRule(rule, tagName, parentTagName, attributes)) { - applicableRules.Add(rule); + tempRulesBuilder.Add(rule); } } - if (applicableRules.Count > 0) + if (tempRulesBuilder.Count > 0) { - applicableDescriptors[descriptor] = applicableRules.DrainToImmutable(); + resultsBuilder.Add(new(descriptor, tempRulesBuilder.ToImmutable())); } - applicableRules.Clear(); + tempRulesBuilder.Clear(); } } } diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinding.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinding.cs index 5d05dd747b6..4cc3fcca097 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinding.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinding.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Frozen; using System.Collections.Generic; using System.Collections.Immutable; @@ -10,34 +9,44 @@ namespace Microsoft.AspNetCore.Razor.Language; internal sealed class TagHelperBinding { - private readonly FrozenDictionary> _mappings; - public string TagName { get; } public string? ParentTagName { get; } public ImmutableArray> Attributes { get; } + public ImmutableArray AllBoundRules { get; } public string? TagHelperPrefix { get; } - public ImmutableArray Descriptors => _mappings.Keys; + private ImmutableArray _descriptors; internal TagHelperBinding( string tagName, ImmutableArray> attributes, string? parentTagName, - FrozenDictionary> mappings, + ImmutableArray allBoundRules, string? tagHelperPrefix) { TagName = tagName; Attributes = attributes; ParentTagName = parentTagName; - _mappings = mappings; + AllBoundRules = allBoundRules; TagHelperPrefix = tagHelperPrefix; } - public ImmutableArray GetBoundRules(TagHelperDescriptor descriptor) + public ImmutableArray Descriptors { - return _mappings[descriptor]; + get + { + if (_descriptors.IsDefault) + { + ImmutableInterlocked.InterlockedInitialize(ref _descriptors, AllBoundRules.SelectAsArray(x => x.Descriptor)); + } + + return _descriptors; + } } + public ImmutableArray GetBoundRules(TagHelperDescriptor descriptor) + => AllBoundRules.First(descriptor, static (info, d) => info.Descriptor.Equals(d)).Rules; + /// /// Gets a value that indicates whether the the binding matched on attributes only. /// @@ -50,7 +59,7 @@ public bool IsAttributeMatch { get { - foreach (var descriptor in _mappings.Keys) + foreach (var descriptor in Descriptors) { if (!descriptor.Metadata.TryGetValue(TagHelperMetadata.Common.ClassifyAttributesOnly, out var value) || !string.Equals(value, bool.TrueString, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBoundRulesInfo.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBoundRulesInfo.cs new file mode 100644 index 00000000000..67a3a68b57b --- /dev/null +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBoundRulesInfo.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; + +namespace Microsoft.AspNetCore.Razor.Language; + +internal readonly struct TagHelperBoundRulesInfo( + TagHelperDescriptor descriptor, + ImmutableArray boundRules) +{ + public TagHelperDescriptor Descriptor { get; } = descriptor; + public ImmutableArray Rules { get; } = boundRules; +} From 1fc41caaf07a91877d72a3ae46772c36a5b3aae0 Mon Sep 17 00:00:00 2001 From: Dustin Campbell Date: Wed, 26 Mar 2025 11:28:53 -0700 Subject: [PATCH 06/13] Clean up and refactor TagHelperBinder and TagHelperBinding a bit --- .../test/TagHelperBinderTest.cs | 2 +- .../Legacy/TagHelperParseTreeRewriter.cs | 16 +-- .../src/Language/TagHelperBinder.cs | 117 ++++++++++-------- .../src/Language/TagHelperBinding.cs | 16 +-- 4 files changed, 81 insertions(+), 70 deletions(-) diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TagHelperBinderTest.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TagHelperBinderTest.cs index dcf47aaf0c6..5473b4cb924 100644 --- a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TagHelperBinderTest.cs +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TagHelperBinderTest.cs @@ -38,7 +38,7 @@ public void GetBinding_ReturnsBindingWithInformation() Assert.Equal("th:div", bindingResult.TagName); Assert.Equal("body", bindingResult.ParentTagName); Assert.Equal>(expectedAttributes, bindingResult.Attributes); - Assert.Equal("th:", bindingResult.TagHelperPrefix); + Assert.Equal("th:", bindingResult.TagNamePrefix); Assert.Equal(divTagHelper.TagMatchingRules, bindingResult.GetBoundRules(divTagHelper)); } diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperParseTreeRewriter.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperParseTreeRewriter.cs index 2c456bfd4a0..7404385a75e 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperParseTreeRewriter.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperParseTreeRewriter.cs @@ -34,9 +34,9 @@ public static RazorSyntaxTree Rewrite(RazorSyntaxTree syntaxTree, TagHelperBinde builder.AddRange(treeDiagnostics); builder.AddRange(sinkDiagnostics); - foreach (var tagHelper in binder.TagHelpers) + foreach (var descriptor in binder.Descriptors) { - foreach (var diagnostic in tagHelper.GetAllDiagnostics()) + foreach (var diagnostic in descriptor.GetAllDiagnostics()) { builder.Add(diagnostic); } @@ -137,7 +137,7 @@ public override SyntaxNode VisitMarkupElement(MarkupElementSyntax node) else { // Tag helper start tag. Keep track. - var tracker = new TagHelperTracker(_binder.TagHelperPrefix, tagHelperInfo); + var tracker = new TagHelperTracker(_binder.TagNamePrefix, tagHelperInfo); _trackerStack.Push(tracker); } } @@ -726,7 +726,7 @@ private record TagHelperTracker : TagTracker { public uint OpenMatchingTags; - private readonly string? _tagHelperPrefix; + private readonly string? _tagNamePrefix; private readonly TagHelperBinding _binding; private readonly Lazy<(ImmutableArray Names, HashSet NameSet)> _lazyAllowedChildren; @@ -734,10 +734,10 @@ private record TagHelperTracker : TagTracker public ImmutableArray AllowedChildren => _lazyAllowedChildren.Value.Names; - public TagHelperTracker(string? tagHelperPrefix, TagHelperInfo info) + public TagHelperTracker(string? tagNamePrefix, TagHelperInfo info) : base(info.TagName, IsTagHelper: true) { - _tagHelperPrefix = tagHelperPrefix; + _tagNamePrefix = tagNamePrefix; _binding = info.BindingResult; _lazyAllowedChildren = new(CreateAllowedChildren); @@ -773,7 +773,7 @@ public bool AllowsChild(string tagName, bool nameIncludesPrefix) private HashSet CreatePrefixedAllowedChildren() { - if (_tagHelperPrefix is not string tagHelperPrefix) + if (_tagNamePrefix is not string tagNamePrefix) { return _lazyAllowedChildren.Value.NameSet; } @@ -782,7 +782,7 @@ private HashSet CreatePrefixedAllowedChildren() foreach (var childName in AllowedChildren) { - distinctSet.Add(tagHelperPrefix + childName); + distinctSet.Add(tagNamePrefix + childName); } return distinctSet; diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs index fa8c7f8f26a..accaf2a5ff6 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs @@ -17,28 +17,41 @@ internal sealed class TagHelperBinder private readonly ImmutableArray _catchAllDescriptors; private readonly Dictionary> _tagNameToDescriptorsMap; - public string? TagHelperPrefix { get; } - public ImmutableArray TagHelpers { get; } + public string? TagNamePrefix { get; } + public ImmutableArray Descriptors { get; } + + private readonly ReadOnlyMemory _tagNamePrefix; /// /// Instantiates a new instance of the . /// - /// The tag helper prefix being used by the document. - /// The s that the + /// The tag helper prefix being used by the document. + /// The s that the /// will pull from. - public TagHelperBinder(string? tagHelperPrefix, ImmutableArray tagHelpers) + public TagHelperBinder(string? tagNamePrefix, ImmutableArray descriptors) { - TagHelperPrefix = tagHelperPrefix; - TagHelpers = tagHelpers; + TagNamePrefix = tagNamePrefix; + Descriptors = descriptors.NullToEmpty(); + + _tagNamePrefix = TagNamePrefix.AsMemory(); + + ProcessDescriptors(descriptors, tagNamePrefix, out _tagNameToDescriptorsMap, out _catchAllDescriptors); + } - using var catchAllDescriptors = new PooledArrayBuilder(); + private static void ProcessDescriptors( + ImmutableArray descriptors, + string? tagNamePrefix, + out Dictionary> tagNameToDescriptorsMap, + out ImmutableArray catchAllDescriptors) + { + using var catchAllBuilder = new PooledArrayBuilder(); using var pooledMap = StringDictionaryPool.Builder>.OrdinalIgnoreCase.GetPooledObject(out var mapBuilder); - using var pooledSet = HashSetPool.GetPooledObject(out var processedDescriptors); + using var pooledSet = HashSetPool.GetPooledObject(out var distinctSet); // Build a map of tag name -> tag helpers. - foreach (var descriptor in tagHelpers) + foreach (var descriptor in descriptors) { - if (!processedDescriptors.Add(descriptor)) + if (!distinctSet.Add(descriptor)) { // We're already seen this descriptor, skip it. continue; @@ -49,30 +62,32 @@ public TagHelperBinder(string? tagHelperPrefix, ImmutableArray ImmutableArray.CreateBuilder()); + var builder = mapBuilder.GetOrAdd(tagName, _ => ImmutableArray.CreateBuilder()); - builder.Add(descriptor); + builder.Add(descriptor); + } } } // Build the final dictionary with immutable arrays. - _tagNameToDescriptorsMap = new(capacity: mapBuilder.Count, StringComparer.OrdinalIgnoreCase); + tagNameToDescriptorsMap = new(capacity: mapBuilder.Count, StringComparer.OrdinalIgnoreCase); foreach (var (key, value) in mapBuilder) { - _tagNameToDescriptorsMap.Add(key, value.DrainToImmutable()); + tagNameToDescriptorsMap.Add(key, value.DrainToImmutable()); } // Build the catch all descriptors array. - _catchAllDescriptors = catchAllDescriptors.DrainToImmutable(); + catchAllDescriptors = catchAllBuilder.DrainToImmutable(); } /// @@ -84,37 +99,40 @@ public TagHelperBinder(string? tagHelperPrefix, ImmutableArrayThe parent tag name of the given tag. /// Is the parent tag of the given tag a tag helper. /// s that apply to the given HTML tag criteria. - /// Will return null if no s are a match. + /// Will return if no s are a match. public TagHelperBinding? GetBinding( string tagName, ImmutableArray> attributes, string? parentTagName, bool parentIsTagHelper) { - if (!TagHelperPrefix.IsNullOrEmpty() && - (tagName.Length <= TagHelperPrefix.Length || - !tagName.StartsWith(TagHelperPrefix, StringComparison.OrdinalIgnoreCase))) - { - // The tagName doesn't have the tag helper prefix, we can short circuit. - return null; - } + var tagNameSpan = tagName.AsSpan(); + var parentTagNameSpan = parentTagName.AsSpan(); + var tagNamePrefixSpan = _tagNamePrefix.Span; - var tagNameSpan = tagName.AsSpanOrDefault(); - var parentTagNameSpan = parentTagName.AsSpanOrDefault(); - - if (TagHelperPrefix is { Length: var length and > 0 }) + if (!tagNamePrefixSpan.IsEmpty) { - tagNameSpan = tagNameSpan[length..]; + if (!tagNameSpan.StartsWith(tagNamePrefixSpan, StringComparison.OrdinalIgnoreCase)) + { + // The tag name doesn't start with the prefix. So, we're done. + return null; + } + + tagNameSpan = tagNameSpan[tagNamePrefixSpan.Length..]; if (parentIsTagHelper) { - parentTagNameSpan = parentTagNameSpan[length..]; + Debug.Assert( + parentTagNameSpan.StartsWith(tagNamePrefixSpan, StringComparison.OrdinalIgnoreCase), + "If the parent is a tag helper, it must start with the tag name prefix."); + + parentTagNameSpan = parentTagNameSpan[tagNamePrefixSpan.Length..]; } } - using var pooledSet = HashSetPool.GetPooledObject(out var distinctSet); using var resultsBuilder = new PooledArrayBuilder(); using var tempRulesBuilder = new PooledArrayBuilder(); + using var pooledSet = HashSetPool.GetPooledObject(out var distinctSet); // First, try any tag helpers with this tag name. if (_tagNameToDescriptorsMap.TryGetValue(tagName, out var matchingDescriptors)) @@ -122,41 +140,34 @@ public TagHelperBinder(string? tagHelperPrefix, ImmutableArray 0 + ? new(resultsBuilder.DrainToImmutable(), tagName, parentTagName, attributes, TagNamePrefix) + : null; static void CollectBoundRulesInfo( ImmutableArray descriptors, ReadOnlySpan tagName, ReadOnlySpan parentTagName, ImmutableArray> attributes, - HashSet distinctSet, ref PooledArrayBuilder resultsBuilder, - ref PooledArrayBuilder tempRulesBuilder) + ref PooledArrayBuilder tempRulesBuilder, + HashSet distinctSet) { foreach (var descriptor in descriptors) { if (!distinctSet.Add(descriptor)) { - continue; // We've already seen this descriptor. + // We're already seen this descriptor, skip it. + continue; } Debug.Assert(tempRulesBuilder.Count == 0); diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinding.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinding.cs index 4cc3fcca097..b4ddeb55a42 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinding.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinding.cs @@ -9,26 +9,26 @@ namespace Microsoft.AspNetCore.Razor.Language; internal sealed class TagHelperBinding { + public ImmutableArray AllBoundRules { get; } + public string? TagNamePrefix { get; } public string TagName { get; } public string? ParentTagName { get; } public ImmutableArray> Attributes { get; } - public ImmutableArray AllBoundRules { get; } - public string? TagHelperPrefix { get; } private ImmutableArray _descriptors; internal TagHelperBinding( + ImmutableArray allBoundRules, string tagName, - ImmutableArray> attributes, string? parentTagName, - ImmutableArray allBoundRules, - string? tagHelperPrefix) + ImmutableArray> attributes, + string? tagNamePrefix) { + AllBoundRules = allBoundRules; TagName = tagName; - Attributes = attributes; ParentTagName = parentTagName; - AllBoundRules = allBoundRules; - TagHelperPrefix = tagHelperPrefix; + Attributes = attributes; + TagNamePrefix = tagNamePrefix; } public ImmutableArray Descriptors From 6439831065bb72b30a38787f8d92630d41a0a77d Mon Sep 17 00:00:00 2001 From: Dustin Campbell Date: Wed, 26 Mar 2025 11:46:23 -0700 Subject: [PATCH 07/13] Avoid calling TagHelperBinding.GetBoundRules(...) when unnecessary A common pattern is to iterate over TagHelperBinding.Descriptors, call GetBoundRules for each descriptor and iterate over the rules. Because TagHelperBinding no longer contains a Dictionary, these double-loops are now O(n^2). Fortunately, it's easy to just iterate over AllBoundRules, which is what these cases are really trying to do. --- .../src/Language/Legacy/TagHelperBlockRewriter.cs | 6 +++--- .../Language/Legacy/TagHelperParseTreeRewriter.cs | 14 +++++++------- .../AutoClosingTagOnAutoInsertProvider.cs | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperBlockRewriter.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperBlockRewriter.cs index 5a364468e50..3dbbd550198 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperBlockRewriter.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperBlockRewriter.cs @@ -27,10 +27,9 @@ public static TagMode GetTagMode( } var hasDirectiveAttribute = false; - foreach (var descriptor in bindingResult.Descriptors) + foreach (var boundRulesInfo in bindingResult.AllBoundRules) { - var boundRules = bindingResult.GetBoundRules(descriptor); - var nonDefaultRule = boundRules.FirstOrDefault(static rule => rule.TagStructure != TagStructure.Unspecified); + var nonDefaultRule = boundRulesInfo.Rules.FirstOrDefault(static rule => rule.TagStructure != TagStructure.Unspecified); if (nonDefaultRule?.TagStructure == TagStructure.WithoutEndTag) { @@ -42,6 +41,7 @@ public static TagMode GetTagMode( // vs // // We don't want this to become an error just because you added a directive attribute. + var descriptor = boundRulesInfo.Descriptor; if (descriptor.IsAnyComponentDocumentTagHelper() && !descriptor.IsComponentOrChildContentTagHelper) { hasDirectiveAttribute = true; diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperParseTreeRewriter.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperParseTreeRewriter.cs index 7404385a75e..c75e373ea72 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperParseTreeRewriter.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Legacy/TagHelperParseTreeRewriter.cs @@ -347,10 +347,9 @@ private bool TryRewriteTagHelperEnd( return false; } - foreach (var descriptor in tagHelperBinding.Descriptors) + foreach (var boundRulesInfo in tagHelperBinding.AllBoundRules) { - var boundRules = tagHelperBinding.GetBoundRules(descriptor); - var invalidRule = boundRules.FirstOrDefault(static rule => rule.TagStructure == TagStructure.WithoutEndTag); + var invalidRule = boundRulesInfo.Rules.FirstOrDefault(static rule => rule.TagStructure == TagStructure.WithoutEndTag); if (invalidRule != null) { @@ -359,7 +358,7 @@ private bool TryRewriteTagHelperEnd( RazorDiagnosticFactory.CreateParsing_TagHelperMustNotHaveAnEndTag( new SourceSpan(SourceLocationTracker.Advance(endTag.GetSourceLocation(_source), " Date: Wed, 26 Mar 2025 11:49:35 -0700 Subject: [PATCH 08/13] Cache the result of TagHelperBinding.IsAttributeMatch --- .../src/Language/TagHelperBinding.cs | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinding.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinding.cs index b4ddeb55a42..efd2091857a 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinding.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinding.cs @@ -16,6 +16,7 @@ internal sealed class TagHelperBinding public ImmutableArray> Attributes { get; } private ImmutableArray _descriptors; + private bool? _isAttributeMatch; internal TagHelperBinding( ImmutableArray allBoundRules, @@ -48,32 +49,39 @@ public ImmutableArray GetBoundRules(TagHelperDescript => AllBoundRules.First(descriptor, static (info, d) => info.Descriptor.Equals(d)).Rules; /// - /// Gets a value that indicates whether the the binding matched on attributes only. + /// Gets a value that indicates whether the the binding matched on attributes only. /// - /// false if the entire element should be classified as a tag helper. + /// + /// Returns if the entire element should be classified as a tag helper. + /// /// - /// If this returns true, use TagHelperFactsService.GetBoundTagHelperAttributes to find the - /// set of attributes that should be considered part of the match. + /// If this returns , use TagHelperFactsService.GetBoundTagHelperAttributes to find the + /// set of attributes that should be considered part of the match. /// public bool IsAttributeMatch { get { - foreach (var descriptor in Descriptors) + return _isAttributeMatch ??= ComputeIsAttributeMatch(Descriptors); + + static bool ComputeIsAttributeMatch(ImmutableArray descriptors) { - if (!descriptor.Metadata.TryGetValue(TagHelperMetadata.Common.ClassifyAttributesOnly, out var value) || - !string.Equals(value, bool.TrueString, StringComparison.OrdinalIgnoreCase)) + foreach (var descriptor in descriptors) { - return false; + if (!descriptor.Metadata.TryGetValue(TagHelperMetadata.Common.ClassifyAttributesOnly, out var value) || + !string.Equals(value, bool.TrueString, StringComparison.OrdinalIgnoreCase)) + { + return false; + } } - } - // All the matching tag helpers want to be classified with **attributes only**. - // - // Ex: (components) - // - //