blazorblazor-server-side

How can I determine when all child components have completed OnInitializedAsync()?


I have a dashboard page with 4 - 7 child components (some are not included via @if(...){ ... } if the user is not an admin).

I want to put up a single loading... circle over the entire page while all the child components are loading. The main page has 3 lines of code in its OnInitializedAsync so that is done and returned before the children are even started.

I can give each child an event in the parent to call when they complete OnInitializedAsync and when I receive that event the 4 - 7 times, then hide the loading overlay. But I'm not wild on this approach as it requires I get the count of children execatly right for every combination of user rights on every page. In other words, I'll make a mistake somewhere.

I would also like to use the for my bUnit tests to WaitFor() until the page is fully rendered. But this is a nice to have as in the unit tests I can just wait for the bottom-most element to render.


Solution

  • You need to design for this to happen. This functionality isn't built in.

    The following code uses the registration pattern used in components such a QuickGrid where the wrapper component cascades a registration Action. In this example the sub-components register a Task which the wrapper component can wait on.

    The Overlay component wrapper component for all the components you want to wait on.

    <div class="@_css">
        <div class="container text-center">
            <div class="alert alert-warning m-5 p-5">
                We are experiencing very high call volumes today [nothing out if the norm now].
                You are at call position 999.  Your business, not you, is important to us!
            </div>
        </div>
    </div>
    @if (this.ChildContent is not null)
    {
        <CascadingValue Value="Register">
            @this.ChildContent
        </CascadingValue>
    }
    @code {
        private string _css => _isLoading ? "loading" : "loaded";
        private readonly List<Task> _loadingTasks = new();
    
        private bool _isLoading { get; set; }
        [Parameter] public RenderFragment? ChildContent { get; set; }
    
        protected override async Task OnInitializedAsync()
        {
            _isLoading = true;
    
            // Yield so that subcomponents can be first rendered
            await Task.Yield();
    
            //At this point anyone that wants to will have registered a loading task which will be running
            // So we can await the completion of all of them before we complete and drop the loading banner
            await Task.WhenAll(_loadingTasks);
    
            // Clean up
            _loadingTasks.Clear();
            _isLoading = false;
        }
    
        public void Register(Task loadingTask)
        {
            _loadingTasks.Add(loadingTask);
        }
    }
    

    And Css. I've set the overlay opacity to 50% so you can see what's going on in the background.

    div.loading {
        display: block;
        position: fixed;
        z-index: 101; /* Sit on top */
        left: 0;
        top: 0;
        width: 100%; /* Full width */
        height: 100%; /* Full height */
        overflow: auto; /* Enable scroll if needed */
        background-color: rgba(0,0,0,0.5); /* Black w/ opacity */
    }
    
    div.loaded {
        display: none;
    }
    

    To make this work you need to add some functionality to the sub components. I've added that to a new base component. This overrides SetParametersAsync. It creates a TaskCompletionSource, picks up cascaded method and registers it's associated Task before starting the lifecycle process. It sets the TaskCompletionSource to completed when the lifecycle completes.

    public class LoadingComponentBase : ComponentBase
    {
        [CascadingParameter] private Action<Task>? Registration { get; set; }
    
        private TaskCompletionSource? _registrationTaskCompletionSource;
        private bool _firstRenderCycle = true;
    
        public async override Task SetParametersAsync(ParameterView parameters)
        {
            parameters.SetParameterProperties(this);
            
            if (Registration is not null && _firstRenderCycle)
            {
                _registrationTaskCompletionSource = new();
                Registration(_registrationTaskCompletionSource.Task);
            }
    
            await base.SetParametersAsync(ParameterView.Empty);
    
            if (_firstRenderCycle && _registrationTaskCompletionSource is not null)
            {
                _registrationTaskCompletionSource.SetResult();
                _firstRenderCycle = false;
                _registrationTaskCompletionSource = null;
            }
        }
    }
    

    Here's a test sub-component.

    @inherits LoadingComponentBase
    <h3>TestComponent</h3>
    @if(_loading)
    {
        <div class="alert alert-danger"> Loading </div>
    }
    else
    {
        <div class="alert alert-success"> Loaded </div>
    }
    @code {
        [Parameter] public int Delay { get; set; } = 1;
    
        private bool _loading;
        protected override async Task OnInitializedAsync()
        {
            _loading = true;
            await Task.Delay(Delay);
            _loading = false;
        }
    }
    

    And finally a demo page:

    @page "/counter"
    
    <PageTitle>Counter</PageTitle>
    
    <h1>Counter</h1>
    
    <p role="status">Current count: @currentCount</p>
    
    <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
    
    <LoadingOverlay>
    
        <TestComponent Delay="1000" />
    
        <TestComponent Delay="5000" />
    
        <TestComponent Delay="500" />
    
    </LoadingOverlay>
    
    @code {
        private int currentCount = 0;
    
        private void IncrementCount()
        {
            currentCount++;
        }
    }
    

    I think I've got all the logic right, but if anyone spots a problem, please comment.

    enter image description here

    Additional answers to questions in the comments.

    Question 1.

    Show me the MS document that says Task.Yield() will change? If it concerns you, use Task.Delay(1); The purpose of the Delay/Yield is to let all the subcomponents run an initial render, and thus register.

    Task.Yield creates a continuation of the code following the call that is "posted" to the end of the execution queue. That's all we need as it's beyond the Renderer servicing it's queue. It creates and calls SetParametersAsync on each sub-component: which registers them.

    Why not? The code is not JSInterop, mutates the state of the component (requiring another render), and may not happen early enough. See my previous comments on OnAfterRender being a UI Event, not Lifecycle code.

    Question 2

    You want to register before any render event occurs. Consider the following code from a component that inherits from LoadingComponentBase.

    You may well only register after everything else has completed.

        protected async override Task OnInitilizedAsync()
        {
            await GetSomeVerySlowDataAsync()
    
            await base.OnIntializedAsync();
        }