Skip to content

[Blazor] InputBase - allow correct async handling of ValueChanged callbacks with SetCurrentValueAsStringAsync() #59477

Open
@hakenr

Description

@hakenr

Relates to #44105 and #54279, cc @MackinnonBuck @lewing @SteveSandersonMS

Background and Motivation

In the current InputBase design, binding to the underlying input element is implemented using a synchronous setter (CurrentValue_set). The asynchronous ValueChanged callback is invoked from this synchronous setter as a "fire and forget" task (line 78):

protected TValue? CurrentValue
{
get => Value;
set
{
var hasChanged = !EqualityComparer<TValue>.Default.Equals(value, Value);
if (hasChanged)
{
Value = value;
_ = ValueChanged.InvokeAsync(Value);
EditContext?.NotifyFieldChanged(FieldIdentifier);
}
}
}

While there have already been some less common cases where this has caused issues (e.g., manual binding using the Value, ValueChanged, and ValueExpression parameters), the introduction of @bind-Value:after is likely to cause many more users to hit this issue. Developers use it much more frequently now (e.g., for continuous saving of form values).

Consider this example. The exception is "lost" - neither blazor-error-ui, ErrorBoundary, nor the browser/server console will capture it:

@page "/"

<EditForm Model="model">
    <ErrorBoundary>
        <InputText @bind-Value="model" @bind-Value:after="DoSomethingAfterValueChanged" />
    </ErrorBoundary>
    @*<input type="text" @bind-value="model" @bind-value:after="DoSomethingAfterValueChanged" />*@
</EditForm>

@code {
    string model = string.Empty;

    private Task DoSomethingAfterValueChanged()
    {
        Console.WriteLine("This executes.");
        throw new InvalidOperationException("[1] This exception is lost in async-over-sync call from InputBase.CurrentValue_set.");
        // Exception not logged in Console
        // Exception not caught by ErrorBoundary
        // Exception not caught by Blazor global error UI (the yellow strip of death :-D)
    }
}

In contrast, a plain input HTML element behaves as expected.

I propose changes to InputBase and its derived components and enable proper asynchronous handling of the ValueChanged callback, including the @bind-Value:after variant.

Proposed API

To resolve the async-over-sync issue in InputBase and its derived components, I propose to introduce a new protected async Task SetCurrentValueAsStringAsync() method. This method will encapsulate the logic currently sitting in the set_CurrentValueAsString property setter, including proper asynchronous invocation of ValueChanged.InvokeAsync(Value).

namespace Microsoft.AspNetCore.Components.Forms;

public abstract class InputBase<TValue> : ComponentBase, IDisposable
{
+  protected async Task SetCurrentValueAsStringAsync(TValue value);
}

Example Implementation

I already proposed a PR which implements this approach

The InputBase will move the logic from CurrentValueAsString setter to the new method:

protected async Task SetCurrentValueAsStringAsync(TValue value)
{
    var hasChanged = !EqualityComparer<TValue>.Default.Equals(value, Value);
    if (hasChanged)
    {
        _parsingFailed = false;
        Value = value;
        await ValueChanged.InvokeAsync(Value);
        EditContext?.NotifyFieldChanged(FieldIdentifier);
    }
}

The existing set_CurrentValueAsString property setter will trigger this new method asynchronously without awaiting its completion (same as now, to preserve backwards compatibility):

protected TValue? CurrentValue
{
    get => Value;
    set => _ = SetCurrentValueAsStringAsync(value);
}

Updates to Bindings:

All bindings that currently rely on CurrentValueAsString in InputBase will be updated to use SetCurrentValueAsStringAsync() for proper asynchronous handling. For example:

builder.AddAttribute(11, "onchange", EventCallback.Factory.CreateBinder<string>(
    this, 
    async (string ___value) => await SetCurrentValueAsStringAsync(___value), 
    CurrentValueAsString
));

Benefits

  • Asynchronous Handling: The new approach ensures that ValueChanged.InvokeAsync(Value) is awaited, resolving the issue of exceptions being suppressed or "lost" in async-over-sync calls.
  • Backward Compatibility: Existing third-party components depending on CurrentValueAsString will remain functional. The setter will continue to execute asynchronously as a "fire and forget" fallback when synchronous usage is unavoidable.
  • Error Propagation: Exceptions thrown in the ValueChanged callback will now propagate correctly to Blazor error handling mechanisms (ErrorBoundary, blazor-error-ui, etc.).

Usage Examples

The developers of input-components (mainly component libraries developers) are expected to adopt the new approach. Instead of using the CurrentValueAsString property setter, their InputBase-derived components will use this type of binding:

builder.AddAttribute(11, "onchange", EventCallback.Factory.CreateBinder<string>(
    this, 
    async (string ___value) => await SetCurrentValueAsStringAsync(___value), 
    CurrentValueAsString
));

which is equivalent to

<... @bind-value:set="CurrentValue" @bind-value:get="SetCurrentValueAsStringAsync" ... />

Alternative Designs

<NewInputText @bind-Value="model" @bind-Value:after="DoSomethingAsync" />

A brand new set of input components (NewInputText, NewInputSelect, etc.) could provide a clear separation for developers adopting the updated pattern while maintaining backward compatibility with the existing Input* components.

Risks

As the primary goal of proposed approach is to preserve backwards compatibility and avoid breaking changes, the developers will still be able to use the synchronous CurrentValueAsString setter, where the issues described above remain unresolved.

Metadata

Metadata

Assignees

No one assigned

    Labels

    api-suggestionEarly API idea and discussion, it is NOT ready for implementationarea-blazorIncludes: Blazor, Razor Components

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions