+ {
+ EditContext = editContext,
+ ValueChanged = value => model.DateProperty = value,
+ ValueExpression = () => model.DateProperty
+ };
+ var fieldIdentifier = FieldIdentifier.Create(() => model.DateProperty);
+ var unrelatedFieldIdentifier = FieldIdentifier.Create(() => model.StringProperty);
+ var unrelatedMessages = new ValidationMessageStore(editContext);
+ var fieldChangedCount = 0;
+ editContext.OnFieldChanged += (sender, eventArgs) =>
+ {
+ fieldChangedCount++;
+ unrelatedMessages.Add(unrelatedFieldIdentifier, "Unrelated validation ran");
+ };
+ var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);
+ await inputComponent.SetCurrentValueAsStringAsync("1991/11/20");
+ unrelatedMessages.Clear();
+ editContext.MarkAsUnmodified(fieldIdentifier);
+
+ await inputComponent.SetCurrentValueAsStringAsync("1991/11/40");
+
+ Assert.Equal(new DateTime(1991, 11, 20), model.DateProperty);
+ Assert.True(editContext.IsModified(fieldIdentifier));
+ Assert.Equal(1, fieldChangedCount);
+ Assert.Equal(new[] { "Bad date value" }, editContext.GetValidationMessages(fieldIdentifier));
+ Assert.Empty(editContext.GetValidationMessages(unrelatedFieldIdentifier));
+ Assert.False(editContext.IsModified(unrelatedFieldIdentifier));
+ }
+
[Fact]
public async Task ClearsParsingValidationMessagesWhenDisposed()
{
diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs
index 21a982e107c1..a063af13de0b 100644
--- a/src/Components/test/E2ETest/Tests/FormsTest.cs
+++ b/src/Components/test/E2ETest/Tests/FormsTest.cs
@@ -186,6 +186,21 @@ public void InputNumberInteractsWithEditContext_NullableFloat()
Browser.Empty(messagesAccessor);
}
+ [Fact]
+ public void InputNumberParsingFailureDoesNotTriggerModelValidation()
+ {
+ var appElement = MountTypicalValidationComponent();
+ var input = appElement.FindElement(By.ClassName("required-number")).FindElement(By.TagName("input"));
+ var value = appElement.FindElement(By.ClassName("required-number-value"));
+ var messagesAccessor = CreateValidationMessagesAccessor(appElement);
+
+ input.SendKeys("1e+10\t");
+
+ Browser.Equal("modified invalid", () => input.GetDomAttribute("class"));
+ Browser.Equal("", () => value.Text);
+ Browser.Equal(new[] { "The RequiredNumber field must be a number." }, messagesAccessor);
+ }
+
[Fact]
public void InputTextAreaInteractsWithEditContext()
{
diff --git a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor
index 27e09dd7846f..d12cfe3fe051 100644
--- a/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor
+++ b/src/Components/test/testassets/BasicTestApp/FormsTest/TypicalValidationComponent.razor
@@ -24,6 +24,10 @@
Height (optional):
+
+ Required number:
+ @person.RequiredNumber
+
Description:
@@ -185,6 +189,9 @@
public float? OptionalHeight { get; set; }
+ [Required(ErrorMessage = "Enter a required number")]
+ public int? RequiredNumber { get; set; }
+
public DateTime RenewalDate { get; set; } = DateTime.Now;
public DateTimeOffset? OptionalExpiryDate { get; set; }
From 7c418792dc7d6ecbd3c653114428298d746c30a3 Mon Sep 17 00:00:00 2001
From: PureWeen <223556219+Copilot@users.noreply.github.com>
Date: Tue, 11 Aug 2026 14:59:20 -0500
Subject: [PATCH 2/3] Preserve modified state on parse failure
Keep the mark-only EditContext operation internal and bridge it from InputBase without raising OnFieldChanged or expanding public API surface. Strengthen repeated-invalid, recovery, model-preservation, and browser coverage.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f88407e7-efcf-44b1-9387-cde2a2557973
---
src/Components/Forms/src/EditContext.cs | 11 +---------
.../Forms/src/PublicAPI.Unshipped.txt | 1 -
.../Web/src/Forms/EditContextAccessor.cs | 12 ++++++++++
src/Components/Web/src/Forms/InputBase.cs | 2 +-
.../Web/test/Forms/InputBaseTest.cs | 9 ++++++--
.../test/E2ETest/Tests/FormsTest.cs | 22 +++++++++++++++++++
6 files changed, 43 insertions(+), 14 deletions(-)
create mode 100644 src/Components/Web/src/Forms/EditContextAccessor.cs
diff --git a/src/Components/Forms/src/EditContext.cs b/src/Components/Forms/src/EditContext.cs
index dcc65323efbb..d86d458ed01d 100644
--- a/src/Components/Forms/src/EditContext.cs
+++ b/src/Components/Forms/src/EditContext.cs
@@ -94,16 +94,7 @@ public void NotifyFieldChanged(in FieldIdentifier fieldIdentifier)
OnFieldChanged?.Invoke(this, new FieldChangedEventArgs(fieldIdentifier));
}
- ///
- /// Marks the specified field as modified without raising the event.
- ///
- ///
- /// Unlike , this method does not raise the
- /// event. Use this when the user's input changed without changing
- /// the underlying model value, such as when the input fails to parse.
- ///
- /// Identifies the field to mark as modified.
- public void MarkAsModified(in FieldIdentifier fieldIdentifier)
+ internal void MarkAsModified(in FieldIdentifier fieldIdentifier)
=> GetOrAddFieldState(fieldIdentifier).IsModified = true;
///
diff --git a/src/Components/Forms/src/PublicAPI.Unshipped.txt b/src/Components/Forms/src/PublicAPI.Unshipped.txt
index 911d949ae352..d00a29c69892 100644
--- a/src/Components/Forms/src/PublicAPI.Unshipped.txt
+++ b/src/Components/Forms/src/PublicAPI.Unshipped.txt
@@ -1,5 +1,4 @@
#nullable enable
-Microsoft.AspNetCore.Components.Forms.EditContext.MarkAsModified(in Microsoft.AspNetCore.Components.Forms.FieldIdentifier fieldIdentifier) -> void
Microsoft.AspNetCore.Components.Forms.ValidationRequestedEventArgs.AddAsyncValidator(System.Func! validator) -> void
*REMOVED*static Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.AddDataAnnotationsValidation(this Microsoft.AspNetCore.Components.Forms.EditContext! editContext) -> Microsoft.AspNetCore.Components.Forms.EditContext!
*REMOVED*static Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.EnableDataAnnotationsValidation(this Microsoft.AspNetCore.Components.Forms.EditContext! editContext) -> System.IDisposable!
diff --git a/src/Components/Web/src/Forms/EditContextAccessor.cs b/src/Components/Web/src/Forms/EditContextAccessor.cs
new file mode 100644
index 000000000000..d68a3b412d4e
--- /dev/null
+++ b/src/Components/Web/src/Forms/EditContextAccessor.cs
@@ -0,0 +1,12 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Runtime.CompilerServices;
+
+namespace Microsoft.AspNetCore.Components.Forms;
+
+internal static class EditContextAccessor
+{
+ [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "MarkAsModified")]
+ internal static extern void MarkAsModified(EditContext editContext, in FieldIdentifier fieldIdentifier);
+}
diff --git a/src/Components/Web/src/Forms/InputBase.cs b/src/Components/Web/src/Forms/InputBase.cs
index 309d91c1b8db..c619c04b040d 100644
--- a/src/Components/Web/src/Forms/InputBase.cs
+++ b/src/Components/Web/src/Forms/InputBase.cs
@@ -141,7 +141,7 @@ protected string? CurrentValueAsString
// The raw input changed, but the model value did not. Track the user interaction
// without revalidating the unchanged model value.
- EditContext.MarkAsModified(FieldIdentifier);
+ EditContextAccessor.MarkAsModified(EditContext, FieldIdentifier);
}
}
diff --git a/src/Components/Web/test/Forms/InputBaseTest.cs b/src/Components/Web/test/Forms/InputBaseTest.cs
index a4cdbccf73ce..a5381cd19054 100644
--- a/src/Components/Web/test/Forms/InputBaseTest.cs
+++ b/src/Components/Web/test/Forms/InputBaseTest.cs
@@ -326,7 +326,12 @@ public async Task ParsesCurrentValueAsStringWhenChanged_Invalid()
Assert.Equal(new[] { "Bad date value" }, rootComponent.EditContext.GetValidationMessages(fieldIdentifier));
Assert.Equal(1, numValidationStateChanges);
- // Act/Assert 2: Transition to valid
+ await inputComponent.SetCurrentValueAsStringAsync("invalid");
+ Assert.Empty(valueChangedArgs);
+ Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier));
+ Assert.Equal(new[] { "Bad date value" }, rootComponent.EditContext.GetValidationMessages(fieldIdentifier));
+ Assert.Equal(2, numValidationStateChanges);
+
await inputComponent.SetCurrentValueAsStringAsync("1991/11/20");
var receivedParsedValue = valueChangedArgs.Single();
Assert.Equal(1991, receivedParsedValue.Year);
@@ -334,7 +339,7 @@ public async Task ParsesCurrentValueAsStringWhenChanged_Invalid()
Assert.Equal(20, receivedParsedValue.Day);
Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier));
Assert.Empty(rootComponent.EditContext.GetValidationMessages(fieldIdentifier));
- Assert.Equal(2, numValidationStateChanges);
+ Assert.Equal(3, numValidationStateChanges);
}
[Fact]
diff --git a/src/Components/test/E2ETest/Tests/FormsTest.cs b/src/Components/test/E2ETest/Tests/FormsTest.cs
index a063af13de0b..36a602cdf301 100644
--- a/src/Components/test/E2ETest/Tests/FormsTest.cs
+++ b/src/Components/test/E2ETest/Tests/FormsTest.cs
@@ -199,6 +199,28 @@ public void InputNumberParsingFailureDoesNotTriggerModelValidation()
Browser.Equal("modified invalid", () => input.GetDomAttribute("class"));
Browser.Equal("", () => value.Text);
Browser.Equal(new[] { "The RequiredNumber field must be a number." }, messagesAccessor);
+
+ input.Clear();
+ input.SendKeys("5\t");
+
+ Browser.Equal("modified valid", () => input.GetDomAttribute("class"));
+ Browser.Equal("5", () => value.Text);
+ Browser.Empty(messagesAccessor);
+
+ input.SendKeys(Keys.Control + "a");
+ input.SendKeys(Keys.Delete);
+ input.SendKeys("1e+10\t");
+
+ Browser.Equal("modified invalid", () => input.GetDomAttribute("class"));
+ Browser.Equal("5", () => value.Text);
+ Browser.Equal(new[] { "The RequiredNumber field must be a number." }, messagesAccessor);
+
+ input.Clear();
+ input.SendKeys("\t");
+
+ Browser.Equal("modified invalid", () => input.GetDomAttribute("class"));
+ Browser.Equal("", () => value.Text);
+ Browser.Equal(new[] { "Enter a required number" }, messagesAccessor);
}
[Fact]
From 2b772c10b0991330f3ff21d9367ea09ffed77d6e Mon Sep 17 00:00:00 2001
From: PureWeen <223556219+Copilot@users.noreply.github.com>
Date: Thu, 13 Aug 2026 11:08:18 -0500
Subject: [PATCH 3/3] Use public API for InputBase modified state
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f88407e7-efcf-44b1-9387-cde2a2557973
---
src/Components/Forms/src/EditContext.cs | 6 +++++-
src/Components/Forms/src/PublicAPI.Unshipped.txt | 1 +
src/Components/Web/src/Forms/EditContextAccessor.cs | 12 ------------
src/Components/Web/src/Forms/InputBase.cs | 2 +-
4 files changed, 7 insertions(+), 14 deletions(-)
delete mode 100644 src/Components/Web/src/Forms/EditContextAccessor.cs
diff --git a/src/Components/Forms/src/EditContext.cs b/src/Components/Forms/src/EditContext.cs
index d86d458ed01d..a5c5d815667d 100644
--- a/src/Components/Forms/src/EditContext.cs
+++ b/src/Components/Forms/src/EditContext.cs
@@ -94,7 +94,11 @@ public void NotifyFieldChanged(in FieldIdentifier fieldIdentifier)
OnFieldChanged?.Invoke(this, new FieldChangedEventArgs(fieldIdentifier));
}
- internal void MarkAsModified(in FieldIdentifier fieldIdentifier)
+ ///
+ /// Marks the specified field as modified.
+ ///
+ /// Identifies the field whose modification flag should be set.
+ public void MarkAsModified(in FieldIdentifier fieldIdentifier)
=> GetOrAddFieldState(fieldIdentifier).IsModified = true;
///
diff --git a/src/Components/Forms/src/PublicAPI.Unshipped.txt b/src/Components/Forms/src/PublicAPI.Unshipped.txt
index d00a29c69892..692bf5a51ae6 100644
--- a/src/Components/Forms/src/PublicAPI.Unshipped.txt
+++ b/src/Components/Forms/src/PublicAPI.Unshipped.txt
@@ -12,3 +12,4 @@ Microsoft.AspNetCore.Components.Forms.EditContext.IsValidationPending() -> bool
Microsoft.AspNetCore.Components.Forms.EditContext.IsValidationFaulted(in Microsoft.AspNetCore.Components.Forms.FieldIdentifier fieldIdentifier) -> bool
Microsoft.AspNetCore.Components.Forms.EditContext.IsValidationFaulted(System.Linq.Expressions.Expression!>! accessor) -> bool
Microsoft.AspNetCore.Components.Forms.EditContext.IsValidationFaulted() -> bool
+Microsoft.AspNetCore.Components.Forms.EditContext.MarkAsModified(in Microsoft.AspNetCore.Components.Forms.FieldIdentifier fieldIdentifier) -> void
diff --git a/src/Components/Web/src/Forms/EditContextAccessor.cs b/src/Components/Web/src/Forms/EditContextAccessor.cs
deleted file mode 100644
index d68a3b412d4e..000000000000
--- a/src/Components/Web/src/Forms/EditContextAccessor.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using System.Runtime.CompilerServices;
-
-namespace Microsoft.AspNetCore.Components.Forms;
-
-internal static class EditContextAccessor
-{
- [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "MarkAsModified")]
- internal static extern void MarkAsModified(EditContext editContext, in FieldIdentifier fieldIdentifier);
-}
diff --git a/src/Components/Web/src/Forms/InputBase.cs b/src/Components/Web/src/Forms/InputBase.cs
index c619c04b040d..309d91c1b8db 100644
--- a/src/Components/Web/src/Forms/InputBase.cs
+++ b/src/Components/Web/src/Forms/InputBase.cs
@@ -141,7 +141,7 @@ protected string? CurrentValueAsString
// The raw input changed, but the model value did not. Track the user interaction
// without revalidating the unchanged model value.
- EditContextAccessor.MarkAsModified(EditContext, FieldIdentifier);
+ EditContext.MarkAsModified(FieldIdentifier);
}
}