Skip to content

Commit c5ed31e

Browse files
author
ladeak
committed
TagBuilder using SearchValues
- Adding further tests and a new benchmark to compare before-after performance - Updating the implementation of TagBuilder to use SearchValues instead manual operation
1 parent 3f16e78 commit c5ed31e

File tree

3 files changed

+66
-53
lines changed

3 files changed

+66
-53
lines changed

src/Mvc/Mvc.ViewFeatures/src/Rendering/TagBuilder.cs

Lines changed: 23 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
#nullable enable
55

6+
using System.Buffers;
67
using System.Diagnostics;
78
using System.Globalization;
89
using System.Text;
@@ -19,6 +20,11 @@ namespace Microsoft.AspNetCore.Mvc.Rendering;
1920
[DebuggerDisplay("{DebuggerToString()}")]
2021
public class TagBuilder : IHtmlContent
2122
{
23+
// Note '.' is valid according to the HTML 4.01 specification. Disallowed here to avoid
24+
// confusion with CSS class selectors or when using jQuery.
25+
private static readonly SearchValues<char> _html401IdChars =
26+
SearchValues.Create("-0123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz");
27+
2228
private AttributeDictionary? _attributes;
2329
private HtmlContentBuilder? _innerHtml;
2430

@@ -154,51 +160,39 @@ public static string CreateSanitizedId(string? name, string invalidCharReplaceme
154160
}
155161

156162
// If there are no invalid characters in the string, then we don't have to create the buffer.
157-
var firstIndexOfInvalidCharacter = 1;
158-
for (; firstIndexOfInvalidCharacter < name.Length; firstIndexOfInvalidCharacter++)
163+
var indexOfInvalidCharacter = name.AsSpan(1).IndexOfAnyExcept(_html401IdChars);
164+
var firstChar = name[0];
165+
var startsWithAsciiLetter = char.IsAsciiLetter(firstChar);
166+
if (startsWithAsciiLetter && indexOfInvalidCharacter < 0)
159167
{
160-
if (!Html401IdUtil.IsValidIdCharacter(name[firstIndexOfInvalidCharacter]))
161-
{
162-
break;
163-
}
168+
return name;
164169
}
165170

166-
var firstChar = name[0];
167-
var startsWithAsciiLetter = char.IsAsciiLetter(firstChar);
168171
if (!startsWithAsciiLetter)
169172
{
170173
// The first character must be a letter according to the HTML 4.01 specification.
171174
firstChar = 'z';
172175
}
173176

174-
if (firstIndexOfInvalidCharacter == name.Length && startsWithAsciiLetter)
175-
{
176-
return name;
177-
}
178-
179177
var stringBuffer = new StringBuilder(name.Length);
180178
stringBuffer.Append(firstChar);
179+
var remainingName = name.AsSpan(1);
181180

182-
// Characters until 'firstIndexOfInvalidCharacter' have already been checked for validity.
183-
// So just copy them. This avoids running them through Html401IdUtil.IsValidIdCharacter again.
184-
for (var index = 1; index < firstIndexOfInvalidCharacter; index++)
185-
{
186-
stringBuffer.Append(name[index]);
187-
}
188-
189-
for (var index = firstIndexOfInvalidCharacter; index < name.Length; index++)
181+
// Copy values until an invalid character found. Replace the invalid character with the replacement string
182+
// and search for the next invalid character.
183+
while (remainingName.Length > 0)
190184
{
191-
var thisChar = name[index];
192-
if (Html401IdUtil.IsValidIdCharacter(thisChar))
185+
if (indexOfInvalidCharacter < 0)
193186
{
194-
stringBuffer.Append(thisChar);
195-
}
196-
else
197-
{
198-
stringBuffer.Append(invalidCharReplacement);
187+
stringBuffer.Append(remainingName);
188+
break;
199189
}
200-
}
201190

191+
stringBuffer.Append(remainingName.Slice(0, indexOfInvalidCharacter));
192+
stringBuffer.Append(invalidCharReplacement);
193+
remainingName = remainingName.Slice(indexOfInvalidCharacter + 1);
194+
indexOfInvalidCharacter = remainingName.IndexOfAnyExcept(_html401IdChars);
195+
}
202196
return stringBuffer.ToString();
203197
}
204198

@@ -418,28 +412,4 @@ public void WriteTo(TextWriter writer, HtmlEncoder encoder)
418412
TagBuilder.WriteTo(_tagBuilder, writer, encoder, _tagRenderMode);
419413
}
420414
}
421-
422-
private static class Html401IdUtil
423-
{
424-
public static bool IsValidIdCharacter(char testChar)
425-
{
426-
return char.IsAsciiLetterOrDigit(testChar) || IsAllowableSpecialCharacter(testChar);
427-
}
428-
429-
private static bool IsAllowableSpecialCharacter(char testChar)
430-
{
431-
switch (testChar)
432-
{
433-
case '-':
434-
case '_':
435-
case ':':
436-
// Note '.' is valid according to the HTML 4.01 specification. Disallowed here to avoid
437-
// confusion with CSS class selectors or when using jQuery.
438-
return true;
439-
440-
default:
441-
return false;
442-
}
443-
}
444-
}
445415
}

src/Mvc/Mvc.ViewFeatures/test/Rendering/TagBuilderTest.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ public void WriteTo_IgnoresIdAttributeCase(TagRenderMode renderingMode, string e
9595
[InlineData("0", "z")]
9696
[InlineData("-", "z")]
9797
[InlineData(",", "z")]
98+
[InlineData(",.", "z-")]
99+
[InlineData("a😊", "a--")]
100+
[InlineData("a😊.", "a---")]
101+
[InlineData("😊", "z-")]
102+
[InlineData("😊.", "z--")]
103+
[InlineData(",a", "za")]
104+
[InlineData("a,", "a-")]
105+
[InlineData("a0,", "a0-")]
106+
[InlineData("a,0,", "a-0-")]
107+
[InlineData("a,0", "a-0")]
98108
[InlineData("00Hello,World", "z0Hello-World")]
99109
[InlineData(",,Hello,,World,,", "z-Hello--World--")]
100110
[InlineData("-_:Hello-_:Hello-_:", "z_:Hello-_:Hello-_:")]
@@ -110,6 +120,23 @@ public void CreateSanitizedIdCreatesId(string input, string output)
110120
Assert.Equal(output, result);
111121
}
112122

123+
[Fact]
124+
public void CreateSanitizedIdCreatesIdReplacesAllInvalidCharacters()
125+
{
126+
foreach (char c in Enumerable.Range(char.MinValue, char.MaxValue))
127+
{
128+
var result = TagBuilder.CreateSanitizedId($"a{c}", "z");
129+
if (char.IsAsciiLetterOrDigit(c) || c == '-' || c == '_' || c == ':')
130+
{
131+
Assert.Equal($"a{c}", result);
132+
}
133+
else
134+
{
135+
Assert.Equal("az", result);
136+
}
137+
}
138+
}
139+
113140
[Theory]
114141
[InlineData("attribute", "value", "<p attribute=\"HtmlEncode[[value]]\"></p>")]
115142
[InlineData("attribute", null, "<p attribute=\"\"></p>")]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using BenchmarkDotNet.Attributes;
5+
using Microsoft.AspNetCore.Mvc.Rendering;
6+
7+
namespace Microsoft.AspNetCore.Mvc.Microbenchmarks;
8+
9+
public class TagBuilderBenchmark
10+
{
11+
[Benchmark]
12+
public string ValidFieldName() => TagBuilder.CreateSanitizedId("LongIdForFieldWithMaxLength", "z");
13+
14+
[Benchmark]
15+
public string InvalidFieldName() => TagBuilder.CreateSanitizedId("LongIdForField$WithMaxLength", "z");
16+
}

0 commit comments

Comments
 (0)