Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { render } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { useMouseTrail } from './use-mouse-trail.js'

function MouseTrailProbe({ limit = 4 }: { limit?: number }) {
useMouseTrail(limit)
return null
}

describe('useMouseTrail', () => {
it('shares a single global pointermove listener across subscribers', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')

const { unmount } = render(
<React.Fragment>
<MouseTrailProbe />
<MouseTrailProbe />
</React.Fragment>,
)

const addPointerMoveCalls = addEventListenerSpy.mock.calls.filter(
([type]) => type === 'pointermove',
)

expect(addPointerMoveCalls).toHaveLength(1)

unmount()

const removePointerMoveCalls = removeEventListenerSpy.mock.calls.filter(
([type]) => type === 'pointermove',
)

expect(removePointerMoveCalls).toHaveLength(1)

addEventListenerSpy.mockRestore()
removeEventListenerSpy.mockRestore()
})
})
60 changes: 53 additions & 7 deletions packages/react/src/internal/popup-menu/utils/use-mouse-trail.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,66 @@
import * as React from 'react'

type MousePoint = [number, number]

interface MouseTrailSubscriber {
trailRef: React.MutableRefObject<MousePoint[]>
limit: number
}

const mouseTrailSubscribers = new Set<MouseTrailSubscriber>()

let removeMouseTrailListener: (() => void) | null = null

function ensureMouseTrailListener() {
if (removeMouseTrailListener !== null || typeof window === 'undefined') {
return
}

const onMove = (event: PointerEvent) => {
for (const subscriber of mouseTrailSubscribers) {
const trail = subscriber.trailRef.current
trail.push([event.clientX, event.clientY])
if (trail.length > subscriber.limit) {
trail.shift()
}
}
}

window.addEventListener('pointermove', onMove, { passive: true })

removeMouseTrailListener = () => {
window.removeEventListener('pointermove', onMove)
removeMouseTrailListener = null
}
}

function cleanupMouseTrailListenerIfIdle() {
if (mouseTrailSubscribers.size === 0) {
removeMouseTrailListener?.()
}
}

/**
* Keeps track of the last N mouse positions without causing re-renders.
* Used for calculating mouse trajectory in aim guard.
*/
export function useMouseTrail(n = 4) {
const trailRef = React.useRef<[number, number][]>([])
const trailRef = React.useRef<MousePoint[]>([])

React.useEffect(() => {
const onMove = (e: PointerEvent) => {
const a = trailRef.current
a.push([e.clientX, e.clientY])
if (a.length > n) a.shift()
const subscriber: MouseTrailSubscriber = {
trailRef,
limit: n,
}

mouseTrailSubscribers.add(subscriber)
ensureMouseTrailListener()

return () => {
mouseTrailSubscribers.delete(subscriber)
trailRef.current = []
cleanupMouseTrailListenerIfIdle()
}
window.addEventListener('pointermove', onMove, { passive: true })
return () => window.removeEventListener('pointermove', onMove)
}, [n])

return trailRef
Expand Down
Loading