LeptonX 5.3.0 Side Menu Click Handlers Not Bound (Blazor Server)
Summary
After upgrading from Volo.Abp.AspNetCore.Components.Web.LeptonXTheme 5.0.1 → 5.3.0 (alongside ABP Framework 10.0.1 → 10.3.0), the side-menu items in our Blazor Server application render correctly but do not respond to clicks:
- Parent group items (e.g., "AI", "Knowledge") do not expand their child
<ul>(thecollapsedclass is never removed). - Leaf items still navigate (because they have a real
href), butselectedhighlighting and any other LeptonX-driven behaviour is missing. - No JavaScript errors are logged in the browser console; no exceptions are logged server-side.
Environment
| 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) |
Pre-upgrade versions (where the menu worked): ABP 10.0.1, LeptonX 5.0.1.
Reproduction
- Create or use an ABP Blazor Server solution at 10.3.0 with the
SideMenuLeptonX layout. - Author a
IMenuContributorthat adds at least one parent group with children (e.g.,"AI" → ["Chat", "Usage"]). - Run the app, log in, load the home page.
- Click the "AI" parent menu item.
Expected: The inner <ul> loses its collapsed class and the children become visible.
Actual: Nothing happens. The DOM is unchanged.
Proof — manual reproduction in DevTools
Open Chrome DevTools → Console on the running app and run the script below. It captures the <ul> class before and after a programmatic click, then re-runs the side-menu initializer and clicks again. The before/after output proves that the click handler is missing until the initializer is re-invoked against the populated DOM.
(async () => {
const out = {};
const aiParent = Array.from(document.querySelectorAll('a.lpx-menu-item-link'))
.find(a => a.innerText.trim() === 'AI'); // any parent group works
const ul = aiParent?.nextElementSibling;
// Step 1 — simulate a real click BEFORE re-init
out.before = { ulClasses: ul?.className };
aiParent?.click();
await new Promise(r => setTimeout(r, 500));
out.afterClickBeforeInit = { ulClasses: ul?.className };
// Step 2 — re-run the side-menu initializer manually
// (equivalent: window.initLeptonX('side-menu') — but that re-runs every initializer)
window.leptonx.init.initializers.get('initSideMenu')();
// Step 3 — click again AFTER re-init
aiParent?.click();
await new Promise(r => setTimeout(r, 500));
out.afterClickAfterInit = { ulClasses: ul?.className };
console.log(JSON.stringify(out, null, 2));
})();
Observed output on a fresh page load (no workaround in place):
{
"before": { "ulClasses": "lpx-inner-menu hidden-in-hover-trigger collapsed " },
"afterClickBeforeInit": { "ulClasses": "lpx-inner-menu hidden-in-hover-trigger collapsed " }, // click did nothing
"afterClickAfterInit": { "ulClasses": "lpx-inner-menu hidden-in-hover-trigger" } // collapsed removed → menu expanded
}
The single behavioural difference between the two clicks is the manual initializers.get('initSideMenu')() call between them — confirming that the initializer registered at init.push('initSideMenu', …) ran originally against an empty #lpx-sidebar and therefore bound no click handlers.
Root cause
In Volo.Abp.AspNetCore.Components.Web.LeptonXTheme 5.3.0, the side-menu initializer is registered as:
// html-build/src/scripts/layouts/pro/side-menu/pro-side-menu.ts
init.push('initSideMenu', () => {
if (document.querySelector('#lpx-sidebar')) {
sideMenu = SideMenu.create();
}
});
SideMenu.create() (extends BaseSideMenu) immediately queries .outer-menu-item > .lpx-menu-item and binds click handlers via MenuItem's constructor:
// html-build/src/scripts/layouts/common/side-menu/base-side-menu.ts
createMenuItems() {
const menuItems = this.el.querySelectorAll(`.outer-menu-item > .lpx-menu-item`);
menuItems.forEach(item => {
const menuItem = new MenuItem(item); // attaches click + keydown listeners
this.menuItems.push(menuItem);
});
}
The initializer is invoked from SideMenuLayout.OnAfterRenderAsync(firstRender: true):
// src/Volo.Abp.AspNetCore.Components.Web.LeptonXTheme/Components/ApplicationLayout/SideMenuLayout.razor.cs
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await UtilsService.AddClassToTagAsync("body", GetBodyClassName());
await JSRuntime.InvokeVoidAsync("initLeptonX",
new[] { "side-menu", Options.Value.DefaultStyle, Options.Value.ContainerWidth });
await JSRuntime.InvokeVoidAsync("afterLeptonXInitialization",
new[] { "side-menu", Options.Value.DefaultStyle });
}
}
The problem is the timing relative to MainMenu:
// src/Volo.Abp.AspNetCore.Components.Web.LeptonXTheme/Components/ApplicationLayout/SideMenu/Navigation/MainMenu.razor.cs
protected override async Task OnInitializedAsync()
{
Menu = await MainMenuProvider.GetMenuAsync(); // async permission/feature checks
Menu.StateChanged += Menu_StateChanged;
ApplicationConfigurationChangedService.Changed += ApplicationConfigurationChanged;
}
Because MainMenuProvider.GetMenuAsync() is awaiting (permission resolution, feature checks, etc.), Menu is null during the first render of MainMenu.razor and the @if (Menu != null) { … } block emits nothing. By the time the surrounding SideMenuLayout reaches OnAfterRenderAsync(firstRender: true), #lpx-sidebar exists in the DOM but contains zero .outer-menu-item children.
SideMenu.create() therefore constructs an instance with an empty menuItems array. No click handlers are bound. When MainMenu later re-renders with the actual items (after GetMenuAsync resolves), nothing re-runs the initializer — there is no MutationObserver / waitForElement wrapping initSideMenu in 5.3.0.
This worked in 5.0.x because the side-menu bundle's init logic was wrapped in a waitForElement-style observer (the 5.0.1 minified bundle still contains MutationObserver(function(o){document.querySelector(t)&&(n.disconnect()…}) near the side-menu wiring).
Evidence captured
Captured live from the running app:
// Before manual fix
{
"url": "https://localhost:44320/",
"leptonxInit": "object",
"menuLinkCount": 148, // items DO render eventually
"bodyClasses": "abp-application-layout lpx-theme-system lpx-theme-light",
"currentLayout": "side-menu", // initLeptonX did run
"defaultSettings": { "appearance": "dim", "containerWidth": "full" }
}
// Click "AI" parent — UL stays collapsed
{ "ulClasses": "lpx-inner-menu hidden-in-hover-trigger collapsed " }
// After window.initLeptonX() (manual)
// Click "AI" parent — UL expands
{ "ulClasses": "lpx-inner-menu hidden-in-hover-trigger" }
Bundle size diff between versions (suggests the regression is part of a bundle rewrite):
62,404 bytes /volo.abp.aspnetcore.components.web.leptonxtheme/5.0.1/.../side-menu/js/lepton-x.bundle.min.js
39,070 bytes /volo.abp.aspnetcore.components.web.leptonxtheme/5.3.0/.../side-menu/js/lepton-x.bundle.min.js
Additional reproduction — host ↔ tenant impersonation
The same broken state recurs whenever MainMenu re-renders. The simplest path to trigger it:
- Log in as a host admin.
- Navigate to Saas → Tenants and click Login on a tenant row (
Volo.Saasimpersonation). - The app reloads in the impersonated tenant context — the side menu works there.
- Click Back to my account (
Volo.Abp.Account.Pro.Public.Web.Impersonation) to return to the host session. - The host menu re-renders, but is once again unclickable.
The trigger is MainMenu.ApplicationConfigurationChanged at MainMenu.razor.cs:35-41:
private async void ApplicationConfigurationChanged()
{
Menu.StateChanged -= Menu_StateChanged;
Menu = await MainMenuProvider.GetMenuAsync();
Menu.StateChanged += Menu_StateChanged;
await InvokeAsync(StateHasChanged);
}
When the impersonation switch fires ApplicationConfigurationChangedService.Changed, MainMenuProvider.GetMenuAsync() is awaited and Blazor diffs the new menu over the old one. The previously-bound <a class="lpx-menu-item-link"> elements are replaced, dropping the click listeners that MenuItem's constructor attached. Nothing re-runs initSideMenu, so the menu becomes unclickable a second time — and a third time on every subsequent impersonation/return cycle.
Workaround applied in our app
Inline script appended to Pages/_Host.cshtml, after <abp-script-bundle name="@BlazorLeptonXThemeBundles.Scripts.Global"/> and app.js. It re-runs initSideMenu whenever new (unbound) menu items appear in #lpx-sidebar, using a per-element marker to avoid double-binding when Blazor preserves elements in place: Works sporadically.
<script>
(function () {
const SEL = '#lpx-sidebar .outer-menu-item > .lpx-menu-item';
const MARKER = '__lpxBound';
function ensure() {
const items = document.querySelectorAll(SEL);
if (items.length === 0) return;
let needsInit = false;
for (const el of items) { if (!el[MARKER]) { needsInit = true; break; } }
if (!needsInit) return;
window.leptonx?.init?.initializers?.get('initSideMenu')?.();
items.forEach(el => { el[MARKER] = true; });
}
new MutationObserver(ensure).observe(document.body, { childList: true, subtree: true });
ensure();
})();
</script>
Why this shape:
- Calls only the
initSideMenuinitializer (not the fullinitLeptonX) so toolbar / mobile navbar / theme switcher are not re-bound. - The marker is a JavaScript property set directly on the DOM node, not an attribute — when Blazor's diff algorithm replaces an
<a>element, the new element has no marker and we re-init; when Blazor updates the same element in place, the marker survives and we skip re-init (so handlers attached by the previousMenuItemconstructor are not duplicated byaddEventListener). - Survives every
ApplicationConfigurationChangedcycle, so impersonation in either direction recovers automatically.
Suggested upstream fix
Wrap the initSideMenu initializer in waitForElement (already exported by @lpx/pro/utils) so it waits for menu items to be present, similar to how it worked in 5.0.x:
// html-build/src/scripts/layouts/pro/side-menu/pro-side-menu.ts
init.push('initSideMenu', () => {
waitForElement('#lpx-sidebar .outer-menu-item > .lpx-menu-item', () => {
sideMenu = SideMenu.create();
});
});
Equivalently, MainMenu.razor.cs could trigger JSRuntime.InvokeVoidAsync("initLeptonX", ...) after Menu is set, but the JS-side fix is less invasive and covers any host project that has slow MainMenuProvider providers.
What works every time, open the console when the issue is present and type window.initLeptonX()
This is a hiigh priority issue as end users cannot be expected to open console for anything.
1 Answer(s)
-
0
Hi,
Can you replace the contents of the inline workaround script block in your
_Host.cshtmlwith this (the surrounding script tag and the rest of the page stay as-is):// LeptonX 5.3.0 side-menu rebind workaround. // initSideMenu runs once from SideMenuLayout.OnAfterRenderAsync and may // execute against an empty #lpx-sidebar (when MainMenuProvider yields) or // be skipped on impersonation rebuild. We watch the sidebar and, whenever // an outer menu item ends up without click listeners, remove every // previously attached listener (so we never double-bind a preserved // element) and call initSideMenu once more to re-attach a single clean // set. window.initLeptonX is wrapped so the natural call from // OnAfterRenderAsync goes through the same cleanup path. (function () { try { const allBinds = new WeakMap(); let timer = null; const origAdd = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function (type, fn, opts) { if (this instanceof Element && this.classList && this.classList.contains('lpx-menu-item') && this.closest && this.closest('#lpx-sidebar')) { let arr = allBinds.get(this); if (!arr) { arr = []; allBinds.set(this, arr); } arr.push({ type: type, fn: fn, opts: opts }); } return origAdd.call(this, type, fn, opts); }; function cleanupBinds() { document.querySelectorAll('#lpx-sidebar .lpx-menu-item').forEach(function (el) { const arr = allBinds.get(el); if (arr) { arr.forEach(function (b) { el.removeEventListener(b.type, b.fn, b.opts); }); allBinds.delete(el); } }); } const origInitLeptonX = window.initLeptonX; window.initLeptonX = function () { cleanupBinds(); return origInitLeptonX.apply(this, arguments); }; function reinit() { cleanupBinds(); const fn = window.leptonx && window.leptonx.init && window.leptonx.init.initializers && window.leptonx.init.initializers.get('initSideMenu'); if (fn) fn(); } function check() { const items = document.querySelectorAll('#lpx-sidebar .outer-menu-item > .lpx-menu-item'); if (items.length === 0) return; let needs = false; for (let i = 0; i < items.length; i++) { if (!allBinds.has(items[i])) { needs = true; break; } } if (needs) reinit(); } function debounced() { clearTimeout(timer); timer = setTimeout(check, 300); } new MutationObserver(debounced).observe(document.body, { childList: true, subtree: true }); debounced(); } catch (err) { console.error('[lpx-fix] failed:', err); } })();A proper upstream fix is also queued for the next LeptonX patch release, so once you upgrade you can drop this whole inline script.
Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)