diff --git a/packages/react-native/Libraries/ReactNative/FabricUIManager.js b/packages/react-native/Libraries/ReactNative/FabricUIManager.js index 40de0d069c752f..e6767b622c9745 100644 --- a/packages/react-native/Libraries/ReactNative/FabricUIManager.js +++ b/packages/react-native/Libraries/ReactNative/FabricUIManager.js @@ -43,6 +43,7 @@ export interface Spec { +appendChild: (parentNode: Node, child: Node) => Node; +appendChildToSet: (childSet: NodeSet, child: Node) => void; +completeRoot: (rootTag: RootTag, childSet: NodeSet) => void; + +mountPortalChildren: (targetTag: number, childSet: NodeSet) => void; +measure: ( node: Node | NativeElementReference, callback: MeasureOnSuccessCallback, @@ -114,6 +115,7 @@ const CACHED_PROPERTIES = [ 'appendChild', 'appendChildToSet', 'completeRoot', + 'mountPortalChildren', 'measure', 'measureInWindow', 'measureLayout', diff --git a/packages/react-native/Libraries/ReactNative/RendererImplementation.js b/packages/react-native/Libraries/ReactNative/RendererImplementation.js index 5874de821cf131..0d980d976ecbce 100644 --- a/packages/react-native/Libraries/ReactNative/RendererImplementation.js +++ b/packages/react-native/Libraries/ReactNative/RendererImplementation.js @@ -171,6 +171,9 @@ export const getPublicInstanceFromInternalInstanceHandle: ReactFabricType['getPu export const getPublicInstanceFromRootTag: ReactFabricType['getPublicInstanceFromRootTag'] = getFabricMethod('getPublicInstanceFromRootTag'); +export const createPortal: ReactFabricType['createPortal'] = + getFabricMethod('createPortal'); + export function isProfilingRenderer(): boolean { return Boolean(__DEV__); } diff --git a/packages/react-native/Libraries/Renderer/implementations/ReactFabric-dev.js b/packages/react-native/Libraries/Renderer/implementations/ReactFabric-dev.js index daab0e35a8e516..5683c5b620ec7e 100644 --- a/packages/react-native/Libraries/Renderer/implementations/ReactFabric-dev.js +++ b/packages/react-native/Libraries/Renderer/implementations/ReactFabric-dev.js @@ -9091,10 +9091,11 @@ __DEV__ && pushHostContext(workInProgress); break; case 4: - pushHostContainer( - workInProgress, - workInProgress.stateNode.containerInfo - ); + var _containerInfo = workInProgress.stateNode.containerInfo; + if (_containerInfo._isPortal) { + _containerInfo.surfaceId = requiredContext(rootInstanceStackCursor.current).containerTag; + } + pushHostContainer(workInProgress, _containerInfo); break; case 10: pushProvider( @@ -9426,11 +9427,15 @@ __DEV__ && return null; case 13: return updateSuspenseComponent(current, workInProgress, renderLanes); - case 4: + case 4: { + var _containerInfo2 = workInProgress.stateNode.containerInfo; + if (_containerInfo2._isPortal) { + _containerInfo2.surfaceId = requiredContext(rootInstanceStackCursor.current).containerTag; + } return ( pushHostContainer( workInProgress, - workInProgress.stateNode.containerInfo + _containerInfo2 ), (returnFiber = workInProgress.pendingProps), null === current @@ -9448,6 +9453,7 @@ __DEV__ && ), workInProgress.child ); + } case 11: return updateForwardRef( current, @@ -10056,7 +10062,7 @@ __DEV__ && node: createNode( renderLanes, _type2.uiViewClassName, - current.containerTag, + current.surfaceId || current.containerTag, keepChildren, workInProgress ), @@ -15902,7 +15908,7 @@ __DEV__ && node: createNode( hostContext, "RCTRawText", - rootContainerInstance.containerTag, + rootContainerInstance.surfaceId || rootContainerInstance.containerTag, { text: text }, internalInstanceHandle ) @@ -15961,7 +15967,14 @@ __DEV__ && }; } function replaceContainerChildren(container, newChildren) { - completeRoot(container.containerTag, newChildren); + if (container._isPortal) { + mountPortalChildren(container.containerTag, newChildren); + if (newChildren.length === 0) { + portalContainerCache.delete(container.containerTag); + } + } else { + completeRoot(container.containerTag, newChildren); + } } function nativeOnUncaughtError(error, errorInfo) { !1 !== @@ -18701,6 +18714,7 @@ __DEV__ && appendChildNode = _nativeFabricUIManage.appendChild, appendChildNodeToSet = _nativeFabricUIManage.appendChildToSet, completeRoot = _nativeFabricUIManage.completeRoot, + mountPortalChildren = _nativeFabricUIManage.mountPortalChildren, registerEventHandler = _nativeFabricUIManage.registerEventHandler, FabricDiscretePriority = _nativeFabricUIManage.unstable_DiscreteEventPriority, @@ -18924,10 +18938,20 @@ __DEV__ && internals.getCurrentFiber = getCurrentFiberForDevTools; return injectInternals(internals); })(); + var portalContainerCache = new Map(); exports.createPortal = function (children, containerTag) { + var portalContainer = portalContainerCache.get(containerTag); + if (!portalContainer) { + portalContainer = { + containerTag: containerTag, + publicInstance: null, + _isPortal: true + }; + portalContainerCache.set(containerTag, portalContainer); + } return createPortal$1( children, - containerTag, + portalContainer, null, 2 < arguments.length && void 0 !== arguments[2] ? arguments[2] : null ); diff --git a/packages/react-native/Libraries/Renderer/shims/ReactNativeTypes.js b/packages/react-native/Libraries/Renderer/shims/ReactNativeTypes.js index bd5cf5eaace7b0..f0af203f0f42ec 100644 --- a/packages/react-native/Libraries/Renderer/shims/ReactNativeTypes.js +++ b/packages/react-native/Libraries/Renderer/shims/ReactNativeTypes.js @@ -200,6 +200,11 @@ export type ReactFabricType = { internalInstanceHandle: InternalInstanceHandle, ): PublicInstance | PublicTextInstance | null, getPublicInstanceFromRootTag(rootTag: number): PublicRootInstance | null, + createPortal( + children: React.Node, + containerTag: number, + key?: ?string, + ): React.MixedElement, ... }; diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManager.cpp b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManager.cpp index c1d12096c2fda5..f9fd6c63f4c6fb 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManager.cpp +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManager.cpp @@ -21,6 +21,7 @@ #include +#include #include namespace { @@ -182,6 +183,29 @@ void UIManager::appendChild( componentDescriptor.appendChild(parentShadowNode, childShadowNode); } +static std::shared_ptr findShadowNodeByTagRecursively( + std::shared_ptr parentShadowNode, + Tag tag); + +void UIManager::mountPortalChildren( + Tag targetTag, + const ShadowNode::UnsharedListOfShared& portalChildren) { + if (portalChildren->empty()) { + auto it = portalChildren_.find(targetTag); + if (it != portalChildren_.end()) { + std::unordered_set tags; + for (const auto& child : *(it->second)) { + tags.insert(child->getTag()); + } + pendingPortalRemovals_[targetTag] = std::move(tags); + portalChildren_.erase(it); + } + } else { + portalChildren_[targetTag] = portalChildren; + pendingPortalRemovals_.erase(targetTag); + } +} + void UIManager::completeSurface( SurfaceId surfaceId, const ShadowNode::UnsharedListOfShared& rootChildren, @@ -191,12 +215,82 @@ void UIManager::completeSurface( shadowTreeRegistry_.visit(surfaceId, [&](const ShadowTree& shadowTree) { auto result = shadowTree.commit( [&](const RootShadowNode& oldRootShadowNode) { - return std::make_shared( + auto newRoot = std::make_shared( oldRootShadowNode, ShadowNodeFragment{ .props = ShadowNodeFragment::propsPlaceholder(), .children = rootChildren, }); + + // Append portal children to the target node + for (const auto& [targetTag, portalChildren] : portalChildren_) { + const auto& localPortalChildren = portalChildren; + + auto targetNode = findShadowNodeByTagRecursively( + std::static_pointer_cast(newRoot), + targetTag); + + if (targetNode) { + auto clonedRoot = newRoot->cloneTree( + targetNode->getFamily(), + [&](const ShadowNode& oldNode) { + auto existingChildren = oldNode.getChildren(); + + std::unordered_set portalTags; + for (const auto& child : *localPortalChildren) { + portalTags.insert(child->getTag()); + } + + auto mergedChildren = std::make_shared>>(); + mergedChildren->reserve(existingChildren.size() + localPortalChildren->size()); + for (const auto& child : existingChildren) { + if (portalTags.find(child->getTag()) == portalTags.end()) { + mergedChildren->push_back(child); + } + } + + // Append portal children + for (const auto& child : *localPortalChildren) { + mergedChildren->push_back(child); + } + + return oldNode.clone({.children = mergedChildren}); + }); + if (clonedRoot) { + newRoot = std::static_pointer_cast(clonedRoot); + } + } + } + + // Remove unmounted portal children + for (const auto& [targetTag, tagsToRemove] : pendingPortalRemovals_) { + const auto& localTagsToRemove = tagsToRemove; + + auto targetNode = findShadowNodeByTagRecursively( + std::static_pointer_cast(newRoot), + targetTag); + + if (targetNode) { + auto clonedRoot = newRoot->cloneTree( + targetNode->getFamily(), + [&](const ShadowNode& oldNode) { + auto existingChildren = oldNode.getChildren(); + auto newChildren = std::make_shared>>(); + for (const auto& child : existingChildren) { + if (localTagsToRemove.find(child->getTag()) == localTagsToRemove.end()) { + newChildren->push_back(child); + } + } + return oldNode.clone({.children = newChildren}); + }); + if (clonedRoot) { + newRoot = std::static_pointer_cast(clonedRoot); + } + } + } + pendingPortalRemovals_.clear(); + + return newRoot; }, commitOptions); diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManager.h b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManager.h index eac4b5d9a49a1a..66549b5b01162a 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManager.h +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManager.h @@ -154,6 +154,10 @@ class UIManager final : public ShadowTreeDelegate { const ShadowNode::UnsharedListOfShared &rootChildren, ShadowTree::CommitOptions commitOptions); + void mountPortalChildren( + Tag targetTag, + const ShadowNode::UnsharedListOfShared &portalChildren); + void setIsJSResponder( const std::shared_ptr &shadowNode, bool isJSResponder, @@ -249,6 +253,12 @@ class UIManager final : public ShadowTreeDelegate { std::unique_ptr lazyShadowTreeRevisionConsistencyManager_; std::shared_ptr animationBackend_; + + // Portal children that are active and need to be committed + std::unordered_map portalChildren_; + + // Portal children that are pending removal and need to be removed on the next commit + std::unordered_map> pendingPortalRemovals_; }; } // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp index 5f661273a67834..53b108ad6c11e1 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp @@ -471,6 +471,28 @@ jsi::Value UIManagerBinding::get( }); } + if (methodName == "mountPortalChildren") { + auto paramCount = 2; + return jsi::Function::createFromHostFunction( + runtime, + name, + paramCount, + [uiManager, methodName, paramCount]( + jsi::Runtime& runtime, + const jsi::Value& /*thisValue*/, + const jsi::Value* arguments, + size_t count) -> jsi::Value { + validateArgumentCount(runtime, methodName, paramCount, count); + + auto targetTag = static_cast(arguments[0].asNumber()); + auto shadowNodeList = + shadowNodeListFromValue(runtime, arguments[1]); + uiManager->mountPortalChildren(targetTag, shadowNodeList); + + return jsi::Value::undefined(); + }); + } + if (methodName == "registerEventHandler") { auto paramCount = 1; return jsi::Function::createFromHostFunction( diff --git a/packages/react-native/src/private/renderer/mounting/__tests__/Portal-itest.js b/packages/react-native/src/private/renderer/mounting/__tests__/Portal-itest.js new file mode 100644 index 00000000000000..204da21fc0c8a7 --- /dev/null +++ b/packages/react-native/src/private/renderer/mounting/__tests__/Portal-itest.js @@ -0,0 +1,357 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HostInstance} from 'react-native'; + +import ensureInstance from '../../../__tests__/utilities/ensureInstance'; +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {View} from 'react-native'; +import {createPortal} from 'react-native/Libraries/ReactNative/RendererProxy'; +import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; + +function getTargetTag(ref: {current: HostInstance | null}): number { + const element = ensureInstance(ref.current, ReactNativeElement); + return element.__nativeTag; +} + +describe('Portal', () => { + test('renders children into target view', () => { + const root = Fantom.createRoot(); + let targetTag: ?number = null; + const targetRef = React.createRef(); + + function TestComponent({showPortal}: {showPortal: boolean}) { + React.useLayoutEffect(() => { + if (targetRef.current) { + targetTag = getTargetTag(targetRef); + } + }, []); + + return ( + + + {showPortal && targetTag != null + ? createPortal(, targetTag) + : null} + + + + ); + } + + // Initial render to get the target tag + Fantom.runTask(() => { + root.render(); + }); + + expect(targetTag).not.toBeNull(); + + // Mount portal + Fantom.runTask(() => { + root.render(); + }); + + // Portal child should appear inside the target view + expect(root.getRenderedOutput().toJSON()).toEqual({ + type: 'View', + props: {nativeID: 'root'}, + children: [ + { + type: 'View', + props: {nativeID: 'target'}, + children: [ + {type: 'View', props: {nativeID: 'portaled-child'}, children: []}, + ], + }, + ], + }); + }); + + test('unmounts portal children from target view', () => { + const root = Fantom.createRoot(); + let targetTag: ?number = null; + const targetRef = React.createRef(); + + function TestComponent({showPortal}: {showPortal: boolean}) { + React.useLayoutEffect(() => { + if (targetRef.current) { + targetTag = getTargetTag(targetRef); + } + }, []); + + return ( + + + {showPortal && targetTag != null + ? createPortal(, targetTag) + : null} + + + + ); + } + + Fantom.runTask(() => { + root.render(); + }); + + expect(targetTag).not.toBeNull(); + + // Mount portal + Fantom.runTask(() => { + root.render(); + }); + + expect(root.getRenderedOutput().toJSON()).toEqual({ + type: 'View', + props: {nativeID: 'root'}, + children: [ + { + type: 'View', + props: {nativeID: 'target'}, + children: [ + {type: 'View', props: {nativeID: 'portaled-child'}, children: []}, + ], + }, + ], + }); + + // Unmount portal + Fantom.runTask(() => { + root.render(); + }); + + // Portal child should be removed from target view + expect(root.getRenderedOutput().toJSON()).toEqual({ + type: 'View', + props: {nativeID: 'root'}, + children: [{type: 'View', props: {nativeID: 'target'}, children: []}], + }); + }); + + test('preserves React context through portals', () => { + const root = Fantom.createRoot(); + let targetTag: ?number = null; + let capturedTheme: ?string = null; + const targetRef = React.createRef(); + const ThemeContext = React.createContext('light'); + + function ContextReader() { + capturedTheme = React.useContext(ThemeContext); + return ; + } + + function TestComponent({showPortal}: {showPortal: boolean}) { + React.useLayoutEffect(() => { + if (targetRef.current) { + targetTag = getTargetTag(targetRef); + } + }, []); + + return ( + + + + {showPortal && targetTag != null + ? createPortal(, targetTag) + : null} + + + + + ); + } + + Fantom.runTask(() => { + root.render(); + }); + + Fantom.runTask(() => { + root.render(); + }); + + expect(capturedTheme).toBe('dark'); + }); + + test('does not duplicate portal children on re-render and updates content', () => { + const root = Fantom.createRoot(); + let targetTag: ?number = null; + const targetRef = React.createRef(); + + function TestComponent({ + showPortal, + counter, + }: { + showPortal: boolean, + counter: number, + }) { + React.useLayoutEffect(() => { + if (targetRef.current) { + targetTag = getTargetTag(targetRef); + } + }, []); + + return ( + + + {showPortal && targetTag != null + ? createPortal( + , + targetTag, + ) + : null} + + + + + ); + } + + Fantom.runTask(() => { + root.render(); + }); + + // Mount portal + Fantom.runTask(() => { + root.render(); + }); + + expect(root.getRenderedOutput().toJSON()).toEqual({ + type: 'View', + props: {nativeID: 'root'}, + children: [ + { + type: 'View', + props: {nativeID: 'target'}, + children: [ + {type: 'View', props: {nativeID: 'portaled-0'}, children: []}, + ], + }, + {type: 'View', props: {nativeID: 'counter-0'}, children: []}, + ], + }); + + // Re-render: portal content should update + Fantom.runTask(() => { + root.render(); + }); + + expect(root.getRenderedOutput().toJSON()).toEqual({ + type: 'View', + props: {nativeID: 'root'}, + children: [ + { + type: 'View', + props: {nativeID: 'target'}, + children: [ + {type: 'View', props: {nativeID: 'portaled-1'}, children: []}, + ], + }, + {type: 'View', props: {nativeID: 'counter-1'}, children: []}, + ], + }); + + // Re-render again + Fantom.runTask(() => { + root.render(); + }); + + expect(root.getRenderedOutput().toJSON()).toEqual({ + type: 'View', + props: {nativeID: 'root'}, + children: [ + { + type: 'View', + props: {nativeID: 'target'}, + children: [ + {type: 'View', props: {nativeID: 'portaled-2'}, children: []}, + ], + }, + {type: 'View', props: {nativeID: 'counter-2'}, children: []}, + ], + }); + }); + + test('mount and unmount cycle works multiple times', () => { + const root = Fantom.createRoot(); + let targetTag: ?number = null; + const targetRef = React.createRef(); + + function TestComponent({showPortal}: {showPortal: boolean}) { + React.useLayoutEffect(() => { + if (targetRef.current) { + targetTag = getTargetTag(targetRef); + } + }, []); + + return ( + + + {showPortal && targetTag != null + ? createPortal(, targetTag) + : null} + + + + ); + } + + Fantom.runTask(() => { + root.render(); + }); + + const withPortal = { + type: 'View', + props: {nativeID: 'root'}, + children: [ + { + type: 'View', + props: {nativeID: 'target'}, + children: [ + {type: 'View', props: {nativeID: 'portaled-child'}, children: []}, + ], + }, + ], + }; + + const withoutPortal = { + type: 'View', + props: {nativeID: 'root'}, + children: [{type: 'View', props: {nativeID: 'target'}, children: []}], + }; + + // mount + Fantom.runTask(() => { + root.render(); + }); + expect(root.getRenderedOutput().toJSON()).toEqual(withPortal); + + // unmount + Fantom.runTask(() => { + root.render(); + }); + expect(root.getRenderedOutput().toJSON()).toEqual(withoutPortal); + + // mount + Fantom.runTask(() => { + root.render(); + }); + expect(root.getRenderedOutput().toJSON()).toEqual(withPortal); + + // unmount + Fantom.runTask(() => { + root.render(); + }); + expect(root.getRenderedOutput().toJSON()).toEqual(withoutPortal); + }); +}); diff --git a/packages/rn-tester/js/examples/Portal/PortalExample.js b/packages/rn-tester/js/examples/Portal/PortalExample.js new file mode 100644 index 00000000000000..661339eadc926f --- /dev/null +++ b/packages/rn-tester/js/examples/Portal/PortalExample.js @@ -0,0 +1,250 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; + +import * as React from 'react'; +import {useCallback, useState} from 'react'; +import {Button, StyleSheet, Text, View, findNodeHandle} from 'react-native'; +import {createPortal} from 'react-native/Libraries/ReactNative/RendererProxy'; + +function PortalBasicExample(): React.Node { + const [showPortal, setShowPortal] = useState(false); + const [targetTag, setTargetTag] = useState(null); + const targetRef = React.useRef | null>(null); + + const handleMount = useCallback(() => { + const tag = findNodeHandle(targetRef.current); + if (tag != null) { + setTargetTag(tag); + setShowPortal(true); + } + }, []); + + const handleUnmount = useCallback(() => { + setShowPortal(false); + setTargetTag(null); + }, []); + + return ( + + +