diff --git a/src/Components/Forms/src/EditContext.cs b/src/Components/Forms/src/EditContext.cs index a8360ec06aa5..940781d1cadd 100644 --- a/src/Components/Forms/src/EditContext.cs +++ b/src/Components/Forms/src/EditContext.cs @@ -66,6 +66,11 @@ public FieldIdentifier Field(string fieldName) /// public EditContextProperties Properties { get; } + /// + /// Gets whether field identifiers should be generated for <input> elements. + /// + public bool ShouldUseFieldIdentifiers { get; set; } = !OperatingSystem.IsBrowser(); + /// /// Signals that the value for the specified field has changed. /// diff --git a/src/Components/Forms/src/PublicAPI.Unshipped.txt b/src/Components/Forms/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..20d22e152809 100644 --- a/src/Components/Forms/src/PublicAPI.Unshipped.txt +++ b/src/Components/Forms/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.AspNetCore.Components.Forms.EditContext.ShouldUseFieldIdentifiers.get -> bool +Microsoft.AspNetCore.Components.Forms.EditContext.ShouldUseFieldIdentifiers.set -> void diff --git a/src/Components/Web/src/Forms/ExpressionFormatting/ExpressionFormatter.cs b/src/Components/Web/src/Forms/ExpressionFormatting/ExpressionFormatter.cs new file mode 100644 index 000000000000..e3fc1b199616 --- /dev/null +++ b/src/Components/Web/src/Forms/ExpressionFormatting/ExpressionFormatter.cs @@ -0,0 +1,260 @@ +// 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.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reflection; + +namespace Microsoft.AspNetCore.Components.Forms; + +internal static class ExpressionFormatter +{ + internal const int StackAllocBufferSize = 128; + + private delegate void CapturedValueFormatter(object closure, ref ReverseStringBuilder builder); + + private static readonly ConcurrentDictionary s_capturedValueFormatterCache = new(); + private static readonly ConcurrentDictionary s_methodInfoDataCache = new(); + + public static void ClearCache() + { + s_capturedValueFormatterCache.Clear(); + s_methodInfoDataCache.Clear(); + } + + public static string FormatLambda(LambdaExpression expression) + { + var builder = new ReverseStringBuilder(stackalloc char[StackAllocBufferSize]); + var node = expression.Body; + var wasLastExpressionMemberAccess = false; + + while (node is not null) + { + switch (node.NodeType) + { + case ExpressionType.Call: + var methodCallExpression = (MethodCallExpression)node; + + if (!IsSingleArgumentIndexer(methodCallExpression)) + { + throw new InvalidOperationException("Method calls cannot be formatted."); + } + + if (wasLastExpressionMemberAccess) + { + wasLastExpressionMemberAccess = false; + builder.InsertFront("."); + } + + builder.InsertFront("]"); + FormatIndexArgument(methodCallExpression.Arguments[0], ref builder); + builder.InsertFront("["); + node = methodCallExpression.Object; + break; + + case ExpressionType.ArrayIndex: + var binaryExpression = (BinaryExpression)node; + + if (wasLastExpressionMemberAccess) + { + wasLastExpressionMemberAccess = false; + builder.InsertFront("."); + } + + builder.InsertFront("]"); + FormatIndexArgument(binaryExpression.Right, ref builder); + builder.InsertFront("["); + node = binaryExpression.Left; + break; + + case ExpressionType.MemberAccess: + var memberExpression = (MemberExpression)node; + var nextNode = memberExpression.Expression; + + if (nextNode?.NodeType == ExpressionType.Constant) + { + // The next node has a compiler-generated closure type, + // which means the current member access is on the captured model. + // We don't want to include the model variable name in the generated + // string, so we exit. + node = null; + break; + } + + if (wasLastExpressionMemberAccess) + { + builder.InsertFront("."); + } + wasLastExpressionMemberAccess = true; + + var name = memberExpression.Member.Name; + builder.InsertFront(name); + + node = nextNode; + break; + + default: + // Unsupported expression type. + node = null; + break; + } + } + + var result = builder.ToString(); + + builder.Dispose(); + + return result; + } + + private static bool IsSingleArgumentIndexer(Expression expression) + { + if (expression is not MethodCallExpression methodExpression || methodExpression.Arguments.Count != 1) + { + return false; + } + + var methodInfoData = GetOrCreateMethodInfoData(methodExpression.Method); + return methodInfoData.IsSingleArgumentIndexer; + } + + private static MethodInfoData GetOrCreateMethodInfoData(MethodInfo methodInfo) + { + if (!s_methodInfoDataCache.TryGetValue(methodInfo, out var methodInfoData)) + { + methodInfoData = GetMethodInfoData(methodInfo); + s_methodInfoDataCache[methodInfo] = methodInfoData; + } + + return methodInfoData; + + [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "The relevant members should be preserved since they were referenced in a LINQ expression")] + static MethodInfoData GetMethodInfoData(MethodInfo methodInfo) + { + var declaringType = methodInfo.DeclaringType; + if (declaringType is null) + { + return new(IsSingleArgumentIndexer: false); + } + + // Check whether GetDefaultMembers() (if present in CoreCLR) would return a member of this type. Compiler + // names the indexer property, if any, in a generated [DefaultMember] attribute for the containing type. + var defaultMember = declaringType.GetCustomAttribute(inherit: true); + if (defaultMember is null) + { + return new(IsSingleArgumentIndexer: false); + } + + // Find default property (the indexer) and confirm its getter is the method in this expression. + var runtimeProperties = declaringType.GetRuntimeProperties(); + if (runtimeProperties is null) + { + return new(IsSingleArgumentIndexer: false); + } + + foreach (var property in runtimeProperties) + { + if (string.Equals(defaultMember.MemberName, property.Name, StringComparison.Ordinal) && + property.GetMethod == methodInfo) + { + return new(IsSingleArgumentIndexer: true); + } + } + + return new(IsSingleArgumentIndexer: false); + } + } + + private static void FormatIndexArgument( + Expression indexExpression, + ref ReverseStringBuilder builder) + { + switch (indexExpression) + { + case MemberExpression memberExpression when memberExpression.Expression is ConstantExpression constantExpression: + FormatCapturedValue(memberExpression, constantExpression, ref builder); + break; + case ConstantExpression constantExpression: + FormatConstantValue(constantExpression, ref builder); + break; + default: + throw new InvalidOperationException($"Unable to evaluate index expressions of type '{indexExpression.GetType().Name}'."); + } + } + + private static void FormatCapturedValue(MemberExpression memberExpression, ConstantExpression constantExpression, ref ReverseStringBuilder builder) + { + var member = memberExpression.Member; + if (!s_capturedValueFormatterCache.TryGetValue(member, out var format)) + { + format = CreateCapturedValueFormatter(memberExpression); + s_capturedValueFormatterCache[member] = format; + } + + format(constantExpression.Value!, ref builder); + } + + private static CapturedValueFormatter CreateCapturedValueFormatter(MemberExpression memberExpression) + { + var memberType = memberExpression.Type; + + if (memberType == typeof(int)) + { + var func = CompileMemberEvaluator(memberExpression); + return (object closure, ref ReverseStringBuilder builder) => builder.InsertFront(func.Invoke(closure)); + } + else if (memberType == typeof(string)) + { + var func = CompileMemberEvaluator(memberExpression); + return (object closure, ref ReverseStringBuilder builder) => builder.InsertFront(func.Invoke(closure)); + } + else if (typeof(ISpanFormattable).IsAssignableFrom(memberType)) + { + var func = CompileMemberEvaluator(memberExpression); + return (object closure, ref ReverseStringBuilder builder) => builder.InsertFront(func.Invoke(closure)); + } + else if (typeof(IFormattable).IsAssignableFrom(memberType)) + { + var func = CompileMemberEvaluator(memberExpression); + return (object closure, ref ReverseStringBuilder builder) => builder.InsertFront(func.Invoke(closure)); + } + else + { + throw new InvalidOperationException($"Cannot format an index argument of type '{memberType}'."); + } + + static Func CompileMemberEvaluator(MemberExpression memberExpression) + { + var parameterExpression = Expression.Parameter(typeof(object)); + var convertExpression = Expression.Convert(parameterExpression, memberExpression.Member.DeclaringType!); + var replacedMemberExpression = memberExpression.Update(convertExpression); + var replacedExpression = Expression.Lambda>(replacedMemberExpression, parameterExpression); + return replacedExpression.Compile(); + } + } + + private static void FormatConstantValue(ConstantExpression constantExpression, ref ReverseStringBuilder builder) + { + switch (constantExpression.Value) + { + case string s: + builder.InsertFront(s); + break; + case ISpanFormattable spanFormattable: + // This is better than the formattable case because we don't allocate an extra string. + builder.InsertFront(spanFormattable); + break; + case IFormattable formattable: + builder.InsertFront(formattable); + break; + case null: + builder.InsertFront("null"); + break; + case var x: + throw new InvalidOperationException($"Unable to format constant values of type '{x.GetType()}'."); + } + } + + private record struct MethodInfoData(bool IsSingleArgumentIndexer); +} diff --git a/src/Components/Web/src/Forms/ExpressionFormatting/ReverseStringBuilder.cs b/src/Components/Web/src/Forms/ExpressionFormatting/ReverseStringBuilder.cs new file mode 100644 index 000000000000..6c296e842c71 --- /dev/null +++ b/src/Components/Web/src/Forms/ExpressionFormatting/ReverseStringBuilder.cs @@ -0,0 +1,163 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Globalization; + +namespace Microsoft.AspNetCore.Components.Forms; + +internal ref struct ReverseStringBuilder +{ + public const int MinimumRentedArraySize = 1024; + + private static readonly ArrayPool s_arrayPool = ArrayPool.Shared; + + private int _nextEndIndex; + private Span _currentBuffer; + private SequenceSegment? _fallbackSequenceSegment; + + // For testing. + internal int SequenceSegmentCount => _fallbackSequenceSegment?.Count() ?? 0; + + public ReverseStringBuilder(int conservativeEstimatedStringLength) + { + var array = s_arrayPool.Rent(conservativeEstimatedStringLength); + _fallbackSequenceSegment = new(array); + _currentBuffer = array; + _nextEndIndex = _currentBuffer.Length; + } + + public ReverseStringBuilder(Span initialBuffer) + { + _currentBuffer = initialBuffer; + _nextEndIndex = _currentBuffer.Length; + } + + public void InsertFront(scoped ReadOnlySpan span) + { + var startIndex = _nextEndIndex - span.Length; + if (startIndex >= 0) + { + // The common case. There is enough space in the current buffer to copy the given span. + // No additional work needs to be done here after the copy. + span.CopyTo(_currentBuffer[startIndex..]); + _nextEndIndex = startIndex; + return; + } + + // There wasn't enough space in the current buffer. + // What we do next depends on whether we're writing to the provided "initial" buffer or a rented one. + + if (_fallbackSequenceSegment is null) + { + // We've been writing to a stack-allocated buffer, but there is no more room on the stack. + // We rent new memory with a length sufficiently larger than the initial buffer + // and copy the contents over. + var remainingLength = -startIndex; + var sizeToRent = _currentBuffer.Length + Math.Max(MinimumRentedArraySize, remainingLength * 2); + var newBuffer = s_arrayPool.Rent(sizeToRent); + _fallbackSequenceSegment = new(newBuffer); + + _nextEndIndex = newBuffer.Length - _currentBuffer.Length; + _currentBuffer.CopyTo(newBuffer.AsSpan()[_nextEndIndex..]); + _currentBuffer = newBuffer; + + startIndex = _nextEndIndex - span.Length; + span.CopyTo(_currentBuffer[startIndex..]); + _nextEndIndex = startIndex; + } + else + { + // We can't fit the whole string in the current heap-allocated buffer. + // Copy as much as we can to the current buffer, rent a new buffer, and + // continue copying the remaining contents. + var remainingLength = -startIndex; + span[remainingLength..].CopyTo(_currentBuffer); + span = span[..remainingLength]; + + var sizeToRent = Math.Max(MinimumRentedArraySize, remainingLength * 2); + var newBuffer = s_arrayPool.Rent(sizeToRent); + _fallbackSequenceSegment = new(newBuffer, _fallbackSequenceSegment); + _currentBuffer = newBuffer; + + startIndex = _currentBuffer.Length - remainingLength; + span.CopyTo(_currentBuffer[startIndex..]); + _nextEndIndex = startIndex; + } + } + + public void InsertFront(T value) where T : ISpanFormattable + { + // This is large enough for any integer value (10 digits plus the possible sign). + // We won't try to optimize for anything larger. + Span result = stackalloc char[11]; + + if (value.TryFormat(result, out var charsWritten, format: default, CultureInfo.InvariantCulture)) + { + InsertFront(result[..charsWritten]); + } + else + { + InsertFront((IFormattable)value); + } + } + + public void InsertFront(IFormattable formattable) + => InsertFront(formattable.ToString(null, CultureInfo.InvariantCulture)); + + public override readonly string ToString() + => _fallbackSequenceSegment is null + ? new(_currentBuffer[_nextEndIndex..]) + : _fallbackSequenceSegment.ToString(_nextEndIndex); + + public readonly void Dispose() + { + _fallbackSequenceSegment?.Dispose(); + } + + private sealed class SequenceSegment : ReadOnlySequenceSegment, IDisposable + { + private readonly char[] _array; + + public SequenceSegment(char[] array, SequenceSegment? next = null) + { + _array = array; + Memory = array; + Next = next; + } + + // For testing. + internal int Count() + { + var count = 0; + for (var current = this; current is not null; current = current.Next as SequenceSegment) + { + count++; + } + return count; + } + + public string ToString(int startIndex) + { + RunningIndex = 0; + + var tail = this; + while (tail.Next is SequenceSegment next) + { + next.RunningIndex = tail.RunningIndex + tail.Memory.Length; + tail = next; + } + + var sequence = new ReadOnlySequence(this, startIndex, tail, tail.Memory.Length); + return sequence.ToString(); + } + + public void Dispose() + { + for (var current = this; current is not null; current = current.Next as SequenceSegment) + { + s_arrayPool.Return(current._array); + } + } + } +} diff --git a/src/Components/Web/src/Forms/InputBase.cs b/src/Components/Web/src/Forms/InputBase.cs index 8761648d0bdc..6041ddee9a4c 100644 --- a/src/Components/Web/src/Forms/InputBase.cs +++ b/src/Components/Web/src/Forms/InputBase.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Linq; using System.Linq.Expressions; @@ -18,6 +19,7 @@ public abstract class InputBase : ComponentBase, IDisposable private bool _hasInitializedParameters; private bool _parsingFailed; private string? _incomingValueBeforeParsing; + private string? _formattedValueExpression; private bool _previousParsingAttemptFailed; private ValidationMessageStore? _parsingValidationMessages; private Type? _nullableUnderlyingType; @@ -156,7 +158,7 @@ protected InputBase() } /// - /// Formats the value as a string. Derived classes can override this to determine the formating used for . + /// Formats the value as a string. Derived classes can override this to determine the formatting used for . /// /// The value to format. /// A string representation of the value. @@ -187,6 +189,32 @@ protected string CssClass } } + /// + /// Gets the value to be used for the input's "name" attribute. + /// + protected string NameAttributeValue + { + get + { + if (AdditionalAttributes?.TryGetValue("name", out var nameAttributeValue) ?? false) + { + return Convert.ToString(nameAttributeValue, CultureInfo.InvariantCulture) ?? string.Empty; + } + + if (EditContext?.ShouldUseFieldIdentifiers ?? false) + { + if (_formattedValueExpression is null && ValueExpression is not null) + { + _formattedValueExpression = ExpressionFormatter.FormatLambda(ValueExpression); + } + + return _formattedValueExpression ?? string.Empty; + } + + return string.Empty; + } + } + /// public override Task SetParametersAsync(ParameterView parameters) { diff --git a/src/Components/Web/src/Forms/InputCheckbox.cs b/src/Components/Web/src/Forms/InputCheckbox.cs index 806f63c29105..ad78a4ff8074 100644 --- a/src/Components/Web/src/Forms/InputCheckbox.cs +++ b/src/Components/Web/src/Forms/InputCheckbox.cs @@ -34,11 +34,12 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.OpenElement(0, "input"); builder.AddMultipleAttributes(1, AdditionalAttributes); builder.AddAttribute(2, "type", "checkbox"); - builder.AddAttribute(3, "class", CssClass); - builder.AddAttribute(4, "checked", BindConverter.FormatValue(CurrentValue)); - builder.AddAttribute(5, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValue = __value, CurrentValue)); + builder.AddAttributeIfNotNullOrEmpty(3, "name", NameAttributeValue); + builder.AddAttribute(4, "class", CssClass); + builder.AddAttribute(5, "checked", BindConverter.FormatValue(CurrentValue)); + builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValue = __value, CurrentValue)); builder.SetUpdatesAttributeName("checked"); - builder.AddElementReferenceCapture(6, __inputReference => Element = __inputReference); + builder.AddElementReferenceCapture(7, __inputReference => Element = __inputReference); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/InputDate.cs b/src/Components/Web/src/Forms/InputDate.cs index 0833c6e32094..0c1865fd8877 100644 --- a/src/Components/Web/src/Forms/InputDate.cs +++ b/src/Components/Web/src/Forms/InputDate.cs @@ -80,11 +80,12 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.OpenElement(0, "input"); builder.AddMultipleAttributes(1, AdditionalAttributes); builder.AddAttribute(2, "type", _typeAttributeValue); - builder.AddAttribute(3, "class", CssClass); - builder.AddAttribute(4, "value", CurrentValueAsString); - builder.AddAttribute(5, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddAttributeIfNotNullOrEmpty(3, "name", NameAttributeValue); + builder.AddAttribute(4, "class", CssClass); + builder.AddAttribute(5, "value", CurrentValueAsString); + builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); builder.SetUpdatesAttributeName("value"); - builder.AddElementReferenceCapture(6, __inputReference => Element = __inputReference); + builder.AddElementReferenceCapture(7, __inputReference => Element = __inputReference); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/InputNumber.cs b/src/Components/Web/src/Forms/InputNumber.cs index ef477af31279..fa4d31c0708e 100644 --- a/src/Components/Web/src/Forms/InputNumber.cs +++ b/src/Components/Web/src/Forms/InputNumber.cs @@ -55,11 +55,12 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(1, "step", _stepAttributeValue); builder.AddMultipleAttributes(2, AdditionalAttributes); builder.AddAttribute(3, "type", "number"); - builder.AddAttributeIfNotNullOrEmpty(4, "class", CssClass); - builder.AddAttribute(5, "value", CurrentValueAsString); - builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddAttributeIfNotNullOrEmpty(4, "name", NameAttributeValue); + builder.AddAttributeIfNotNullOrEmpty(5, "class", CssClass); + builder.AddAttribute(6, "value", CurrentValueAsString); + builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); builder.SetUpdatesAttributeName("value"); - builder.AddElementReferenceCapture(7, __inputReference => Element = __inputReference); + builder.AddElementReferenceCapture(8, __inputReference => Element = __inputReference); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/InputRadioGroup.cs b/src/Components/Web/src/Forms/InputRadioGroup.cs index f4ca4b94c733..d3a4d02dad34 100644 --- a/src/Components/Web/src/Forms/InputRadioGroup.cs +++ b/src/Components/Web/src/Forms/InputRadioGroup.cs @@ -44,7 +44,21 @@ protected override void OnParametersSet() // Mutate the InputRadioContext instance in place. Since this is a non-fixed cascading parameter, the descendant // InputRadio/InputRadioGroup components will get notified to re-render and will see the new values. - _context.GroupName = !string.IsNullOrEmpty(Name) ? Name : _defaultGroupName; + if (!string.IsNullOrEmpty(Name)) + { + // Prefer the explicitly-specified group name over anything else. + _context.GroupName = Name; + } + else if (!string.IsNullOrEmpty(NameAttributeValue)) + { + // If the user specifies a "name" attribute, or we're using "name" as a form field identifier, use that. + _context.GroupName = NameAttributeValue; + } + else + { + // Otherwise, just use a GUID to disambiguate this group's radio inputs from any others on the page. + _context.GroupName = _defaultGroupName; + } _context.CurrentValue = CurrentValue; _context.FieldClass = EditContext?.FieldCssClass(FieldIdentifier); } diff --git a/src/Components/Web/src/Forms/InputSelect.cs b/src/Components/Web/src/Forms/InputSelect.cs index e6fe5c723db6..1fa8ecb1df72 100644 --- a/src/Components/Web/src/Forms/InputSelect.cs +++ b/src/Components/Web/src/Forms/InputSelect.cs @@ -40,24 +40,25 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { builder.OpenElement(0, "select"); builder.AddMultipleAttributes(1, AdditionalAttributes); - builder.AddAttributeIfNotNullOrEmpty(2, "class", CssClass); - builder.AddAttribute(3, "multiple", _isMultipleSelect); + builder.AddAttributeIfNotNullOrEmpty(2, "name", NameAttributeValue); + builder.AddAttributeIfNotNullOrEmpty(3, "class", CssClass); + builder.AddAttribute(4, "multiple", _isMultipleSelect); if (_isMultipleSelect) { - builder.AddAttribute(4, "value", BindConverter.FormatValue(CurrentValue)?.ToString()); - builder.AddAttribute(5, "onchange", EventCallback.Factory.CreateBinder(this, SetCurrentValueAsStringArray, default)); + builder.AddAttribute(5, "value", BindConverter.FormatValue(CurrentValue)?.ToString()); + builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder(this, SetCurrentValueAsStringArray, default)); builder.SetUpdatesAttributeName("value"); } else { - builder.AddAttribute(6, "value", CurrentValueAsString); - builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, default)); + builder.AddAttribute(7, "value", CurrentValueAsString); + builder.AddAttribute(8, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, default)); builder.SetUpdatesAttributeName("value"); } - builder.AddElementReferenceCapture(8, __selectReference => Element = __selectReference); - builder.AddContent(9, ChildContent); + builder.AddElementReferenceCapture(9, __selectReference => Element = __selectReference); + builder.AddContent(10, ChildContent); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/InputText.cs b/src/Components/Web/src/Forms/InputText.cs index a36d402238eb..c3eb9b5f1d89 100644 --- a/src/Components/Web/src/Forms/InputText.cs +++ b/src/Components/Web/src/Forms/InputText.cs @@ -33,11 +33,12 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { builder.OpenElement(0, "input"); builder.AddMultipleAttributes(1, AdditionalAttributes); - builder.AddAttributeIfNotNullOrEmpty(2, "class", CssClass); - builder.AddAttribute(3, "value", CurrentValueAsString); - builder.AddAttribute(4, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddAttributeIfNotNullOrEmpty(2, "name", NameAttributeValue); + builder.AddAttributeIfNotNullOrEmpty(3, "class", CssClass); + builder.AddAttribute(4, "value", CurrentValueAsString); + builder.AddAttribute(5, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); builder.SetUpdatesAttributeName("value"); - builder.AddElementReferenceCapture(5, __inputReference => Element = __inputReference); + builder.AddElementReferenceCapture(6, __inputReference => Element = __inputReference); builder.CloseElement(); } diff --git a/src/Components/Web/src/Forms/InputTextArea.cs b/src/Components/Web/src/Forms/InputTextArea.cs index 84b9344f63f3..2495ce3d07f7 100644 --- a/src/Components/Web/src/Forms/InputTextArea.cs +++ b/src/Components/Web/src/Forms/InputTextArea.cs @@ -33,11 +33,12 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { builder.OpenElement(0, "textarea"); builder.AddMultipleAttributes(1, AdditionalAttributes); - builder.AddAttributeIfNotNullOrEmpty(2, "class", CssClass); - builder.AddAttribute(3, "value", CurrentValueAsString); - builder.AddAttribute(4, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); + builder.AddAttributeIfNotNullOrEmpty(2, "name", NameAttributeValue); + builder.AddAttributeIfNotNullOrEmpty(3, "class", CssClass); + builder.AddAttribute(4, "value", CurrentValueAsString); + builder.AddAttribute(5, "onchange", EventCallback.Factory.CreateBinder(this, __value => CurrentValueAsString = __value, CurrentValueAsString)); builder.SetUpdatesAttributeName("value"); - builder.AddElementReferenceCapture(5, __inputReference => Element = __inputReference); + builder.AddElementReferenceCapture(6, __inputReference => Element = __inputReference); builder.CloseElement(); } diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index cb6f4221f02d..f490997416ed 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -1,5 +1,6 @@ #nullable enable *REMOVED*override Microsoft.AspNetCore.Components.Forms.InputFile.OnInitialized() -> void +Microsoft.AspNetCore.Components.Forms.InputBase.NameAttributeValue.get -> string! Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.BeginRenderingComponent(System.Type! componentType, Microsoft.AspNetCore.Components.ParameterView initialParameters) -> Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlRootComponent Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.StaticHtmlRenderer(System.IServiceProvider! serviceProvider, Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> void diff --git a/src/Components/Web/test/Forms/ExpressionFormatterTest.cs b/src/Components/Web/test/Forms/ExpressionFormatterTest.cs new file mode 100644 index 000000000000..d3188d0553d4 --- /dev/null +++ b/src/Components/Web/test/Forms/ExpressionFormatterTest.cs @@ -0,0 +1,176 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Forms; + +public sealed class ExpressionFormatterTest : IDisposable +{ + [Fact] + public void Works_MemberAccessOnly() + { + // Arrange + var person = new Person(); + + // Act + var result = ExpressionFormatter.FormatLambda(() => person.Parent.Name); + + // Assert + Assert.Equal("Parent.Name", result); + } + + [Fact] + public void Works_MemberAccessWithConstIndex() + { + // Arrange + var person = new Person(); + + // Act + var result = ExpressionFormatter.FormatLambda(() => person.Parent.Children[3].Name); + + // Assert + Assert.Equal("Parent.Children[3].Name", result); + } + + [Fact] + public void Works_MemberAccessWithConstIndex_SameLambdaMultipleTimes() + { + // Arrange + var person = new Person(); + var result = new string[3]; + + // Act + for (var i = 0; i < result.Length; i++) + { + result[i] = ExpressionFormatter.FormatLambda(() => person.Parent.Children[3].Name); + } + + // Assert + Assert.Equal("Parent.Children[3].Name", result[0]); + Assert.Equal("Parent.Children[3].Name", result[1]); + Assert.Equal("Parent.Children[3].Name", result[2]); + } + + [Fact] + public void Works_MemberAccessWithVariableIndex() + { + // Arrange + var person = new Person(); + var i = 42; + + // Act + var result = ExpressionFormatter.FormatLambda(() => person.Parent.Children[i].Name); + + // Assert + Assert.Equal("Parent.Children[42].Name", result); + } + + [Fact] + public void Works_ForLoopIteratorVariableIndex_Short() + { + // Arrange + var person = new Person(); + var i = 0; + var result = new string[3]; + + // Act + for (; i < result.Length; i++) + { + result[i] = ExpressionFormatter.FormatLambda(() => person.Parent.Children[i].Name); + } + + // Assert + Assert.Equal("Parent.Children[0].Name", result[0]); + Assert.Equal("Parent.Children[1].Name", result[1]); + Assert.Equal("Parent.Children[2].Name", result[2]); + } + + [Fact] + public void Works_ForLoopIteratorVariableIndex_MultipleClosures() + { + // Arrange + var person = new Person(); + + // Act + var result1 = ComputeResult(); + var result2 = ComputeResult(); + + // Assert + Assert.Equal("Parent.Children[0].Name", result1[0]); + Assert.Equal("Parent.Children[1].Name", result1[1]); + Assert.Equal("Parent.Children[2].Name", result1[2]); + + Assert.Equal("Parent.Children[0].Name", result2[0]); + Assert.Equal("Parent.Children[1].Name", result2[1]); + Assert.Equal("Parent.Children[2].Name", result2[2]); + + string[] ComputeResult() + { + var result = new string[3]; + + for (var i = 0; i < result.Length; i++) + { + result[i] = ExpressionFormatter.FormatLambda(() => person.Parent.Children[i].Name); + } + + return result; + } + } + + [Fact] + public void Works_ForLoopIteratorVariableIndex_Long() + { + // Arrange + var person = new Person(); + var i = 0; + var result = new string[3]; + + // Act + for (; i < result.Length; i++) + { + result[i] = ExpressionFormatter.FormatLambda(() => person.Parent.Parent.Children[i].Parent.Children[i].Children[i].Name); + } + + // Assert + Assert.Equal("Parent.Parent.Children[0].Parent.Children[0].Children[0].Name", result[0]); + Assert.Equal("Parent.Parent.Children[1].Parent.Children[1].Children[1].Name", result[1]); + Assert.Equal("Parent.Parent.Children[2].Parent.Children[2].Children[2].Name", result[2]); + } + + [Fact] + public void Works_ForLoopIteratorVariableIndex_NonArrayType() + { + // Arrange + var person = new Person(); + var i = 0; + var result = new string[3]; + + // Act + for (; i < result.Length; i++) + { + result[i] = ExpressionFormatter.FormatLambda(() => person.Parent.Nicknames[i]); + } + + // Assert + Assert.Equal("Parent.Nicknames[0]", result[0]); + Assert.Equal("Parent.Nicknames[1]", result[1]); + Assert.Equal("Parent.Nicknames[2]", result[2]); + } + + public void Dispose() + { + ExpressionFormatter.ClearCache(); + } + + private class Person + { + public string Name { get; init; } + + public int Age { get; init; } + + public Person Parent { get; init; } + + public Person[] Children { get; init; } = Array.Empty(); + + public IReadOnlyList Nicknames { get; init; } = Array.Empty(); + } +} diff --git a/src/Components/Web/test/Forms/InputRadioTest.cs b/src/Components/Web/test/Forms/InputRadioTest.cs index 53438ceba14e..483b80697986 100644 --- a/src/Components/Web/test/Forms/InputRadioTest.cs +++ b/src/Components/Web/test/Forms/InputRadioTest.cs @@ -30,7 +30,10 @@ public async Task GroupGeneratesNameGuidWhenInvalidNameSupplied() var model = new TestModel(); var rootComponent = new TestInputRadioHostComponent { - EditContext = new EditContext(model), + EditContext = new EditContext(model) + { + ShouldUseFieldIdentifiers = false, + }, InnerContent = RadioButtonsWithGroup(null, () => model.TestEnum) }; diff --git a/src/Components/Web/test/Forms/ReverseStringBuilderTest.cs b/src/Components/Web/test/Forms/ReverseStringBuilderTest.cs new file mode 100644 index 000000000000..df2209305bb1 --- /dev/null +++ b/src/Components/Web/test/Forms/ReverseStringBuilderTest.cs @@ -0,0 +1,145 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Forms; + +public class ReverseStringBuilderTest +{ + [Fact] + public void ToString_ReturnsEmptyString_WhenNoWritesOccur() + { + // Arrange + Span initialBuffer = stackalloc char[128]; + using var builder = new ReverseStringBuilder(initialBuffer); + + // Act + var result = builder.ToString(); + + // Assert + Assert.Equal(string.Empty, result); + Assert.Equal(0, builder.SequenceSegmentCount); + } + + [Fact] + public void ToString_ReturnsEmptyString_WhenBufferIsEmpty() + { + // Arrange + using var builder = new ReverseStringBuilder(Span.Empty); + + // Act + var result = builder.ToString(); + + // Assert + Assert.Equal(string.Empty, result); + Assert.Equal(0, builder.SequenceSegmentCount); + } + + [Fact] + public void ToString_Works_WhenOnlyUsingStackAllocatedBuffer() + { + // Arrange + Span initialBuffer = stackalloc char[128]; + using var builder = new ReverseStringBuilder(initialBuffer); + + // Act + builder.InsertFront("world!"); + builder.InsertFront(" "); + builder.InsertFront(","); + builder.InsertFront("Hello"); + var result = builder.ToString(); + + // Assert + Assert.Equal("Hello, world!", result); + Assert.Equal(0, builder.SequenceSegmentCount); + } + + [Fact] + public void ToString_Works_WithNumbers() + { + // Arrange + Span initialBuffer = stackalloc char[128]; + using var builder = new ReverseStringBuilder(initialBuffer); + + // Act + builder.InsertFront("worlds!"); + builder.InsertFront(" "); + builder.InsertFront(123); + builder.InsertFront(", "); + builder.InsertFront("Hello"); + var result = builder.ToString(); + + // Assert + Assert.Equal("Hello, 123 worlds!", result); + Assert.Equal(0, builder.SequenceSegmentCount); + } + + [Fact] + public void ToString_Works_AfterExceedingStackAllocatedBuffer() + { + // Arrange + Span initialBuffer = stackalloc char[8]; + using var builder = new ReverseStringBuilder(initialBuffer); + + // Act + builder.InsertFront("world!"); + builder.InsertFront(" "); + builder.InsertFront(","); + builder.InsertFront("Hello"); + var result = builder.ToString(); + + // Assert + Assert.Equal("Hello, world!", result); + Assert.Equal(1, builder.SequenceSegmentCount); + } + + [Fact] + public void ToString_Works_AfterExpandingIntoMultipleBuffersFromEstimatedStringSize() + { + // Arrange + using var builder = new ReverseStringBuilder(8); + var padding = new string('A', ReverseStringBuilder.MinimumRentedArraySize - 10); + var expected = padding + "Hello, world!"; + + // Act + builder.InsertFront("world!"); + builder.InsertFront(" "); + builder.InsertFront(","); + builder.InsertFront("Hello"); + builder.InsertFront(padding); + var result = builder.ToString(); + + // Assert + Assert.Equal(expected, result); + Assert.Equal(2, builder.SequenceSegmentCount); + } + + [Fact] + public void ToString_Works_AfterUsingFallbackBuffer() + { + // Arrange + using var builder = new ReverseStringBuilder(ReverseStringBuilder.MinimumRentedArraySize); + var segmentCount = 5; + var expected = string.Empty; + + // Act + for (var i = 0; i < segmentCount; i++) + { + var c = (char)(i + 65); + + // Update the expected string. + expected = new string(c, ReverseStringBuilder.MinimumRentedArraySize) + expected; + + // Append just one character to ensure we get a buffer with the minimum possible + // length. + builder.InsertFront(c.ToString()); + + // Fill up the rest of the buffer. + var s = new string(c, ReverseStringBuilder.MinimumRentedArraySize - 1); + builder.InsertFront(s); + } + + var actual = builder.ToString(); + Assert.Equal(expected, actual); + Assert.Equal(segmentCount, builder.SequenceSegmentCount); + } +}