Open Closed

LeptonX Blazor ContentToolbar: ObjectDisposedException under Blazor Web App prerendering (event subscriptions never unsubscribed) #10730


User avatar
0
rcalv002 created

Summary

ContentToolbar in Volo.Abp.AspNetCore.Components.Web.LeptonXTheme (LeptonX 5.4.1) subscribes two async lambdas to the scoped PageLayout service's events in OnInitializedAsync and never unsubscribes (the component does not implement IDisposable):

// Components/ApplicationLayout/Common/ContentToolbar.razor.cs
protected override Task OnInitializedAsync()
{
    PageLayout.ToolbarItems.CollectionChanged += async (s, e) => await RenderAsync();
    PageLayout.PropertyChanged += async (s, e) => await InvokeAsync(StateHasChanged);
    return base.OnInitializedAsync();
}

This causes two problems:

  1. Crash under Blazor Web App prerendering: during the static prerender pass, the component subscribes to the request-scoped PageLayout. The request's DI scope is disposed when the response completes, but a pending event-handler continuation can still execute afterwards. Its StateHasChanged triggers a render in which Blazorise's ComponentActivator resolves toolbar item component types from the now-disposed Autofac lifetime scope → ObjectDisposedException (stack trace below). Component disposal is not ordered before scope disposal, so this is a race that fires intermittently on any prerendered page using the application layout.
  2. Subscription leak in interactive sessions: because the handlers are anonymous lambdas and never removed, every ContentToolbar instance stays referenced by the scoped PageLayout for the lifetime of the circuit/scope.

Exception message and full stack trace

System.ObjectDisposedException
  Message=Instances cannot be resolved and nested lifetimes cannot be created from this LifetimeScope as it (or one of its parent scopes) has already been disposed.
  Source=Autofac
   at Autofac.Core.Lifetime.LifetimeScope.ThrowDisposedException()
   at Autofac.Extensions.DependencyInjection.AutofacServiceProvider.GetService(Type serviceType)
   at Blazorise.ComponentActivator.CreateInstance(Type componentType)
   at Microsoft.AspNetCore.Components.ComponentFactory.InstantiateComponent(...)
   at Microsoft.AspNetCore.Components.RenderTree.Renderer.InstantiateChildComponentOnFrame(...)
   at Microsoft.AspNetCore.Components.RenderTree.RenderTreeDiffBuilder.InitializeNewComponentFrame(...)
   ...
   at Microsoft.AspNetCore.Components.ComponentBase.StateHasChanged()
   at Microsoft.AspNetCore.Components.Rendering.RendererSynchronizationContext.<InvokeAsync>g__Execute|8_0(ValueTuple`3 state)
--- End of stack trace from previous location ---
   at Volo.Abp.AspNetCore.Components.Web.LeptonXTheme.Components.ApplicationLayout.Common.ContentToolbar.&lt;&lt;OnInitializedAsync&gt;b__7_1>d.MoveNext()
   at System.Threading.Tasks.Task.&lt;&gt;c.&lt;ThrowAsync&gt;b__124_1(Object state)

Steps to reproduce the issue

  1. Create a Blazor WebApp solution (non-tiered, LeptonX) on ABP 10.4.1 / LeptonX 5.4.1 — prerendering is enabled by default on the InteractiveAuto render mode.
  2. Add pages that use the application layout and set PageLayout toolbar items / title (e.g. PageToolbar items added from OnAfterRenderAsync, as in standard CRUD pages).
  3. Navigate to such a page via full document loads repeatedly (each load runs a prerender pass).
  4. Intermittently (it is a race between the event continuation and request-scope disposal), the ObjectDisposedException above is thrown from the prerender pass. With a debugger attached it breaks every time it occurs; without one it surfaces as unobserved-task exceptions / log noise on every unlucky prerender.

Environment

  • ABP Commercial 10.4.1, LeptonX 5.4.1 (Volo.Abp.AspNetCore.Components.Server.LeptonXTheme + Volo.Abp.AspNetCore.Components.WebAssembly.LeptonXTheme)
  • .NET 10 (10.0.9 shared framework), Blazor Web App hosting model, InteractiveServer + InteractiveAuto render modes with prerendering
  • Autofac (AbpAutofacModule), Blazorise 2.0.4

Suggested fix

In ContentToolbar (and any other LeptonX components subscribing to scoped services from lifecycle methods):

  1. Skip the event subscriptions entirely when RendererInfo.IsInteractive == false — a static prerender produces a single snapshot, so change events can never affect its output.
  2. Implement IDisposable, store the handlers in fields, and unsubscribe on dispose (fixes the leak for interactive sessions).

We have verified this exact change resolves the crash in our application by replacing the component via [ExposeServices(typeof(ContentToolbar))] / [Dependency(ReplaceServices = true)]. Note that a "disposed" flag alone is not sufficient — component disposal is not guaranteed to happen before the DI scope is disposed under prerendering, so the not-yet-subscribing gate (or catching ObjectDisposedException) is required.

Sample workaround class


using System;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Threading.Tasks;

using Volo.Abp.AspNetCore.Components.Web.LeptonXTheme.Components.ApplicationLayout.Common;
using Volo.Abp.DependencyInjection;

namespace Cns.Cloud.Apps.Blazor.Client;

// Replaces LeptonX's ContentToolbar (5.4.1): the stock component subscribes async lambdas to the
// scoped PageLayout's events in OnInitializedAsync and never unsubscribes. Under Blazor Web App
// prerendering (the Interactive Auto island), a continuation can fire after the request's DI scope
// is disposed, and the resulting StateHasChanged render throws ObjectDisposedException out of
// Autofac when Blazorise's activator tries to resolve toolbar item components.
[ExposeServices(typeof(ContentToolbar))]
[Dependency(ReplaceServices = true)]
public class SafeContentToolbar : ContentToolbar, IDisposable
{
    private bool _disposed;
    private NotifyCollectionChangedEventHandler? _collectionChanged;
    private PropertyChangedEventHandler? _propertyChanged;

    protected override Task OnInitializedAsync()
    {
        // Deliberately does NOT call base.OnInitializedAsync(): the base body subscribes the
        // unguarded, never-removed lambdas this class exists to replace.
        //
        // Static prerender (RendererInfo.IsInteractive == false) produces a single HTML snapshot:
        // change events can never affect the output, but their continuations CAN outlive the
        // request's DI scope and throw ObjectDisposedException (component disposal is not ordered
        // before scope disposal, so a _disposed guard alone is insufficient). Don't subscribe.
        if (!RendererInfo.IsInteractive)
        {
            return Task.CompletedTask;
        }

        _collectionChanged = async (_, _) =>
        {
            if (!_disposed)
            {
                await RenderAsync();
            }
        };
        _propertyChanged = async (_, _) =>
        {
            if (!_disposed)
            {
                await InvokeAsync(StateHasChanged);
            }
        };

        PageLayout.ToolbarItems.CollectionChanged += _collectionChanged;
        PageLayout.PropertyChanged += _propertyChanged;
        return Task.CompletedTask;
    }

    protected override async Task RenderAsync()
    {
        if (_disposed)
        {
            return;
        }

        await base.RenderAsync();
    }

    public void Dispose()
    {
        _disposed = true;

        if (_collectionChanged is not null)
        {
            PageLayout.ToolbarItems.CollectionChanged -= _collectionChanged;
        }

        if (_propertyChanged is not null)
        {
            PageLayout.PropertyChanged -= _propertyChanged;
        }
    }
}

Markdown supported.
Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)

3 Answer(s)
  • User Avatar
    0
    AI-Bot created
    Support Team ABP Agent
    • Your analysis is correct for LeptonX 5.4.1: ContentToolbar subscribes to PageLayout change events in OnInitializedAsync using anonymous async lambdas and does not unsubscribe. Under Blazor Web App prerendering, those event continuations can run after the request DI scope is disposed, causing intermittent ObjectDisposedException. In interactive sessions, it also creates a subscription leak (component instances kept alive by the scoped PageLayout).

    • The safest/cleanest workaround today is exactly what you implemented: replace ContentToolbar with your own component that:

      1. does not subscribe during prerender (RendererInfo.IsInteractive == false), and
      2. implements IDisposable and unsubscribes using stored delegate fields (not anonymous lambdas).
    • Recommended implementation (matches your workaround, shown here as a self-contained “drop-in” replacement pattern):

    using System;
    using System.Collections.Specialized;
    using System.ComponentModel;
    using System.Threading.Tasks;
    using Volo.Abp.AspNetCore.Components.Web.LeptonXTheme.Components.ApplicationLayout.Common;
    using Volo.Abp.DependencyInjection;
    
    [ExposeServices(typeof(ContentToolbar))]
    [Dependency(ReplaceServices = true)]
    public class SafeContentToolbar : ContentToolbar, IDisposable
    {
        private bool _disposed;
        private NotifyCollectionChangedEventHandler? _collectionChanged;
        private PropertyChangedEventHandler? _propertyChanged;
    
        protected override Task OnInitializedAsync()
        {
            // IMPORTANT: do not call base.OnInitializedAsync() since it subscribes anonymous handlers.
            if (!RendererInfo.IsInteractive)
            {
                // Prerender snapshot: no need to listen; avoids continuations after request-scope disposal.
                return Task.CompletedTask;
            }
    
            _collectionChanged = async (_, _) =>
            {
                if (!_disposed)
                {
                    await RenderAsync();
                }
            };
    
            _propertyChanged = async (_, _) =>
            {
                if (!_disposed)
                {
                    await InvokeAsync(StateHasChanged);
                }
            };
    
            PageLayout.ToolbarItems.CollectionChanged += _collectionChanged;
            PageLayout.PropertyChanged += _propertyChanged;
    
            return Task.CompletedTask;
        }
    
        public void Dispose()
        {
            _disposed = true;
    
            if (_collectionChanged is not null)
            {
                PageLayout.ToolbarItems.CollectionChanged -= _collectionChanged;
            }
    
            if (_propertyChanged is not null)
            {
                PageLayout.PropertyChanged -= _propertyChanged;
            }
        }
    }
    
    • Apply the same pattern to any other LeptonX layout components that subscribe to PageLayout events (your stack trace also shows similar risk for components like Breadcrumbs).

    • If you want the “official” supported customization route: ABP documents replacing ContentToolbar via DI replacement (the exact approach you used with [ExposeServices] + [Dependency(ReplaceServices = true)]).

    Sources (validated):

    • https://abp.io/docs/latest/ui-themes/lepton-x/blazor
    • https://abp.io/docs/10.4/framework/ui/blazor/page-layout

    This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    Hi,

    The code matches your quote exactly in LeptonX Blazorise 5.4.0 / 5.4.1 / current dev — anonymous lambdas in ContentToolbar.OnInitializedAsync, no IDisposable. Your stack trace lines up — <<OnInitializedAsync>b__7_1>d.MoveNext() is the PropertyChanged lambda. The MudBlazor LeptonX ContentToolbar is already structured differently (implements IDisposable, named handlers, swallows ObjectDisposedException around the render call), so the fix shape on the Blazorise side is the same direction.

    On reproduction: the race didn't fire in our local Blazor Web App + InteractiveAuto + Blazorise LeptonX setup. The code path is unambiguous regardless, and your SafeContentToolbar is on the right pattern.

    Breadcrumbs in the same folder has the same bug — anonymous lambdas in OnInitializedAsync, no IDisposable. Two more LeptonX Blazorise menu components (ApplicationLayout/TopMenu/Navigation/MainMenuItem and PublicWebsiteLayout/Navigation/MainMenuItem) have a related leak: they wire PageLayout.PropertyChanged from OnParametersSet (so the handler re-adds on every render) and only unsubscribe NavigationManager.LocationChanged in Dispose. Slow leak rather than the crash you saw, but we'll cover them in the framework fix.

    Before we land the framework change, could you try this Breadcrumbs override next to your SafeContentToolbar and let us know whether the prerender ObjectDisposedException and the related unobserved-task log noise stop showing up?

    using System;
    using System.Collections.Specialized;
    using System.ComponentModel;
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Components;
    using Volo.Abp.AspNetCore.Components.Web.LeptonXTheme.Components.ApplicationLayout.Common;
    using Volo.Abp.DependencyInjection;
    
    namespace MyCompanyName.MyProjectName;
    
    [ExposeServices(typeof(Breadcrumbs))]
    [Dependency(ReplaceServices = true)]
    public class SafeBreadcrumbs : Breadcrumbs, IDisposable
    {
        private bool _disposed;
        private NotifyCollectionChangedEventHandler? _collectionChanged;
        private PropertyChangedEventHandler? _propertyChanged;
    
        protected override Task OnInitializedAsync()
        {
            // Skip base — its body wires the same unguarded lambdas this class replaces.
            if (!RendererInfo.IsInteractive)
            {
                return Task.CompletedTask;
            }
    
            _collectionChanged = async (_, _) =>
            {
                if (!_disposed)
                {
                    await InvokeAsync(StateHasChanged);
                }
            };
            _propertyChanged = async (_, _) =>
            {
                if (!_disposed)
                {
                    await InvokeAsync(StateHasChanged);
                }
            };
    
            PageLayout.BreadcrumbItems.CollectionChanged += _collectionChanged;
            PageLayout.PropertyChanged += _propertyChanged;
            return Task.CompletedTask;
        }
    
        public void Dispose()
        {
            _disposed = true;
    
            if (_collectionChanged is not null)
            {
                PageLayout.BreadcrumbItems.CollectionChanged -= _collectionChanged;
            }
    
            if (_propertyChanged is not null)
            {
                PageLayout.PropertyChanged -= _propertyChanged;
            }
        }
    }
    

    If running with SafeContentToolbar + SafeBreadcrumbs for a few days confirms the crash and the log noise go away on your side, we'll roll the same pattern into the LeptonX Blazorise components in the next release and cover the two MainMenuItem files at the same time.

    Thanks

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    Hi,

    The code matches your quote exactly in LeptonX Blazorise 5.4.0 / 5.4.1 / current dev — anonymous lambdas in ContentToolbar.OnInitializedAsync, no IDisposable. Your stack trace lines up — <<OnInitializedAsync>b__7_1>d.MoveNext() is the PropertyChanged lambda. The MudBlazor LeptonX ContentToolbar is already structured differently (implements IDisposable, named handlers, swallows ObjectDisposedException around the render call), so the fix shape on the Blazorise side is the same direction.

    On reproduction: the race didn't fire in our local Blazor Web App + InteractiveAuto + Blazorise LeptonX setup. The code path is unambiguous regardless, and your SafeContentToolbar is on the right pattern.

    Breadcrumbs in the same folder has the same bug — anonymous lambdas in OnInitializedAsync, no IDisposable. Two more LeptonX Blazorise menu components (ApplicationLayout/TopMenu/Navigation/MainMenuItem and PublicWebsiteLayout/Navigation/MainMenuItem) have a related leak: they wire PageLayout.PropertyChanged from OnParametersSet (so the handler re-adds on every render) and only unsubscribe NavigationManager.LocationChanged in Dispose. Slow leak rather than the crash you saw, but we'll cover them in the framework fix.

    Before we land the framework change, could you try this Breadcrumbs override next to your SafeContentToolbar and let us know whether the prerender ObjectDisposedException and the related unobserved-task log noise stop showing up?

    using System;
    using System.Collections.Specialized;
    using System.ComponentModel;
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Components;
    using Volo.Abp.AspNetCore.Components.Web.LeptonXTheme.Components.ApplicationLayout.Common;
    using Volo.Abp.DependencyInjection;
    
    namespace MyCompanyName.MyProjectName;
    
    [ExposeServices(typeof(Breadcrumbs))]
    [Dependency(ReplaceServices = true)]
    public class SafeBreadcrumbs : Breadcrumbs, IDisposable
    {
        private bool _disposed;
        private NotifyCollectionChangedEventHandler? _collectionChanged;
        private PropertyChangedEventHandler? _propertyChanged;
    
        protected override Task OnInitializedAsync()
        {
            // Skip base — its body wires the same unguarded lambdas this class replaces.
            if (!RendererInfo.IsInteractive)
            {
                return Task.CompletedTask;
            }
    
            _collectionChanged = async (_, _) =>
            {
                if (!_disposed)
                {
                    await InvokeAsync(StateHasChanged);
                }
            };
            _propertyChanged = async (_, _) =>
            {
                if (!_disposed)
                {
                    await InvokeAsync(StateHasChanged);
                }
            };
    
            PageLayout.BreadcrumbItems.CollectionChanged += _collectionChanged;
            PageLayout.PropertyChanged += _propertyChanged;
            return Task.CompletedTask;
        }
    
        public void Dispose()
        {
            _disposed = true;
    
            if (_collectionChanged is not null)
            {
                PageLayout.BreadcrumbItems.CollectionChanged -= _collectionChanged;
            }
    
            if (_propertyChanged is not null)
            {
                PageLayout.PropertyChanged -= _propertyChanged;
            }
        }
    }
    

    If running with SafeContentToolbar + SafeBreadcrumbs for a few days confirms the crash and the log noise go away on your side, we'll roll the same pattern into the LeptonX Blazorise components in the next release and cover the two MainMenuItem files at the same time.

    Thanks

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
Boost Your Development
ABP Live Training
Packages
See Trainings
Mastering ABP Framework Book
The Official Guide
Mastering
ABP Framework
Learn More
Mastering ABP Framework Book
Made with ❤️ on ABP v10.8.0-preview. Updated on September 16, 2026, 14:50
1
ABP Assistant
🔐 You need to be logged in to use the chatbot. Please log in first.