Store `GetFullHtmlFieldName()` and `CreateSanitizedId()` values

- #3918
- don't repeat allocations for identical calls; helps w/ e.g. label / input / validation clusters
- add `NameAndIdProvider`; it stores `PreviousNameAndId` in `HttpContext.Items`
 - `PreviousNameAndId` allocated only when `string`s are allocated e.g. "id"s were sanitized
This commit is contained in:
Doug Bunting 2016-09-24 23:59:51 -07:00
parent 3ef7d01bb6
commit 4e1ec39a1f
7 changed files with 306 additions and 58 deletions

View File

@ -0,0 +1,214 @@
// 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 Microsoft.AspNetCore.Mvc.Rendering;
namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal
{
/// <summary>
/// Provides cached values for "name" and "id" HTML attributes.
/// </summary>
public static class NameAndIdProvider
{
private static readonly object PreviousNameAndIdKey = typeof(PreviousNameAndId);
/// <summary>
/// Returns a valid HTML 4.01 "id" attribute value for an element with the given <paramref name="fullName"/>.
/// </summary>
/// <param name="viewContext">A <see cref="ViewContext"/> instance for the current scope.</param>
/// <param name="fullName">
/// The fully-qualified expression name, ignoring the current model. Also the original HTML element name.
/// </param>
/// <param name="invalidCharReplacement">
/// The <see cref="string"/> (normally a single <see cref="char"/>) to substitute for invalid characters in
/// <paramref name="fullName"/>.
/// </param>
/// <returns>
/// Valid HTML 4.01 "id" attribute value for an element with the given <paramref name="fullName"/>.
/// </returns>
/// <remarks>
/// Similar to <see cref="TagBuilder.CreateSanitizedId"/> but caches value for repeated invocations.
/// </remarks>
public static string CreateSanitizedId(ViewContext viewContext, string fullName, string invalidCharReplacement)
{
if (viewContext == null)
{
throw new ArgumentNullException(nameof(viewContext));
}
if (invalidCharReplacement == null)
{
throw new ArgumentNullException(nameof(invalidCharReplacement));
}
if (string.IsNullOrEmpty(fullName))
{
return string.Empty;
}
// Check cache to avoid whatever TagBuilder.CreateSanitizedId() may do.
var items = viewContext.HttpContext.Items;
object previousNameAndIdObject;
PreviousNameAndId previousNameAndId = null;
if (items.TryGetValue(PreviousNameAndIdKey, out previousNameAndIdObject) &&
(previousNameAndId = (PreviousNameAndId)previousNameAndIdObject) != null &&
string.Equals(previousNameAndId.FullName, fullName, StringComparison.Ordinal))
{
return previousNameAndId.SanitizedId;
}
var sanitizedId = TagBuilder.CreateSanitizedId(fullName, invalidCharReplacement);
if (previousNameAndId == null)
{
// Do not create a PreviousNameAndId when TagBuilder.CreateSanitizedId() only examined fullName.
if (string.Equals(fullName, sanitizedId, StringComparison.Ordinal))
{
return sanitizedId;
}
previousNameAndId = new PreviousNameAndId();
items[PreviousNameAndIdKey] = previousNameAndId;
}
previousNameAndId.FullName = fullName;
previousNameAndId.SanitizedId = sanitizedId;
return previousNameAndId.SanitizedId;
}
/// <summary>
/// Adds a valid HTML 4.01 "id" attribute for an element with the given <paramref name="fullName"/>. Does
/// nothing if <see cref="TagBuilder.Attributes"/> already contains an "id" attribute or the
/// <paramref name="fullName"/> is <c>null</c> or empty.
/// </summary>
/// <param name="viewContext">A <see cref="ViewContext"/> instance for the current scope.</param>
/// <param name="tagBuilder">A <see cref="TagBuilder"/> instance that will contain the "id" attribute.</param>
/// <param name="fullName">
/// The fully-qualified expression name, ignoring the current model. Also the original HTML element name.
/// </param>
/// <param name="invalidCharReplacement">
/// The <see cref="string"/> (normally a single <see cref="char"/>) to substitute for invalid characters in
/// <paramref name="fullName"/>.
/// </param>
/// <remarks>
/// Similar to <see cref="TagBuilder.GenerateId"/> but caches value for repeated invocations.
/// </remarks>
/// <seealso cref="CreateSanitizedId"/>
public static void GenerateId(
ViewContext viewContext,
TagBuilder tagBuilder,
string fullName,
string invalidCharReplacement)
{
if (viewContext == null)
{
throw new ArgumentNullException(nameof(viewContext));
}
if (tagBuilder == null)
{
throw new ArgumentNullException(nameof(tagBuilder));
}
if (invalidCharReplacement == null)
{
throw new ArgumentNullException(nameof(invalidCharReplacement));
}
if (string.IsNullOrEmpty(fullName))
{
return;
}
if (!tagBuilder.Attributes.ContainsKey("id"))
{
var sanitizedId = CreateSanitizedId(viewContext, fullName, invalidCharReplacement);
// Duplicate check for null or empty to cover the corner case where fullName contains only invalid
// characters and invalidCharReplacement is empty.
if (!string.IsNullOrEmpty(sanitizedId))
{
tagBuilder.Attributes["id"] = sanitizedId;
}
}
}
/// <summary>
/// Returns the full HTML element name for the specified <paramref name="expression"/>.
/// </summary>
/// <param name="viewContext">A <see cref="ViewContext"/> instance for the current scope.</param>
/// <param name="expression">Expression name, relative to the current model.</param>
/// <returns>Fully-qualified expression name for <paramref name="expression"/>.</returns>
/// <remarks>
/// Similar to <see cref="TemplateInfo.GetFullHtmlFieldName"/> but caches value for repeated invocations.
/// </remarks>
public static string GetFullHtmlFieldName(ViewContext viewContext, string expression)
{
var htmlFieldPrefix = viewContext.ViewData.TemplateInfo.HtmlFieldPrefix;
if (string.IsNullOrEmpty(expression))
{
return htmlFieldPrefix;
}
if (string.IsNullOrEmpty(htmlFieldPrefix))
{
return expression;
}
// Need to concatenate. See if we've already done that.
var items = viewContext.HttpContext.Items;
object previousNameAndIdObject;
PreviousNameAndId previousNameAndId = null;
if (items.TryGetValue(PreviousNameAndIdKey, out previousNameAndIdObject) &&
(previousNameAndId = (PreviousNameAndId)previousNameAndIdObject) != null &&
string.Equals(previousNameAndId.HtmlFieldPrefix, htmlFieldPrefix, StringComparison.Ordinal) &&
string.Equals(previousNameAndId.Expression, expression, StringComparison.Ordinal))
{
return previousNameAndId.OutputFullName;
}
if (previousNameAndId == null)
{
previousNameAndId = new PreviousNameAndId();
items[PreviousNameAndIdKey] = previousNameAndId;
}
previousNameAndId.HtmlFieldPrefix = htmlFieldPrefix;
previousNameAndId.Expression = expression;
if (expression.StartsWith("[", StringComparison.Ordinal))
{
// The expression might represent an indexer access, in which case with a 'dot' would be invalid.
previousNameAndId.OutputFullName = htmlFieldPrefix + expression;
}
else
{
previousNameAndId.OutputFullName = htmlFieldPrefix + "." + expression;
}
return previousNameAndId.OutputFullName;
}
private class PreviousNameAndId
{
// Cached ambient input for NameAndIdProvider.GetFullHtmlFieldName(). TemplateInfo.HtmlFieldPrefix may
// change during the lifetime of a ViewContext.
public string HtmlFieldPrefix { get; set; }
// Cached input for NameAndIdProvider.GetFullHtmlFieldName().
public string Expression { get; set; }
// Cached return value for NameAndIdProvider.GetFullHtmlFieldName().
public string OutputFullName { get; set; }
// Cached input for NameAndIdProvider.CreateSanitizedId(). Since IHtmlHelper.GenerateIdFromName() is
// available to all, there is no guarantee this is equal to OutputFullName when CreateSanitizedId() is
// called.
public string FullName { get; set; }
// Cached return value for NameAndIdProvider.CreateSanitizedId().
public string SanitizedId { get; set; }
}
}
}

View File

@ -109,7 +109,9 @@ namespace Microsoft.AspNetCore.Mvc.Rendering
/// <summary>
/// Returns a valid HTML 4.01 "id" attribute value for an element with the given <paramref name="name"/>.
/// </summary>
/// <param name="name">The original element name.</param>
/// <param name="name">
/// The fully-qualified expression name, ignoring the current model. Also the original HTML element name.
/// </param>
/// <param name="invalidCharReplacement">
/// The <see cref="string"/> (normally a single <see cref="char"/>) to substitute for invalid characters in
/// <paramref name="name"/>.
@ -159,7 +161,7 @@ namespace Microsoft.AspNetCore.Mvc.Rendering
stringBuffer.Append(firstChar);
// Characters until 'firstIndexOfInvalidCharacter' have already been checked for validity.
// So just copying them. This avoids running them through Html401IdUtil.IsValidIdCharacter again.
// So just copy them. This avoids running them through Html401IdUtil.IsValidIdCharacter again.
for (var index = 1; index < firstIndexOfInvalidCharacter; index++)
{
stringBuffer.Append(name[index]);
@ -182,13 +184,18 @@ namespace Microsoft.AspNetCore.Mvc.Rendering
}
/// <summary>
/// Generates a sanitized ID attribute for the tag by using the specified name.
/// Adds a valid HTML 4.01 "id" attribute for an element with the given <paramref name="name"/>. Does
/// nothing if <see cref="Attributes"/> already contains an "id" attribute or the <paramref name="name"/>
/// is <c>null</c> or empty.
/// </summary>
/// <param name="name">The name to use to generate an ID attribute.</param>
/// <param name="name">
/// The fully-qualified expression name, ignoring the current model. Also the original HTML element name.
/// </param>
/// <param name="invalidCharReplacement">
/// The <see cref="string"/> (normally a single <see cref="char"/>) to substitute for invalid characters in
/// <paramref name="name"/>.
/// </param>
/// <seealso cref="CreateSanitizedId(string, string)"/>
public void GenerateId(string name, string invalidCharReplacement)
{
if (invalidCharReplacement == null)
@ -196,9 +203,17 @@ namespace Microsoft.AspNetCore.Mvc.Rendering
throw new ArgumentNullException(nameof(invalidCharReplacement));
}
if (string.IsNullOrEmpty(name))
{
return;
}
if (!Attributes.ContainsKey("id"))
{
var sanitizedId = CreateSanitizedId(name, invalidCharReplacement);
// Duplicate check for null or empty to cover the corner case where name contains only invalid
// characters and invalidCharReplacement is empty.
if (!string.IsNullOrEmpty(sanitizedId))
{
Attributes["id"] = sanitizedId;

View File

@ -54,8 +54,9 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
/// <param name="metadataProvider">The <see cref="IModelMetadataProvider"/>.</param>
/// <param name="urlHelperFactory">The <see cref="IUrlHelperFactory"/>.</param>
/// <param name="htmlEncoder">The <see cref="HtmlEncoder"/>.</param>
/// <param name="clientValidatorCache">The <see cref="ClientValidatorCache"/> that provides
/// a list of <see cref="IClientModelValidator"/>s.</param>
/// <param name="clientValidatorCache">
/// The <see cref="ClientValidatorCache"/> that provides a list of <see cref="IClientModelValidator"/>s.
/// </param>
[Obsolete("This constructor is obsolete and will be removed in a future version. The recommended " +
"alternative is to use the other public constructor.")]
public DefaultHtmlGenerator(
@ -64,14 +65,15 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
IModelMetadataProvider metadataProvider,
IUrlHelperFactory urlHelperFactory,
HtmlEncoder htmlEncoder,
ClientValidatorCache clientValidatorCache) : this(
antiforgery,
optionsAccessor,
metadataProvider,
urlHelperFactory,
htmlEncoder,
clientValidatorCache,
new DefaultValidationHtmlAttributeProvider(optionsAccessor, metadataProvider, clientValidatorCache))
ClientValidatorCache clientValidatorCache)
: this(
antiforgery,
optionsAccessor,
metadataProvider,
urlHelperFactory,
htmlEncoder,
clientValidatorCache,
new DefaultValidationHtmlAttributeProvider(optionsAccessor, metadataProvider, clientValidatorCache))
{
}
@ -84,8 +86,9 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
/// <param name="metadataProvider">The <see cref="IModelMetadataProvider"/>.</param>
/// <param name="urlHelperFactory">The <see cref="IUrlHelperFactory"/>.</param>
/// <param name="htmlEncoder">The <see cref="HtmlEncoder"/>.</param>
/// <param name="clientValidatorCache">The <see cref="ClientValidatorCache"/> that provides
/// a list of <see cref="IClientModelValidator"/>s.</param>
/// <param name="clientValidatorCache">
/// The <see cref="ClientValidatorCache"/> that provides a list of <see cref="IClientModelValidator"/>s.
/// </param>
/// <param name="validationAttributeProvider">The <see cref="ValidationHtmlAttributeProvider"/>.</param>
public DefaultHtmlGenerator(
IAntiforgery antiforgery,
@ -280,7 +283,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
tagBuilder.MergeAttribute("value", "false");
tagBuilder.TagRenderMode = TagRenderMode.SelfClosing;
var fullName = GetFullHtmlFieldName(viewContext, expression);
var fullName = NameAndIdProvider.GetFullHtmlFieldName(viewContext, expression);
tagBuilder.MergeAttribute("name", fullName);
return tagBuilder;
@ -424,8 +427,8 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
}
var tagBuilder = new TagBuilder("label");
var idString =
TagBuilder.CreateSanitizedId(GetFullHtmlFieldName(viewContext, expression), IdAttributeDotReplacement);
var fullName = NameAndIdProvider.GetFullHtmlFieldName(viewContext, expression);
var idString = NameAndIdProvider.CreateSanitizedId(viewContext, fullName, IdAttributeDotReplacement);
tagBuilder.Attributes.Add("for", idString);
tagBuilder.InnerHtml.SetContent(resolvedLabelText);
tagBuilder.MergeAttributes(GetHtmlAttributeDictionaryOrNull(htmlAttributes), replaceExisting: true);
@ -599,7 +602,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
throw new ArgumentNullException(nameof(viewContext));
}
var fullName = GetFullHtmlFieldName(viewContext, expression);
var fullName = NameAndIdProvider.GetFullHtmlFieldName(viewContext, expression);
if (string.IsNullOrEmpty(fullName))
{
throw new ArgumentException(
@ -628,7 +631,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
tagBuilder.InnerHtml.SetHtmlContent(listItemBuilder);
tagBuilder.MergeAttributes(GetHtmlAttributeDictionaryOrNull(htmlAttributes));
tagBuilder.MergeAttribute("name", fullName, true /* replaceExisting */);
tagBuilder.GenerateId(fullName, IdAttributeDotReplacement);
NameAndIdProvider.GenerateId(viewContext, tagBuilder, fullName, IdAttributeDotReplacement);
if (allowMultiple)
{
tagBuilder.MergeAttribute("multiple", "multiple");
@ -675,7 +678,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
Resources.HtmlHelper_TextAreaParameterOutOfRange);
}
var fullName = GetFullHtmlFieldName(viewContext, expression);
var fullName = NameAndIdProvider.GetFullHtmlFieldName(viewContext, expression);
if (string.IsNullOrEmpty(fullName))
{
throw new ArgumentException(
@ -702,7 +705,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
}
var tagBuilder = new TagBuilder("textarea");
tagBuilder.GenerateId(fullName, IdAttributeDotReplacement);
NameAndIdProvider.GenerateId(viewContext, tagBuilder, fullName, IdAttributeDotReplacement);
tagBuilder.MergeAttributes(GetHtmlAttributeDictionaryOrNull(htmlAttributes), true);
if (rows > 0)
{
@ -776,7 +779,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
throw new ArgumentNullException(nameof(viewContext));
}
var fullName = GetFullHtmlFieldName(viewContext, expression);
var fullName = NameAndIdProvider.GetFullHtmlFieldName(viewContext, expression);
if (string.IsNullOrEmpty(fullName))
{
throw new ArgumentException(
@ -967,7 +970,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
throw new ArgumentNullException(nameof(viewContext));
}
var fullName = GetFullHtmlFieldName(viewContext, expression);
var fullName = NameAndIdProvider.GetFullHtmlFieldName(viewContext, expression);
if (string.IsNullOrEmpty(fullName))
{
throw new ArgumentException(
@ -1135,12 +1138,6 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
return tagBuilder;
}
internal static string GetFullHtmlFieldName(ViewContext viewContext, string expression)
{
var fullName = viewContext.ViewData.TemplateInfo.GetFullHtmlFieldName(expression);
return fullName;
}
internal static object GetModelStateValue(ViewContext viewContext, string key, Type destinationType)
{
ModelStateEntry entry;
@ -1216,7 +1213,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
// Not valid to use TextBoxForModel() and so on in a top-level view; would end up with an unnamed input
// elements. But we support the *ForModel() methods in any lower-level template, once HtmlFieldPrefix is
// non-empty.
var fullName = GetFullHtmlFieldName(viewContext, expression);
var fullName = NameAndIdProvider.GetFullHtmlFieldName(viewContext, expression);
if (string.IsNullOrEmpty(fullName))
{
throw new ArgumentException(
@ -1319,7 +1316,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
if (setId)
{
tagBuilder.GenerateId(fullName, IdAttributeDotReplacement);
NameAndIdProvider.GenerateId(viewContext, tagBuilder, fullName, IdAttributeDotReplacement);
}
// If there are any errors for a named field, we add the CSS attribute.

View File

@ -342,7 +342,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
throw new ArgumentNullException(nameof(fullName));
}
return TagBuilder.CreateSanitizedId(fullName, IdAttributeDotReplacement);
return NameAndIdProvider.CreateSanitizedId(ViewContext, fullName, IdAttributeDotReplacement);
}
/// <inheritdoc />
@ -1001,10 +1001,9 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
protected virtual string GenerateId(string expression)
{
var fullName = DefaultHtmlGenerator.GetFullHtmlFieldName(ViewContext, expression: expression);
var id = TagBuilder.CreateSanitizedId(fullName, IdAttributeDotReplacement);
var fullName = NameAndIdProvider.GetFullHtmlFieldName(ViewContext, expression);
return id;
return GenerateIdFromName(fullName);
}
protected virtual IHtmlContent GenerateLabel(
@ -1056,7 +1055,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
protected virtual string GenerateName(string expression)
{
var fullName = DefaultHtmlGenerator.GetFullHtmlFieldName(ViewContext, expression);
var fullName = NameAndIdProvider.GetFullHtmlFieldName(ViewContext, expression);
return fullName;
}
@ -1190,7 +1189,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
protected virtual string GenerateValue(string expression, object value, string format, bool useViewData)
{
var fullName = DefaultHtmlGenerator.GetFullHtmlFieldName(ViewContext, expression);
var fullName = NameAndIdProvider.GetFullHtmlFieldName(ViewContext, expression);
var attemptedValue =
(string)DefaultHtmlGenerator.GetModelStateValue(ViewContext, fullName, typeof(string));

View File

@ -29,12 +29,12 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
UrlEncoder urlEncoder,
ExpressionTextCache expressionTextCache)
: base(
htmlGenerator,
viewEngine,
metadataProvider,
bufferScope,
htmlEncoder,
urlEncoder)
htmlGenerator,
viewEngine,
metadataProvider,
bufferScope,
htmlEncoder,
urlEncoder)
{
if (expressionTextCache == null)
{

View File

@ -1,6 +1,7 @@
// 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 Microsoft.AspNetCore.Mvc.Rendering;
namespace Microsoft.AspNetCore.Mvc.ViewFeatures
@ -10,6 +11,8 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
/// </summary>
public class HtmlHelperOptions
{
private string _idAttributeDotReplacement = "_";
/// <summary>
/// Gets or sets the <see cref="Html5DateRenderingMode.Html5DateRenderingMode"/> value.
/// </summary>
@ -23,7 +26,22 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
/// <summary>
/// Gets or sets the <see cref="string"/> that replaces periods in the ID attribute of an element.
/// </summary>
public string IdAttributeDotReplacement { get; set; } = "_";
public string IdAttributeDotReplacement
{
get
{
return _idAttributeDotReplacement;
}
set
{
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}
_idAttributeDotReplacement = value;
}
}
/// <summary>
/// Gets or sets a value that indicates whether client-side validation is enabled.
@ -31,13 +49,13 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
public bool ClientValidationEnabled { get; set; } = true;
/// <summary>
/// Gets or sets the element name used to wrap a top-level message generated by
/// Gets or sets the element name used to wrap a top-level message generated by
/// <see cref="IHtmlHelper.ValidationMessage"/> and other overloads.
/// </summary>
public string ValidationMessageElement { get; set; } = "span";
/// <summary>
/// Gets or sets the element name used to wrap a top-level message generated by
/// Gets or sets the element name used to wrap a top-level message generated by
/// <see cref="IHtmlHelper.ValidationSummary"/> and other overloads.
/// </summary>
public string ValidationSummaryMessageElement { get; set; } = "span";

View File

@ -8,11 +8,11 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
{
public class TemplateInfo
{
private string _htmlFieldPrefix;
private object _formattedModelValue;
// Keep a collection of visited objects to prevent infinite recursion.
private HashSet<object> _visitedObjects;
private readonly HashSet<object> _visitedObjects;
private object _formattedModelValue;
private string _htmlFieldPrefix;
public TemplateInfo()
{
@ -65,26 +65,31 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
return _visitedObjects.Add(value);
}
/// <summary>
/// Returns the full HTML element name for the specified <paramref name="partialFieldName"/>.
/// </summary>
/// <param name="partialFieldName">Expression name, relative to the current model.</param>
/// <returns>Fully-qualified expression name for <paramref name="partialFieldName"/>.</returns>
public string GetFullHtmlFieldName(string partialFieldName)
{
if (string.IsNullOrEmpty(partialFieldName))
{
return HtmlFieldPrefix;
}
else if (string.IsNullOrEmpty(HtmlFieldPrefix))
if (string.IsNullOrEmpty(HtmlFieldPrefix))
{
return partialFieldName;
}
else if (partialFieldName.StartsWith("[", StringComparison.Ordinal))
if (partialFieldName.StartsWith("[", StringComparison.Ordinal))
{
// The partialFieldName might represent an indexer access, in which case combining
// with a 'dot' would be invalid.
return HtmlFieldPrefix + partialFieldName;
}
else
{
return HtmlFieldPrefix + "." + partialFieldName;
}
return HtmlFieldPrefix + "." + partialFieldName;
}
public bool Visited(ModelExplorer modelExplorer)