11

So I've been playing around with Blazor WebAssembly and I can't figure out how to solve this problem. Basically, I have a NavMenu.razor page that will get an array of NavMenuItem asynchronously from a JSON file. That works fine. Now, what I'm trying to do is add an event listener to each of the anchors that are generated from the <NavLink> tags in the below foreach loop.

The NavLink outside of that loop (the one not populated by the async function) successfully has the addSmoothScrollingToNavLinks() function applied to it correctly. The other NavLinks do not. It seems as if they are not yet in the DOM.

I'm guessing there's some weird race condition, but I don't know how to resolve it. Any ideas on how I might fix this?

NavMenu.razor

@inject HttpClient Http
@inject IJSRuntime jsRuntime

@if (navMenuItems == null)
{
    <div></div>
}
else
{
    @foreach (var navMenuItem in navMenuItems)
    {
        <NavLink class="nav-link" href="@navMenuItem.URL" Match="NavLinkMatch.All">
            <div class="d-flex flex-column align-items-center">
                <div>
                    <i class="@navMenuItem.CssClass fa-2x"></i>
                </div>
                <div class="mt-1">
                    <span>@navMenuItem.Text</span>
                </div>
            </div>
        </NavLink>
    }
}

<NavLink class="nav-link" href="#" Match="NavLinkMatch.All">
    <div class="d-flex flex-column align-items-center">
        This works!
    </div>
</NavLink>

@code {
    private NavMenuItem[] navMenuItems;

    protected override async Task OnInitializedAsync()
    {
        navMenuItems = await Http.GetJsonAsync<NavMenuItem[]>("sample-data/navmenuitems.json");
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            await jsRuntime.InvokeVoidAsync("MyFunctions.addSmoothScrollingToNavLinks");
        }
    }

    public class NavMenuItem
    {
        public string Text { get; set; }

        public string URL { get; set; }

        public string CssClass { get; set; }
    }
}

index.html (underneath the webassembly.js script tag)

 <script>
        function scrollToTop() {
            window.scroll({
                behavior: 'smooth',
                left: 0,
                top: 0
            });
        }

        window.MyFunctions = {
            addSmoothScrollingToNavLinks: function () {
                let links = document.querySelectorAll("a.nav-link");
                console.log(links);
                // links only has 1 item in it here. The one not generated from the async method
                for (const link of links) {
                    link.addEventListener('click', function (event) {
                        event.preventDefault();
                        scrollToTop();
                    })
                }
            }
        }
    </script>

3 Answers 3

13

That's because Blazor will NOT wait for the OnInitializedAsync() to complete and will start rendering the view once the OnInitializedAsync has started. See source code on GitHub:

private async Task RunInitAndSetParametersAsync()
{
    OnInitialized();
    var task = OnInitializedAsync();       // NO await here!

    if (task.Status != TaskStatus.RanToCompletion && task.Status != TaskStatus.Canceled)
    {
        // Call state has changed here so that we render after the sync part of OnInitAsync has run
        // and wait for it to finish before we continue. If no async work has been done yet, we want
        // to defer calling StateHasChanged up until the first bit of async code happens or until
        // the end. Additionally, we want to avoid calling StateHasChanged if no
        // async work is to be performed.
        StateHasChanged();                  // Notify here! (happens before await)

        try
        {
            await task;                     // await the OnInitializedAsync to complete!
        }
        ...

As you see, the StateHasChanged() might start before OnInitializedAsync() is completed. Since you send a HTTP request within the OnInitializedAsync() method, , the component renders before you get the response from the sample-data/navmenuitems.json endpoint.

How to fix

You could bind event in Blazor(instead of js), and trigger the handlers written by JavaScript. For example, if you're using ASP.NET Core 3.1.x (won't work for 3.0, see PR#14509):

@foreach (var navMenuItem in navMenuItems)
{
<a class="nav-link" href="@navMenuItem.URL" @onclick="OnClickNavLink" @onclick:preventDefault>
    <div class="d-flex flex-column align-items-center">
        <div>
            <i class="@navMenuItem.CssClass fa-2x"></i>
        </div>
        <div class="mt-1">
            <span>@navMenuItem.Text</span>
        </div>
    </div>
</a>
}

@code{
    ...


    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        //if (firstRender)
        //{
        //    await jsRuntime.InvokeVoidAsync("MyFunctions.addSmoothScrollingToNavLinks");
        //}
    }
    private async Task OnClickNavLink(MouseEventArgs e)
    {
        Console.WriteLine("click link!");
        await jsRuntime.InvokeVoidAsync("MyFunctions.smoothScrolling");
    }

}

where the MyFunctions.addSmoothScrollingToNavLinks is:

smoothScrolling:function(){
    scrollToTop();
}
2
  • 1
    This is certainly in the right direction. Thanks! The only problem is now I lose the functionality of NavLink and the Match attribute functionality if I do it this way. Also, the links stop working.
    – KSib
    Commented Dec 13, 2019 at 13:57
  • 2
    I ended up getting the CSS smooth scrolling to work by applying scroll-behavior: smooth to html and removing the onclick events. This will likely help someone else though so I'll mark this as the answer. Appreciate it!
    – KSib
    Commented Dec 13, 2019 at 15:21
6

I am also trying to get this pattern right, call some async services in OnInitializedAsync and after that, calling some javascript on the OnAfterRenderAsync. The thing is that the javascript call is there to execute some js that depends on the data returnes by the services (the data must be there to the desired behavior take effect).

What is happening is that the js call is running before the data arrive, is there a way to implement this "dependency" between data from OnInitializedAsync and OnAfterRenderAsync?

6

Not perfect but works for me, lets you wait for the download

private bool load = false;
protected override async Task OnInitializedAsync()
{
    navMenuItems = await Http.GetJsonAsync<NavMenuItem[]>("sample-data/navmenuitems.json");
    load = true;
}

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (load)
    {
        await jsRuntime.InvokeVoidAsync("MyFunctions.addSmoothScrollingToNavLinks");
        load = false;
    }
}

Not the answer you're looking for? Browse other questions tagged or ask your own question.