Aug 22 ~ 0 ~ 2 mins

Test that every MutationObserver is disconnected to avoid memory leaks

MutationObserver is a useful mechanism to watch for changes in the DOM and respond to them.

The MutationObserver interface provides the ability to watch for changes being made to the DOM tree.

Here is an example that watches for a theme class change.

    function setUpThemeClassObservers() {
        const observer = new MutationObserver(() => {
            const themeClass = this.getThemeClass();
            this.fireStylesChangedEvent('themeChanged');
        });

        observer.observe(this.eGridDiv, {
            attributes: true,
            attributeFilter: ['class'],
        });
        
        // we must disconnect otherwise "this" will not be GC'd
        // causing a memory leak
        return () => observer.disconnect();
    }

However, if you forget to disconnect you could be exposing yourself to memory leaks depending on what is accessed from within the MutationObserver functions.

Wouldn't it be great to have a test that can validate that we disconnect our observers?

Automatic Validation for Code

It turns out that it is possible to validate that every MutationObserver that is observing the DOM is also disconnected. (You may need more than one test if you have to exercise different code paths in order to setup the MutationObservers)

The idea is to Mock the global MutationObserver with sub mocks for its observe and disconnect methods. Before the mock is returned we record it in an array so that we can validate all instances at the end of the test run.


describe('Mutation Observers Disconnected', () => {
   
    let originalMutationObserver: typeof MutationObserver;

    const allMockedObservers: any = [];
    const mutationObserverMock = jest.fn<MutationObserver, [MutationCallback]>().mockImplementation(() => {
        const mock = {
            observe: jest.fn(),
            disconnect: jest.fn(),
            takeRecords: jest.fn(),
        };
        allMockedObservers.push(mock);
        return mock;
    });

    beforeEach(() => {
        // Ensure we can restore the real MutationObserver after the test
        originalMutationObserver = global.MutationObserver;
        global.MutationObserver = mutationObserverMock;
    });

    afterEach(() => {
        global.MutationObserver = originalMutationObserver;
    });

    test('observer always disconnected after destroy', async () => {
        const api = createGrid();
        // Perform some actions if required to exercise the code paths
        api.destroy();

        expect(allMockedObservers.length).toBeGreaterThan(0);
        for (const mock of allMockedObservers) {
            expect(mock.observe).toHaveBeenCalled();
            expect(mock.disconnect).toHaveBeenCalled();
        }
    });
});

In this way we can validate that each MutationObserver, setup during the test, is also disconnected at the end of the test.


Stephen Cooper - Senior Developer at AG Grid Follow me on X @ScooperDev


Headshot of Stephen Cooper

Hi, I'm Stephen. I'm a senior software engineer at AG Grid. If you like my content then please follow / contact me on 🦋 Bluesky or 𝕏 (X) and say hello!