Lumeo

Blazor WASM Lifecycle

Most Lumeo bugs that look like the library's fault — a component that flashes empty, a focus trap that fails to attach, a value that doesn't show until the user clicks something — are actually lifecycle mistakes in the consuming app. This page collects the rules: which lifecycle hook runs when, when the DOM is ready, when JS interop is safe, and how to keep StateHasChanged from either silently dropping updates or rendering forever.

The hooks, in order

Hook When Safe to do
SetParametersAsync Each render the parent triggers, before parameter assignment. Intercept parameter changes (rarely needed).
OnInitializedAsync Once per component instance, before the first render. Fetch initial data, subscribe to services. No DOM, no JS interop on WASM prerender.
OnParametersSetAsync After parameters are set, every render. React to parameter changes. Guard against re-running fetches needlessly.
OnAfterRenderAsync(firstRender) After every render, on the UI thread, with the DOM populated. JS interop, ElementReference access, focus management, third-party widget init.
Dispose / DisposeAsync When the component is removed from the render tree. Unsubscribe, dispose JS modules, release focus traps. Catch JSDisconnectedException.

OnInitializedAsync vs OnAfterRenderAsync

The dividing line is the DOM. Anything that touches an ElementReference, calls IJSRuntime with module interop, or reads layout (sizes, scroll positions) belongs in OnAfterRenderAsync. Everything else — fetching the data that the component will render — belongs in OnInitializedAsync.

@inject IDataService Data
@inject ComponentInteropService Interop

<div id="my-panel">
    @if (_items is null) { <Spinner /> }
    else { @* render items *@ }
</div>

@code {
    private List<Item>? _items;

    protected override async Task OnInitializedAsync()
    {
        // Data only. No DOM. No JS.
        _items = await Data.LoadAsync();
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;
        // DOM exists now — safe to set up interop. RegisterClickOutside takes
        // the element id, an optional trigger id, and an async handler.
        await Interop.RegisterClickOutside("my-panel", triggerElementId: null, OnClickedOutsideAsync);
    }

    private Task OnClickedOutsideAsync() { /* ... */ return Task.CompletedTask; }
}

Prerender quirks

If your WASM app is hosted in an ASP.NET Core endpoint with prerendering enabled, OnInitializedAsync runs twice: once on the server during prerender, and again in the browser after the WASM payload boots. JS interop in OnInitializedAsync fails on the server pass with InvalidOperationException: JavaScript interop calls cannot be issued at this time. Stick to data-only work there.

  • Use OperatingSystem.IsBrowser() to branch when needed.
  • Use PersistentComponentState to avoid double-fetching across prerender + WASM boot.
  • Anything DOM-bound (a sheet's focus trap, scroll position) won't run on the server pass anyway — it lives in OnAfterRenderAsync.
protected override async Task OnInitializedAsync()
{
    if (OperatingSystem.IsBrowser())
    {
        _items = await Data.LoadAsync();
    }
    // On the server prerender pass, leave _items null — we render a skeleton.
    // After WASM boots, this method runs again on the client and fetches.
}

StateHasChanged hygiene

Blazor calls StateHasChanged for you after every event handler and every awaited lifecycle method. You need to call it manually only when the change happens outside those flows: a service event you subscribe to, a System.Timers.Timer tick, a JS-to-.NET callback, a Task.Run.

  • Calls from non-UI threads must go through InvokeAsync(StateHasChanged).
  • Do not call StateHasChanged inside OnAfterRender unconditionally — it causes an infinite render loop.
  • If you must trigger a second render from OnAfterRender (e.g. after measuring the DOM), gate it on a bool _needsRemeasure flag and flip the flag.
// 1. Service event from background — non-UI thread.
protected override void OnInitialized()
{
    _hub.NotificationReceived += OnNotification;
}

private void OnNotification(Notification n)
{
    _notifications.Add(n);
    InvokeAsync(StateHasChanged);
}

// 2. Measure-then-render — guarded with a flag.
private bool _measured;
private double _height;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender && !_measured)
    {
        _height = await Interop.GetElementHeightAsync(_root);
        _measured = true;
        StateHasChanged(); // safe: guarded by _measured
    }
}

JS interop timing

Lumeo overlay components (Sheet, Dialog, Drawer, Popover, Tooltip) call into ComponentInteropService from OnAfterRenderAsync because they need the element to exist in the DOM. If you build your own interop wrappers, follow the same rule and remember to clean up on dispose.

private IJSObjectReference? _module;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        _module = await JS.InvokeAsync<IJSObjectReference>(
            "import", "./_content/MyLib/widget.js");
        await _module.InvokeVoidAsync("init", _hostRef);
    }
}

public async ValueTask DisposeAsync()
{
    try
    {
        if (_module is not null)
        {
            await _module.InvokeVoidAsync("dispose");
            await _module.DisposeAsync();
        }
    }
    catch (JSDisconnectedException) { /* circuit gone; nothing to clean up */ }
}

JSDisconnectedException is thrown when the circuit closes (page unload, browser tab close) before your dispose finishes. It is not a bug — swallow it in dispose paths so the disposal can complete.

Common symptoms and their causes

Symptom Likely cause
Data appears one render late Awaiting inside OnAfterRender without flipping a flag + calling StateHasChanged.
Component renders forever, CPU at 100 % Unconditional StateHasChanged in OnAfterRender.
JavaScript interop calls cannot be issued at this time JS call during prerender or before OnAfterRender.
JSDisconnectedException in logs Cleanup running after circuit close. Catch and ignore inside dispose.
UI doesn't update after a service event Missing InvokeAsync(StateHasChanged) from the event handler.
Sheet opens but focus trap doesn't attach Custom code setting Open in OnInitializedAsync before the DOM exists.

See also