From a8b0b1a10ba8c5bb941687da92b3e05b34064b7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Tue, 21 Jul 2026 06:01:10 -0700 Subject: [PATCH] Fix parentNode for nested root host views like MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: `NativeDOM::getParentNode` returned the document for any shadow node carrying the `RootNodeKind` trait. That trait is set not only on a surface's actual root node but also on nested host views such as `` (and portal/overlay-style components), so their `parentNode`/`parentElement` incorrectly resolved to the document instead of their real containing element. Because the EventTarget-based event dispatch builds its capture/bubble ancestor path by walking `getParentNode` up the shadow tree, this truncated the path at the modal boundary: a listener registered on an ancestor rendered above the modal never received events (e.g. focus/blur) originating inside it. Fix by returning the document only for the surface's actual root node, identified via `ShadowNode::sameFamily(*currentRevision, *shadowNode)`. Every other node — including nested root-kind nodes — now reports its real structural parent, while disconnected nodes still report none. Changelog: [General][Fixed] - Fix `parentNode`/`parentElement` returning the document instead of the containing element for `` and other nested root host views, which severed capture/bubble event propagation to ancestors rendered above them Differential Revision: D113012362 --- .../react/nativemodule/dom/NativeDOM.cpp | 14 ++++-- .../__tests__/ReactNativeElement-itest.js | 44 ++++++++++++++++++- 2 files changed, 53 insertions(+), 5 deletions(-) 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', () => {