diff --git a/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp b/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp index ba1ad60d4db9..b01fab0c8913 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp @@ -198,10 +198,6 @@ jsi::Value NativeDOM::getParentNode( } auto shadowNode = getShadowNode(rt, nativeNodeReference); - if (isRootShadowNode(*shadowNode)) { - // The parent of the root node is the document. - return jsi::Value{shadowNode->getSurfaceId()}; - } auto currentRevision = getCurrentShadowTreeRevision(rt, shadowNode->getSurfaceId()); @@ -209,6 +205,16 @@ jsi::Value NativeDOM::getParentNode( return jsi::Value::undefined(); } + // The parent of the surface's root node is the document. Only the actual + // root node qualifies: nested nodes that carry the `RootNodeKind` trait + // (e.g. , portals/overlays) still have a real parent in the shadow + // tree and must report it. Otherwise capture/bubble event propagation is + // silently severed at that boundary (a listener on an ancestor rendered + // above the modal would never receive descendant focus/blur, etc.). + if (ShadowNode::sameFamily(*currentRevision, *shadowNode)) { + return jsi::Value{shadowNode->getSurfaceId()}; + } + auto parentShadowNode = dom::getParentNode(currentRevision, *shadowNode); if (parentShadowNode == nullptr) { return jsi::Value::undefined(); diff --git a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js index d0757fcdba75..63770292a848 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js +++ b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js @@ -20,13 +20,14 @@ import TextInputState from '../../../../../../Libraries/Components/TextInput/Tex import * as Fantom from '@react-native/fantom'; import * as React from 'react'; import {createRef} from 'react'; -import {ScrollView, Text, TextInput, View} from 'react-native'; +import {Modal, ScrollView, Text, TextInput, View} from 'react-native'; import { NativeText, NativeVirtualText, } from 'react-native/Libraries/Text/TextNativeComponent'; import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags'; import Event from 'react-native/src/private/webapis/dom/events/Event'; +import ReactNativeDocument from 'react-native/src/private/webapis/dom/nodes/ReactNativeDocument'; import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; import ReadOnlyElement from 'react-native/src/private/webapis/dom/nodes/ReadOnlyElement'; import ReadOnlyNode from 'react-native/src/private/webapis/dom/nodes/ReadOnlyNode'; @@ -398,6 +399,47 @@ describe('ReactNativeElement', () => { expect(childNodeC.parentNode).toBe(null); expect(childNodeC.parentElement).toBe(null); }); + + it('returns the containing element as the parent of a modal host view, not the document', () => { + const parentRef = createRef(); + const modalRef = createRef(); + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + const parentNode = ensureReactNativeElement(parentRef.current); + const modalNode = ensureReactNativeElement(modalRef.current); + const document = ensureInstance( + parentNode.ownerDocument, + ReactNativeDocument, + ); + + // Capture the relations before tearing down, so cleanup runs even if + // the assertions below fail. + const modalParentNode = modalNode.parentNode; + const modalParentElement = modalNode.parentElement; + + // Unmount and drain the queue so the modal's AppContainer passive + // effects (in __DEV__) don't trip the global "MessageQueue is not + // empty" validation hook. + root.destroy(); + Fantom.runWorkLoop(); + + // The host view is a root-kind shadow node, but its parent + // must still be its actual containing element, NOT the document. + // Two-phase event propagation (e.g. focus/blur bubbling to ancestors + // rendered above the modal) walks this parent chain, so returning the + // document here silently severs bubbling at the modal boundary. + expect(modalParentNode).toBe(parentNode); + expect(modalParentElement).toBe(parentNode); + expect(modalParentNode).not.toBe(document); + }); }); describe('compareDocumentPosition / contains', () => {