diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index 4a525677bd214..dbd86245516ab 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -465,3 +465,7 @@ export function unmountFundamentalComponent(fundamentalInstance) { export function getInstanceFromNode(node) { throw new Error('Not yet implemented.'); } + +export function beforeRemoveInstance(instance) { + // noop +} diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 5cda5e1ec48ef..2d4d9ba109d3e 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -104,6 +104,12 @@ export type ChildSet = void; // Unused export type TimeoutHandle = TimeoutID; export type NoTimeout = -1; +type SelectionInformation = {| + blurredActiveElement: null | HTMLElement, + focusedElem: null | HTMLElement, + selectionRange: mixed, +|}; + import { enableSuspenseServerRenderer, enableFlareAPI, @@ -127,7 +133,7 @@ const SUSPENSE_FALLBACK_START_DATA = '$!'; const STYLE = 'style'; let eventsEnabled: ?boolean = null; -let selectionInformation: ?mixed = null; +let selectionInformation: null | SelectionInformation = null; function shouldAutoFocusHostComponent(type: string, props: Props): boolean { switch (type) { @@ -205,6 +211,13 @@ export function prepareForCommit(containerInfo: Container): void { export function resetAfterCommit(containerInfo: Container): void { restoreSelection(selectionInformation); + if (enableFlareAPI) { + const blurredActiveElement = (selectionInformation: any) + .blurredActiveElement; + if (blurredActiveElement !== null) { + dispatchActiveElementBlur(blurredActiveElement); + } + } selectionInformation = null; ReactBrowserEventEmitterSetEnabled(eventsEnabled); eventsEnabled = null; @@ -452,30 +465,50 @@ export function insertInContainerBefore( } } +function dispatchFlareDetachedBlurEvent( + elementDetached: boolean, + targetInstance: null | Object, + target: Element | Document, +): void { + // Simlulate the custom event to the React Flare responder system. + dispatchEventForResponderEventSystem( + 'blur', + targetInstance, + ({ + elementDetached, + target, + timeStamp: Date.now(), + }: any), + target, + RESPONDER_EVENT_SYSTEM | IS_PASSIVE, + ); +} + +function dispatchBeforeActiveElementBlur(element: HTMLElement): void { + const targtInstance = getClosestInstanceFromNode(element); + ((selectionInformation: any): SelectionInformation).blurredActiveElement = element; + dispatchFlareDetachedBlurEvent(false, targtInstance, element); +} + +function dispatchActiveElementBlur( + node: Instance | TextInstance | SuspenseInstance, +): void { + dispatchFlareDetachedBlurEvent(true, null, ((node: any): HTMLElement)); +} + // This is a specific event for the React Flare // event system, so event responders can act // accordingly to a DOM node being unmounted that // previously had active document focus. -function dispatchDetachedVisibleNodeEvent( - child: Instance | TextInstance | SuspenseInstance, +export function beforeRemoveInstance( + instance: Instance | TextInstance | SuspenseInstance, ): void { if ( enableFlareAPI && selectionInformation && - child === selectionInformation.focusedElem + instance === selectionInformation.focusedElem ) { - const targetFiber = getClosestInstanceFromNode(child); - // Simlulate a blur event to the React Flare responder system. - dispatchEventForResponderEventSystem( - 'detachedvisiblenode', - targetFiber, - ({ - target: child, - timeStamp: Date.now(), - }: any), - ((child: any): Document | Element), - RESPONDER_EVENT_SYSTEM | IS_PASSIVE, - ); + dispatchBeforeActiveElementBlur(((instance: any): HTMLElement)); } } @@ -483,7 +516,6 @@ export function removeChild( parentInstance: Instance, child: Instance | TextInstance | SuspenseInstance, ): void { - dispatchDetachedVisibleNodeEvent(child); parentInstance.removeChild(child); } @@ -494,7 +526,6 @@ export function removeChildFromContainer( if (container.nodeType === COMMENT_NODE) { (container.parentNode: any).removeChild(child); } else { - dispatchDetachedVisibleNodeEvent(child); container.removeChild(child); } } diff --git a/packages/react-dom/src/client/ReactInputSelection.js b/packages/react-dom/src/client/ReactInputSelection.js index f2d0987608b3e..cf413367f7a67 100644 --- a/packages/react-dom/src/client/ReactInputSelection.js +++ b/packages/react-dom/src/client/ReactInputSelection.js @@ -100,6 +100,8 @@ export function hasSelectionCapabilities(elem) { export function getSelectionInformation() { const focusedElem = getActiveElementDeep(); return { + // Used by Flare + blurredActiveElement: null, focusedElem: focusedElem, selectionRange: hasSelectionCapabilities(focusedElem) ? getSelection(focusedElem) diff --git a/packages/react-interactions/events/src/dom/Focus.js b/packages/react-interactions/events/src/dom/Focus.js index 1c7d261fc4686..9886011c66bf1 100644 --- a/packages/react-interactions/events/src/dom/Focus.js +++ b/packages/react-interactions/events/src/dom/Focus.js @@ -45,12 +45,7 @@ type FocusProps = { onFocusVisibleChange: boolean => void, }; -type FocusEventType = - | 'focus' - | 'blur' - | 'focuschange' - | 'focusvisiblechange' - | 'detachedvisiblenode'; +type FocusEventType = 'focus' | 'blur' | 'focuschange' | 'focusvisiblechange'; type FocusWithinProps = { disabled?: boolean, @@ -58,7 +53,8 @@ type FocusWithinProps = { onBlurWithin?: (e: FocusEvent) => void, onFocusWithinChange?: boolean => void, onFocusWithinVisibleChange?: boolean => void, - onDetachedVisibleNode?: (e: FocusEvent) => void, + onBeforeFocusedElementDetached?: (e: FocusEvent) => void, + onFocusedElementDetached?: (e: FocusEvent) => void, }; type FocusWithinEventType = @@ -66,7 +62,8 @@ type FocusWithinEventType = | 'focuswithinchange' | 'blurwithin' | 'focuswithin' - | 'detachedvisiblenode'; + | 'focusedelementdetached' + | 'beforefocusedelementdetached'; /** * Shared between Focus and FocusWithin @@ -79,14 +76,22 @@ const isMac = ? /^Mac/.test(window.navigator.platform) : false; -const targetEventTypes = ['focus', 'blur', 'detachedvisiblenode']; +const targetEventTypes = ['focus', 'blur']; const hasPointerEvents = typeof window !== 'undefined' && window.PointerEvent != null; const rootEventTypes = hasPointerEvents - ? ['keydown', 'keyup', 'pointermove', 'pointerdown', 'pointerup'] - : ['keydown', 'keyup', 'mousedown', 'touchmove', 'touchstart', 'touchend']; + ? ['keydown', 'keyup', 'pointermove', 'pointerdown', 'pointerup', 'blur'] + : [ + 'keydown', + 'keyup', + 'mousedown', + 'touchmove', + 'touchstart', + 'touchend', + 'blur', + ]; function isFunction(obj): boolean { return typeof obj === 'function'; @@ -504,6 +509,23 @@ const focusWithinResponderImpl = { break; } case 'blur': { + if ((nativeEvent: any).elementDetached === false) { + const onBeforeFocusedElementDetached = (props.onBeforeFocusedElementDetached: any); + if (isFunction(onBeforeFocusedElementDetached)) { + const syntheticEvent = createFocusEvent( + context, + 'beforefocusedelementdetached', + event.target, + state.pointerType, + ); + context.dispatchEvent( + syntheticEvent, + onBeforeFocusedElementDetached, + DiscreteEvent, + ); + } + return; + } if ( state.isFocused && !context.isTargetWithinResponder(relatedTarget) @@ -514,22 +536,6 @@ const focusWithinResponderImpl = { } break; } - case 'detachedvisiblenode': { - const onDetachedVisibleNode = (props.onDetachedVisibleNode: any); - if (isFunction(onDetachedVisibleNode)) { - const syntheticEvent = createFocusEvent( - context, - 'detachedvisiblenode', - event.target, - state.pointerType, - ); - context.dispatchEvent( - syntheticEvent, - onDetachedVisibleNode, - DiscreteEvent, - ); - } - } } }, onRootEvent( @@ -538,6 +544,23 @@ const focusWithinResponderImpl = { props: FocusWithinProps, state: FocusState, ): void { + if ((event.nativeEvent: any).elementDetached === true) { + const onFocusedElementDetached = (props.onFocusedElementDetached: any); + if (isFunction(onFocusedElementDetached)) { + const syntheticEvent = createFocusEvent( + context, + 'focusedelementdetached', + event.target, + state.pointerType, + ); + context.dispatchEvent( + syntheticEvent, + onFocusedElementDetached, + DiscreteEvent, + ); + } + return; + } handleRootEvent(event, context, state, isFocusVisible => { if (state.isFocused && state.isFocusVisible !== isFocusVisible) { state.isFocusVisible = isFocusVisible; diff --git a/packages/react-interactions/events/src/dom/__tests__/FocusWithin-test.internal.js b/packages/react-interactions/events/src/dom/__tests__/FocusWithin-test.internal.js index d90a95b8413d8..388bce20f2b27 100644 --- a/packages/react-interactions/events/src/dom/__tests__/FocusWithin-test.internal.js +++ b/packages/react-interactions/events/src/dom/__tests__/FocusWithin-test.internal.js @@ -262,37 +262,77 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => { }); }); - describe('onDetachedVisibleNode', () => { - let onDetachedVisibleNode, ref, innerRef, innerRef2; - - const Component = ({show}) => { - const listener = useFocusWithin({ - onDetachedVisibleNode, - }); - return ( -
- {show && } -
-
- ); - }; + describe('onBeforeFocusedElementDetached/onFocusedElementDetached', () => { + let onBeforeFocusedElementDetached, + onFocusedElementDetached, + ref, + innerRef, + innerRef2; beforeEach(() => { - onDetachedVisibleNode = jest.fn(); + onBeforeFocusedElementDetached = jest.fn(); + onFocusedElementDetached = jest.fn(); ref = React.createRef(); innerRef = React.createRef(); innerRef2 = React.createRef(); - ReactDOM.render(, container); }); it('is called after a focused element is unmounted', () => { + const Component = ({show}) => { + const listener = useFocusWithin({ + onBeforeFocusedElementDetached, + onFocusedElementDetached, + }); + return ( +
+ {show && } +
+
+ ); + }; + + ReactDOM.render(, container); + + const inner = innerRef.current; + const target = createEventTarget(inner); + target.keydown({key: 'Tab'}); + target.focus(); + expect(onBeforeFocusedElementDetached).toHaveBeenCalledTimes(0); + expect(onFocusedElementDetached).toHaveBeenCalledTimes(0); + ReactDOM.render(, container); + expect(onBeforeFocusedElementDetached).toHaveBeenCalledTimes(1); + expect(onFocusedElementDetached).toHaveBeenCalledTimes(1); + }); + + it('is called after a nested focused element is unmounted', () => { + const Component = ({show}) => { + const listener = useFocusWithin({ + onBeforeFocusedElementDetached, + onFocusedElementDetached, + }); + return ( +
+ {show && ( +
+ +
+ )} +
+
+ ); + }; + + ReactDOM.render(, container); + const inner = innerRef.current; const target = createEventTarget(inner); target.keydown({key: 'Tab'}); target.focus(); - expect(onDetachedVisibleNode).toHaveBeenCalledTimes(0); + expect(onBeforeFocusedElementDetached).toHaveBeenCalledTimes(0); + expect(onFocusedElementDetached).toHaveBeenCalledTimes(0); ReactDOM.render(, container); - expect(onDetachedVisibleNode).toHaveBeenCalledTimes(1); + expect(onBeforeFocusedElementDetached).toHaveBeenCalledTimes(1); + expect(onFocusedElementDetached).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index 4b71f19f5557c..0731867150beb 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -495,3 +495,7 @@ export function cloneFundamentalInstance(fundamentalInstance) { export function getInstanceFromNode(node) { throw new Error('Not yet implemented.'); } + +export function beforeRemoveInstance(instance) { + // noop +} diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index 4714fb3dd1b98..8cf0f8d4b4f66 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -534,3 +534,7 @@ export function unmountFundamentalComponent(fundamentalInstance) { export function getInstanceFromNode(node) { throw new Error('Not yet implemented.'); } + +export function beforeRemoveInstance(instance) { + // noop +} diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 02b525a12391f..88a42db93dcaa 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -105,6 +105,7 @@ import { updateFundamentalComponent, commitHydratedContainer, commitHydratedSuspenseInstance, + beforeRemoveInstance, } from './ReactFiberHostConfig'; import { captureCommitPhaseError, @@ -808,6 +809,7 @@ function commitUnmount( dependencies.responders = null; } } + beforeRemoveInstance(current.stateNode); } safelyDetachRef(current); return; diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index bcc228bcd693c..f5097eba0b4b6 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -70,6 +70,7 @@ export const mountFundamentalComponent = export const shouldUpdateFundamentalComponent = $$$hostConfig.shouldUpdateFundamentalComponent; export const getInstanceFromNode = $$$hostConfig.getInstanceFromNode; +export const beforeRemoveInstance = $$$hostConfig.beforeRemoveInstance; // ------------------- // Mutation diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index ea56e7616aa1b..52848ad3aba2a 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -367,3 +367,7 @@ export function getInstanceFromNode(mockNode: Object) { } return null; } + +export function beforeRemoveInstance(instance) { + // noop +}