Search code examples
javascriptreactjstypescriptmemory-leakscode-cleanup

How to clean up this `await nextEvent(element, 'mousemove')` pattern when I no longer need it?


I've got a React component with some code like follows:

class MyComponent extends React.Component {
    // ...

    trackStats = false

    componentDidMount() {
        this.monitorActivity()
    }

    componentWillUnmount() {
        this.trackStats = false
    }

    async monitorActivity() {
        this.trackStats = true

        while (this.trackStats && this.elRef.current) {
            // elRef is a React ref to a DOM element rendered in render()
            await Promise.race([
                nextEvent(this.elRef.current, 'keydown'),
                nextEvent(this.elRef.current, 'click'),
                nextEvent(this.elRef.current, 'mousemove'),
                nextEvent(this.elRef.current, 'pointermove'),
            ])

            this.logUserActivity()
        }
    }

    logUserActivity() {
        // ...
    }

    render() { /* ... */ }
}

const nextEvent = (target, eventName) => new Promise(resolve => {
    target.addEventListener(eventName, resolve, { once: true })
})

The problem is, if this component is unmounted, then the event handlers that are added onto the DOM element referenced by this.elRef.current will remain in memory, as the user will no longer be interacting with the element which is no longer in the DOM.

So the while loop will be stuck waiting for the next events, which will never happen, and because the while loop is still waiting for one last event, I believe this will cause the instance of the MyComponent to be leaked in memory.

Or is the engine somehow smart enough to clean this up? If I have no reachable references to any of this stuff, and the only thing linked is the while loop's scope which is waiting for some promises to fulfill, will the engine every discard it? Or will it leave the while loop scope running, waiting for the Promises?

If the while loop remains (which I am guessing it does), how should I clean this up?


Solution

  • Thanks to Surma's direction, I was able to come up with a way to completely clean up when unmounting the component:

    class MyComponent extends React.Component {
        // ...
    
        trackStats = false
        statsAbort = undefined
    
        componentDidMount() {
            this.monitorActivity()
        }
    
        componentWillUnmount() {
            this.trackStats = false
            this.statsAbort.abort()
        }
    
        async monitorActivity() {
            this.trackStats = true
    
            while (this.trackStats && this.elRef.current) {
                this.statsAbort = new AbortController
    
                try {
                    // elRef is a React ref to a DOM element rendered in render()
                    await Promise.race([
                        nextEvent(this.elRef.current, 'keydown'),
                        nextEvent(this.elRef.current, 'click'),
                        nextEvent(this.elRef.current, 'mousemove'),
                        nextEvent(this.elRef.current, 'pointermove'),
                    ])
                } catch(e) {
                    if (e.message !== 'abort_stats') throw e
                }
    
                this.statsAbort.abort()
    
                this.logUserActivity()
            }
        }
    
        logUserActivity() {
            // ...
        }
    
        render() { /* ... */ }
    }
    
    const nextEvent = (target, eventName, abortSignal) => new Promise((resolve, reject) => {
        target.addEventListener(eventName, resolve, { once: true })
        abortSignal.addEventListener("abort", () => {
          target.removeEventListener(eventName, resolve)
          reject(new Error('abort_stats'))
        });
    })
    

    But it was simpler to just use addEventListener directly, so I settled with the following which is also easier to understand for this use case:

    class MyComponent extends React.Component {
        // ...
    
        componentDidMount() {
            const el = this.elRef.current
            el.addEventListener('keydown', this.logUserActivity)
            el.addEventListener('click', this.logUserActivity)
            el.addEventListener('mousemove', this.logUserActivity)
            el.addEventListener('pointermove', this.logUserActivity)
        }
    
        componentWillUnmount() {
            const el = this.elRef.current
            el.removeEventListener('keydown', this.logUserActivity)
            el.removeEventListener('click', this.logUserActivity)
            el.removeEventListener('mousemove', this.logUserActivity)
            el.removeEventListener('pointermove', this.logUserActivity)
        }
    
        logUserActivity() {
            // ...
        }
    
        render() { /* ... */ }
    }