From 5a98950fcae1ce7f24637f1917cb5ed0564558ec Mon Sep 17 00:00:00 2001 From: Michael Molisani Date: Sun, 13 Apr 2025 17:13:08 -0400 Subject: [PATCH 1/5] Fix dispatch wrapping for internal events --- src/event/dispatchEvent.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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)) } From a4e0545a660acd12cd38c0dc787a13081ba977a0 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Sat, 22 Aug 2026 13:36:50 +1000 Subject: [PATCH 2/5] add test --- tests/event/wrapEvent.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/event/wrapEvent.ts 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) +}) From c09f2b571bc8eefda5313fa7a7d4c3ad054ec87b Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Sat, 22 Aug 2026 14:38:42 +1000 Subject: [PATCH 3/5] add test for case where a user might do something that invokes a user event dispatched event --- tests/react/index.tsx | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/tests/react/index.tsx b/tests/react/index.tsx index 0ba2e7b0..e3d1d75a 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,42 @@ 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) +}) + describe('typing in a formatted input', () => { function DollarInput({initialValue = ''}) { const [val, setVal] = useState(initialValue) From d47cbbdb619baae218951facf3a0cadbc629f125 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Sat, 22 Aug 2026 14:44:22 +1000 Subject: [PATCH 4/5] Add a test that goes through one of the user event methods to blur the input --- tests/react/index.tsx | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/react/index.tsx b/tests/react/index.tsx index e3d1d75a..dedbb595 100644 --- a/tests/react/index.tsx +++ b/tests/react/index.tsx @@ -154,6 +154,43 @@ test('wrapping a bare blur in `act` keeps the internal `change` inside `act`', a expect(actWarnings).toHaveLength(0) }) + +test('2 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`. + 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) From ea44c35901cab1d0cc2c04a7b168a8e277074083 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Sat, 22 Aug 2026 14:45:57 +1000 Subject: [PATCH 5/5] better name test --- tests/react/index.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/react/index.tsx b/tests/react/index.tsx index dedbb595..df44c09f 100644 --- a/tests/react/index.tsx +++ b/tests/react/index.tsx @@ -155,7 +155,7 @@ test('wrapping a bare blur in `act` keeps the internal `change` inside `act`', a }) -test('2 wrapping a bare blur in `act` keeps the internal `change` inside `act`', async () => { +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) @@ -178,8 +178,6 @@ test('2 wrapping a bare blur in `act` keeps the internal `change` inside `act`', 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`. await user.tab(); expect(screen.getByRole('button')).toHaveFocus()