Skip to content

[StimulusBundle] Stop watching the DOM once every lazy controller is loaded - #3783

Open
Kocal wants to merge 1 commit into
symfony:3.xfrom
Kocal:perf/stimulus-bundle-lazy-observer
Open

[StimulusBundle] Stop watching the DOM once every lazy controller is loaded#3783
Kocal wants to merge 1 commit into
symfony:3.xfrom
Kocal:perf/stimulus-bundle-lazy-observer

Conversation

@Kocal

@Kocal Kocal commented Aug 15, 2026

Copy link
Copy Markdown
Member
Q A
Bug fix? no
New feature? no
Deprecations? no
Documentation? no
Issues -
License MIT

The MutationObserver installed for lazy controllers kept running for the lifetime of the page. Every childList mutation triggered a querySelectorAll('[data-controller]') over the mutated subtree, even after all lazy controllers had already been requested and nothing could match anymore.

Disconnect the observer as soon as the lazy controller map is empty. On Turbo or Live Component pages, which mutate the DOM constantly, this removes the repeated subtree scans entirely.

300 DOM mutations on a 500-row page, after the last lazy controller has been loaded: ~530 ms -> ~81 ms (means of 3 runs, jsdom via Vitest).

Browser-side JavaScript, so there is no Blackfire profile for this one. Measured by loading the only lazy controller, then churning the DOM the way a Turbo page does, with this throwaway test in src/StimulusBundle/assets:

import { Application, Controller } from '@hotwired/stimulus';
import { describe, expect, it } from 'vitest';
import { loadControllers } from '../../dist/loader';
import type { LazyControllersCollection } from '../../src/controllers';

describe('loader cost after every lazy controller is loaded', () => {
    it('measures repeated DOM mutations', async () => {
        const page = document.createElement('div');
        page.innerHTML = Array.from(
            { length: 500 },
            (_, i) => `<div class="row"><span data-x="${i}">row ${i}</span><button>go</button></div>`
        ).join('');
        document.body.appendChild(page);

        const application = Application.start();
        const lazyControllers: LazyControllersCollection = {
            lazy1: () => Promise.resolve({ default: class extends Controller {} }),
        };
        loadControllers(application, {}, lazyControllers);

        // Load the only lazy controller, so nothing is left to look for.
        const trigger = document.createElement('div');
        trigger.setAttribute('data-controller', 'lazy1');
        document.body.appendChild(trigger);
        await new Promise((resolve) => setTimeout(resolve, 20));
        expect(Object.keys(lazyControllers)).toHaveLength(0);

        const start = performance.now();
        for (let i = 0; i < 300; i++) {
            const node = document.createElement('div');
            node.innerHTML = `<p>update ${i}</p>`;
            page.appendChild(node);
            page.removeChild(node);
        }
        await new Promise((resolve) => setTimeout(resolve, 50));
        console.log(`300 mutations -> ${(performance.now() - start).toFixed(2)} ms`);

        application.stop();
    });
});

The behaviour itself is covered by a permanent unit test asserting the observer is disconnected once the last lazy controller has been loaded:

pnpm exec vitest --run test/unit/loader.test.ts

Analysis, implementation and benchmarks by Claude Opus 5.

@Kocal Kocal self-assigned this Aug 15, 2026
@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

📊 Packages dist files size difference

Thanks for the PR! Here is the difference in size of the packages dist files between the base branch and the PR.
Please review the changes and make sure they are expected.

FileBefore (Size / Gzip)After (Size / Gzip)
StimulusBundle
loader.js 2.87 kB / 948 B 2.97 kB+4% 📈 / 972 B+3% 📈

@Kocal
Kocal requested review from kbond and smnandre August 15, 2026 05:31
@Kocal
Kocal force-pushed the perf/stimulus-bundle-lazy-observer branch 2 times, most recently from 046f3cc to 8b9797f Compare August 15, 2026 22:21
…loaded

| Q              | A
| -------------- | ---
| Bug fix?       | no
| New feature?   | no
| Deprecations?  | no
| Documentation? | no
| Issues         | -
| License        | MIT

The `MutationObserver` installed for lazy controllers kept running for the
lifetime of the page. Every `childList` mutation triggered a
`querySelectorAll('[data-controller]')` over the mutated subtree, even
after all lazy controllers had already been requested and nothing could
match anymore.

Disconnect the observer as soon as the lazy controller map is empty. On
Turbo or Live Component pages, which mutate the DOM constantly, this
removes the repeated subtree scans entirely.

300 DOM mutations on a 500-row page, after the last lazy controller has
been loaded: **~530 ms -> ~81 ms** (means of 3 runs, jsdom via Vitest).

Browser-side JavaScript, so there is no Blackfire profile for this one.
Measured by loading the only lazy controller, then churning the DOM the
way a Turbo page does, with this throwaway test in
`src/StimulusBundle/assets`:

```ts
import { Application, Controller } from '@hotwired/stimulus';
import { describe, expect, it } from 'vitest';
import { loadControllers } from '../../dist/loader';
import type { LazyControllersCollection } from '../../src/controllers';

describe('loader cost after every lazy controller is loaded', () => {
    it('measures repeated DOM mutations', async () => {
        const page = document.createElement('div');
        page.innerHTML = Array.from(
            { length: 500 },
            (_, i) => `<div class="row"><span data-x="${i}">row ${i}</span><button>go</button></div>`
        ).join('');
        document.body.appendChild(page);

        const application = Application.start();
        const lazyControllers: LazyControllersCollection = {
            lazy1: () => Promise.resolve({ default: class extends Controller {} }),
        };
        loadControllers(application, {}, lazyControllers);

        // Load the only lazy controller, so nothing is left to look for.
        const trigger = document.createElement('div');
        trigger.setAttribute('data-controller', 'lazy1');
        document.body.appendChild(trigger);
        await new Promise((resolve) => setTimeout(resolve, 20));
        expect(Object.keys(lazyControllers)).toHaveLength(0);

        const start = performance.now();
        for (let i = 0; i < 300; i++) {
            const node = document.createElement('div');
            node.innerHTML = `<p>update ${i}</p>`;
            page.appendChild(node);
            page.removeChild(node);
        }
        await new Promise((resolve) => setTimeout(resolve, 50));
        console.log(`300 mutations -> ${(performance.now() - start).toFixed(2)} ms`);

        application.stop();
    });
});
```

The behaviour itself is covered by a permanent unit test asserting the
observer is disconnected once the last lazy controller has been loaded:

```bash
pnpm exec vitest --run test/unit/loader.test.ts
```

Analysis, implementation and benchmarks by Claude Opus 5.
@Kocal
Kocal force-pushed the perf/stimulus-bundle-lazy-observer branch from 8b9797f to 97b7713 Compare August 15, 2026 22:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants