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:
- 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. ItsStateHasChangedtriggers a render in which Blazorise'sComponentActivatorresolves 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. - Subscription leak in interactive sessions: because the handlers are anonymous lambdas and never removed, every
ContentToolbarinstance stays referenced by the scopedPageLayoutfor 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.<<OnInitializedAsync>b__7_1>d.MoveNext()
at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__124_1(Object state)
Steps to reproduce the issue
- Create a Blazor WebApp solution (non-tiered, LeptonX) on ABP 10.4.1 / LeptonX 5.4.1 — prerendering is enabled by default on the
InteractiveAutorender mode. - Add pages that use the application layout and set
PageLayouttoolbar items / title (e.g.PageToolbaritems added fromOnAfterRenderAsync, as in standard CRUD pages). - Navigate to such a page via full document loads repeatedly (each load runs a prerender pass).
- Intermittently (it is a race between the event continuation and request-scope disposal), the
ObjectDisposedExceptionabove 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+InteractiveAutorender 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):
- Skip the event subscriptions entirely when
RendererInfo.IsInteractive == false— a static prerender produces a single snapshot, so change events can never affect its output. - 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;
}
}
}
3 Answer(s)
-
0
Your analysis is correct for LeptonX 5.4.1:
ContentToolbarsubscribes toPageLayoutchange events inOnInitializedAsyncusing anonymousasynclambdas and does not unsubscribe. Under Blazor Web App prerendering, those event continuations can run after the request DI scope is disposed, causing intermittentObjectDisposedException. In interactive sessions, it also creates a subscription leak (component instances kept alive by the scopedPageLayout).The safest/cleanest workaround today is exactly what you implemented: replace
ContentToolbarwith your own component that:- does not subscribe during prerender (
RendererInfo.IsInteractive == false), and - implements
IDisposableand unsubscribes using stored delegate fields (not anonymous lambdas).
- does not subscribe during prerender (
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
PageLayoutevents (your stack trace also shows similar risk for components likeBreadcrumbs).If you want the “official” supported customization route: ABP documents replacing
ContentToolbarvia 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) -
0
Hi,
The code matches your quote exactly in LeptonX Blazorise 5.4.0 / 5.4.1 / current dev — anonymous lambdas in
ContentToolbar.OnInitializedAsync, noIDisposable. Your stack trace lines up —<<OnInitializedAsync>b__7_1>d.MoveNext()is thePropertyChangedlambda. The MudBlazor LeptonXContentToolbaris already structured differently (implementsIDisposable, named handlers, swallowsObjectDisposedExceptionaround 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 yourSafeContentToolbaris on the right pattern.Breadcrumbsin the same folder has the same bug — anonymous lambdas inOnInitializedAsync, noIDisposable. Two more LeptonX Blazorise menu components (ApplicationLayout/TopMenu/Navigation/MainMenuItemandPublicWebsiteLayout/Navigation/MainMenuItem) have a related leak: they wirePageLayout.PropertyChangedfromOnParametersSet(so the handler re-adds on every render) and only unsubscribeNavigationManager.LocationChangedinDispose. 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
Breadcrumbsoverride next to yourSafeContentToolbarand let us know whether the prerenderObjectDisposedExceptionand 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+SafeBreadcrumbsfor 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 twoMainMenuItemfiles 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) -
0
Hi,
The code matches your quote exactly in LeptonX Blazorise 5.4.0 / 5.4.1 / current dev — anonymous lambdas in
ContentToolbar.OnInitializedAsync, noIDisposable. Your stack trace lines up —<<OnInitializedAsync>b__7_1>d.MoveNext()is thePropertyChangedlambda. The MudBlazor LeptonXContentToolbaris already structured differently (implementsIDisposable, named handlers, swallowsObjectDisposedExceptionaround 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 yourSafeContentToolbaris on the right pattern.Breadcrumbsin the same folder has the same bug — anonymous lambdas inOnInitializedAsync, noIDisposable. Two more LeptonX Blazorise menu components (ApplicationLayout/TopMenu/Navigation/MainMenuItemandPublicWebsiteLayout/Navigation/MainMenuItem) have a related leak: they wirePageLayout.PropertyChangedfromOnParametersSet(so the handler re-adds on every render) and only unsubscribeNavigationManager.LocationChangedinDispose. 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
Breadcrumbsoverride next to yourSafeContentToolbarand let us know whether the prerenderObjectDisposedExceptionand 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+SafeBreadcrumbsfor 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 twoMainMenuItemfiles 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)