Skip to content

Commit 720aee9

Browse files
committed
feat: fire event when events renabled
closes Reenabling events while cursor over mesh should fire pointerEnter Fixes #3537
1 parent 8eb7b7f commit 720aee9

File tree

3 files changed

+342
-2
lines changed

3 files changed

+342
-2
lines changed

packages/fiber/src/core/events.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,11 +361,15 @@ export function createEvents(store: RootStore) {
361361

362362
// Any other pointer goes here ...
363363
return function handleEvent(event: DomEvent) {
364-
const { onPointerMissed, onDragOverMissed, onDropMissed, internal } = store.getState()
364+
const state = store.getState()
365+
const { onPointerMissed, onDragOverMissed, onDropMissed, internal } = state
365366

366367
// prepareRay(event)
367368
internal.lastEvent.current = event
368369

370+
// Early exit if events are disabled - prevents raycasting and intersection checks
371+
if (!state.events.enabled) return
372+
369373
// Get fresh intersects
370374
const isPointerMove = name === 'onPointerMove'
371375
const isDragOver = name === 'onDragOver'
@@ -522,9 +526,12 @@ const DOM_EVENTS = {
522526
export function createPointerEvents(store: RootStore): EventManager<HTMLElement> {
523527
const { handlePointer } = createEvents(store)
524528

529+
//* EventManager object ==============================
530+
// For portals and others we do it as a SPREADABLE object instead of a class.
525531
return {
526532
priority: 1,
527533
enabled: true,
534+
528535
compute(event: DomEvent, state: RootState, previous?: RootState) {
529536
// https://github.com/pmndrs/react-three-fiber/pull/782
530537
// Events trigger outside of canvas when moved, use offsetX/Y by default and allow overrides

packages/fiber/src/core/renderer.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,20 @@ export function createRoot<TCanvas extends HTMLCanvasElement | OffscreenCanvas>(
311311
}
312312

313313
// Store events internally
314-
if (events && !state.events.handlers) state.set({ events: events(store) })
314+
if (events && !state.events.handlers) {
315+
state.set({ events: events(store) })
316+
317+
// Subscribe to enabled changes to auto-trigger raycaster update
318+
let wasEnabled = true
319+
store.subscribe((state) => {
320+
const { enabled } = state.events
321+
// When re-enabled, trigger raycaster to detect hover state
322+
if (enabled && !wasEnabled) {
323+
state.events.update?.()
324+
}
325+
wasEnabled = enabled
326+
})
327+
}
315328
// Check size, allow it to take on container bounds initially
316329
const size = computeInitialSize(canvas, propsSize)
317330
if (!is.equ(size, state.size, shallowLoose)) {
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
import * as React from 'react'
2+
import * as THREE from '#three'
3+
import { render, fireEvent } from '@testing-library/react'
4+
import { Canvas, extend, act, useThree } from '../src/index'
5+
6+
//* PointerEvent Polyfill ==============================
7+
// JSDOM doesn't include PointerEvent
8+
// https://github.com/jsdom/jsdom/pull/2666#issuecomment-691216178
9+
if (!global.PointerEvent) {
10+
global.PointerEvent = class extends MouseEvent {
11+
readonly pointerId: number = 0
12+
readonly width: number = 1
13+
readonly height: number = 1
14+
readonly pressure: number = 0
15+
readonly tangentialPressure: number = 0
16+
readonly tiltX: number = 0
17+
readonly tiltY: number = 0
18+
readonly twist: number = 0
19+
readonly pointerType: string = ''
20+
readonly isPrimary: boolean = false
21+
readonly altitudeAngle: number = 0
22+
readonly azimuthAngle: number = 0
23+
24+
constructor(type: string, params: PointerEventInit = {}) {
25+
super(type, params)
26+
Object.assign(this, params)
27+
}
28+
29+
getCoalescedEvents = () => []
30+
getPredictedEvents = () => []
31+
}
32+
}
33+
34+
extend(THREE as any)
35+
36+
const getContainer = () => document.querySelector('canvas')?.parentNode?.parentNode as HTMLDivElement
37+
38+
describe('EventManager enabled property', () => {
39+
it('should auto-trigger hover events (onPointerEnter) when re-enabled over mesh', async () => {
40+
const handlePointerEnter = jest.fn()
41+
const handlePointerLeave = jest.fn()
42+
let setEnabled: ((value: boolean) => void) | null = null
43+
44+
function EventController() {
45+
const state = useThree()
46+
47+
React.useEffect(() => {
48+
setEnabled = (value: boolean) => {
49+
state.set((prev) => ({ events: { ...prev.events, enabled: value } }))
50+
}
51+
}, [state])
52+
53+
return null
54+
}
55+
56+
await act(async () => {
57+
render(
58+
<Canvas>
59+
<EventController />
60+
<mesh onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
61+
<boxGeometry args={[2, 2]} />
62+
<meshBasicMaterial />
63+
</mesh>
64+
</Canvas>,
65+
)
66+
})
67+
68+
const moveOverMesh = new PointerEvent('pointermove')
69+
Object.defineProperty(moveOverMesh, 'offsetX', { get: () => 577 })
70+
Object.defineProperty(moveOverMesh, 'offsetY', { get: () => 480 })
71+
72+
const moveAway = new PointerEvent('pointermove')
73+
Object.defineProperty(moveAway, 'offsetX', { get: () => 0 })
74+
Object.defineProperty(moveAway, 'offsetY', { get: () => 0 })
75+
76+
//* Step 1: Move over mesh - should fire enter
77+
fireEvent(getContainer(), moveOverMesh)
78+
expect(handlePointerEnter).toHaveBeenCalledTimes(1)
79+
80+
//* Step 2: Move away - should fire leave
81+
fireEvent(getContainer(), moveAway)
82+
expect(handlePointerLeave).toHaveBeenCalledTimes(1)
83+
84+
//* Step 3: Disable events
85+
act(() => {
86+
if (setEnabled) setEnabled(false)
87+
})
88+
89+
//* Step 4: Move back over mesh while disabled - should NOT fire
90+
fireEvent(getContainer(), moveOverMesh)
91+
expect(handlePointerEnter).toHaveBeenCalledTimes(1) // Still 1
92+
93+
//* Step 5: Re-enable - should auto-trigger and fire enter event
94+
act(() => {
95+
if (setEnabled) setEnabled(true)
96+
})
97+
98+
//* Should have auto-fired enter event because pointer is over mesh
99+
expect(handlePointerEnter).toHaveBeenCalledTimes(2)
100+
})
101+
102+
it('should auto-trigger raycaster update when re-enabled', async () => {
103+
const handlePointerMove = jest.fn()
104+
let setEnabled: ((value: boolean) => void) | null = null
105+
106+
function EventController() {
107+
const state = useThree()
108+
109+
React.useEffect(() => {
110+
setEnabled = (value: boolean) => {
111+
state.set((prev) => ({ events: { ...prev.events, enabled: value } }))
112+
}
113+
}, [state])
114+
115+
return null
116+
}
117+
118+
await act(async () => {
119+
render(
120+
<Canvas>
121+
<EventController />
122+
<mesh onPointerMove={handlePointerMove}>
123+
<boxGeometry args={[2, 2]} />
124+
<meshBasicMaterial />
125+
</mesh>
126+
</Canvas>,
127+
)
128+
})
129+
130+
//* Step 1: Move pointer over mesh to establish lastEvent
131+
const moveOverMesh = new PointerEvent('pointermove')
132+
Object.defineProperty(moveOverMesh, 'offsetX', { get: () => 577 })
133+
Object.defineProperty(moveOverMesh, 'offsetY', { get: () => 480 })
134+
fireEvent(getContainer(), moveOverMesh)
135+
136+
expect(handlePointerMove).toHaveBeenCalledTimes(1)
137+
138+
//* Step 2: Disable events
139+
act(() => {
140+
if (setEnabled) setEnabled(false)
141+
})
142+
143+
//* Step 3: Move pointer while disabled (this updates lastEvent but doesn't fire handlers)
144+
fireEvent(getContainer(), moveOverMesh)
145+
146+
//* Should NOT have fired while disabled
147+
expect(handlePointerMove).toHaveBeenCalledTimes(1)
148+
149+
//* Step 4: Re-enable - should auto-trigger with lastEvent
150+
act(() => {
151+
if (setEnabled) setEnabled(true)
152+
})
153+
154+
//* Should have auto-fired because lastEvent has pointer over mesh
155+
expect(handlePointerMove).toHaveBeenCalledTimes(2)
156+
})
157+
158+
it('should not fire events when disabled', async () => {
159+
const handleClick = jest.fn()
160+
const handlePointerMove = jest.fn()
161+
let setEnabled: ((value: boolean) => void) | null = null
162+
163+
function EventController() {
164+
const state = useThree()
165+
166+
React.useEffect(() => {
167+
setEnabled = (value: boolean) => {
168+
state.set((prev) => ({ events: { ...prev.events, enabled: value } }))
169+
}
170+
}, [state])
171+
172+
return null
173+
}
174+
175+
await act(async () => {
176+
render(
177+
<Canvas>
178+
<EventController />
179+
<mesh onClick={handleClick} onPointerMove={handlePointerMove}>
180+
<boxGeometry args={[2, 2]} />
181+
<meshBasicMaterial />
182+
</mesh>
183+
</Canvas>,
184+
)
185+
})
186+
187+
//* Disable events
188+
act(() => {
189+
if (setEnabled) setEnabled(false)
190+
})
191+
192+
//* Try to trigger events while disabled
193+
const moveEvent = new PointerEvent('pointermove')
194+
Object.defineProperty(moveEvent, 'offsetX', { get: () => 577 })
195+
Object.defineProperty(moveEvent, 'offsetY', { get: () => 480 })
196+
fireEvent(getContainer(), moveEvent)
197+
198+
const downEvent = new PointerEvent('pointerdown')
199+
Object.defineProperty(downEvent, 'offsetX', { get: () => 577 })
200+
Object.defineProperty(downEvent, 'offsetY', { get: () => 480 })
201+
fireEvent(getContainer(), downEvent)
202+
203+
const upEvent = new PointerEvent('pointerup')
204+
Object.defineProperty(upEvent, 'offsetX', { get: () => 577 })
205+
Object.defineProperty(upEvent, 'offsetY', { get: () => 480 })
206+
fireEvent(getContainer(), upEvent)
207+
208+
const clickEvent = new MouseEvent('click')
209+
Object.defineProperty(clickEvent, 'offsetX', { get: () => 577 })
210+
Object.defineProperty(clickEvent, 'offsetY', { get: () => 480 })
211+
fireEvent(getContainer(), clickEvent)
212+
213+
//* None of the handlers should have been called
214+
expect(handleClick).not.toHaveBeenCalled()
215+
expect(handlePointerMove).not.toHaveBeenCalled()
216+
})
217+
218+
it('should fire events normally when enabled', async () => {
219+
const handleClick = jest.fn()
220+
const handlePointerMove = jest.fn()
221+
222+
await act(async () => {
223+
render(
224+
<Canvas>
225+
<mesh onClick={handleClick} onPointerMove={handlePointerMove}>
226+
<boxGeometry args={[2, 2]} />
227+
<meshBasicMaterial />
228+
</mesh>
229+
</Canvas>,
230+
)
231+
})
232+
233+
//* Events are enabled by default
234+
const moveEvent = new PointerEvent('pointermove')
235+
Object.defineProperty(moveEvent, 'offsetX', { get: () => 577 })
236+
Object.defineProperty(moveEvent, 'offsetY', { get: () => 480 })
237+
fireEvent(getContainer(), moveEvent)
238+
239+
expect(handlePointerMove).toHaveBeenCalled()
240+
241+
const downEvent = new PointerEvent('pointerdown')
242+
Object.defineProperty(downEvent, 'offsetX', { get: () => 577 })
243+
Object.defineProperty(downEvent, 'offsetY', { get: () => 480 })
244+
fireEvent(getContainer(), downEvent)
245+
246+
const upEvent = new PointerEvent('pointerup')
247+
Object.defineProperty(upEvent, 'offsetX', { get: () => 577 })
248+
Object.defineProperty(upEvent, 'offsetY', { get: () => 480 })
249+
fireEvent(getContainer(), upEvent)
250+
251+
const clickEvent = new MouseEvent('click')
252+
Object.defineProperty(clickEvent, 'offsetX', { get: () => 577 })
253+
Object.defineProperty(clickEvent, 'offsetY', { get: () => 480 })
254+
fireEvent(getContainer(), clickEvent)
255+
256+
expect(handleClick).toHaveBeenCalled()
257+
})
258+
259+
it('should allow toggling enabled on and off', async () => {
260+
const handlePointerMove = jest.fn()
261+
let setEnabled: ((value: boolean) => void) | null = null
262+
263+
function EventController() {
264+
const state = useThree()
265+
266+
React.useEffect(() => {
267+
setEnabled = (value: boolean) => {
268+
state.set((prev) => ({ events: { ...prev.events, enabled: value } }))
269+
}
270+
}, [state])
271+
272+
return null
273+
}
274+
275+
await act(async () => {
276+
render(
277+
<Canvas>
278+
<EventController />
279+
<mesh onPointerMove={handlePointerMove}>
280+
<boxGeometry args={[2, 2]} />
281+
<meshBasicMaterial />
282+
</mesh>
283+
</Canvas>,
284+
)
285+
})
286+
287+
const moveEvent = new PointerEvent('pointermove')
288+
Object.defineProperty(moveEvent, 'offsetX', { get: () => 577 })
289+
Object.defineProperty(moveEvent, 'offsetY', { get: () => 480 })
290+
291+
//* Initially enabled - should work
292+
fireEvent(getContainer(), moveEvent)
293+
expect(handlePointerMove).toHaveBeenCalledTimes(1)
294+
295+
//* Disable - should not work
296+
act(() => {
297+
if (setEnabled) setEnabled(false)
298+
})
299+
fireEvent(getContainer(), moveEvent)
300+
expect(handlePointerMove).toHaveBeenCalledTimes(1) // Still 1, not called
301+
302+
//* Re-enable - auto-trigger fires + manual fire = 2 more calls
303+
act(() => {
304+
if (setEnabled) setEnabled(true)
305+
})
306+
// Auto-trigger from subscription fires here (call #2)
307+
expect(handlePointerMove).toHaveBeenCalledTimes(2)
308+
309+
fireEvent(getContainer(), moveEvent)
310+
// Manual fire (call #3)
311+
expect(handlePointerMove).toHaveBeenCalledTimes(3)
312+
313+
//* Disable again
314+
act(() => {
315+
if (setEnabled) setEnabled(false)
316+
})
317+
fireEvent(getContainer(), moveEvent)
318+
expect(handlePointerMove).toHaveBeenCalledTimes(3) // Still 3, disabled again
319+
})
320+
})

0 commit comments

Comments
 (0)