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