Skip to content

Commit 2b3d71e

Browse files
committed
fix(fireEvent): Flush effects from discrete events immediately
BREAKING CHANGE: Importing from `@testing-library/react` will no longer wrap event dispatches of `@testing-library/dom` in `act(() => {})`
1 parent a50c9e1 commit 2b3d71e

File tree

2 files changed

+108
-44
lines changed

2 files changed

+108
-44
lines changed

src/__tests__/events.js

Lines changed: 49 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react'
22
import * as ReactDOM from 'react-dom'
3-
import {render, fireEvent} from '../'
3+
import {render, createEvent, fireEvent} from '../'
44

55
const eventTypes = [
66
{
@@ -256,49 +256,55 @@ test('blur/focus bubbles in react', () => {
256256
expect(handleBubbledFocus).toHaveBeenCalledTimes(1)
257257
})
258258

259-
test('discrete events are not wrapped in act', () => {
260-
function AddDocumentClickListener({onClick}) {
261-
React.useEffect(() => {
262-
document.addEventListener('click', onClick)
263-
return () => {
264-
document.removeEventListener('click', onClick)
265-
}
266-
}, [onClick])
267-
return null
268-
}
269-
function Component({onDocumentClick}) {
270-
const [open, setOpen] = React.useState(false)
259+
test.each([
260+
['fireEvent.click', element => fireEvent.click(element)],
261+
['fireEvent()', element => fireEvent(element, createEvent.click(element))],
262+
])(
263+
'discrete events are not wrapped in act when using %s',
264+
(_, dispatchClick) => {
265+
function AddDocumentClickListener({onClick}) {
266+
React.useEffect(() => {
267+
document.addEventListener('click', onClick)
268+
return () => {
269+
document.removeEventListener('click', onClick)
270+
}
271+
}, [onClick])
272+
return null
273+
}
274+
function Component({onDocumentClick}) {
275+
const [open, setOpen] = React.useState(false)
271276

272-
return (
273-
<React.Fragment>
274-
<button onClick={() => setOpen(true)} />
275-
{open &&
276-
ReactDOM.createPortal(
277-
<AddDocumentClickListener onClick={onDocumentClick} />,
278-
document.body,
279-
)}
280-
</React.Fragment>
281-
)
282-
}
283-
const onDocumentClick = jest.fn()
284-
render(<Component onDocumentClick={onDocumentClick} />)
277+
return (
278+
<React.Fragment>
279+
<button onClick={() => setOpen(true)} />
280+
{open &&
281+
ReactDOM.createPortal(
282+
<AddDocumentClickListener onClick={onDocumentClick} />,
283+
document.body,
284+
)}
285+
</React.Fragment>
286+
)
287+
}
288+
const onDocumentClick = jest.fn()
289+
render(<Component onDocumentClick={onDocumentClick} />)
285290

286-
const button = document.querySelector('button')
287-
fireEvent.click(button)
291+
const button = document.querySelector('button')
292+
dispatchClick(button)
288293

289-
// We added a native click listener from an effect.
290-
// There are two possible scenarios:
291-
// 1. If that effect is flushed during the click the native click listener would still receive the event that caused the native listener to be added.
292-
// 2. If that effect is flushed before we return from fireEvent.click the native click listener would not receive the event that caused the native listener to be added.
293-
// React flushes effects scheduled from an update by a "discrete" event immediately.
294-
// but not effects in a batched context (e.g. act(() => {}))
295-
// So if we were in act(() => {}), we would see scenario 2 i.e. `onDocumentClick` would not be called
296-
// If we were not in `act(() => {})`, we would see scenario 1 i.e. `onDocumentClick` would already be called
297-
expect(onDocumentClick).toHaveBeenCalledTimes(1)
294+
// We added a native click listener from an effect.
295+
// There are two possible scenarios:
296+
// 1. If that effect is flushed during the click the native click listener would still receive the event that caused the native listener to be added.
297+
// 2. If that effect is flushed before we return from fireEvent.click the native click listener would not receive the event that caused the native listener to be added.
298+
// React flushes effects scheduled from an update by a "discrete" event immediately.
299+
// but not effects in a batched context (e.g. act(() => {}))
300+
// So if we were in act(() => {}), we would see scenario 2 i.e. `onDocumentClick` would not be called
301+
// If we were not in `act(() => {})`, we would see scenario 1 i.e. `onDocumentClick` would already be called
302+
expect(onDocumentClick).toHaveBeenCalledTimes(1)
298303

299-
// verify we did actually flush the effect before we returned from `fireEvent.click` i.e. the native click listener is mounted.
300-
document.dispatchEvent(
301-
new MouseEvent('click', {bubbles: true, cancelable: true}),
302-
)
303-
expect(onDocumentClick).toHaveBeenCalledTimes(2)
304-
})
304+
// verify we did actually flush the effect before we returned from `fireEvent.click` i.e. the native click listener is mounted.
305+
document.dispatchEvent(
306+
new MouseEvent('click', {bubbles: true, cancelable: true}),
307+
)
308+
expect(onDocumentClick).toHaveBeenCalledTimes(2)
309+
},
310+
)

src/fire-event.js

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,59 @@
11
import {fireEvent as dtlFireEvent} from '@testing-library/dom'
22
import act from './act-compat'
33

4-
const discreteEvents = new Set()
4+
// https://github.com/facebook/react/blob/b48b38af68c27fd401fe4b923a8fa0b229693cd4/packages/react-dom/src/events/ReactDOMEventListener.js#L310-L366
5+
const discreteEvents = new Set([
6+
'cancel',
7+
'click',
8+
'close',
9+
'contextmenu',
10+
'copy',
11+
'cut',
12+
'auxclick',
13+
'dblclick',
14+
'dragend',
15+
'dragstart',
16+
'drop',
17+
'focusin',
18+
'focusout',
19+
'input',
20+
'invalid',
21+
'keydown',
22+
'keypress',
23+
'keyup',
24+
'mousedown',
25+
'mouseup',
26+
'paste',
27+
'pause',
28+
'play',
29+
'pointercancel',
30+
'pointerdown',
31+
'pointerup',
32+
'ratechange',
33+
'reset',
34+
'seeked',
35+
'submit',
36+
'touchcancel',
37+
'touchend',
38+
'touchstart',
39+
'volumechange',
40+
'change',
41+
'selectionchange',
42+
'textInput',
43+
'compositionstart',
44+
'compositionend',
45+
'compositionupdate',
46+
'beforeblur',
47+
'afterblur',
48+
'beforeinput',
49+
'blur',
50+
'fullscreenchange',
51+
'focus',
52+
'hashchange',
53+
'popstate',
54+
'select',
55+
'selectstart',
56+
])
557
function isDiscreteEvent(type) {
658
return discreteEvents.has(type)
759
}
@@ -15,6 +67,9 @@ function noAct(cb) {
1567
// we make this distinction however is because we have
1668
// a few extra events that work a bit differently
1769
function fireEvent(element, event, ...args) {
70+
// `act` would simulate how this event would behave if dispatched from a React event listener.
71+
// In almost all cases we want to simulate how this event behaves in response to a user interaction.
72+
// See discussion in https://github.com/facebook/react/pull/21202
1873
const eventWrapper = isDiscreteEvent(event.type) ? noAct : act
1974

2075
let fireEventReturnValue
@@ -26,6 +81,9 @@ function fireEvent(element, event, ...args) {
2681

2782
Object.keys(dtlFireEvent).forEach(key => {
2883
fireEvent[key] = (element, ...args) => {
84+
// `act` would simulate how this event would behave if dispatched from a React event listener.
85+
// In almost all cases we want to simulate how this event behaves in response to a user interaction.
86+
// See discussion in https://github.com/facebook/react/pull/21202
2987
const eventWrapper = isDiscreteEvent(key.toLowerCase()) ? noAct : act
3088

3189
let fireEventReturnValue

0 commit comments

Comments
 (0)