Wappler UI Slowdown when using multiple/nested collapses

Version: 7.9.5
Mac (Apple Silicon)
Both PHP and NodeJS projects

I have raised this issue before but seemingly been ignored every time (as I think it was conflated into other memory leaks/slow downs that you have cleaned up). The issue still persists, though. I have some pages which, after just a few minutes editing, can take two to three minutes to respond after each keystroke so I don't even both in Wappler (the whole Wappler UI freezes so I can't even do something else). The common denominator of the pages where I see this is the collapse elements (via accordion or collapse) - and seems especially problematic when there is nested collapses e.g. a collapse inside an accordion

To help identify/resolve it, I put the following prompt in to both Gemini and Claude:

When you have a page that has multiple accordion/collapses (especially when they are nested) the whole of Wappler freezes while the page refresh takes place. I put this prompt
Is there anything in this, when combined with Bootstrap 5 collapse, that would cause slow-down in Monaco editor / preview (in Wappler UI) when the page has multiple collapses/accordions (some may be nested)?

dmx.Component("bs5-collapse",{initialData:{collapsed:!0},attributes:{show:{type:Boolean,default:!1}},methods:{toggle(){this._instance.toggle()},show(){this._instance.show()},hide(){this._instance.hide()}},events:{show:Event,shown:Event,hide:Event,hidden:Event},init(s){s.classList.add("collapse"),s.addEventListener("show.bs.collapse",this.dispatchEvent.bind(this,"show")),s.addEventListener("shown.bs.collapse",this.dispatchEvent.bind(this,"shown")),s.addEventListener("hide.bs.collapse",this.dispatchEvent.bind(this,"hide")),s.addEventListener("hidden.bs.collapse",this.dispatchEvent.bind(this,"hidden")),s.addEventListener("shown.bs.collapse",this._shownHandler.bind(this)),s.addEventListener("hidden.bs.collapse",this._hiddenHandler.bind(this));const t={toggle:!1};s.hasAttribute("data-bs-parent")&&(t.parent=s.getAttribute("data-bs-parent")),this._instance=new bootstrap.Collapse(s,t),this._instance[this.props.show?"show":"hide"]()},destroy(){this._instance.dispose()},performUpdate(s){s.has("show")&&(this._instance[this.props.show?"show":"hide"](),this.set("collapsed",!this.props.show))},_shownHandler(){this.set("collapsed",!1)},_hiddenHandler(){this.set("collapsed",!0)}});

to see what they came back with and both had similar themes around handling of event listeners (that seem like potentially easy fixes)

Gemini:

Yes, there are a few architectural things happening in this custom component registration (dmx.Component for Wappler's App Connect framework) that can cause a severe performance bottleneck inside a design-time environment like Wappler's UI / Monaco Preview when dealing with multiple or nested collapses.

Here is exactly what is likely causing the lag:

1. The Real-Time Initialization Loop (The Biggest Culprit)

Look closely at the init(s) function:

this._instance = new bootstrap.Collapse(s, t);
this._instance[this.props.show ? "show" : "hide"]();

When Wappler parses and renders pages in its Design View or App Connect Preview, it constantly updates, tears down, and re-initializes components as you move elements, change properties, or switch files.

If you have dozens of collapses, especially nested ones, Wappler's UI is instantiating Bootstrap’s native JavaScript bootstrap.Collapse over and over again. Inside the Monaco/Preview environment, this forces the browser to calculate layout, transitions, and DOM heights repeatedly, locking up the main thread.

2. Event Listener Multipliers & Memory Leaks

In the init block, you are binding 6-7 event listeners per instance:

s.addEventListener("show.bs.collapse", this.dispatchEvent.bind(this, "show"));
// ... and so on

  • The .bind(this) problem: Every time you use .bind(this), it creates a new function reference in memory.
  • The destroy() omission: In the destroy() method, you have this._instance.dispose();, which cleans up Bootstrap's internal listeners. However, **you are not removing the event listeners you attached manually to s**.
  • The Result: When Wappler forces a re-render in the preview, the old listeners stay detached but bound in memory, or worse, they pile up on the DOM nodes if the nodes aren't completely wiped. With nested accordions, this memory footprint grows exponentially, causing Monaco and the preview iframe to crawl.

3. Infinite Update Loops (performUpdate)

performUpdate(s) {
    s.has("show") && (this._instance[this.props.show ? "show" : "hide"](), this.set("collapsed", !this.props.show))
}

Wappler’s App Connect relies on a reactive key-value store.

  1. When show changes, performUpdate triggers Bootstrap's .show() or .hide().
  2. Bootstrap's transition fires, triggering the shown.bs.collapse event listener.
  3. Your listener calls this._shownHandler(), which runs this.set("collapsed", false).
  4. Updating component state (this.set) can force Wappler to schedule another component update cycle.

In a nested setup, parent collapses toggling can trigger layout updates on children, which triggers state changes, causing a cascade of dirty-checking cycles that the Wappler preview engine has to process in real time.


How to Fix / Mitigate the Slowdown

To make this snippet friendly for Wappler's UI, you need to add guardrails for design-time rendering and clean up after yourself.

Step 1: Clean up listeners on Destroy

Save your bound handlers or use a single handler so you can remove them when Wappler destroys the component instance.

Step 2: Skip heavy Bootstrap JS initialization in Design View (Optional but Powerful)

If Wappler provides a way to detect the design environment (e.g., checking wappler or dmx.design), you can skip calling new bootstrap.Collapse() if it's only being rendered for the IDE preview, relying on CSS instead.

Optimized Code Structure

Here is a safer way to structure it to avoid memory leaks:

dmx.Component("bs5-collapse", {
    initialData: { collapsed: !0 },
    attributes: { show: { type: Boolean, default: !1 } },
    methods: {
        toggle() { this._instance && this._instance.toggle() },
        show() { this._instance && this._instance.show() },
        hide() { this._instance && this._instance.hide() }
    },
    events: { show: Event, shown: Event, hide: Event, hidden: Event },
    
    init(s) {
        s.classList.add("collapse");
        
        // Save references so they can be explicitly removed later
        this._handlers = {
            show: this.dispatchEvent.bind(this, "show"),
            shown: () => { this.dispatchEvent("shown"); this._shownHandler(); },
            hide: this.dispatchEvent.bind(this, "hide"),
            hidden: () => { this.dispatchEvent("hidden"); this._hiddenHandler(); }
        };

        s.addEventListener("show.bs.collapse", this._handlers.show);
        s.addEventListener("shown.bs.collapse", this._handlers.shown);
        s.addEventListener("hide.bs.collapse", this._handlers.hide);
        s.addEventListener("hidden.bs.collapse", this._handlers.hidden);

        const t = { toggle: !1 };
        if (s.hasAttribute("data-bs-parent")) {
            t.parent = s.getAttribute("data-bs-parent");
        }

        // Initialize Bootstrap
        this._instance = new bootstrap.Collapse(s, t);
        
        // Guard clause for initial state to prevent unnecessary layout thrashing
        if (this.props.show) {
            this._instance.show();
        }
    },

    destroy() {
        // 1. Clean up native Bootstrap instance
        if (this._instance) {
            this._instance.dispose();
        }
        // 2. Clean up Wappler DOM listeners to stop memory leaks in Monaco Preview
        const s = this.$node; // or the element reference saved from init
        if (s && this._handlers) {
            s.removeEventListener("show.bs.collapse", this._handlers.show);
            s.removeEventListener("shown.bs.collapse", this._handlers.shown);
            s.removeEventListener("hide.bs.collapse", this._handlers.hide);
            s.removeEventListener("hidden.bs.collapse", this._handlers.hidden);
        }
    },

    performUpdate(s) {
        if (s.has("show") && this._instance) {
            // Prevent updating if the state already matches to break loop cycles
            const isShowing = this.props.show;
            this._instance[isShowing ? "show" : "hide"]();
            this.set("collapsed", !isShowing);
        }
    },
    
    _shownHandler() { this.set("collapsed", !1) },
    _hiddenHandler() { this.set("collapsed", !0) }
});

Claude:

Looking at this component, a few things stand out that could compound into real slowdown, especially with nested accordions in a live-reloading Monaco/Wappler preview:

1. Event listeners are never removed in destroy()

init() registers six listeners per instance:

s.addEventListener("show.bs.collapse", this.dispatchEvent.bind(this,"show"))
s.addEventListener("shown.bs.collapse", this.dispatchEvent.bind(this,"shown"))
s.addEventListener("hide.bs.collapse", this.dispatchEvent.bind(this,"hide"))
s.addEventListener("hidden.bs.collapse", this.dispatchEvent.bind(this,"hidden"))
s.addEventListener("shown.bs.collapse", this._shownHandler.bind(this))
s.addEventListener("hidden.bs.collapse", this._hiddenHandler.bind(this))

destroy() only calls this._instance.dispose() — it never calls removeEventListener. Worse, since .bind() creates a new function reference every time init() runs, there's no way to remove these later even if you tried, because you don't have a stored reference to the original bound function.

If Wappler's live preview re-runs init() on the same nodes during hot-reload/incremental updates (rather than a full iframe reload each time), you get duplicate listeners stacking up on every edit. Each subsequent show.bs.collapse then fires N handlers instead of 1, and N grows with every save. With multiple/nested accordions this multiplies fast — that's the kind of thing that looks like "the editor just gets slower the longer I work on this page."

2. init() unconditionally calls show()/hide()

this._instance[this.props.show ? "show" : "hide"]()

Bootstrap's hide() bails out early if not currently shown, so that path is cheap. But show() forces a synchronous scrollHeight read (layout reflow) to animate height. If several collapse panels default to show:true on a page, each one forces a reflow during init — and if the preview re-inits all components on every keystroke-triggered rebuild, that's repeated forced reflows across every open panel, every save.

3. Nested accordions cascade through data-bs-parent

When one panel opens, Bootstrap auto-closes its siblings via the shared data-bs-parent. Each sibling's hide.bs.collapse/hidden.bs.collapse fires, which runs _hiddenHandlerthis.set('collapsed', true) → pushes into DMX's reactive data layer. With deep nesting, one toggle can ripple through several set() calls, each potentially triggering downstream data-bound re-renders in the preview. Not slow in isolation, but it adds up when it's layered on top of #1's leaking listeners.

Bottom line: the biggest suspect for a progressively worsening slowdown during a Monaco editing session is #1 — the missing listener cleanup. It's not something you'd notice with one or two collapses, but with multiple/nested accordions and repeated live-reload cycles, listener count grows unbounded and each event dispatch does more and more redundant work. #2 and #3 are more about per-toggle cost, which matters more as the panel count grows but wouldn't degrade over time the way #1 would.

If you have control over this component, the fix is straightforward: store the bound handler references on this in init() (e.g. this._boundShow = this.dispatchEvent.bind(this,"show")) and call the matching removeEventListener in destroy().

What exactly is slowing down? The design view renders in a webview running in a separate thread that should not influence the Wappler UI. It is possible that design view itself becomes unresponsive but not the rest of Wappler.

The AI has a good point, the event listeners seem not being removed in destroy, that could cause a memory leak.

The init could also be inproved, it doesn't have to animate but should directly set the correct state. Problem with this is that initial events are not triggered, so that could be a breaking change if you depend on it.

Nested accordions calling multiple set() calls doesn't affect performance much, the set() is a lazy binding and is very lightweight.

I will make an update for the component for improved destroy and init but not sure if that fixes your slow Wappler UI. Will post the updated component here later.

Thanks for looking at this so promptly.
I'm working on a M4 Pro Mac Mini so plenty powerful but the whole Wappler UI comes to a halt. The cursor in code view stops blinking, I am unable to switch/close files etc.

When it happens I get a spike in CPU usage:
image

This straight from a full restart of Wappler (from the system tray) - just inserting a simple div within a collapse that's inside an accordion. It took about 2 ½ minutes to recover. You'll see in the video that mouse clicks don't take effect until it has recovered. Another character typed and it is repeated...

It looks like you have App Connect disabled in design view, then the components won't render and should not be the cause of the slowdown. I will post an update here so we can verify that it isn't the App Connect component that causes it.

Does the slowdown and high cpu usage only happen on pages with accordions on them?

Here the updated bootstrap 5 collapse component. Instead of calling the show()/hide() method on init it now simply adds the show class to the element which skips the opening animation and directly shows the content. The destroy now removes all event listeners from the element preventing memory leaks.

dmxBootstrap5Collapse.zip (2.3 KB)

I've noticed similar issues with other bootstrap 5 components, will create an update for them all.

Let me know if it helps with your Wappler slowdown issue.

It seems so. That appears to be the common factor when it happens (can be collapses or accordions I think - although I can keep an eye if it is just accordion pages)

Is there a way of suppressing Bootstrap animations in design view? As you say, AC is disabled so may be how design view keeps initiating Bootstrap animated elements

It should not initialize the bootstrap elements if AC is disabled. Does it do any animations on the page?

While it isn't smooth animations, collapse/accordion elements do not show until they are selected in the tree/code view so there must be something going on tho hide/show them.

It also raises another issue in the UI - when a collapse is nested in an accordion, you actually can never see it in the Design view as it is either the accordion that is selected or the collapse. If the collapse is selected, the accordion body hides, also hiding the collapse...

You're correct, design view has some framework integrations that interacts with the page on selections. It toggles the visibility from hidden components so that you can see them for editing. I will check the bootstrap 5 integrations for design view, this is indeed working separate from the AC components.

Remove the collapse class from element, that will prevent the show/hide in design view. If you have is="dmx-bs5-collapse" on it then it will add the class when AC is enabled.

Let me know if removing the collapse classes fixes the slowdown, then I can be sure where the issue is and create a fix for the next update.