diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/NameAndIdProvider.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/NameAndIdProvider.cs new file mode 100644 index 0000000000..83f9e518e9 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/NameAndIdProvider.cs @@ -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 +{ + /// + /// Provides cached values for "name" and "id" HTML attributes. + /// + public static class NameAndIdProvider + { + private static readonly object PreviousNameAndIdKey = typeof(PreviousNameAndId); + + /// + /// Returns a valid HTML 4.01 "id" attribute value for an element with the given . + /// + /// A instance for the current scope. + /// + /// The fully-qualified expression name, ignoring the current model. Also the original HTML element name. + /// + /// + /// The (normally a single ) to substitute for invalid characters in + /// . + /// + /// + /// Valid HTML 4.01 "id" attribute value for an element with the given . + /// + /// + /// Similar to but caches value for repeated invocations. + /// + 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; + } + + /// + /// Adds a valid HTML 4.01 "id" attribute for an element with the given . Does + /// nothing if already contains an "id" attribute or the + /// is null or empty. + /// + /// A instance for the current scope. + /// A instance that will contain the "id" attribute. + /// + /// The fully-qualified expression name, ignoring the current model. Also the original HTML element name. + /// + /// + /// The (normally a single ) to substitute for invalid characters in + /// . + /// + /// + /// Similar to but caches value for repeated invocations. + /// + /// + 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; + } + } + } + + /// + /// Returns the full HTML element name for the specified . + /// + /// A instance for the current scope. + /// Expression name, relative to the current model. + /// Fully-qualified expression name for . + /// + /// Similar to but caches value for repeated invocations. + /// + 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; } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Rendering/TagBuilder.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Rendering/TagBuilder.cs index eed02e76f4..e25a305835 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Rendering/TagBuilder.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Rendering/TagBuilder.cs @@ -109,7 +109,9 @@ namespace Microsoft.AspNetCore.Mvc.Rendering /// /// Returns a valid HTML 4.01 "id" attribute value for an element with the given . /// - /// The original element name. + /// + /// The fully-qualified expression name, ignoring the current model. Also the original HTML element name. + /// /// /// The (normally a single ) to substitute for invalid characters in /// . @@ -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 } /// - /// 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 . Does + /// nothing if already contains an "id" attribute or the + /// is null or empty. /// - /// The name to use to generate an ID attribute. + /// + /// The fully-qualified expression name, ignoring the current model. Also the original HTML element name. + /// /// /// The (normally a single ) to substitute for invalid characters in /// . /// + /// 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; diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs index 514cde5faf..587f88cfc6 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs @@ -54,8 +54,9 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures /// The . /// The . /// The . - /// The that provides - /// a list of s. + /// + /// The that provides a list of s. + /// [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 /// The . /// The . /// The . - /// The that provides - /// a list of s. + /// + /// The that provides a list of s. + /// /// The . 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. diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelper.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelper.cs index 666b6bef78..feaa3b7615 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelper.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelper.cs @@ -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); } /// @@ -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)); diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelperOfT.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelperOfT.cs index d8625dc0b0..bb61b5867d 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelperOfT.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelperOfT.cs @@ -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) { diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelperOptions.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelperOptions.cs index 85748fb09c..1b132a4491 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelperOptions.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelperOptions.cs @@ -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 /// public class HtmlHelperOptions { + private string _idAttributeDotReplacement = "_"; + /// /// Gets or sets the value. /// @@ -23,7 +26,22 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures /// /// Gets or sets the that replaces periods in the ID attribute of an element. /// - public string IdAttributeDotReplacement { get; set; } = "_"; + public string IdAttributeDotReplacement + { + get + { + return _idAttributeDotReplacement; + } + set + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + _idAttributeDotReplacement = value; + } + } /// /// 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; /// - /// 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 /// and other overloads. /// public string ValidationMessageElement { get; set; } = "span"; /// - /// 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 /// and other overloads. /// public string ValidationSummaryMessageElement { get; set; } = "span"; diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/TemplateInfo.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/TemplateInfo.cs index 34ab4b63b9..76abaa2389 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/TemplateInfo.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/TemplateInfo.cs @@ -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 _visitedObjects; + private readonly HashSet _visitedObjects; + + private object _formattedModelValue; + private string _htmlFieldPrefix; public TemplateInfo() { @@ -65,26 +65,31 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures return _visitedObjects.Add(value); } + /// + /// Returns the full HTML element name for the specified . + /// + /// Expression name, relative to the current model. + /// Fully-qualified expression name for . 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)