From b7278e563767da8c84110c51958e390c8ea0532c Mon Sep 17 00:00:00 2001 From: ostapondo <33957189+ostapondo@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:51:03 +0200 Subject: [PATCH] fix(useElementByPoint): avoid a re-render on every frame in multiple mode elementsFromPoint() allocates a new array on every call, so the rAF loop handed setElement a fresh reference each frame and re-rendered the consuming component roughly 60 times a second even while the pointer sat still. Compare the hit list by contents and keep the previous array when nothing under the point changed. The single-element branch needs no such check - elementFromPoint returns the same node and React bails out on its own. Adds the first tests for this hook, covering both that an unchanged hit list does not re-render and that a changed one still does. --- .../core/src/useElementByPoint/index.spec.ts | 74 +++++++++++++++++++ packages/core/src/useElementByPoint/index.ts | 16 +++- 2 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/useElementByPoint/index.spec.ts diff --git a/packages/core/src/useElementByPoint/index.spec.ts b/packages/core/src/useElementByPoint/index.spec.ts new file mode 100644 index 00000000..cf68baf2 --- /dev/null +++ b/packages/core/src/useElementByPoint/index.spec.ts @@ -0,0 +1,74 @@ +import { act, renderHook } from '@testing-library/react' +import { useElementByPoint } from '.' + +function patchRaf() { + let callbacks: Array<(t: number) => void> = [] + ;(global as any).requestAnimationFrame = (cb: (t: number) => void) => { + callbacks.push(cb) + return callbacks.length + } + ;(global as any).cancelAnimationFrame = jest.fn() + return { + flush() { + const cbs = callbacks + callbacks = [] + cbs.forEach(cb => cb(0)) + }, + } +} + +describe('useElementByPoint', () => { + it('reports the element under the point', () => { + const raf = patchRaf() + const el = document.createElement('div') + const doc = { elementFromPoint: () => el } as unknown as Document + + const { result } = renderHook(() => useElementByPoint({ x: 1, y: 2, document: doc })) + + act(() => { raf.flush() }) + expect(result.current.element).toBe(el) + }) + + it('does not re-render while the hit list is unchanged in multiple mode', () => { + const raf = patchRaf() + const el = document.createElement('div') + // A real document returns a new array on every call, even when the pointer + // has not moved. + const doc = { elementsFromPoint: () => [el] } as unknown as Document + + let renders = 0 + renderHook(() => { + renders++ + return useElementByPoint({ x: 1, y: 2, document: doc, multiple: true }) + }) + + // First frame legitimately moves the state from null to the hit list. + act(() => { raf.flush() }) + const settled = renders + + act(() => { raf.flush() }) + act(() => { raf.flush() }) + act(() => { raf.flush() }) + + expect(renders).toBe(settled) + }) + + it('re-renders when the hit list actually changes', () => { + const raf = patchRaf() + const first = document.createElement('div') + const second = document.createElement('span') + let hit = [first] + const doc = { elementsFromPoint: () => [...hit] } as unknown as Document + + const { result } = renderHook(() => + useElementByPoint({ x: 1, y: 2, document: doc, multiple: true }), + ) + + act(() => { raf.flush() }) + expect(result.current.element).toEqual([first]) + + hit = [second, first] + act(() => { raf.flush() }) + expect(result.current.element).toEqual([second, first]) + }) +}) diff --git a/packages/core/src/useElementByPoint/index.ts b/packages/core/src/useElementByPoint/index.ts index a57f5a64..2f28ddbb 100644 --- a/packages/core/src/useElementByPoint/index.ts +++ b/packages/core/src/useElementByPoint/index.ts @@ -35,10 +35,20 @@ export const useElementByPoint: UseElementByPoint = options => { const cb = useCallback(() => { const { x: currentX, y: currentY } = getXY() - setElement( + const next = multiple + ? doc?.elementsFromPoint(currentX, currentY) ?? [] + : doc?.elementFromPoint(currentX, currentY) ?? null + // elementsFromPoint allocates a fresh array on every call, so storing it + // as-is would re-render on every frame of the rAF loop even while the + // pointer sits still. The single-element branch needs no such check — + // elementFromPoint returns the same node and React bails out on its own. + setElement((prev: any) => multiple - ? doc?.elementsFromPoint(currentX, currentY) ?? [] - : doc?.elementFromPoint(currentX, currentY) ?? null, + && Array.isArray(prev) + && prev.length === next.length + && prev.every((el: Element, i: number) => el === next[i]) + ? prev + : next, ) }, [doc, multiple, getXY])