Home > Net >  Blazor component removed from DOM before disposal causes js interop to fail
Blazor component removed from DOM before disposal causes js interop to fail

Time:12-28

My Blazor component has some associated JavaScript, which performs (async) animations.

MyComponent.razor

protected override async Task OnAfterRenderAsync(bool firstRender)
{
  if (someCondition & jsModule != null)
    await jsModule.InvokeVoidAsync("startAnimation", "#my-component");
}


public async ValueTask DisposeAsync()
{
  if (jsModule != null)
    await jsModule.InvokeVoidAsync("stopAnimationPrematurely", "#my-component");
}

MyComponent.razor.js

export function startAnimation(id) {
  let element = document.getElementById(id);
  element.addEventListener(
    'animationend',
    function() { element.classList.remove("cool-animation") },
    { once: true }
  );
  element.classList.add("cool-animation");
}


export function stopAnimationPrematurely(id) {
  let element = document.getElementById(id);      // fails here
  element.removeEventListener(
    'animationend',
    function() { element.classList.remove("cool-animation") },
    { once: true }
  );
  element.classList.remove("cool-animation");
}

As you can see, the animation cleans up after itself (via { once: true }).

However when the user clicks to a different page or component - and thus the blazor component is destroyed - there could be an animation in progress. If I don't remove the js event listener then I'll get a memory leak. So in DisposeAsync() I invoke js cleanup code which explicitly calls removeEventListener().

The problem is that by the time the js code runs, the component is already destroyed - so the DOM element is missing, the id is invalid, and thus the js cleanup fails (and throws).

This is very surprising. There is a race condition between the various lifecycle methods and disposal. In MudBlazor they also encountered this and introduced some really hard to understand (undocumented) locking as a workaround.

How can I deal with this without workarounds or hacks? (If that's not possible, please show a working solution, even using locking or whatever... a hacky solution is better than nothing.)

CodePudding user response:

Basically your problem is that the Renderer has rebuilt it's DOM and passed it to the Browser before it gets round to disposing the component (that is now no longer attached to the Render Tree) and running the code. The DOM element is history as you've found out.

So you need to make sure your Animation stopping code runs before the Render gets a chance to update the DOM.

Pre Net7.0 this would not have been possible. Net7.0 introduced a new feature to the NavigationManager. It now has an async method called OnLocationChanging which it calls before raising the LocationChanged event. It's primarily designed to prevent navigation away from a dirty Edit Form. You take the provided LocationChangingContext and call PreventNavigation to cancel the navigation event (No LocationChanged event gets raised).

You don't want to cancel the navigation event, just delay it till you've done the necessary housework. It's Task based, so NavigationManager waits till it completes before raising the LocationChanged event (the Router uses this event to trigger routing,....). I think we can use it to achieve your goal.

Here's some code that demonstrates the pattern I believe you can use.

@implements IDisposable
@page "/"

<PageTitle>Index</PageTitle>

<h1>Hello, world!</h1>

Welcome to your new app.

<SurveyPrompt Title="How is Blazor working for you?" />

@code {
    [Inject] public NavigationManager NavManager { get; set; } = default!;

    private IDisposable? NavDisposer;

    protected override void OnInitialized()
        => NavDisposer = NavManager.RegisterLocationChangingHandler(OnLocationChanging);

    private async ValueTask OnLocationChanging(LocationChangingContext context)
        => await this.SortTheAnimation();

    public async ValueTask SortTheAnimation()
    {
        // mock calling the js to do the cleanup and wait for the task to complete
        // Make the Navigation Manager wait
        await Task.Delay(3000);
    }

    public void Dispose()
        => this.NavDisposer?.Dispose();
}

Test it and let me know.

CodePudding user response:

Another approach: I also asked on the repo, and was told to use a MutationObserver.

Tradeoffs:

  • The accepted answer is a lighter option. But it's only appropriate when there are page URL changes - it won't work when one is just removing the component (no URL change in that case).
  • The MutationObserver feels heavier, but it would always work - it's not dependent on URL changes.
  • Related