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