Search code examples
c#winformsblazormaui-blazorblazorwebview

Why is the Blazor value not immediately being rendered after changing it?


Using a BlazorWebView control (introduction) inside a WinForms .NET 8 form, I do have this "Counter.razor" file being displayed:

<p><button @onclick="IncrementCount">Increment counter</button></p>
<p>@_counterValue</p>

@code
{
    private int _counterValue = 0;

    private void IncrementCount()
    {
        _counterValue++;
    }
}

enter image description here

Correct behavior

The above code works perfectly:

  1. The user clicks the button.
  2. The variable is incremented.
  3. The page displays the new variable value.

enter image description here

Incorrect behavior

Now I change the IncrementCount() method to this:

private void IncrementCount()
{
    SynchronizationContext.Current!.Post(
        delegate
        {
            _counterValue++;
        }, 
        null);
}

Now it misbehaves like this:

  1. The user clicks the button.
  2. The variable is incremented (now being "1").
  3. The page still displays "0".
  4. The user clicks the button again.
  5. The variable is incremented again. (Now being "2").
  6. The page displays "1".
  7. and so on.

I.e. I always do have an off-by-one error.

Fixing it

To fix this, I do have to change my code to:

private void IncrementCount()
{
    SynchronizationContext.Current!.Post(
        delegate
        {
            _counterValue++;
            StateHasChanged();
        }, 
        null);
}

Now, after adding a call to StateHasChanged(), it behaves as intended:

  1. The user clicks the button.
  2. The variable is incremented.
  3. The page displays the new variable value.

Context

The above usage of SynchronizationContext is a minimal example of a larger application where I have to dispatch calls through this mechanism (primarily because I have to call other WinForms dialogs).

I do want to understand why the call to "StateHasChanged" is required as I want to avoid it.

In my real application (not the above minimal example) this would force to much re-rendering to be suitable for the application.

My question

Could someone please explain to me why the behavior is different whether I change the variable directly vs. changing it through SynchronizationContext?

Is there a change to make this working without the extra call to StateHasChanged()?


Update 1

In my real world application I'm doing roughly these steps:

  1. User clicks a Blazor button.
  2. A WinForms form is shown.
  3. After closing the form, I have to update some values in Blazor.

So this is the reason I am doing my code inside a SynchronizationContext.Current!.Post() call, as recommended here.

I've also tried to place my code into a queue and process this queue in an Application.Idle event handler of my WinForms app, but this behaves just the same (the off-by-one error).


Solution

  • IncrementCounter is posted to the Synchronization Context by the Renderer's UI Event process. It's passed as callback inside the ComponentBase UI handler.

    Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg)
    {
        var task = callback.InvokeAsync(arg);
        var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion &&
            task.Status != TaskStatus.Canceled;
    
        // After each event, we synchronously re-render (unless !ShouldRender())
        // This just saves the developer the trouble of putting "StateHasChanged();"
        // at the end of every event callback.
        StateHasChanged();
    
        return shouldAwaitTask ?
            CallStateHasChangedOnAsyncCompletion(task) :
            Task.CompletedTask;
    }
    

    The full code can be found here: https://github.com/dotnet/aspnetcore/blob/63c8031b6a6af5009b3c5bb4291fcc4c32b06b10/src/Components/Components/src/ComponentBase.cs#L322

    So what you're doing is posting this

    {
        _counterValue++;
    } 
    

    as a block of code into the Synchronization Context queue behind IncrementCounter. IncrementCounter does all the rendering before your code is executed and mutates the _counterValue.

    When it's changed directly the mutation takes place before StateHasChanged is called in the UI handler.

    Question: Why do you want to post code directly to the Synchronization Context?

    ComponentBase has the inbuilt function InvokeAsync to call any code that must be executed on the Synchronization Context see - https://github.com/dotnet/aspnetcore/blob/63c8031b6a6af5009b3c5bb4291fcc4c32b06b10/src/Components/Components/src/ComponentBase.cs#L178

    Note [personal]:

    I now always change:

    private void IncrementCount()
    { }
    

    To this:

    private Task IncrementCount()
    { 
        //work
        return Task.CompletedTask;
    }
    

    It prevents Visual Studio automatically doing this when you type await:

    private async void IncrementCount()
    { }
    

    Update:

    Based on your update you need to call StateHasChanged in the posted [anonymous] method.

    Note that you can disable all automatic calls to StateHasChanged in a component like this:

    @page "/counter"
    @implements IHandleEvent
    
    @code {
        Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg)
        {
            return callback.InvokeAsync(arg);
        }
    }
    

    You then only call StateHasChanged when you need to.