Product / version: ABP Framework 10.7.0 (Volo.Abp.Core, Volo.Abp.AspNetCore). Also present on abpframework/abp dev at ec3203128f.
Severity: memory leak; blocks large integration-test suites (out-of-memory).
Volo.Abp.Internal.Telemetry.TelemetryService.AddActivityAsync subscribes two handlers on every call,
AppDomain.CurrentDomain.ProcessExit and Console.CancelKeyPress, and never removes them. Both
lambdas share one closure with the Task.Run lambda, which uses _serviceScopeFactory, so the closure
captures this. Each handler therefore roots:
static AppDomain.ProcessExit / Console.CancelKeyPress invocation list
-> TelemetryService+<>c__DisplayClass7_0
-> TelemetryService
-> AutofacServiceScopeFactory
-> root Autofac LifetimeScope (the whole container)
-> ComponentRegistry -> ... -> AbpApplicationWithExternalServiceProvider
AbpApplicationBase calls AddActivityAsync(ActivityNameConsts.ApplicationRun) once per application
at initialization. Shutdown() and Dispose() do not remove the handlers, so a disposed
application, its container and every singleton in it stay reachable until the process exits.
Telemetry runs only when IAbpHostEnvironment.IsDevelopment(). In a process with no web host (for example an
AbpIntegratedTest<T> test host) whose module graph includes AbpAspNetCoreModule,
AbpAspNetCoreModule.PreConfigureServices sets the environment from
services.GetHostingEnvironment(). When no IWebHostEnvironment is registered, that returns an
EmptyHostingEnvironment with EnvironmentName = Development. So every integration test runs in
"Development", sends telemetry, and leaks its application.
AbpIntegratedTest tests, run serially in one process.OutOfMemoryException while the Autofac container builds.AddActivityAsync / AddErrorActivityAsync call adds two more handlers that are never removed, so the ProcessExit invocation list grows for the life of the process.AbpAspNetCoreModule and AbpAutofacModule. Do not register IWebHostEnvironment, and do not set Abp:Telemetry:IsEnabled.AbpApplicationFactory.Create<TModule>(o => o.UseAutofac()), Initialize(), Shutdown(), Dispose(), then keep a WeakReference to the application.GC.Collect() and GC.WaitForPendingFinalizers(). Every WeakReference is still alive.GCRoot, or dotnet-gcdump) shows the path above. Setting Abp:Telemetry:IsEnabled=false makes the applications collectible.ProcessExit / CancelKeyPress or in OnApplicationShutdown. At the least, unsubscribe the handlers when telemetryTask completes.this. Today they share a closure with the Task.Run lambda.AbpApplicationBase.Shutdown() / Dispose().EmptyHostingEnvironment fallback as "Development" for telemetry, or not sending telemetry from test hosts (AbpIntegratedTest, AbpWebApplicationFactoryIntegratedTest).Test configuration sets Abp:Telemetry:IsEnabled=false in our AbpIntegratedTest base class (our module test bases already did).
LeptonX's FormSelect replaces every select.form-select with markup of its own
<div class="custom-select-wrapper" data-lpx-bound="true">
<div class="custom-select-display form-select" role="combobox" aria-labelledby="{select id}">…</div>
<select …> <!-- Blazor owns only this -->
</div>
The wrapper is created by JS and is not part of Blazor's render tree. Its observer only ever reacts to additions:
setupMutationObserver() {
observer = new MutationObserver(e => e.forEach(t => {
…
t.addedNodes.forEach(n => { … c.processSelect(a) }) // added only — no removedNodes branch
}))
observer.observe(document.body, { childList: true, subtree: true, … })
}
and there is no dispose/destroy/cleanup on the class (Object.getOwnPropertyNames(leptonx.FormSelect) has none).
So when Blazor removes a <select>, only the <select> goes. LeptonX's fully-rendered combobox stays behind — visible, clickable, with an aria-labelledby pointing at an id that no longer exists — and the next render binds the new select and builds another wrapper beside it.
Measured live: orphan wrappers climbed 4 → 5 → 6 → 7 while clicking between columns, and never dropped.
We run a non-tiered ABP Commercial Blazor Web App. The WASM/Auto island carries the commercial Pro admin surfaces (Account.Pro, Identity.Pro, Saas.Host, OpenIddict.Pro, AuditLogging, SettingManagement, FeatureManagement, LanguageManagement, TextTemplateManagement, Gdpr, plus the LeptonX WASM theme). We deploy as Linux containers (mcr.microsoft.com/dotnet/aspnet:10.0) via Docker Compose (Coolify) and GitHub Actions. Host is framework-dependent with PublishReadyToRun=true, SatelliteResourceLanguages=en; publish is dotnet publish -c Release -r linux-x64 --no-self-contained.
We want to reduce the WASM cold-start payload and improve production startup, and we need your official position on which .NET 10 publish optimizations are supported/recommended with ABP Commercial 10.4 before we change our Dockerfile/publish. We're aware trimming and single-file had problems with ABP in the past — we want to know what's changed for ABP 10 / .NET 10.
What we've already measured (untrimmed Release, brotli, our app):
Published _framework brotli total 14.3 MB (461 files); cold wire transfer 8.3 MB across 184 files, ~29.6 MB decoded. Full IL trimming reduces _framework brotli to 9.6 MB (−33%, 356 files) and the build succeeds and boots — but the trim-analysis pass (-p:SuppressTrimAnalysisWarnings=false) reports 918 IL2xxx warnings, ~490 of them inside ABP's reflection core: Castle.DynamicProxy (132), Autofac (95), Volo.Abp (273) — interceptors, dynamic HTTP client proxies, and reflection-based DI. The rest are app/3rd-party. TrimMode=partial is a no-op on Blazor WASM for us — measured identical size (9.6 MB) and identical 918 warnings to full trim. We must keep BlazorWebAssemblyLoadAllGlobalizationData=true (see Q8). Questions
A. IL Trimming (PublishTrimmed)
Is full IL trimming officially supported and tested for ABP Commercial 10.4 Blazor WASM, including the dynamic HTTP client proxies, Castle DynamicProxy interception, reflection-based DI, object mapping, and the Pro modules listed above? Does ABP ship (or plan to ship) trimmer-safety metadata for its reflection-heavy paths — [DynamicDependency], ILLink.Descriptors.xml, [DynamicallyAccessedMembers], [RequiresUnreferencedCode], or feature switches — so those ~490 Castle/Autofac/Volo.Abp warnings are annotated-safe rather than something each customer must validate and re-validate on every ABP bump? Our hard blocker is that trimming can silently drop un-rooted DTO JSON properties (no exception — just missing fields over the dynamic-proxy wire). What is the recommended pattern to guarantee Application.Contracts DTO round-trip safety under trimming — rooting Contracts via TrimmerRootAssembly, an ABP-provided descriptor, a System.Text.Json source-generator context, or something else? Since TrimMode=partial is a no-op on Blazor WASM, is there an ABP-sanctioned way to trim the .NET framework/runtime assemblies while leaving Volo.Abp.* and Castle.* whole, to capture the framework savings without the reflection risk surface? B. WASM AOT (RunAOTCompilation) 5. Is RunAOTCompilation=true supported/recommended for ABP Commercial 10.4 WASM? AOT requires trimming and disables runtime IL generation — does ABP's WASM interception/proxy path (Castle DynamicProxy uses Reflection.Emit) remain functional under AOT, or does ABP rely solely on static/source-generated client proxies there? What size and startup deltas have you measured on a representative commercial app?
C. Single-file & publish packaging 6. Is PublishSingleFile=true (and trimming the server host) supported for the ABP host, given the embedded resources / Virtual File System and the LeptonX bundling pipeline? Single-file historically broke embedded-resource/IFileProvider resolution — is that resolved on ABP 10 / .NET 10? 7. Our published _framework has 461 files (356 trimmed). Is WasmEnableWebcil (default) the recommended packaging, and is there a supported way to reduce WASM request count (assembly bundling) on ABP 10.4?
D. Globalization data 8. We keep BlazorWebAssemblyLoadAllGlobalizationData=true because ABP applies the user/tenant culture dynamically during WASM startup; with it false we get "Blazor detected a change in the application's culture that is not supported" on every load. Is there an ABP-supported way to do dynamic culture switching with a bounded culture set (predefined supported cultures / custom ICU shard) so we don't ship the full ICU dataset? We only need en and es-* (Latin America).
E. Lazy loading Pro module assemblies 9. Can the commercial Pro module assemblies (Identity.Pro, Saas.Host, AuditLogging, OpenIddict.Pro, LanguageManagement, TextTemplateManagement, Gdpr — admin-only surfaces) be BlazorWebAssemblyLazyLoad-ed so they're fetched on-demand per route instead of in the cold-boot closure, given the ABP module system discovers/initializes modules from loaded assemblies at startup? If not supported today, is route-based code-splitting on the roadmap?
F. Compressed static assets & serving (we run behind a reverse proxy) 10. Is ABP's host static-file + LeptonX bundling pipeline fully compatible with .NET 10 compressed static web assets (build-time .br/.gz + fingerprinted import maps / MapStaticAssets)? In a containerized deploy behind a reverse proxy, should Kestrel serve the precompressed _framework/*.br, or should we offload compression/caching to the proxy? Recommended cache-control/immutable headers for the fingerprinted WASM assets?
G. Server image / runtime 11. Beyond PublishReadyToRun=true, what server-side production knobs do you recommend for the ABP host on .NET 10 containers (host trimming, R2R composite, tiered compilation, runtime/GC settings)? InvariantGlobalization is off the table for us (multi-culture) — anything else ABP-specific that interferes?
What would close this out for us: a concrete, supported set of publish properties we can pin in the .csproj / Dockerfile, and — ideally — a support matrix marking each of the above as supported / works-with-caveats / not supported on ABP Commercial 10.4 + .NET 10, plus a link to an official trimmed/AOT ABP Commercial Blazor WASM reference if one exists.
Hi Mali,
Thanks for your suggestion. AbpScriptsRenderMode worked as suggestion and the exception is gone
we are on Blazor WebApp with Parameters.InteractiveAuto = true and the standard bundle wiring, exactly as your reference, we checked by creating new project in web app mode to check before we ported from server to wasm pages. The divergence is a single page that is intentionally WebAssembly-only (new InteractiveWebAssemblyRenderMode(prerender: false)) rather than Interactive Auto, and that's the page that hits the race.
Why this page is WASM-only (prerender:false), not Auto by design, not oversight.
This page must not do the Interactive Auto server-first → WASM transition:
.Blazor.Client project. An Interactive Auto page renders server-first on a cold cache, which would require those client-only services (one of them an internal type) to also be registered server-side — which we can't do without a .Blazor.Server → .Blazor.Client reference we deliberately don't have./_blazor circuit it should be pure WebAssembly, not a server circuit that later hands off to WASM.So InteractiveWebAssemblyRenderMode(prerender: false) is the correct, intended render mode for it. That is precisely the mode in which the abp/LeptonX bundle is not prerendered, so on this page <AbpScripts> (whose @rendermode follows the page's) emits the bundle only after WASM activation. Your template doesn't reproduce it only because the default has no genuinely WASM-only page; the moment a page is WASM-only, SideMenuLayout races the bundle.
(1) App.razor — <AbpScripts> wiring:
<AbpScripts BundleName="@BlazorLeptonXThemeBundles.Scripts.Global"
WebAssemblyScriptFiles="GlobalScripts"
@rendermode="PageRenderMode" />
@code {
// Per-route render mode. The affected page is WASM-only, so for it
// PageRenderMode resolves to InteractiveWebAssemblyRenderMode(prerender:false).
private IComponentRenderMode PageRenderMode =>
IsWasmOnlyRoute ? new InteractiveWebAssemblyRenderMode(prerender: false)
: IsAutoRoute ? InteractiveAuto
: InteractiveServer;
private List<string> GlobalScripts => ["global.js", "global-scripts.js"];
}
The routed page renders the same way: <ClientRoutes @rendermode="@(new InteractiveWebAssemblyRenderMode(prerender: false))" />.
(2) Configure<AbpBundlingOptions> note InteractiveAuto = true is present:
Configure<AbpBundlingOptions>(options =>
{
options.Parameters["LeptonXTheme.Layout"] = "side-menu";
options.Parameters.InteractiveAuto = true; // present
options.ScriptBundles.Configure(
BlazorLeptonXThemeBundles.Scripts.Global,
bundle => bundle.AddFiles("/global-scripts.js"));
options.StyleBundles.Configure(
BlazorLeptonXThemeBundles.Styles.Global,
bundle => bundle.AddFiles("/blazor-global-styles.css"));
// (MVC bundles omitted)
});
(3) Template: Blazor WebApp (non-tiered), LeptonX side-menu, OpenIddict in-host, .NET 10, ABP 10.4.1, LeptonX 5.4.1
A minimal repro isolating the framework layout.
To rule out our own code, we ran a controlled A/B: two trivial pages with identical content and the same InteractiveWebAssemblyRenderMode(prerender:false) only the layout differs:
SideMenuLayout → crashes on cold direct load (the JSException storm → fatal error bar).Same render mode, same bundle wiring; only the layout differs which isolates SideMenuLayout / LeptonXStyleProvider as the cause, independent of our app code.
What we're asking.
a. Framework hardening: can SideMenuLayout / LeptonXStyleProvider feature-detect window.abp (and the LeptonX globals), or await a "scripts ready" signal, before the cookie / addClassToTag / initLeptonX interop so a genuinely WASM-only page initializes the layout once, cleanly, after the bundle lands?
b. Supported workaround meanwhile: is there a supported way to get the abp/LeptonX bundle into the initial HTML for a WASM-only page without introducing a /_blazor server circuit on it (which, per above, this page must not have)? For example, prerendering only the <AbpScripts> tags while the routed page stays prerender:false WASM-only. If decoupling <AbpScripts>'s render mode from the page is the intended approach, we'd want to confirm it doesn't create a circuit or an asset-swap conflict.
On a Blazor WebApp page rendered with InteractiveWebAssembly(prerender: false),
the LeptonX SideMenuLayout calls JS-interop functions from the abp / LeptonX
script bundle during component initialization, before that bundle has loaded.
This throws repeated Microsoft.JSInterop.JSExceptions that are caught by the
router's ErrorBoundary, which auto-recovers and retries — re-mounting the whole
layout dozens of times for ~10–15s until the WASM script bundle finishes loading,
then self-heals.
This is reproducible in a clean template (not specific to our code) and is caused by two framework components together:
AbpScripts.razor emits the WebAssembly script bundle (the one that defines
window.abp and the LeptonX globals) only when OperatingSystem.IsBrowser()
is true — i.e. after the WASM runtime boots and the component re-renders.SideMenuLayout (and LeptonXStyleProvider) call into that bundle from
OnInitializedAsync and OnAfterRenderAsync, with no guard for the script
not yet being loaded.On a prerender:false page there is no server-prerendered copy of the bundle, so
(2) runs before (1) has injected/loaded the scripts.
Pages rendered with prerender (the default) do not hit this, because the
bundle is written into the initial HTML and window.abp exists before the layout
becomes interactive.
new InteractiveWebAssemblyRenderMode(prerender: false)<MyPage @rendermode="new InteractiveWebAssemblyRenderMode(prerender: false)" />
(or set prerender: false on the component/route). Keep the default
App.razor wiring of <AbpScripts ... WebAssemblyScriptFiles="..." @rendermode="..." />.ErrorBoundary (as the default template does) so the
exception is observable rather than fatal.Expected: the side-menu layout initializes once the page is interactive, with no exceptions.
Actual: repeated JSExceptions during warm-up (see below), each caught by the
ErrorBoundary and auto-recovered, re-mounting the layout many times until the
abp/LeptonX bundle loads.
(1) During OnInitializedAsync:
Microsoft.JSInterop.JSException: Could not find 'abp.utils.getCookieValue' ('abp' was undefined).
Error: Could not find 'abp.utils.getCookieValue' ('abp' was undefined).
at Microsoft.JSInterop.JSRuntime.<InvokeAsync>d__23`1[[System.String, System.Private.CoreLib]].MoveNext()
at Volo.Abp.AspNetCore.Components.Web.CookieService.GetAsync(String key)
at Volo.Abp.AspNetCore.Components.WebAssembly.LeptonXTheme.LeptonXStyleProvider.GetSideMenuStateAsync()
at Volo.Abp.AspNetCore.Components.Web.LeptonXTheme.Components.ApplicationLayout.SideMenuLayout.OnInitializedAsync()
at Microsoft.AspNetCore.Components.ComponentBase.RunInitAndSetParametersAsync()
at Microsoft.AspNetCore.Components.RenderTree.Renderer.GetErrorHandledTask(Task taskToHandle, ComponentState owningComponentState)
(2) During OnAfterRenderAsync (after (1) is worked around, or in parallel):
Microsoft.JSInterop.JSException: The value 'abp.utils.addClassToTag' is not a function.
Error: The value 'abp.utils.addClassToTag' is not a function.
at Microsoft.JSInterop.JSRuntime.<InvokeAsync>d__23`1[[Microsoft.JSInterop.Infrastructure.IJSVoidResult, Microsoft.JSInterop]].MoveNext()
at Microsoft.JSInterop.JSRuntimeExtensions.InvokeVoidAsync(IJSRuntime jsRuntime, String identifier, Object[] args)
at Volo.Abp.AspNetCore.Components.Web.LeptonXTheme.Components.ApplicationLayout.SideMenuLayout.OnAfterRenderAsync(Boolean firstRender)
at Microsoft.AspNetCore.Components.RenderTree.Renderer.GetErrorHandledTask(Task taskToHandle, ComponentState owningComponentState)
SideMenuLayout.OnAfterRenderAsync also invokes initLeptonX and
afterLeptonXInitialization (theme initialization) from the same bundle, which
fail the same way until the bundle is loaded.
AbpScripts.razor resolves WebAssemblyScriptFiles only under
if (OperatingSystem.IsBrowser() && WebAssemblyScriptFiles != null), so on a
prerender:false page the script bundle is injected only after WASM activation.
SideMenuLayout/LeptonXStyleProvider then call abp.utils.getCookieValue,
abp.utils.addClassToTag, initLeptonX, and afterLeptonXInitialization from
OnInitializedAsync/OnAfterRenderAsync without verifying the bundle is loaded,
so they throw until it is.
SideMenuLayout / LeptonXStyleProvider should make their JS interop resilient
to the script bundle not yet being present under
InteractiveWebAssembly(prerender: false) — e.g. feature-detect window.abp
before calling, await a "scripts ready" signal, or defer the cookie/class/init
interop until the abp + LeptonX bundle is guaranteed loaded.
abp.utils.* functions early (in a plain <head>
script) does not work: it just moves the failure to the next call, and
initLeptonX is the theme's real initialization, which cannot be stubbed.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:
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.ContentToolbar instance stays referenced by the scoped PageLayout for the lifetime of the circuit/scope.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)
InteractiveAuto render mode.PageLayout toolbar items / title (e.g. PageToolbar items added from OnAfterRenderAsync, as in standard CRUD pages).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.Volo.Abp.AspNetCore.Components.Server.LeptonXTheme + Volo.Abp.AspNetCore.Components.WebAssembly.LeptonXTheme)InteractiveServer + InteractiveAuto render modes with prerenderingAbpAutofacModule), Blazorise 2.0.4In ContentToolbar (and any other LeptonX components subscribing to scoped services from lifecycle methods):
RendererInfo.IsInteractive == false — a static prerender produces a single snapshot, so change events can never affect its output.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.
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;
}
}
}
[AI-Bot] said:
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.
theres no content
<optgroup> and Breaks Inside <Modal> (Blazor Server)LeptonX's custom-select overlay (the Bootstrap-style <button class="select-button"> /
<ul class="dropdown-menu"> overlay that LeptonX paints over every native <select> /
Blazorise <Select> on the page) has two distinct rendering bugs that surface in a
Blazor Server app running ABP 10.3.0 + LeptonX 5.3.0 + Blazorise 2.0.4:
<optgroup> headers are dropped — when a Blazorise <Select> contains
<SelectGroup> (or a native <select> contains <optgroup>), the overlay
flattens all options into a single ungrouped list. The group labels are gone
and the user cannot tell which entry belongs to which group.<Select> is inside a <Modal> — the overlay
button paints (showing the placeholder text) but the dropdown panel is empty
when opened, no matter how many <SelectItem>s the underlying <select> has.
This appears specific to Blazorise modals, where the <select> element is
moved/wrapped after LeptonX has run its initial overlay pass.| Component | Version |
| --- | --- |
| .NET | 10.0 |
| ABP Framework | 10.3.0 |
| Volo.Abp.AspNetCore.Components.Server.LeptonXTheme | 5.3.0 |
| Volo.Abp.AspNetCore.Components.Web.LeptonXTheme | 5.3.0 (transitive) |
| Volo.Abp.AspNetCore.Mvc.UI.Theme.LeptonX | 5.3.0 |
| Blazorise | 2.0.4 |
| LeptonX layout | LeptonXBlazorLayouts.SideMenu |
| Browser | Chrome (latest), reproduced in Edge |
Confirmed on LeptonX 5.3.0 with Blazorise 2.0.4 (our 10.3 baseline), so the bugs are LeptonX-side, not Blazorise.
<optgroup> headers droppedSideMenu layout.<Select> outside any modal with grouped options, e.g.:<Select TValue="string" @bind-Value="@SelectedLayoutCode">
<SelectItem Value="@string.Empty">Select report or layout</SelectItem>
@foreach (var reportType in ReportCollection)
{
<SelectGroup Label="@reportType.TypeName">
@foreach (var reportLayout in reportType.ReportLayoutList ?? [])
{
<SelectItem Value="@reportLayout.LayoutCode">@reportLayout.LayoutName</SelectItem>
}
</SelectGroup>
}
</Select>
Expected (and what the underlying <select> actually emits):
[Select report or layout]
─── A/R Aging ───
AR_Aging_Standard
AR_Aging_Detailed
─── Sales ───
Sales_Order_Open
Sales_Order_Closed
Actual: the overlay strips the group rows; the user sees a flat list:
[Select report or layout]
AR_Aging_Standard
AR_Aging_Detailed
Sales_Order_Open
Sales_Order_Closed
Inspect <select> in DevTools and the <optgroup label="…"> elements are
present and well-formed. Native browser <select> rendering (when LeptonX's
overlay JS is removed) shows the groups correctly. Bug is in the overlay's
DOM walk; it iterates <option> only and ignores <optgroup>.
"Reports and Layouts" picker. Layouts are grouped by report type (e.g., A/R Aging, Sales). With the overlay, every layout name appears flattened with no indication of which report family it belongs to.
<Select> content empty when hosted inside <Modal><Select> inside a Blazorise <Modal>:<Modal @ref="MyModal" Centered="true">
<ModalContent>
<ModalBody>
<Select TValue="string" @bind-Value="@SelectedCurrency">
<SelectItem Value="">Select currency</SelectItem>
@foreach (var c in AvailableCurrencies)
{
<SelectItem Value="@c.Code">@c.Code - @c.Name</SelectItem>
}
</Select>
</ModalBody>
</ModalContent>
</Modal>
Expected: the dropdown panel lists every currency.
Actual: the overlay button shows the placeholder ("Select currency"),
but the dropdown panel is empty — no <li> rows are rendered, even though
the underlying <select> is fully populated.
<select> in DevTools while the modal is open: every
<option> is present and has the right value/text content.<ul> is empty.<Select> with a native <select class="form-control">
in the same modal: the overlay still paints, but its <ul> is now populated
correctly. This is the smoking gun: the overlay successfully consumes a
static native <select> but fails when the <select> is the one Blazorise
rendered. Our hypothesis is that LeptonX runs its overlay-decoration pass
once on modal open, before Blazorise has finished rendering the <select>'s
<option> children, and never re-runs after Blazorise's second render pass.Replace Blazorise <Select> with native <select class="form-control">
elements (with explicit @onchange handlers, since @bind does not work
the same on native <select> for non-string values). The native <select>
keeps its native behaviour underneath the overlay, and either renders
correctly (groups visible) or, when LeptonX's overlay still mis-renders,
the native control behind it remains usable as a fallback.
@* Native <select> — LeptonX's custom-select overlay ignores <optgroup> *@
<select class="form-control" value="@Filter.LayoutCode" @onchange="OnLayoutCodeChanged" disabled="@IsLoading">
<option value="">@(IsLoading ? L["LoadingWithThreeDot"] : L["SelectReportOrLayout"])</option>
@foreach (var reportType in ReportCollection)
{
<optgroup label="@reportType.TypeName">
@foreach (var reportLayout in reportType.ReportLayoutList ?? [])
{
<option value="@reportLayout.LayoutCode">@reportLayout.LayoutName</option>
}
</optgroup>
}
</select>
private async Task OnLayoutCodeChanged(ChangeEventArgs e)
{
var layoutCode = e.Value?.ToString() ?? string.Empty;
Filter.LayoutCode = layoutCode;
await LoadLayoutParametersAsync(layoutCode);
}
@* Native <select> — LeptonX's custom-select overlay breaks inside modals *@
<select class="form-control" value="@NewSyncJob.BaseCurrency" @onchange="OnBaseCurrencyChanged" disabled="@(!AvailableCurrencies.Any())">
<option value="">@L["SelectCurrency"]</option>
@foreach (var currency in AvailableCurrencies)
{
<option value="@currency.Code">@currency.Code - @currency.Name</option>
}
</select>
private void OnBaseCurrencyChanged(ChangeEventArgs e)
{
NewSyncJob.BaseCurrency = e.Value?.ToString() ?? string.Empty;
}
For nullable enums in modals (e.g., DayOfWeek?), the value round-trips as
the integer string:
<select class="form-control" value="@((int?)NewSyncJob.SyncDayOfWeek)" @onchange="OnDayOfWeekChanged" disabled="@(NewSyncJob.Frequency != SyncFrequency.Weekly)">
<option value="">@L["SelectDay"]</option>
@foreach (var day in Enum.GetValues<DayOfWeek>())
{
<option value="@((int)day)">@L[$"Enum:DayOfWeek.{day}"]</option>
}
</select>
private void OnDayOfWeekChanged(ChangeEventArgs e)
{
var val = e.Value?.ToString();
NewSyncJob.SyncDayOfWeek = string.IsNullOrEmpty(val) ? null : (DayOfWeek)int.Parse(val);
}
We too are waiting on abp delivered pro module for elsa, we'd love to offload our other workflow solutions to an integrated abp module.