diff --git a/package.json b/package.json index 7c9a6998db..dd6119e98f 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "test-persistent": "cross-env NODE_ENV=development jest --config ./scripts/jest/config.source-persistent.js", "debug-test-persistent": "cross-env NODE_ENV=development node --inspect-brk node_modules/jest/bin/jest.js --config ./scripts/jest/config.source-persistent.js --runInBand", "test-prod": "cross-env NODE_ENV=production jest --config ./scripts/jest/config.source.js", + "debug-test-prod": "cross-env NODE_ENV=production node --inspect-brk node_modules/jest/bin/jest.js --config ./scripts/jest/config.source.js --runInBand", "test-prod-build": "yarn test-build-prod", "test-build": "cross-env NODE_ENV=development jest --config ./scripts/jest/config.build.js", "test-build-prod": "cross-env NODE_ENV=production jest --config ./scripts/jest/config.build.js", diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 2d9d080b41..099eded56e 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -7,6 +7,7 @@ * @flow */ +import type {TopLevelType} from 'legacy-events/TopLevelEventTypes'; import type {RootType} from './ReactDOMRoot'; import { @@ -84,6 +85,7 @@ import { attachTargetEventListener, } from '../events/DOMModernPluginEventSystem'; import {getListenerMapForElement} from '../events/DOMEventListenerMap'; +import {TOP_BEFORE_BLUR, TOP_AFTER_BLUR} from '../events/DOMTopLevelEventTypes'; export type ReactListenerEvent = ReactDOMListenerEvent; export type ReactListenerMap = ReactDOMListenerMap; @@ -238,11 +240,11 @@ export function resetAfterCommit(containerInfo: Container): void { restoreSelection(selectionInformation); ReactBrowserEventEmitterSetEnabled(eventsEnabled); eventsEnabled = null; - if (enableDeprecatedFlareAPI) { + if (enableDeprecatedFlareAPI || enableUseEventAPI) { const activeElementDetached = (selectionInformation: any) .activeElementDetached; if (activeElementDetached !== null) { - dispatchDetachedBlur(activeElementDetached); + dispatchAfterDetachedBlur(activeElementDetached); } } selectionInformation = null; @@ -490,34 +492,66 @@ export function insertInContainerBefore( } } +function createEvent(type: TopLevelType): Event { + const event = document.createEvent('Event'); + event.initEvent(((type: any): string), false, false); + return event; +} + function dispatchBeforeDetachedBlur(target: HTMLElement): void { const targetInstance = getClosestInstanceFromNode(target); ((selectionInformation: any): SelectionInformation).activeElementDetached = target; - DEPRECATED_dispatchEventForResponderEventSystem( - 'beforeblur', - targetInstance, - ({ + if (enableDeprecatedFlareAPI) { + DEPRECATED_dispatchEventForResponderEventSystem( + 'beforeblur', + targetInstance, + ({ + target, + timeStamp: Date.now(), + }: any), target, - timeStamp: Date.now(), - }: any), - target, - RESPONDER_EVENT_SYSTEM | IS_PASSIVE, - ); + RESPONDER_EVENT_SYSTEM | IS_PASSIVE, + ); + } + if (enableUseEventAPI) { + try { + // We need to temporarily enable the event system + // to dispatch the "beforeblur" event. + ReactBrowserEventEmitterSetEnabled(true); + const event = createEvent(TOP_BEFORE_BLUR); + // Dispatch "beforeblur" directly on the target, + // so it gets picked up by the event system and + // can propagate through the React internal tree. + target.dispatchEvent(event); + } finally { + ReactBrowserEventEmitterSetEnabled(false); + } + } } -function dispatchDetachedBlur(target: HTMLElement): void { - DEPRECATED_dispatchEventForResponderEventSystem( - 'blur', - null, - ({ - isTargetAttached: false, +function dispatchAfterDetachedBlur(target: HTMLElement): void { + if (enableDeprecatedFlareAPI) { + DEPRECATED_dispatchEventForResponderEventSystem( + 'blur', + null, + ({ + isTargetAttached: false, + target, + timeStamp: Date.now(), + }: any), target, - timeStamp: Date.now(), - }: any), - target, - RESPONDER_EVENT_SYSTEM | IS_PASSIVE, - ); + RESPONDER_EVENT_SYSTEM | IS_PASSIVE, + ); + } + if (enableUseEventAPI) { + const event = createEvent(TOP_AFTER_BLUR); + // So we know what was detached, make the relatedTarget the + // detached target on the "afterblur" event. + (event: any).relatedTarget = target; + // Dispatch the event on the document. + document.dispatchEvent(event); + } } // This is a specific event for the React Flare @@ -528,7 +562,7 @@ export function beforeRemoveInstance( instance: Instance | TextInstance | SuspenseInstance, ): void { if ( - enableDeprecatedFlareAPI && + (enableDeprecatedFlareAPI || enableUseEventAPI) && selectionInformation && instance === selectionInformation.focusedElem ) { @@ -639,7 +673,7 @@ export function hideInstance(instance: Instance): void { // is ether the instance of a child or the instance. We need // to traverse the Fiber tree here rather than use node.contains() // as the child node might be inside a Portal. - if (enableDeprecatedFlareAPI && selectionInformation) { + if ((enableDeprecatedFlareAPI || enableUseEventAPI) && selectionInformation) { const focusedElem = selectionInformation.focusedElem; if (focusedElem !== null && instanceContainsElem(instance, focusedElem)) { dispatchBeforeDetachedBlur(((focusedElem: any): HTMLElement)); diff --git a/packages/react-dom/src/events/DOMEventProperties.js b/packages/react-dom/src/events/DOMEventProperties.js index 6a3b0e68fb..d30fc8f991 100644 --- a/packages/react-dom/src/events/DOMEventProperties.js +++ b/packages/react-dom/src/events/DOMEventProperties.js @@ -23,6 +23,7 @@ import { UserBlockingEvent, ContinuousEvent, } from 'shared/ReactTypes'; +import {enableUseEventAPI} from 'shared/ReactFeatureFlags'; // Needed for SimpleEventPlugin, rather than // do it in two places, which duplicates logic @@ -95,6 +96,13 @@ const otherDiscreteEvents = [ DOMTopLevelEventTypes.TOP_COMPOSITION_UPDATE, ]; +if (enableUseEventAPI) { + otherDiscreteEvents.push( + DOMTopLevelEventTypes.TOP_BEFORE_BLUR, + DOMTopLevelEventTypes.TOP_AFTER_BLUR, + ); +} + // prettier-ignore const userBlockingPairsForSimpleEventPlugin = [ DOMTopLevelEventTypes.TOP_DRAG, 'drag', diff --git a/packages/react-dom/src/events/DOMModernPluginEventSystem.js b/packages/react-dom/src/events/DOMModernPluginEventSystem.js index a284e726f3..e550cdb7a3 100644 --- a/packages/react-dom/src/events/DOMModernPluginEventSystem.js +++ b/packages/react-dom/src/events/DOMModernPluginEventSystem.js @@ -75,6 +75,8 @@ import { TOP_PROGRESS, TOP_PLAYING, TOP_CLICK, + TOP_BEFORE_BLUR, + TOP_AFTER_BLUR, } from './DOMTopLevelEventTypes'; import { getClosestInstanceFromNode, @@ -84,7 +86,10 @@ import { import {COMMENT_NODE} from '../shared/HTMLNodeType'; import {topLevelEventsToDispatchConfig} from './DOMEventProperties'; -import {enableLegacyFBSupport} from 'shared/ReactFeatureFlags'; +import { + enableLegacyFBSupport, + enableUseEventAPI, +} from 'shared/ReactFeatureFlags'; const capturePhaseEvents = new Set([ TOP_FOCUS, @@ -122,6 +127,11 @@ const capturePhaseEvents = new Set([ TOP_WAITING, ]); +if (enableUseEventAPI) { + capturePhaseEvents.add(TOP_BEFORE_BLUR); + capturePhaseEvents.add(TOP_AFTER_BLUR); +} + const emptyDispatchConfigForCustomEvents: CustomDispatchConfig = { customEvent: true, phasedRegistrationNames: { diff --git a/packages/react-dom/src/events/DOMTopLevelEventTypes.js b/packages/react-dom/src/events/DOMTopLevelEventTypes.js index 2cd2918556..274705c32d 100644 --- a/packages/react-dom/src/events/DOMTopLevelEventTypes.js +++ b/packages/react-dom/src/events/DOMTopLevelEventTypes.js @@ -149,6 +149,9 @@ export const TOP_VOLUME_CHANGE = unsafeCastStringToDOMTopLevelType( export const TOP_WAITING = unsafeCastStringToDOMTopLevelType('waiting'); export const TOP_WHEEL = unsafeCastStringToDOMTopLevelType('wheel'); +export const TOP_AFTER_BLUR = unsafeCastStringToDOMTopLevelType('afterblur'); +export const TOP_BEFORE_BLUR = unsafeCastStringToDOMTopLevelType('beforeblur'); + // List of events that need to be individually attached to media elements. // Note that events in this list will *not* be listened to at the top level // unless they're explicitly whitelisted in `ReactBrowserEventEmitter.listenTo`. diff --git a/packages/react-dom/src/events/SimpleEventPlugin.js b/packages/react-dom/src/events/SimpleEventPlugin.js index bb48fde73a..a07b7517a5 100644 --- a/packages/react-dom/src/events/SimpleEventPlugin.js +++ b/packages/react-dom/src/events/SimpleEventPlugin.js @@ -105,6 +105,8 @@ const SimpleEventPlugin: PluginModule = { break; case DOMTopLevelEventTypes.TOP_BLUR: case DOMTopLevelEventTypes.TOP_FOCUS: + case DOMTopLevelEventTypes.TOP_BEFORE_BLUR: + case DOMTopLevelEventTypes.TOP_AFTER_BLUR: EventConstructor = SyntheticFocusEvent; break; case DOMTopLevelEventTypes.TOP_CLICK: diff --git a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js index 0794b89a2e..0547c66a2a 100644 --- a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js @@ -9,10 +9,13 @@ 'use strict'; +import {createEventTarget} from 'dom-event-testing-library'; + let React; let ReactFeatureFlags; let ReactDOM; let ReactDOMServer; +let ReactTestUtils; let Scheduler; function dispatchEvent(element, type) { @@ -64,6 +67,7 @@ describe('DOMModernPluginEventSystem', () => { ReactDOM = require('react-dom'); Scheduler = require('scheduler'); ReactDOMServer = require('react-dom/server'); + ReactTestUtils = require('react-dom/test-utils'); container = document.createElement('div'); document.body.appendChild(container); startNativeEventListenerClearDown(); @@ -1092,6 +1096,7 @@ describe('DOMModernPluginEventSystem', () => { ReactDOM = require('react-dom'); Scheduler = require('scheduler'); ReactDOMServer = require('react-dom/server'); + ReactTestUtils = require('react-dom/test-utils'); }); if (!__EXPERIMENTAL__) { @@ -2139,6 +2144,189 @@ describe('DOMModernPluginEventSystem', () => { expect(log[4]).toEqual(['bubble', divElement]); expect(log[5]).toEqual(['bubble', buttonElement]); }); + + it('beforeblur and afterblur are called after a focused element is unmounted', () => { + const log = []; + // We have to persist here because we want to read relatedTarget later. + const onAfterBlur = jest.fn(e => { + e.persist(); + log.push(e.type); + }); + const onBeforeBlur = jest.fn(e => log.push(e.type)); + const innerRef = React.createRef(); + const innerRef2 = React.createRef(); + + const Component = ({show}) => { + const ref = React.useRef(null); + const afterBlurHandle = ReactDOM.unstable_useEvent('afterblur'); + const beforeBlurHandle = ReactDOM.unstable_useEvent('beforeblur'); + + React.useEffect(() => { + afterBlurHandle.setListener(document, onAfterBlur); + beforeBlurHandle.setListener(ref.current, onBeforeBlur); + }); + + return ( +
+ {show && } +
+
+ ); + }; + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + const inner = innerRef.current; + const target = createEventTarget(inner); + target.focus(); + expect(onBeforeBlur).toHaveBeenCalledTimes(0); + expect(onAfterBlur).toHaveBeenCalledTimes(0); + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + expect(onBeforeBlur).toHaveBeenCalledTimes(1); + expect(onAfterBlur).toHaveBeenCalledTimes(1); + expect(onAfterBlur).toHaveBeenCalledWith( + expect.objectContaining({relatedTarget: inner}), + ); + expect(log).toEqual(['beforeblur', 'afterblur']); + }); + + it('beforeblur and afterblur are called after a nested focused element is unmounted', () => { + const log = []; + // We have to persist here because we want to read relatedTarget later. + const onAfterBlur = jest.fn(e => { + e.persist(); + log.push(e.type); + }); + const onBeforeBlur = jest.fn(e => log.push(e.type)); + const innerRef = React.createRef(); + const innerRef2 = React.createRef(); + + const Component = ({show}) => { + const ref = React.useRef(null); + const afterBlurHandle = ReactDOM.unstable_useEvent('afterblur'); + const beforeBlurHandle = ReactDOM.unstable_useEvent('beforeblur'); + + React.useEffect(() => { + afterBlurHandle.setListener(document, onAfterBlur); + beforeBlurHandle.setListener(ref.current, onBeforeBlur); + }); + + return ( +
+ {show && ( +
+ +
+ )} +
+
+ ); + }; + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + const inner = innerRef.current; + const target = createEventTarget(inner); + target.focus(); + expect(onBeforeBlur).toHaveBeenCalledTimes(0); + expect(onAfterBlur).toHaveBeenCalledTimes(0); + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + expect(onBeforeBlur).toHaveBeenCalledTimes(1); + expect(onAfterBlur).toHaveBeenCalledTimes(1); + expect(onAfterBlur).toHaveBeenCalledWith( + expect.objectContaining({relatedTarget: inner}), + ); + expect(log).toEqual(['beforeblur', 'afterblur']); + }); + + it.experimental( + 'beforeblur and afterblur are called after a focused element is suspended', + () => { + const log = []; + // We have to persist here because we want to read relatedTarget later. + const onAfterBlur = jest.fn(e => { + e.persist(); + log.push(e.type); + }); + const onBeforeBlur = jest.fn(e => log.push(e.type)); + const innerRef = React.createRef(); + const Suspense = React.Suspense; + let suspend = false; + let resolve; + let promise = new Promise( + resolvePromise => (resolve = resolvePromise), + ); + + function Child() { + if (suspend) { + throw promise; + } else { + return ; + } + } + + const Component = () => { + const ref = React.useRef(null); + const afterBlurHandle = ReactDOM.unstable_useEvent('afterblur'); + const beforeBlurHandle = ReactDOM.unstable_useEvent( + 'beforeblur', + ); + + React.useEffect(() => { + afterBlurHandle.setListener(document, onAfterBlur); + beforeBlurHandle.setListener(ref.current, onBeforeBlur); + }); + + return ( +
+ + + +
+ ); + }; + + const container2 = document.createElement('div'); + document.body.appendChild(container2); + + let root = ReactDOM.createRoot(container2); + + ReactTestUtils.act(() => { + root.render(); + }); + jest.runAllTimers(); + + const inner = innerRef.current; + const target = createEventTarget(inner); + target.focus(); + expect(onBeforeBlur).toHaveBeenCalledTimes(0); + expect(onAfterBlur).toHaveBeenCalledTimes(0); + + suspend = true; + ReactTestUtils.act(() => { + root.render(); + }); + jest.runAllTimers(); + + expect(onBeforeBlur).toHaveBeenCalledTimes(1); + expect(onAfterBlur).toHaveBeenCalledTimes(1); + expect(onAfterBlur).toHaveBeenCalledWith( + expect.objectContaining({relatedTarget: inner}), + ); + resolve(); + expect(log).toEqual(['beforeblur', 'afterblur']); + + document.body.removeChild(container2); + }, + ); }); }, );