diff --git a/src/event/dispatchEvent.ts b/src/event/dispatchEvent.ts index 4627296d..1a630714 100644 --- a/src/event/dispatchEvent.ts +++ b/src/event/dispatchEvent.ts @@ -63,11 +63,15 @@ export function dispatchEvent( return wrapEvent(() => target.dispatchEvent(event), target) } +/** + * Dispatch a DOM event without wrapping it with the configured wrapper. + * This is used internally to trigger events that are not triggered natively by JSDOM. + * These should not be wrapped explicitly as they are already executed in the triggering wrapped scope. + */ export function dispatchDOMEvent( target: Element, type: K, init?: EventTypeInit, -) { - const event = createEvent(type, target, init) - wrapEvent(() => target.dispatchEvent(event), target) +): boolean { + return target.dispatchEvent(createEvent(type, target, init)) } diff --git a/tests/event/wrapEvent.ts b/tests/event/wrapEvent.ts new file mode 100644 index 00000000..615e0729 --- /dev/null +++ b/tests/event/wrapEvent.ts @@ -0,0 +1,36 @@ +import {configure, getConfig} from '@testing-library/dom' +import userEvent from '#src' +import {render} from '#testHelpers' + +test('does not re-wrap internally dispatched events in the configured event wrapper', async () => { + const { + elements: [input, other], + } = render(``, {focus: false}) + + let depth = 0 + let maxDepth = 0 + const {eventWrapper: originalEventWrapper} = getConfig() + configure({ + eventWrapper: cb => { + depth++ + maxDepth = Math.max(maxDepth, depth) + try { + return cb() + } finally { + depth-- + } + }, + }) + + try { + const user = userEvent.setup() + await user.click(input) + await user.keyboard('hello') + // Blurring the now-modified field dispatches an internal `change` event. + await user.click(other) + } finally { + configure({eventWrapper: originalEventWrapper}) + } + + expect(maxDepth).toBe(1) +}) diff --git a/tests/react/index.tsx b/tests/react/index.tsx index 0ba2e7b0..df44c09f 100644 --- a/tests/react/index.tsx +++ b/tests/react/index.tsx @@ -1,5 +1,5 @@ -import React, {useLayoutEffect, useRef, useState} from 'react' -import {render, screen, waitFor} from '@testing-library/react' +import React, {useEffect, useLayoutEffect, useRef, useState} from 'react' +import {act, render, screen, waitFor} from '@testing-library/react' import userEvent from '#src' import {getUISelection, getUIValue} from '#src/document' import {addListeners} from '#testHelpers' @@ -118,6 +118,77 @@ test('trigger onChange SyntheticEvent on input', async () => { expect(changeHandler).toHaveBeenCalledTimes(6) }) + +test('wrapping a bare blur in `act` keeps the internal `change` inside `act`', async () => { + function Comp() { + const [changes, setChanges] = useState(0) + const ref = useRef(null) + useEffect(() => { + const el = ref.current as HTMLInputElement + const onChange = () => setChanges(c => c + 1) + el.addEventListener('change', onChange) + return () => el.removeEventListener('change', onChange) + }, []) + return ( + <> + + {changes} + + ) + } + + render() + const user = userEvent.setup() + await user.type(screen.getByLabelText('field'), 'hello') + + // The blur is not a user-event action but it results in + // a user-event change event being fired, so the caller wraps it in `act`. + act(() => { + ;(screen.getByLabelText('field') as HTMLInputElement).blur() + }) + + expect(screen.getByText('1')).toBeInTheDocument() + const actWarnings = (console.error as jest.Mock).mock.calls.filter(c => + String(c[0]).includes('not wrapped in act'), + ) + expect(actWarnings).toHaveLength(0) +}) + + +test('user event methods already wrap the blur in an act, so the internal `change` is inside `act`', async () => { + function Comp() { + const [changes, setChanges] = useState(0) + const ref = useRef(null) + useEffect(() => { + const el = ref.current as HTMLInputElement + const onChange = () => setChanges(c => c + 1) + el.addEventListener('change', onChange) + return () => el.removeEventListener('change', onChange) + }, []) + return ( + <> + + {changes} + + + ) + } + + render() + const user = userEvent.setup() + await user.type(screen.getByLabelText('field'), 'hello') + + await user.tab(); + + expect(screen.getByRole('button')).toHaveFocus() + + expect(screen.getByText('1')).toBeInTheDocument() + const actWarnings = (console.error as jest.Mock).mock.calls.filter(c => + String(c[0]).includes('not wrapped in act'), + ) + expect(actWarnings).toHaveLength(0) +}) + describe('typing in a formatted input', () => { function DollarInput({initialValue = ''}) { const [val, setVal] = useState(initialValue)