diff --git a/packages/react/src/internal/popup-menu/utils/use-mouse-trail.test.tsx b/packages/react/src/internal/popup-menu/utils/use-mouse-trail.test.tsx
new file mode 100644
index 00000000..55ee3ea3
--- /dev/null
+++ b/packages/react/src/internal/popup-menu/utils/use-mouse-trail.test.tsx
@@ -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(
+
+
+
+ ,
+ )
+
+ 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()
+ })
+})
diff --git a/packages/react/src/internal/popup-menu/utils/use-mouse-trail.ts b/packages/react/src/internal/popup-menu/utils/use-mouse-trail.ts
index d0868139..ca0d228d 100644
--- a/packages/react/src/internal/popup-menu/utils/use-mouse-trail.ts
+++ b/packages/react/src/internal/popup-menu/utils/use-mouse-trail.ts
@@ -1,20 +1,66 @@
import * as React from 'react'
+type MousePoint = [number, number]
+
+interface MouseTrailSubscriber {
+ trailRef: React.MutableRefObject
+ limit: number
+}
+
+const mouseTrailSubscribers = new Set()
+
+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([])
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