Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 74 additions & 50 deletions src/Components/Web/src/Forms/InputBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,24 +79,36 @@ protected TValue? CurrentValue
get => Value;
set
{
var hasChanged = !EqualityComparer<TValue>.Default.Equals(value, Value);
if (hasChanged)
{
_parsingFailed = false;

// If we don't do this, then when the user edits from A to B, we'd:
// - Do a render that changes back to A
// - Then send the updated value to the parent, which sends the B back to this component
// - Do another render that changes it to B again
// The unnecessary reversion from B to A can cause selection to be lost while typing
// A better solution would be somehow forcing the parent component's render to occur first,
// but that would involve a complex change in the renderer to keep the render queue sorted
// by component depth or similar.
Value = value;

_ = ValueChanged.InvokeAsync(Value);
EditContext?.NotifyFieldChanged(FieldIdentifier);
}
// Synchronous fallback for backwards compatibility (published API).
// Await SetCurrentValueAsync() instead to ensure proper async behavior.
_ = SetCurrentValueAsync(value);
}
}

/// <summary>
/// Sets current value of the input.
/// </summary>
/// <param name="value">Value to be set.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected async Task SetCurrentValueAsync(TValue? value)
{
var hasChanged = !EqualityComparer<TValue>.Default.Equals(value, Value);
if (hasChanged)
{
_parsingFailed = false;

// If we don't do this, then when the user edits from A to B, we'd:
// - Do a render that changes back to A
// - Then send the updated value to the parent, which sends the B back to this component
// - Do another render that changes it to B again
// The unnecessary reversion from B to A can cause selection to be lost while typing
// A better solution would be somehow forcing the parent component's render to occur first,
// but that would involve a complex change in the renderer to keep the render queue sorted
// by component depth or similar.
Value = value;

await ValueChanged.InvokeAsync(Value);
EditContext?.NotifyFieldChanged(FieldIdentifier);
}
}

Expand All @@ -113,44 +125,56 @@ protected string? CurrentValueAsString

set
{
_incomingValueBeforeParsing = value;
_parsingValidationMessages?.Clear();

if (_nullableUnderlyingType != null && string.IsNullOrEmpty(value))
{
// Assume if it's a nullable type, null/empty inputs should correspond to default(T)
// Then all subclasses get nullable support almost automatically (they just have to
// not reject Nullable<T> based on the type itself).
_parsingFailed = false;
CurrentValue = default!;
}
else if (TryParseValueFromString(value, out var parsedValue, out var validationErrorMessage))
{
_parsingFailed = false;
CurrentValue = parsedValue!;
}
else
{
_parsingFailed = true;
// Synchronous fallback for backwards compatibility (published API).
// Await SetCurrentValueAsStringAsync() instead to ensure proper async behavior.
_ = SetCurrentValueAsStringAsync(value);
}
}

// EditContext may be null if the input is not a child component of EditForm.
if (EditContext is not null)
{
_parsingValidationMessages ??= new ValidationMessageStore(EditContext);
_parsingValidationMessages.Add(FieldIdentifier, validationErrorMessage);
/// <summary>
/// Sets current value of the input, represented as a string.
/// </summary>
/// <param name="value">Value to be set.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected async Task SetCurrentValueAsStringAsync(string? value)
{
_incomingValueBeforeParsing = value;
_parsingValidationMessages?.Clear();

// Since we're not writing to CurrentValue, we'll need to notify about modification from here
EditContext.NotifyFieldChanged(FieldIdentifier);
}
}
if (_nullableUnderlyingType != null && string.IsNullOrEmpty(value))
{
// Assume if it's a nullable type, null/empty inputs should correspond to default(T)
// Then all subclasses get nullable support almost automatically (they just have to
// not reject Nullable<T> based on the type itself).
_parsingFailed = false;
await SetCurrentValueAsync(default!);
}
else if (TryParseValueFromString(value, out var parsedValue, out var validationErrorMessage))
{
_parsingFailed = false;
await SetCurrentValueAsync(parsedValue!);
}
else
{
_parsingFailed = true;

// We can skip the validation notification if we were previously valid and still are
if (_parsingFailed || _previousParsingAttemptFailed)
// EditContext may be null if the input is not a child component of EditForm.
if (EditContext is not null)
{
EditContext?.NotifyValidationStateChanged();
_previousParsingAttemptFailed = _parsingFailed;
_parsingValidationMessages ??= new ValidationMessageStore(EditContext);
_parsingValidationMessages.Add(FieldIdentifier, validationErrorMessage);

// Since we're not writing to CurrentValue, we'll need to notify about modification from here
EditContext.NotifyFieldChanged(FieldIdentifier);
}
}

// We can skip the validation notification if we were previously valid and still are
if (_parsingFailed || _previousParsingAttemptFailed)
{
EditContext?.NotifyValidationStateChanged();
_previousParsingAttemptFailed = _parsingFailed;
}
}

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/InputCheckbox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
// It sends the "on" value when the checkbox is checked, and nothing otherwise.
builder.AddAttribute(6, "value", bool.TrueString);

builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder<bool>(this, __value => CurrentValue = __value, CurrentValue));
builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder<bool>(this, SetCurrentValueAsync, CurrentValue));
builder.SetUpdatesAttributeName("checked");
builder.AddElementReferenceCapture(8, __inputReference => Element = __inputReference);
builder.CloseElement();
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/InputDate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.AddAttributeIfNotNullOrEmpty(3, "name", NameAttributeValue);
builder.AddAttribute(4, "class", CssClass);
builder.AddAttribute(5, "value", CurrentValueAsString);
builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder<string?>(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder<string?>(this, SetCurrentValueAsStringAsync, CurrentValueAsString));
builder.SetUpdatesAttributeName("value");
builder.AddElementReferenceCapture(7, __inputReference => Element = __inputReference);
builder.CloseElement();
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/InputNumber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.AddAttributeIfNotNullOrEmpty(4, "name", NameAttributeValue);
builder.AddAttributeIfNotNullOrEmpty(5, "class", CssClass);
builder.AddAttribute(6, "value", CurrentValueAsString);
builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder<string?>(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
builder.AddAttribute(7, "onchange", EventCallback.Factory.CreateBinder<string?>(this, SetCurrentValueAsStringAsync, CurrentValueAsString));
builder.SetUpdatesAttributeName("value");
builder.AddElementReferenceCapture(8, __inputReference => Element = __inputReference);
builder.CloseElement();
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/InputRadioGroup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ protected override void OnParametersSet()
// On the first render, we can instantiate the InputRadioContext
if (_context is null)
{
var changeEventCallback = EventCallback.Factory.CreateBinder<string?>(this, __value => CurrentValueAsString = __value, CurrentValueAsString);
var changeEventCallback = EventCallback.Factory.CreateBinder<string?>(this, SetCurrentValueAsStringAsync, CurrentValueAsString);
_context = new InputRadioContext(this, CascadedContext, changeEventCallback);
}
else if (_context.ParentContext != CascadedContext)
Expand Down
10 changes: 5 additions & 5 deletions src/Components/Web/src/Forms/InputSelect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,13 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
if (_isMultipleSelect)
{
builder.AddAttribute(5, "value", BindConverter.FormatValue(CurrentValue)?.ToString());
builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder<string?[]?>(this, SetCurrentValueAsStringArray, default));
builder.AddAttribute(6, "onchange", EventCallback.Factory.CreateBinder<string?[]?>(this, SetCurrentValueAsStringArrayAsync, default));
builder.SetUpdatesAttributeName("value");
}
else
{
builder.AddAttribute(7, "value", CurrentValueAsString);
builder.AddAttribute(8, "onchange", EventCallback.Factory.CreateBinder<string?>(this, __value => CurrentValueAsString = __value, default));
builder.AddAttribute(8, "onchange", EventCallback.Factory.CreateBinder<string?>(this, SetCurrentValueAsStringAsync, default));
builder.SetUpdatesAttributeName("value");
}

Expand Down Expand Up @@ -82,10 +82,10 @@ protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(fa
return base.FormatValueAsString(value);
}

private void SetCurrentValueAsStringArray(string?[]? value)
private Task SetCurrentValueAsStringArrayAsync(string?[]? value)
{
CurrentValue = BindConverter.TryConvertTo<TValue>(value, CultureInfo.CurrentCulture, out var result)
return SetCurrentValueAsync(BindConverter.TryConvertTo<TValue>(value, CultureInfo.CurrentCulture, out var result)
? result
: default;
: default);
}
}
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/InputText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.AddAttributeIfNotNullOrEmpty(2, "name", NameAttributeValue);
builder.AddAttributeIfNotNullOrEmpty(3, "class", CssClass);
builder.AddAttribute(4, "value", CurrentValueAsString);
builder.AddAttribute(5, "onchange", EventCallback.Factory.CreateBinder<string?>(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
builder.AddAttribute(5, "onchange", EventCallback.Factory.CreateBinder<string?>(this, SetCurrentValueAsStringAsync, CurrentValueAsString));
builder.SetUpdatesAttributeName("value");
builder.AddElementReferenceCapture(6, __inputReference => Element = __inputReference);
builder.CloseElement();
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/InputTextArea.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.AddAttributeIfNotNullOrEmpty(2, "name", NameAttributeValue);
builder.AddAttributeIfNotNullOrEmpty(3, "class", CssClass);
builder.AddAttribute(4, "value", CurrentValueAsString);
builder.AddAttribute(5, "onchange", EventCallback.Factory.CreateBinder<string?>(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
builder.AddAttribute(5, "onchange", EventCallback.Factory.CreateBinder<string?>(this, SetCurrentValueAsStringAsync, CurrentValueAsString));
builder.SetUpdatesAttributeName("value");
builder.AddElementReferenceCapture(6, __inputReference => Element = __inputReference);
builder.CloseElement();
Expand Down
2 changes: 2 additions & 0 deletions src/Components/Web/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#nullable enable
Microsoft.AspNetCore.Components.Forms.InputBase<TValue>.SetCurrentValueAsStringAsync(string? value) -> System.Threading.Tasks.Task!
Microsoft.AspNetCore.Components.Forms.InputBase<TValue>.SetCurrentValueAsync(TValue? value) -> System.Threading.Tasks.Task!
10 changes: 5 additions & 5 deletions src/Components/Web/test/Forms/InputBaseTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ public async Task ParsesCurrentValueAsStringWhenChanged_Valid()
rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; };

// Act
await inputComponent.SetCurrentValueAsStringAsync("1991/11/20");
await inputComponent.InvokeCurrentValueAsStringSetterAsync("1991/11/20");

// Assert
var receivedParsedValue = valueChangedArgs.Single();
Expand Down Expand Up @@ -320,14 +320,14 @@ public async Task ParsesCurrentValueAsStringWhenChanged_Invalid()
rootComponent.EditContext.OnValidationStateChanged += (sender, eventArgs) => { numValidationStateChanges++; };

// Act/Assert 1: Transition to invalid
await inputComponent.SetCurrentValueAsStringAsync("1991/11/40");
await inputComponent.InvokeCurrentValueAsStringSetterAsync("1991/11/40");
Assert.Empty(valueChangedArgs);
Assert.True(rootComponent.EditContext.IsModified(fieldIdentifier));
Assert.Equal(new[] { "Bad date value" }, rootComponent.EditContext.GetValidationMessages(fieldIdentifier));
Assert.Equal(1, numValidationStateChanges);

// Act/Assert 2: Transition to valid
await inputComponent.SetCurrentValueAsStringAsync("1991/11/20");
await inputComponent.InvokeCurrentValueAsStringSetterAsync("1991/11/20");
var receivedParsedValue = valueChangedArgs.Single();
Assert.Equal(1991, receivedParsedValue.Year);
Assert.Equal(11, receivedParsedValue.Month);
Expand All @@ -351,7 +351,7 @@ public async Task ClearsParsingValidationMessagesWhenDisposed()
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);

// Act + Assert 1 (Precondition): The test needs a validation message to be removed later.
await inputComponent.SetCurrentValueAsStringAsync("1991/11/40");
await inputComponent.InvokeCurrentValueAsStringSetterAsync("1991/11/40");
Assert.Equal(new[] { "Bad date value" }, rootComponent.EditContext.GetValidationMessages(fieldIdentifier));

// Act: Dispose the input component
Expand Down Expand Up @@ -562,7 +562,7 @@ protected override bool TryParseValueFromString(string value, out T result, out
throw new NotImplementedException();
}

public async Task SetCurrentValueAsStringAsync(string value)
public async Task InvokeCurrentValueAsStringSetterAsync(string value)
{
// This is equivalent to the subclass writing to CurrentValueAsString
// (e.g., from @bind), except to simplify the test code there's an InvokeAsync
Expand Down
4 changes: 2 additions & 2 deletions src/Components/Web/test/Forms/InputDateTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public async Task ValidationErrorUsesDisplayAttributeName()
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);

// Act
await inputComponent.SetCurrentValueAsStringAsync("invalidDate");
await inputComponent.InvokeCurrentValueAsStringSetterAsync("invalidDate");

// Assert
var validationMessages = rootComponent.EditContext.GetValidationMessages(fieldIdentifier);
Expand Down Expand Up @@ -56,7 +56,7 @@ private class TestModel

private class TestInputDateComponent : InputDate<DateTime>
{
public async Task SetCurrentValueAsStringAsync(string value)
public async Task InvokeCurrentValueAsStringSetterAsync(string value)
{
// This is equivalent to the subclass writing to CurrentValueAsString
// (e.g., from @bind), except to simplify the test code there's an InvokeAsync
Expand Down
4 changes: 2 additions & 2 deletions src/Components/Web/test/Forms/InputNumberTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public async Task ValidationErrorUsesDisplayAttributeName()
var inputComponent = await InputRenderer.RenderAndGetComponent(rootComponent);

// Act
await inputComponent.SetCurrentValueAsStringAsync("notANumber");
await inputComponent.InvokeCurrentValueAsStringSetterAsync("notANumber");

// Assert
var validationMessages = rootComponent.EditContext.GetValidationMessages(fieldIdentifier);
Expand Down Expand Up @@ -56,7 +56,7 @@ private class TestModel

private class TestInputNumberComponent : InputNumber<int>
{
public async Task SetCurrentValueAsStringAsync(string value)
public async Task InvokeCurrentValueAsStringSetterAsync(string value)
{
// This is equivalent to the subclass writing to CurrentValueAsString
// (e.g., from @bind), except to simplify the test code there's an InvokeAsync
Expand Down
4 changes: 2 additions & 2 deletions src/Components/Web/test/Forms/InputSelectTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ public async Task ValidationErrorUsesDisplayAttributeName()
var inputSelectComponent = await InputRenderer.RenderAndGetComponent(rootComponent);

// Act
await inputSelectComponent.SetCurrentValueAsStringAsync("invalidNumber");
await inputSelectComponent.InvokeCurrentValueAsStringSetterAsync("invalidNumber");

// Assert
var validationMessages = rootComponent.EditContext.GetValidationMessages(fieldIdentifier);
Expand Down Expand Up @@ -239,7 +239,7 @@ class TestInputSelect<TValue> : InputSelect<TValue>
get => base.CurrentValueAsString;
set => base.CurrentValueAsString = value;
}
public async Task SetCurrentValueAsStringAsync(string value)
public async Task InvokeCurrentValueAsStringSetterAsync(string value)
{
// This is equivalent to the subclass writing to CurrentValueAsString
// (e.g., from @bind), except to simplify the test code there's an InvokeAsync
Expand Down
Loading