diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index 459f714b90cf0..43a563e90aa23 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -467,7 +467,7 @@ export function makeClientIdInDEV(warnOnAccessInDEV: () => void): OpaqueIDType { throw new Error('Not yet implemented'); } -export function beforeActiveInstanceBlur() { +export function beforeActiveInstanceBlur(internalInstanceHandle: Object) { // noop } diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 63fdb08a3ae84..8de2f2c5c7ca4 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -232,10 +232,13 @@ export function prepareForCommit(containerInfo: Container): Object | null { return activeInstance; } -export function beforeActiveInstanceBlur(): void { +export function beforeActiveInstanceBlur(internalInstanceHandle: Object): void { if (enableCreateEventHandleAPI) { ReactBrowserEventEmitterSetEnabled(true); - dispatchBeforeDetachedBlur((selectionInformation: any).focusedElem); + dispatchBeforeDetachedBlur( + (selectionInformation: any).focusedElem, + internalInstanceHandle, + ); ReactBrowserEventEmitterSetEnabled(false); } } @@ -499,12 +502,17 @@ function createEvent(type: DOMEventName, bubbles: boolean): Event { return event; } -function dispatchBeforeDetachedBlur(target: HTMLElement): void { +function dispatchBeforeDetachedBlur( + target: HTMLElement, + internalInstanceHandle: Object, +): void { if (enableCreateEventHandleAPI) { const event = createEvent('beforeblur', true); // Dispatch "beforeblur" directly on the target, // so it gets picked up by the event system and // can propagate through the React internal tree. + // $FlowFixMe: internal field + event._detachedInterceptFiber = internalInstanceHandle; target.dispatchEvent(event); } } diff --git a/packages/react-dom/src/events/DOMPluginEventSystem.js b/packages/react-dom/src/events/DOMPluginEventSystem.js index 74b95dea3d4eb..c43d96dcb1c9c 100644 --- a/packages/react-dom/src/events/DOMPluginEventSystem.js +++ b/packages/react-dom/src/events/DOMPluginEventSystem.js @@ -658,10 +658,11 @@ export function accumulateSinglePhaseListeners( nativeEventType: string, inCapturePhase: boolean, accumulateTargetOnly: boolean, + nativeEvent: AnyNativeEvent, ): Array { const captureName = reactName !== null ? reactName + 'Capture' : null; const reactEventName = inCapturePhase ? captureName : reactName; - const listeners: Array = []; + let listeners: Array = []; let instance = targetFiber; let lastHostComponent = null; @@ -740,6 +741,23 @@ export function accumulateSinglePhaseListeners( if (accumulateTargetOnly) { break; } + // If we are processing the onBeforeBlur event, then we need to take + // into consideration that part of the React tree might have been hidden + // or deleted (as we're invoking this event during commit). We can find + // this out by checking if intercept fiber set on the event matches the + // current instance fiber. In which case, we should clear all existing + // listeners. + if (enableCreateEventHandleAPI && nativeEvent.type === 'beforeblur') { + // $FlowFixMe: internal field + const detachedInterceptFiber = nativeEvent._detachedInterceptFiber; + if ( + detachedInterceptFiber !== null && + (detachedInterceptFiber === instance || + detachedInterceptFiber === instance.alternate) + ) { + listeners = []; + } + } instance = instance.return; } return listeners; diff --git a/packages/react-dom/src/events/__tests__/DOMPluginEventSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DOMPluginEventSystem-test.internal.js index 6a3662962a740..004f0bb631f31 100644 --- a/packages/react-dom/src/events/__tests__/DOMPluginEventSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMPluginEventSystem-test.internal.js @@ -2424,6 +2424,60 @@ describe('DOMPluginEventSystem', () => { expect(log).toEqual(['beforeblur', 'afterblur']); }); + // @gate experimental + it('beforeblur should skip handlers from a deleted subtree after the focused element is unmounted', () => { + const onBeforeBlur = jest.fn(); + const innerRef = React.createRef(); + const innerRef2 = React.createRef(); + const setBeforeBlurHandle = ReactDOM.unstable_createEventHandle( + 'beforeblur', + ); + const ref2 = React.createRef(); + + const Component = ({show}) => { + const ref = React.useRef(null); + + React.useEffect(() => { + const clear1 = setBeforeBlurHandle(ref.current, onBeforeBlur); + let clear2; + if (ref2.current) { + clear2 = setBeforeBlurHandle(ref2.current, onBeforeBlur); + } + + return () => { + clear1(); + if (clear2) { + clear2(); + } + }; + }); + + return ( +
+ {show && ( +
+ +
+ )} +
+
+ ); + }; + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + const inner = innerRef.current; + const target = createEventTarget(inner); + target.focus(); + expect(onBeforeBlur).toHaveBeenCalledTimes(0); + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + expect(onBeforeBlur).toHaveBeenCalledTimes(1); + }); + // @gate experimental it('beforeblur and afterblur are called after a focused element is suspended', () => { const log = []; @@ -2510,6 +2564,87 @@ describe('DOMPluginEventSystem', () => { document.body.removeChild(container2); }); + // @gate experimental + it('beforeblur should skip handlers from a deleted subtree after the focused element is suspended', () => { + const onBeforeBlur = jest.fn(); + const innerRef = React.createRef(); + const innerRef2 = React.createRef(); + const setBeforeBlurHandle = ReactDOM.unstable_createEventHandle( + 'beforeblur', + ); + const ref2 = React.createRef(); + const Suspense = React.Suspense; + let suspend = false; + let resolve; + const promise = new Promise( + resolvePromise => (resolve = resolvePromise), + ); + + function Child() { + if (suspend) { + throw promise; + } else { + return ; + } + } + + const Component = () => { + const ref = React.useRef(null); + + React.useEffect(() => { + const clear1 = setBeforeBlurHandle(ref.current, onBeforeBlur); + let clear2; + if (ref2.current) { + clear2 = setBeforeBlurHandle(ref2.current, onBeforeBlur); + } + + return () => { + clear1(); + if (clear2) { + clear2(); + } + }; + }); + + return ( +
+ +
+ +
+
+
+
+ ); + }; + + const container2 = document.createElement('div'); + document.body.appendChild(container2); + + const root = ReactDOM.createRoot(container2); + + act(() => { + root.render(); + }); + jest.runAllTimers(); + + const inner = innerRef.current; + const target = createEventTarget(inner); + target.focus(); + expect(onBeforeBlur).toHaveBeenCalledTimes(0); + + suspend = true; + act(() => { + root.render(); + }); + jest.runAllTimers(); + + expect(onBeforeBlur).toHaveBeenCalledTimes(1); + resolve(); + + document.body.removeChild(container2); + }); + // @gate experimental it('regression: does not fire beforeblur/afterblur if target is already hidden', () => { const Suspense = React.Suspense; diff --git a/packages/react-dom/src/events/plugins/SimpleEventPlugin.js b/packages/react-dom/src/events/plugins/SimpleEventPlugin.js index fafccff92014c..03bf9484b2c04 100644 --- a/packages/react-dom/src/events/plugins/SimpleEventPlugin.js +++ b/packages/react-dom/src/events/plugins/SimpleEventPlugin.js @@ -200,6 +200,7 @@ function extractEvents( nativeEvent.type, inCapturePhase, accumulateTargetOnly, + nativeEvent, ); if (listeners.length > 0) { // Intentionally create event lazily. diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index dcdceb7a60dbd..636542e8d426b 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -481,7 +481,7 @@ export function makeClientIdInDEV(warnOnAccessInDEV: () => void): OpaqueIDType { throw new Error('Not yet implemented'); } -export function beforeActiveInstanceBlur() { +export function beforeActiveInstanceBlur(internalInstanceHandle: Object) { // noop } diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index 0369755c136a0..eba0a2b90462b 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -534,7 +534,7 @@ export function makeClientIdInDEV(warnOnAccessInDEV: () => void): OpaqueIDType { throw new Error('Not yet implemented'); } -export function beforeActiveInstanceBlur() { +export function beforeActiveInstanceBlur(internalInstanceHandle: Object) { // noop } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 4f40bde182310..fffa71d20801b 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -2172,7 +2172,7 @@ function commitBeforeMutationEffectsImpl(fiber: Fiber) { doesFiberContain(fiber, focusedInstanceHandle) ) { shouldFireAfterActiveInstanceBlur = true; - beforeActiveInstanceBlur(); + beforeActiveInstanceBlur(fiber); } } @@ -2206,7 +2206,7 @@ function commitBeforeMutationEffectsDeletions(deletions: Array) { if (doesFiberContain(fiber, ((focusedInstanceHandle: any): Fiber))) { shouldFireAfterActiveInstanceBlur = true; - beforeActiveInstanceBlur(); + beforeActiveInstanceBlur(fiber); } } } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index c0fcb9c5c09b2..cece268e3d017 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -2261,7 +2261,7 @@ function commitBeforeMutationEffects() { if ((nextEffect.flags & Deletion) !== NoFlags) { if (doesFiberContain(nextEffect, focusedInstanceHandle)) { shouldFireAfterActiveInstanceBlur = true; - beforeActiveInstanceBlur(); + beforeActiveInstanceBlur(nextEffect); } } else { // TODO: Move this out of the hot path using a dedicated effect tag. @@ -2271,7 +2271,7 @@ function commitBeforeMutationEffects() { doesFiberContain(nextEffect, focusedInstanceHandle) ) { shouldFireAfterActiveInstanceBlur = true; - beforeActiveInstanceBlur(); + beforeActiveInstanceBlur(nextEffect); } } } diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index b244810736d8a..a6d661d5fe9cc 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -377,7 +377,7 @@ export function makeOpaqueHydratingObject( }; } -export function beforeActiveInstanceBlur() { +export function beforeActiveInstanceBlur(internalInstanceHandle: Object) { // noop }