Preserve `class` casing when merging a `TagBuilder` into a `TagHelperOutput`

- #5313

Also:
- preserve existing `TagHelperAttribute.ValueStyle`
 - fix this in `UrlResolutionTagHelper`, `LinkTagHelper`, and `ScriptTagHelper` as well
- correct handling of non-`string` `classAttribute.Value`s in `TagHelperOutputExtensions`
 - relates to #3918 because new `ClassAttributeHtmlContent` is smaller than any concatenated attribute value

nit: clean up `CacheTagHelper` whitespace and `using`s
This commit is contained in:
Doug Bunting 2016-09-23 12:01:48 -07:00
parent 0d782d9d9a
commit 27e4822a7b
10 changed files with 191 additions and 98 deletions

View File

@ -176,7 +176,10 @@ namespace Microsoft.AspNetCore.Mvc.Razor.TagHelpers
string resolvedUrl;
if (TryResolveUrl(stringValue, resolvedUrl: out resolvedUrl))
{
output.Attributes[i] = new TagHelperAttribute(attribute.Name, resolvedUrl);
output.Attributes[i] = new TagHelperAttribute(
attribute.Name,
resolvedUrl,
attribute.ValueStyle);
}
}
else
@ -202,12 +205,18 @@ namespace Microsoft.AspNetCore.Mvc.Razor.TagHelpers
IHtmlContent resolvedUrl;
if (TryResolveUrl(stringValue, resolvedUrl: out resolvedUrl))
{
output.Attributes[i] = new TagHelperAttribute(attribute.Name, resolvedUrl);
output.Attributes[i] = new TagHelperAttribute(
attribute.Name,
resolvedUrl,
attribute.ValueStyle);
}
else if (htmlString == null)
{
// Not a ~/ URL. Just avoid re-encoding the attribute value later.
output.Attributes[i] = new TagHelperAttribute(attribute.Name, new HtmlString(stringValue));
output.Attributes[i] = new TagHelperAttribute(
attribute.Name,
new HtmlString(stringValue),
attribute.ValueStyle);
}
}
}

View File

@ -2,7 +2,6 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Encodings.Web;
@ -94,8 +93,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
try
{
// The entry is set instead of assigning a value to the
// task so that the expiration options are are not impacted
// The entry is set instead of assigning a value to the
// task so that the expiration options are are not impacted
// by the time it took to compute it.
using (var entry = MemoryCache.CreateEntry(cacheKey))
@ -118,7 +117,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
}
finally
{
// If an exception occurs, ensure the other awaiters
// If an exception occurs, ensure the other awaiters
// render the output by themselves.
tcs.SetResult(null);
}

View File

@ -268,9 +268,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
if (Href != null)
{
var index = output.Attributes.IndexOfName(HrefAttributeName);
var existingAttribute = output.Attributes[index];
output.Attributes[index] = new TagHelperAttribute(
HrefAttributeName,
_fileVersionProvider.AddFileVersionToPath(Href));
existingAttribute.Name,
_fileVersionProvider.AddFileVersionToPath(Href),
existingAttribute.ValueStyle);
}
}

View File

@ -234,10 +234,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
if (Src != null)
{
var index = output.Attributes.IndexOfName(SrcAttributeName);
var existingAttribute = output.Attributes[index];
output.Attributes[index] = new TagHelperAttribute(
SrcAttributeName,
existingAttribute.Name,
_fileVersionProvider.AddFileVersionToPath(Src),
output.Attributes[index].ValueStyle);
existingAttribute.ValueStyle);
}
}

View File

@ -3,7 +3,11 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Razor.TagHelpers;
@ -90,9 +94,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
/// <param name="tagBuilder">The <see cref="TagBuilder"/> to merge attributes from.</param>
/// <remarks>Existing <see cref="TagHelperOutput.Attributes"/> on the given <paramref name="tagHelperOutput"/>
/// are not overridden; "class" attributes are merged with spaces.</remarks>
public static void MergeAttributes(
this TagHelperOutput tagHelperOutput,
TagBuilder tagBuilder)
public static void MergeAttributes(this TagHelperOutput tagHelperOutput, TagBuilder tagBuilder)
{
if (tagHelperOutput == null)
{
@ -110,18 +112,18 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
{
tagHelperOutput.Attributes.Add(attribute.Key, attribute.Value);
}
else if (attribute.Key.Equals("class", StringComparison.OrdinalIgnoreCase))
else if (string.Equals(attribute.Key, "class", StringComparison.OrdinalIgnoreCase))
{
TagHelperAttribute classAttribute;
var found = tagHelperOutput.Attributes.TryGetAttribute("class", out classAttribute);
Debug.Assert(found);
if (tagHelperOutput.Attributes.TryGetAttribute("class", out classAttribute))
{
tagHelperOutput.Attributes.SetAttribute("class", classAttribute.Value + " " + attribute.Value);
}
else
{
tagHelperOutput.Attributes.Add("class", attribute.Value);
}
var newAttribute = new TagHelperAttribute(
classAttribute.Name,
new ClassAttributeHtmlContent(classAttribute.Value, attribute.Value),
classAttribute.ValueStyle);
tagHelperOutput.Attributes.SetAttribute(newAttribute);
}
}
}
@ -201,5 +203,63 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
return -1;
}
private class ClassAttributeHtmlContent : IHtmlContent
{
private readonly object _left;
private readonly string _right;
public ClassAttributeHtmlContent(object left, string right)
{
_left = left;
_right = right;
}
public void WriteTo(TextWriter writer, HtmlEncoder encoder)
{
if (writer == null)
{
throw new ArgumentNullException(nameof(writer));
}
if (encoder == null)
{
throw new ArgumentNullException(nameof(encoder));
}
// Write out "{left} {right}" in the common nothing-empty case.
var wroteLeft = false;
if (_left != null)
{
var htmlContent = _left as IHtmlContent;
if (htmlContent != null)
{
// Ignore case where htmlContent is HtmlString.Empty. At worst, will add a leading space to the
// generated attribute value.
htmlContent.WriteTo(writer, encoder);
wroteLeft = true;
}
else
{
var stringValue = _left.ToString();
if (!string.IsNullOrEmpty(stringValue))
{
encoder.Encode(writer, stringValue);
wroteLeft = true;
}
}
}
if (!string.IsNullOrEmpty(_right))
{
if (wroteLeft)
{
writer.Write(' ');
}
encoder.Encode(writer, _right);
}
}
}
}
}

View File

@ -74,7 +74,7 @@
<input type="HtmlEncode[[radio]]" value="HtmlEncode[[Female]]" checked="HtmlEncode[[checked]]" id="HtmlEncode[[Customer_Gender]]" name="HtmlEncode[[Customer.Gender]]" /> Female
<span class="HtmlEncode[[field-validation-valid]]" data-valmsg-for="HtmlEncode[[Customer.Gender]]" data-valmsg-replace="HtmlEncode[[true]]"></span>
</div>
<div class="HtmlEncode[[order validation-summary-valid]]" data-valmsg-summary="HtmlEncode[[true]]"><ul><li style="display:none"></li>
<div class="order HtmlEncode[[validation-summary-valid]]" data-valmsg-summary="HtmlEncode[[true]]"><ul><li style="display:none"></li>
</ul></div>
<input type="HtmlEncode[[hidden]]" id="HtmlEncode[[Customer_Key]]" name="HtmlEncode[[Customer.Key]]" value="HtmlEncode[[KeyA]]" />
<input type="submit" />

View File

@ -0,0 +1,64 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace Microsoft.AspNetCore.Mvc.TagHelpers
{
public class CaseSensitiveTagHelperAttributeComparer : IEqualityComparer<TagHelperAttribute>
{
public readonly static CaseSensitiveTagHelperAttributeComparer Default =
new CaseSensitiveTagHelperAttributeComparer();
private CaseSensitiveTagHelperAttributeComparer()
{
}
public bool Equals(TagHelperAttribute attributeX, TagHelperAttribute attributeY)
{
if (attributeX == attributeY)
{
return true;
}
// Normal comparer (TagHelperAttribute.Equals()) doesn't care about the Name case, in tests we do.
return attributeX != null &&
string.Equals(attributeX.Name, attributeY.Name, StringComparison.Ordinal) &&
attributeX.ValueStyle == attributeY.ValueStyle &&
(attributeX.ValueStyle == HtmlAttributeValueStyle.Minimized ||
string.Equals(GetString(attributeX.Value), GetString(attributeY.Value)));
}
public int GetHashCode(TagHelperAttribute attribute)
{
// Manually combine hash codes here. We can't reference HashCodeCombiner because we have internals visible
// from Mvc.Core and Mvc.TagHelpers; both of which reference HashCodeCombiner.
var baseHashCode = 0x1505L;
var attributeHashCode = attribute.GetHashCode();
var combinedHash = ((baseHashCode << 5) + baseHashCode) ^ attributeHashCode;
var nameHashCode = StringComparer.Ordinal.GetHashCode(attribute.Name);
combinedHash = ((combinedHash << 5) + combinedHash) ^ nameHashCode;
return combinedHash.GetHashCode();
}
private string GetString(object value)
{
var htmlContent = value as IHtmlContent;
if (htmlContent != null)
{
using (var writer = new StringWriter())
{
htmlContent.WriteTo(writer, NullHtmlEncoder.Default);
return writer.ToString();
}
}
return value?.ToString() ?? string.Empty;
}
}
}

View File

@ -489,7 +489,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
htmlGenerator.Verify();
Assert.Equal(TagMode.StartTagOnly, output.TagMode);
Assert.Equal(expectedAttributes, output.Attributes);
Assert.Equal(expectedAttributes, output.Attributes, CaseSensitiveTagHelperAttributeComparer.Default);
Assert.Equal(expectedPreContent, output.PreContent.GetContent());
Assert.Equal(expectedContent, output.Content.GetContent());
Assert.Equal(expectedPostContent, output.PostContent.GetContent());
@ -590,7 +590,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
htmlGenerator.Verify();
Assert.Equal(TagMode.StartTagOnly, output.TagMode);
Assert.Equal(expectedAttributes, output.Attributes);
Assert.Equal(expectedAttributes, output.Attributes, CaseSensitiveTagHelperAttributeComparer.Default);
Assert.Equal(expectedPreContent, output.PreContent.GetContent());
Assert.Equal(expectedContent, output.Content.GetContent());
Assert.Equal(expectedPostContent, output.PostContent.GetContent());
@ -684,7 +684,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
htmlGenerator.Verify();
Assert.Equal(TagMode.StartTagOnly, output.TagMode);
Assert.Equal(expectedAttributes, output.Attributes);
Assert.Equal(expectedAttributes, output.Attributes, CaseSensitiveTagHelperAttributeComparer.Default);
Assert.Equal(expectedPreContent, output.PreContent.GetContent());
Assert.Equal(expectedContent, output.Content.GetContent());
Assert.Equal(expectedPostContent, output.PostContent.GetContent());
@ -801,7 +801,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
htmlGenerator.Verify();
Assert.Equal(TagMode.StartTagOnly, output.TagMode);
Assert.Equal(expectedAttributes, output.Attributes);
Assert.Equal(expectedAttributes, output.Attributes, CaseSensitiveTagHelperAttributeComparer.Default);
Assert.Equal(expectedPreContent, output.PreContent.GetContent());
Assert.Equal(expectedContent, output.Content.GetContent());
Assert.Equal(expectedPostContent, output.PostContent.GetContent());

View File

@ -676,7 +676,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
},
new TagHelperAttributeList
{
{ "class", "btn2 btn" }
{ "clASS", "btn2 btn" }
}
},
{
@ -691,7 +691,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
},
new TagHelperAttributeList
{
{ "class", "btn2 btn" }
{ "clASS", "btn2 btn" }
}
},
{
@ -706,7 +706,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
},
new TagHelperAttributeList
{
{ "class", "btn2 btn" }
{ "clASS", "btn2 btn" }
}
},
{
@ -725,7 +725,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
new TagHelperAttributeList
{
{ "before", "before value" },
{ "class", "btn2 btn" },
{ "clASS", "btn2 btn" },
{ "mid", "mid value" },
{ "after", "after value" },
}
@ -748,7 +748,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
new TagHelperAttributeList
{
{ "before", "before value" },
{ "class", "btn2 btn" },
{ "clASS", "btn2 btn" },
{ "mid", "mid value" },
{ "after", "after value" },
{ "A", "A Value" },
@ -833,7 +833,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
// Assert
var attribute = Assert.Single(tagHelperOutput.Attributes);
Assert.Equal(expectedAttribute, attribute);
Assert.Equal(expectedAttribute, attribute, CaseSensitiveTagHelperAttributeComparer.Default);
}
[Theory]
@ -859,7 +859,10 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
// Assert
var attribute = Assert.Single(tagHelperOutput.Attributes);
Assert.Equal(new TagHelperAttribute(originalName, "Hello btn"), attribute);
Assert.Equal(
new TagHelperAttribute(originalName, "Hello btn"),
attribute,
CaseSensitiveTagHelperAttributeComparer.Default);
}
[Fact]
@ -959,42 +962,5 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
attribute = Assert.Single(tagHelperOutput.Attributes, attr => attr.Name.Equals("for"));
Assert.Equal(expectedBuilderAttribute.Value, attribute.Value);
}
private class CaseSensitiveTagHelperAttributeComparer : IEqualityComparer<TagHelperAttribute>
{
public readonly static CaseSensitiveTagHelperAttributeComparer Default =
new CaseSensitiveTagHelperAttributeComparer();
private CaseSensitiveTagHelperAttributeComparer()
{
}
public bool Equals(TagHelperAttribute attributeX, TagHelperAttribute attributeY)
{
if (attributeX == attributeY)
{
return true;
}
// Normal comparer (TagHelperAttribute.Equals()) doesn't care about the Name case, in tests we do.
return attributeX != null &&
string.Equals(attributeX.Name, attributeY.Name, StringComparison.Ordinal) &&
attributeX.ValueStyle == attributeY.ValueStyle &&
(attributeX.ValueStyle == HtmlAttributeValueStyle.Minimized || Equals(attributeX.Value, attributeY.Value));
}
public int GetHashCode(TagHelperAttribute attribute)
{
// Manually combine hash codes here. We can't reference HashCodeCombiner because we have internals visible
// from Mvc.Core and Mvc.TagHelpers; both of which reference HashCodeCombiner.
var baseHashCode = 0x1505L;
var attributeHashCode = attribute.GetHashCode();
var combinedHash = ((baseHashCode << 5) + baseHashCode) ^ attributeHashCode;
var nameHashCode = StringComparer.Ordinal.GetHashCode(attribute.Name);
combinedHash = ((combinedHash << 5) + combinedHash) ^ nameHashCode;
return combinedHash.GetHashCode();
}
}
}
}

View File

@ -47,6 +47,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
var expectedTagName = "not-div";
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = new TestableHtmlGenerator(metadataProvider);
var expectedAttributes = new TagHelperAttributeList
{
new TagHelperAttribute("class", "form-control validation-summary-valid"),
new TagHelperAttribute("data-valmsg-summary", "true"),
};
var expectedPreContent = "original pre-content";
var expectedContent = "original content";
@ -80,18 +85,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
await validationSummaryTagHelper.ProcessAsync(tagHelperContext, output);
// Assert
Assert.Collection(
output.Attributes,
attribute =>
{
Assert.Equal("class", attribute.Name);
Assert.Equal("form-control validation-summary-valid", attribute.Value);
},
attribute =>
{
Assert.Equal("data-valmsg-summary", attribute.Name);
Assert.Equal("true", attribute.Value);
});
Assert.Equal(expectedAttributes, output.Attributes, CaseSensitiveTagHelperAttributeComparer.Default);
Assert.Equal(expectedPreContent, output.PreContent.GetContent());
Assert.Equal(expectedContent, output.Content.GetContent());
Assert.Equal(
@ -266,7 +260,11 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
// Assert
Assert.InRange(output.Attributes.Count, low: 1, high: 2);
var attribute = Assert.Single(output.Attributes, attr => attr.Name.Equals("class"));
Assert.Equal("form-control validation-summary-errors", attribute.Value);
Assert.Equal(
new TagHelperAttribute("class", "form-control validation-summary-errors"),
attribute,
CaseSensitiveTagHelperAttributeComparer.Default);
Assert.Equal(expectedPreContent, output.PreContent.GetContent());
Assert.Equal(expectedContent, output.Content.GetContent());
Assert.Equal(
@ -282,9 +280,14 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
var expectedError0 = "I am an error.";
var expectedError2 = "I am also an error.";
var expectedTagName = "not-div";
var expectedAttributes = new TagHelperAttributeList
{
new TagHelperAttribute("class", "form-control validation-summary-errors"),
new TagHelperAttribute("data-valmsg-summary", "true"),
};
var metadataProvider = new TestModelMetadataProvider();
var htmlGenerator = new TestableHtmlGenerator(metadataProvider);
var validationSummaryTagHelper = new ValidationSummaryTagHelper(htmlGenerator)
{
ValidationSummary = ValidationSummary.All,
@ -325,18 +328,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
await validationSummaryTagHelper.ProcessAsync(tagHelperContext, output);
// Assert
Assert.Collection(
output.Attributes,
attribute =>
{
Assert.Equal("class", attribute.Name);
Assert.Equal("form-control validation-summary-errors", attribute.Value);
},
attribute =>
{
Assert.Equal("data-valmsg-summary", attribute.Name);
Assert.Equal("true", attribute.Value);
});
Assert.Equal(expectedAttributes, output.Attributes, CaseSensitiveTagHelperAttributeComparer.Default);
Assert.Equal(expectedPreContent, output.PreContent.GetContent());
Assert.Equal(expectedContent, output.Content.GetContent());
Assert.Equal(