Test that every MutationObserver is disconnected to avoid memory leaks

Stephen Cooper - Aug 22 - - Dev Community

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();
    }
Enter fullscreen mode Exit fullscreen mode

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();
        }
    });
});
Enter fullscreen mode Exit fullscreen mode

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

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .