dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.46k stars 10.03k forks source link

EventCallback.Equals is not giving correct results #53361

Open JelleHissink opened 10 months ago

JelleHissink commented 10 months ago

Is there an existing issue for this?

Describe the bug

We noticed after upgrading our blazor application to .net 8.0 that some EventCallback keep being modified. Eventhough the value was the same, the same target component, the same method. The generated code that renders the component seems to fabricate a new MulticastDelegate, having the same Type, Target and Method. The implementation of EventCallback uses Object.ReferenceEquals, so it concludes it is different every render.

Expected Behavior

The component should see EventCallbacks as equal when the delegate and Target are the same. MulticastDelegate has the Equals method overridden, so use that instead of ReferenceEquals.

Steps To Reproduce

I will make a PR with a fix

Exceptions (if any)

No response

.NET Version

8.0.101

Anything else?

No response

SteveSandersonMS commented 10 months ago

We noticed after upgrading our blazor application to .net 8.0

Are you saying the behavior changed in .NET 8 compared with some earlier framework? If so, can you show some sample code that works differently in (say) .NET 7 compared with .NET 8?

The component should see EventCallbacks as different when the value is the same.

Is that a typo? Do you mean should not instead of should?

JelleHissink commented 10 months ago

@SteveSandersonMS good catch, that was a typo. I fixed the equals.

The previous EventCallback.Equals fell back to the default Equals implementation. It is now implemented in .Net 8. So up until .net 7 it used the MemberwiseEquals because it is a struct.

SteveSandersonMS commented 10 months ago

Can you give an example of code where you would be using this? Normally we only expect EventCallback instances to be compared internally by Blazor's renderer, which is what the new implementation is optimized around.

If we change it to use .Equals then it will only make a different to the renderer if the delegate is uncapturing. AFAIK for capturing delegates the Target will always be different on successive renders. It's possible that delegates could be noncapturing often enough to make this worthwhile but it does require some consideration.

JelleHissink commented 10 months ago

A loading wrapper that does some error handling and progress that has an OnLoad and then a Render. It goes something like this: ( I omitted a lot of code for error handling, retry in case of failure etc)

@if (_loading) {
    <DisplayLoadingIndicator />
} else {
    @ChildContent
}
@code {
    [Parameter, EditorRequired] public required EventCallback OnLoad { get; set; }
    [Parameter, EditorRequired] public required RenderFragment ChildContent { get; set; }

    private bool _loaded = false;
    private  EventCallback _previousOnLoad;

    protected override async Task OnParametersSetAsync()
    {
        await base.OnParametersSetAsync();
        if (!_previousOnLoad.Equals(OnLoad))
        {
            _previousOnLoad = OnLoad;
            await Refresh();
        }
    }

    public async Task Refresh()
    {
        await OnLoad.InvokeAsync();
        StateHasChanged();
        _loaded = true;
    }
}

When a component uses this, OnLoad is always different by the razor generator even though the delegate.Equals() would return true. We see this component being rendered over and over. If the parent was rerendered, OnParametersSetAsync() was called every time, and thus we do something. But in effect the parameter was equal, Receiver, Delegate.Target, Delegate.Method are all equal. For us there is no way to determine if the callback has changed (eg. if the parent component is changing).

JelleHissink commented 10 months ago

Hmm... I see your point in uncaptered events, we only directly point to a method, so for us the Target of the delegate would be equal. We are not doing Onload="@(() => LoadData())" but OnLoad=LoadData.

ghost commented 10 months ago

Thanks for contacting us.

We're moving this issue to the .NET 9 Planning milestone for future evaluation / consideration. We would like to keep this around to collect more feedback, which can help us with prioritizing this work. We will re-evaluate this issue, during our next planning meeting(s). If we later determine, that the issue has no community involvement, or it's very rare and low-impact issue, we will close it - so that the team can focus on more important and high impact issues. To learn more about what to expect next and how this issue will be handled you can read more about our triage process here.

javiercn commented 10 months ago

Discussed this with @SteveSandersonMS and we are planning to make a change in 9.0 for this to use Equals instead of ReferenceEquals

SteveSandersonMS commented 10 months ago

The PR at https://github.com/dotnet/aspnetcore/pull/53362 looks good for that

JelleHissink commented 10 months ago

Thanks, I'll make a story for us to implement this again when .net 9 comes along ;-)

JelleHissink commented 10 months ago

I just managed to mess the PR up, I have created a new PR to main #53395