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.38k stars 10k forks source link

`ElementReference` is sometimes null when performing interop in `OnAfterRenderAsync` #56892

Closed a-scollin closed 3 months ago

a-scollin commented 3 months ago

Is there an existing issue for this?

Describe the bug

Throughout Blazor examples in the docs (https://learn.microsoft.com/en-us/aspnet/core/blazor/javascript-interoperability/call-javascript-from-dotnet?view=aspnetcore-6.0#reference-elements-across-components) and in the Microsoft.FluentUI.AspNetCore.Components library there's this pattern of performing Js interop in OnAfterRender/OnAfterRenderAsync when writing components that pass an ElementReference to Js. In the docs it states :

The instance is only guaranteed to exist after the component is rendered, which is during or after a component's OnAfterRender/OnAfterRenderAsync method executes.

Below is an example where we've found this is sometimes not true -- it appears to happen when rendering/disposing a component that uses this pattern very quickly. This isn't a common use case although in more complex components that use this pattern we've noticed this can sometimes happen in common user flows.

Expected Behavior

We wonder if this is expected behavior and what pattern should we look to employ instead?

Reading the docs it seems like "id"-ing elements in combination with a mutation observer could solve this although it's not very elegant.

Steps To Reproduce

TestComponent.razor

@implements IAsyncDisposable

<div @ref="_eleRef">
    Hello World
</div>

@code {

    private ElementReference? _eleRef;

    [Inject]
    private IJSRuntime JS { get; set; } = default!;

    protected override void OnInitialized()
    {
        Console.WriteLine("Initialized");
        base.OnInitialized();
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        await base.OnAfterRenderAsync(firstRender);

        if (firstRender)
        {
            if (_eleRef is null || _eleRef.Equals(default(ElementReference)))
                throw new InvalidOperationException("Unset element reference!");

            var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/SomeInterop/SomeInterop.js");

            await module.InvokeVoidAsync("doThing", _eleRef);
        }
    }

    public async ValueTask DisposeAsync()
    {
        var module = JS.ImportModule("./_content/SomeInterop/SomeInterop.js");
        await module.InvokeVoidAsync("doOtherThing", _eleRef);
    }
}

It seems to happen after interop-ing to Js as the ElementReference is always set as expected in Blazor.

SomeInterop.js

export function doThing(eleRef) {
    if (eleRef == null) {
        console.error("Element is null on after render!");
    } else {
        console.log("Element is set on after render!");
    }
}

export function doOtherThing(eleRef) {
    if (eleRef == null) {
        console.log("Element is null on dispose!");
    } else {
        console.log("Element is set on dispose!");
    }
}

When rapidly toggling the rendering of the TestComponent we sometimes observe the element is null once the Js fires (happens when tabbed into button and holding down Enter)

<button @onclick="ClickHandler">Click me!</button>

@if (_render)
{
    <TestComponent/>
}

@code {

    bool _render = false;

    void ClickHandler(MouseEventArgs _)
    {
        _render = !_render;
    }
}

image

Exceptions (if any)

No response

.NET Version

8.0.11

Anything else?

a-scollin commented 3 months ago

I've written a minimal repo project here https://github.com/a-scollin/dotnet-56892/

Note this is targeting net7 and it's also noticeable. Testing on Chrome and Firefox whilst debugging one can see many console errors when firing the mouse click multiple times in a row in quick succession. This can be triggered by holding the enter key in or more easily reproducible by running something like for(let i = 0; i < 100; i++) { $0.click() } in the console

javiercn commented 3 months ago

@a-scollin thanks for the additional details.

The first question that we have is, does it reproduce if you upgrade to 8.0? .NET 7.0 is out of support and per our support policy we don't investigate issues unless they are targeting a supported framework version.

a-scollin commented 3 months ago

Hi @javiercn thanks for the quick response. I've bumped the project to target .NET 8.0 and can confirm it still happens.

mkArtakMSFT commented 3 months ago

Thanks for contacting us. The behavior you're observing is by-design. You're trying to access the DOM elements during component disposal, at which point there is no guarantee that those haven't already been removed from the DOM. We recommend updating your code (JS) so that it takes into consideration this risk. Maybe handling any required changes through JSInterop should happen at a different stage - before the component is being disposed.

albintornqvist commented 1 month ago

Thanks for contacting us. The behavior you're observing is by-design. You're trying to access the DOM elements during component disposal, at which point there is no guarantee that those haven't already been removed from the DOM. We recommend updating your code (JS) so that it takes into consideration this risk. Maybe handling any required changes through JSInterop should happen at a different stage - before the component is being disposed.

I am having the same problem. @mkArtakMSFT are you sure that you understood the issue properly? The problem is not that the ElementReference is null on disposal (in DisposeAsync) but that it is null in OnAfterRenderAsync when the component should be fully rendered and active. (Maybe I am the one missing something here?)