react-native-reusables icon indicating copy to clipboard operation
react-native-reusables copied to clipboard

[ BUG ] Portal issue on iOS, RN without expo

Open ndrkltsk opened this issue 5 months ago • 12 comments

Describe the bug It seems to be portal related, I currently working with AlertDialog but every component that requires PortalHost has the same issue. React Native without expo. Working on iOS, didn't tried Android yet. While pressing the trigger, the state actually changes, open property goes from false to true, but nothing happens on screen. I've tested directly using Portal and it seems to work as aspected.

I have <PortalHost/> as last child in my main app.tsx file.

function App() {
  return (
    <>
      <GestureHandlerRootView>
        <SafeAreaProvider>
          <LocalizationProvider>
            <NetworkStatusProvider>
              <QueryProvider>
                <AuthProvider>
                  <ClarityProvider>
                    <NavigationProvider />
                  </ClarityProvider>
                </AuthProvider>
              </QueryProvider>
            </NetworkStatusProvider>
          </LocalizationProvider>
        </SafeAreaProvider>
      </GestureHandlerRootView>
      <PortalHost />
    </>
  );
}

Testing screen with AlertDialog and direct portal test

import React, {useState} from 'react';
import {SafeAreaView, View} from 'react-native';
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from '~/components/ui/alert-dialog';
import {Button} from '~/components/ui/button';
import {Portal} from '@rn-primitives/portal';

import {Text} from '~/components/ui/text/text.component';

export default function TestScreen() {
  const [showPortalTest, setShowPortalTest] = useState(false);

  // Test portal directly
  const togglePortalTest = () => {
    setShowPortalTest(!showPortalTest);
  };

  return (
    <SafeAreaView
      style={{
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center',
        gap: 20,
      }}>
      <AlertDialog>
        <AlertDialogTrigger asChild>
          <Button variant="outline">
            <Text>Show Alert Dialog</Text>
          </Button>
        </AlertDialogTrigger>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
            <AlertDialogDescription>
              This action cannot be undone. This will permanently delete your
              account and remove your data from our servers.
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>
              <Text>Cancel</Text>
            </AlertDialogCancel>
            <AlertDialogAction>
              <Text>Continue</Text>
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>

      {/* Direct portal test */}
      <Button onPress={togglePortalTest}>
        <Text>Toggle Direct Portal Test</Text>
      </Button>

      {/* Direct portal test */}
      {showPortalTest && (
        <Portal name="test-portal">
          <View
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              right: 0,
              bottom: 0,
              backgroundColor: 'rgba(0,0,0,0.5)',
              justifyContent: 'center',
              alignItems: 'center',
              zIndex: 1000,
            }}>
            <View
              style={{
                backgroundColor: 'white',
                padding: 20,
                borderRadius: 8,
                margin: 20,
              }}>
              <Text style={{color: 'black', marginBottom: 10}}>
                Portal Test Working!
              </Text>
              <Button onPress={togglePortalTest}>
                <Text>Close Portal Test</Text>
              </Button>
            </View>
          </View>
        </Portal>
      )}
    </SafeAreaView>
  );
}

Pressing on "Show Alert Dialog" button, show nothing, but the state get updated. Pressing on "Toggle Direct Portal Test" actually shows a Dialog, so I assume that the PortalHost configuration is fine.

Expected behavior Dialog should appears.

Screenshots If applicable, add screenshots to help explain your problem.

Platform (please complete the following information):

  • Type: Simulator, Device
  • OS: iOS

ndrkltsk avatar Jul 09 '25 10:07 ndrkltsk

Hey @ndrkltsk, can you provide a minimal reproduction repo?

mrzachnugent avatar Jul 09 '25 13:07 mrzachnugent

same issues but getting this error: ERROR Warning: Error: NativeViewGestureHandler must be used as a descendant of GestureHandlerRootView. Otherwise the gestures will not be recognized. See https://docs.swmansion.com/react-native-gesture-handler/docs/installation for more details. main layout file: layout.tsx

import "~/global.css";

import {
    DarkTheme,
    DefaultTheme,
    Theme,
    ThemeProvider,
} from "@react-navigation/native";
import { setAndroidNavigationBar } from "~/lib/android-navigation-bar";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { QueryProvider } from "~/providers/query-provider";
import { useColorScheme } from "~/hooks/use-color-scheme";
import { AuthProvider } from "~/contexts/auth-context";
import { Appearance, Platform } from "react-native";
import { PortalHost } from "@rn-primitives/portal";
import { StatusBar } from "expo-status-bar";
import { NAV_THEME } from "~/lib/constants";
import { Stack } from "expo-router";
import * as React from "react";

const LIGHT_THEME: Theme = {
    ...DefaultTheme,
    colors: NAV_THEME.light,
};
const DARK_THEME: Theme = {
    ...DarkTheme,
    colors: NAV_THEME.dark,
};

export {
    // Catch any errors thrown by the Layout component.
    ErrorBoundary,
} from "expo-router";

const usePlatformSpecificSetup = Platform.select({
    web: useSetWebBackgroundClassName,
    android: useSetAndroidNavigationBar,
    default: noop,
});

export default function RootLayout() {
    usePlatformSpecificSetup();
    const { isDarkColorScheme, themeColors } = useColorScheme();

    return (
        <QueryProvider>
            <AuthProvider>
                <ThemeProvider
                    value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}
                >
                    <StatusBar style={isDarkColorScheme ? "light" : "dark"} />
                    <GestureHandlerRootView style={{ flex: 1 }}>
                        <Stack screenOptions={{ headerShown: false }}>
                            <Stack.Screen name="(app)" />
                            <Stack.Screen name="(auth)" />
                        </Stack>
                    </GestureHandlerRootView>
                     <PortalHost />
                </ThemeProvider>
            </AuthProvider>
        </QueryProvider>
    );
}

const useIsomorphicLayoutEffect =
    Platform.OS === "web" && typeof window === "undefined"
        ? React.useEffect
        : React.useLayoutEffect;

function useSetWebBackgroundClassName() {
    useIsomorphicLayoutEffect(() => {
        // Adds the background color to the html element to prevent white background on overscroll.
        document.documentElement.classList.add("bg-background");
    }, []);
}

function useSetAndroidNavigationBar() {
    React.useLayoutEffect(() => {
        setAndroidNavigationBar(Appearance.getColorScheme() ?? "light");
    }, []);
}

function noop() { }

It worked when I put the PortHost inside the GestureHandlerRootView, but about 1 out of 3 times, the tooltip crashes the entire app in both development and production.

mohitramani249 avatar Jul 16 '25 10:07 mohitramani249

@mohitramani249 Yes, if you put the PortalHost outside of a provider, the content inside the Portal components like the Tooltip will no longer have access to the content of that provider.

Not sure I understand this part:

some 1 of 3 times tooltip crash the whole app in all build

Does that mean the app crashes when the PortalHost is outside of the Provider?

mrzachnugent avatar Jul 16 '25 12:07 mrzachnugent

@mohitramani249 Yes, if you put the PortalHost outside of a provider, the content inside the Portal components like the Tooltip will no longer have access to the content of that provider.

Not sure I understand this part:

some 1 of 3 times tooltip crash the whole app in all build

Does that mean the app crashes when the PortalHost is outside of the Provider?

It worked when I put the PortHost inside the GestureHandlerRootView, but about 1 out of 3 times, the tooltip crashes the entire app in both development and production.

mohitramani249 avatar Jul 17 '25 07:07 mohitramani249

For this also, It also crashes sometimes, randomly.

import "~/global.css";

import {
    DarkTheme,
    DefaultTheme,
    Theme,
    ThemeProvider,
} from "@react-navigation/native";
import { setAndroidNavigationBar } from "~/lib/android-navigation-bar";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { QueryProvider } from "~/providers/query-provider";
import { useColorScheme } from "~/hooks/use-color-scheme";
import { AuthProvider } from "~/contexts/auth-context";
import { Appearance, Platform } from "react-native";
import { PortalHost } from "@rn-primitives/portal";
import { StatusBar } from "expo-status-bar";
import { NAV_THEME } from "~/lib/constants";
import { Stack } from "expo-router";
import * as React from "react";

const LIGHT_THEME: Theme = {
    ...DefaultTheme,
    colors: NAV_THEME.light,
};
const DARK_THEME: Theme = {
    ...DarkTheme,
    colors: NAV_THEME.dark,
};

export {
    // Catch any errors thrown by the Layout component.
    ErrorBoundary,
} from "expo-router";

const usePlatformSpecificSetup = Platform.select({
    web: useSetWebBackgroundClassName,
    android: useSetAndroidNavigationBar,
    default: noop,
});

// Placeholder providers - implement these based on your needs
const LocalizationProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
const NetworkStatusProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
const ClarityProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;

const NavigationProvider = () => {
    const { isDarkColorScheme } = useColorScheme();

    return (
        <ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
            <StatusBar style={isDarkColorScheme ? "light" : "dark"} />
            <Stack screenOptions={{ headerShown: false }}>
                <Stack.Screen name="(app)" />
                <Stack.Screen name="(auth)" />
            </Stack>
            <PortalHost />
        </ThemeProvider>
    );
};

export default function RootLayout() {
    usePlatformSpecificSetup();

    return (
        <>
            <GestureHandlerRootView style={{ flex: 1 }}>
                <SafeAreaProvider>
                    <LocalizationProvider>
                        <NetworkStatusProvider>
                            <QueryProvider>
                                <AuthProvider>
                                    <ClarityProvider>
                                        <NavigationProvider />
                                    </ClarityProvider>
                                </AuthProvider>
                            </QueryProvider>
                        </NetworkStatusProvider>
                    </LocalizationProvider>
                </SafeAreaProvider>
            </GestureHandlerRootView>
        </>
    );
}

const useIsomorphicLayoutEffect =
    Platform.OS === "web" && typeof window === "undefined"
        ? React.useEffect
        : React.useLayoutEffect;

function useSetWebBackgroundClassName() {
    useIsomorphicLayoutEffect(() => {
        // Adds the background color to the html element to prevent white background on overscroll.
        document.documentElement.classList.add("bg-background");
    }, []);
}

function useSetAndroidNavigationBar() {
    React.useLayoutEffect(() => {
        setAndroidNavigationBar(Appearance.getColorScheme() ?? "light");
    }, []);
}

function noop() { }

mohitramani249 avatar Jul 17 '25 07:07 mohitramani249

@mohitramani249 please create a new issue with a minimal reproduction repo (required)

mrzachnugent avatar Jul 17 '25 09:07 mrzachnugent

@mrzachnugent Thanks for the support! I’ll do this ASAP. Here’s a screenshot of the error I’m getting:

@mohitramani249 please create a new issue with a minimal reproduction repo (required)

Image

mohitramani249 avatar Jul 17 '25 10:07 mohitramani249

FIX:

I had this issue running in a PNPM monorepo, without Expo, on RN for Windows, Android, and iOS. While I had placed PortalHost from @rn-primitives/portal in my App.tsx as the docs instruct, and @rn-primitives/select uses the same @rn-primitives/portal import internally, these imports did not use the same instance. This means that, even though I only have one @rn-primitives/portal in my node_modules folder, the context and Zustand store in @rn-primitives/portal used different instances between my app and @rn-primitives/select. I have patched the @rn-primitives/select package to re-export its instance of PortalHost, and am using this in my App.tsx.

import React from "react";

import "./global.css";

import { PortalHost } from "@rn-primitives/portal";
import { PortalHost as SelectPortalHost } from "@rn-primitives/select";

export default function App() {
  return (
    <>
      {/* Place other components here */}
      <PortalHost />
      <SelectPortalHost />
    </>
  );
}

diff --git a/dist/select.d.mts b/dist/select.d.mts
index 3292308c0c1f93a4f784955afdaaf4179889f6ab..d1801a69e335a54860798f42008b3b63b207445f 100644
--- a/dist/select.d.mts
+++ b/dist/select.d.mts
@@ -3,6 +3,7 @@ import { ItemTextProps, PortalProps, Option, ScrollDownButtonProps, ScrollUpButt
 import * as react_native from 'react-native';
 import { View, Text, LayoutRectangle } from 'react-native';
 import { LayoutPosition } from '@rn-primitives/hooks';
+import { PortalHost } from "@rn-primitives/portal";
 import * as React from 'react';
 import './select';
 
@@ -92,4 +93,4 @@ declare const ScrollUpButton: ({ children }: ScrollUpButtonProps) => React.JSX.E
 declare const ScrollDownButton: ({ children }: ScrollDownButtonProps) => React.JSX.Element;
 declare const Viewport: ({ children }: ViewportProps) => React.JSX.Element;
 
-export { Content, Group, Item, ItemIndicator, ItemText, Label, Overlay, Portal, Root, ScrollDownButton, ScrollUpButton, Separator, Trigger, Value, Viewport, useItemContext, useRootContext };
+export { Content, Group, Item, ItemIndicator, ItemText, Label, Overlay, Portal, PortalHost, Root, ScrollDownButton, ScrollUpButton, Separator, Trigger, Value, Viewport, useItemContext, useRootContext };
diff --git a/dist/select.d.ts b/dist/select.d.ts
index 4e857ddd6ddd211bca55be90bc198d9b7783fb46..48d5bf66465693b28f6c7f9106333798a0697065 100644
--- a/dist/select.d.ts
+++ b/dist/select.d.ts
@@ -3,6 +3,7 @@ import { ItemTextProps, PortalProps, Option, ScrollDownButtonProps, ScrollUpButt
 import * as react_native from 'react-native';
 import { View, Text, LayoutRectangle } from 'react-native';
 import { LayoutPosition } from '@rn-primitives/hooks';
+import { PortalHost } from "@rn-primitives/portal";
 import * as React from 'react';
 import './select';
 
@@ -92,4 +93,4 @@ declare const ScrollUpButton: ({ children }: ScrollUpButtonProps) => React.JSX.E
 declare const ScrollDownButton: ({ children }: ScrollDownButtonProps) => React.JSX.Element;
 declare const Viewport: ({ children }: ViewportProps) => React.JSX.Element;
 
-export { Content, Group, Item, ItemIndicator, ItemText, Label, Overlay, Portal, Root, ScrollDownButton, ScrollUpButton, Separator, Trigger, Value, Viewport, useItemContext, useRootContext };
+export { Content, Group, Item, ItemIndicator, ItemText, Label, Overlay, Portal, PortalHost, Root, ScrollDownButton, ScrollUpButton, Separator, Trigger, Value, Viewport, useItemContext, useRootContext };
diff --git a/dist/select.js b/dist/select.js
index 2c68c403912caca100a9b3baa62a9442002dbe84..24a30b8932b3928bea736724c26e244577433d84 100644
--- a/dist/select.js
+++ b/dist/select.js
@@ -39,6 +39,7 @@ __export(select_exports, {
   Label: () => Label,
   Overlay: () => Overlay,
   Portal: () => Portal,
+  PortalHost: () => import_portal.PortalHost,
   Root: () => Root,
   ScrollDownButton: () => ScrollDownButton,
   ScrollUpButton: () => ScrollUpButton,
@@ -368,6 +369,7 @@ function onStartShouldSetResponder() {
   Label,
   Overlay,
   Portal,
+  PortalHost: import_portal.PortalHost,
   Root,
   ScrollDownButton,
   ScrollUpButton,
diff --git a/dist/select.mjs b/dist/select.mjs
index 5756165f6d17d4949843a5d86c9f4bfda801ede1..de202b4fb4ceabe3eaec296bd483323d15982173 100644
--- a/dist/select.mjs
+++ b/dist/select.mjs
@@ -6,7 +6,7 @@ import {
   useControllableState,
   useRelativePosition
 } from "@rn-primitives/hooks";
-import { Portal as RNPPortal } from "@rn-primitives/portal";
+import { Portal as RNPPortal, PortalHost } from "@rn-primitives/portal";
 import * as Slot from "@rn-primitives/slot";
 import * as React from "react";
 import {
@@ -327,6 +327,7 @@ export {
   Label,
   Overlay,
   Portal,
+  PortalHost,
   Root,
   ScrollDownButton,
   ScrollUpButton,

matthewaptaylor avatar Jul 30 '25 05:07 matthewaptaylor

@mrzachnugent Even the initial app created by npx @react-native-reusables/cli@latest init crashes if you rapidly click the 'i' icon 5 to 10 times. I’ve attached a video of the issue below, and when it crashes, it opens these two weird files in the code editor.

Image

https://github.com/user-attachments/assets/1e172c48-ce79-4ad9-8662-e1038df5a648

mohitramani249 avatar Aug 05 '25 04:08 mohitramani249

I am using version 0.81.1 without Expo, and I am experiencing the same issue

tastafur avatar Sep 03 '25 08:09 tastafur

✅ Complete Solution: 4 Patches + Configuration

This solution creates dedicated PortalHost instances for each component type, ensuring proper isolation and functionality.

1. Create Patches

Create these 4 patch files in your patches/ directory:

@rn-primitives+dialog+1.2.0.patch

diff --git a/node_modules/@rn-primitives/dialog/dist/dialog.d.ts b/node_modules/@rn-primitives/dialog/dist/dialog.d.ts
index 3d381e9..46e36a6 100644
--- a/node_modules/@rn-primitives/dialog/dist/dialog.d.ts
+++ b/node_modules/@rn-primitives/dialog/dist/dialog.d.ts
@@ -3,6 +3,7 @@ import * as react_native from 'react-native';
 import { View, Text } from 'react-native';
 import * as React from 'react';
 import { PortalProps, RootContext } from './index.js';
+import { PortalHost } from "@rn-primitives/portal";
 import './dialog';
 
 declare const Root: React.ForwardRefExoticComponent<react_native.ViewProps & {
@@ -55,4 +56,4 @@ declare const Description: React.ForwardRefExoticComponent<react_native.TextProp
     asChild?: boolean;
 } & React.RefAttributes<Text>>;
 
-export { Close, Content, Description, Overlay, Portal, Root, Title, Trigger, useRootContext };
+export { Close, Content, Description, Overlay, Portal, PortalHost, Root, Title, Trigger, useRootContext };
diff --git a/node_modules/@rn-primitives/dialog/dist/dialog.js b/node_modules/@rn-primitives/dialog/dist/dialog.js
index 4851de2..6a81722 100644
--- a/node_modules/@rn-primitives/dialog/dist/dialog.js
+++ b/node_modules/@rn-primitives/dialog/dist/dialog.js
@@ -36,6 +36,7 @@ __export(dialog_exports, {
   Description: () => Description,
   Overlay: () => Overlay,
   Portal: () => Portal,
+  PortalHost: () => import_portal.PortalHost,
   Root: () => Root,
   Title: () => Title,
   Trigger: () => Trigger,
@@ -198,6 +199,7 @@ function onStartShouldSetResponder() {
   Description,
   Overlay,
   Portal,
+  PortalHost: import_portal.PortalHost,
   Root,
   Title,
   Trigger,
diff --git a/node_modules/@rn-primitives/dialog/dist/dialog.mjs b/node_modules/@rn-primitives/dialog/dist/dialog.mjs
index 9108d09..866ab9b 100644
--- a/node_modules/@rn-primitives/dialog/dist/dialog.mjs
+++ b/node_modules/@rn-primitives/dialog/dist/dialog.mjs
@@ -2,7 +2,7 @@
 
 // src/dialog.tsx
 import { useControllableState } from "@rn-primitives/hooks";
-import { Portal as RNPPortal } from "@rn-primitives/portal";
+import { Portal as RNPPortal, PortalHost } from "@rn-primitives/portal";
 import * as Slot from "@rn-primitives/slot";
 import * as React from "react";
 import { BackHandler, Pressable as Pressable2, Text, View as View2 } from "react-native";
@@ -156,6 +156,7 @@ export {
   Description,
   Overlay,
   Portal,
+  PortalHost,
   Root,
   Title,
   Trigger,

@rn-primitives+select+1.2.0.patch

diff --git a/node_modules/@rn-primitives/select/dist/select.d.mts b/node_modules/@rn-primitives/select/dist/select.d.mts
index 3292308..d1801a6 100644
--- a/node_modules/@rn-primitives/select/dist/select.d.mts
+++ b/node_modules/@rn-primitives/select/dist/select.d.mts
@@ -3,6 +3,7 @@ import { ItemTextProps, PortalProps, Option, ScrollDownButtonProps, ScrollUpButt
 import * as react_native from 'react-native';
 import { View, Text, LayoutRectangle } from 'react-native';
 import { LayoutPosition } from '@rn-primitives/hooks';
+import { PortalHost } from "@rn-primitives/portal";
 import * as React from 'react';
 import './select';
 
@@ -92,4 +93,4 @@ declare const ScrollUpButton: ({ children }: ScrollUpButtonProps) => React.JSX.E
 declare const ScrollDownButton: ({ children }: ScrollDownButtonProps) => React.JSX.Element;
 declare const Viewport: ({ children }: ViewportProps) => React.JSX.Element;
 
-export { Content, Group, Item, ItemIndicator, ItemText, Label, Overlay, Portal, Root, ScrollDownButton, ScrollUpButton, Separator, Trigger, Value, Viewport, useItemContext, useRootContext };
+export { Content, Group, Item, ItemIndicator, ItemText, Label, Overlay, Portal, PortalHost, Root, ScrollDownButton, ScrollUpButton, Separator, Trigger, Value, Viewport, useItemContext, useRootContext };
diff --git a/node_modules/@rn-primitives/select/dist/select.d.ts b/node_modules/@rn-primitives/select/dist/select.d.ts
index 4e857dd..48d5bf6 100644
--- a/node_modules/@rn-primitives/select/dist/select.d.ts
+++ b/node_modules/@rn-primitives/select/dist/select.d.ts
@@ -3,6 +3,7 @@ import { ItemTextProps, PortalProps, Option, ScrollDownButtonProps, ScrollUpButt
 import * as react_native from 'react-native';
 import { View, Text, LayoutRectangle } from 'react-native';
 import { LayoutPosition } from '@rn-primitives/hooks';
+import { PortalHost } from "@rn-primitives/portal";
 import * as React from 'react';
 import './select';
 
@@ -92,4 +93,4 @@ declare const ScrollUpButton: ({ children }: ScrollUpButtonProps) => React.JSX.E
 declare const ScrollDownButton: ({ children }: ScrollDownButtonProps) => React.JSX.Element;
 declare const Viewport: ({ children }: ViewportProps) => React.JSX.Element;
 
-export { Content, Group, Item, ItemIndicator, ItemText, Label, Overlay, Portal, Root, ScrollDownButton, ScrollUpButton, Separator, Trigger, Value, Viewport, useItemContext, useRootContext };
+export { Content, Group, Item, ItemIndicator, ItemText, Label, Overlay, Portal, PortalHost, Root, ScrollDownButton, ScrollUpButton, Separator, Trigger, Value, Viewport, useItemContext, useRootContext };
diff --git a/node_modules/@rn-primitives/select/dist/select.js b/node_modules/@rn-primitives/select/dist/select.js
index 2c68c40..24a30b8 100644
--- a/node_modules/@rn-primitives/select/dist/select.js
+++ b/node_modules/@rn-primitives/select/dist/select.js
@@ -39,6 +39,7 @@ __export(select_exports, {
   Label: () => Label,
   Overlay: () => Overlay,
   Portal: () => Portal,
+  PortalHost: () => import_portal.PortalHost,
   Root: () => Root,
   ScrollDownButton: () => ScrollDownButton,
   ScrollUpButton: () => ScrollUpButton,
@@ -368,6 +369,7 @@ function onStartShouldSetResponder() {
   Label,
   Overlay,
   Portal,
+  PortalHost: import_portal.PortalHost,
   Root,
   ScrollDownButton,
   ScrollUpButton,
diff --git a/node_modules/@rn-primitives/select/dist/select.mjs b/node_modules/@rn-primitives/select/dist/select.mjs
index 5756165..de202b4 100644
--- a/node_modules/@rn-primitives/select/dist/select.mjs
+++ b/node_modules/@rn-primitives/select/dist/select.mjs
@@ -6,7 +6,7 @@ import {
   useControllableState,
   useRelativePosition
 } from "@rn-primitives/hooks";
-import { Portal as RNPPortal } from "@rn-primitives/portal";
+import { Portal as RNPPortal, PortalHost } from "@rn-primitives/portal";
 import * as Slot from "@rn-primitives/slot";
 import * as React from "react";
 import {
@@ -327,6 +327,7 @@ export {
   Label,
   Overlay,
   Portal,
+  PortalHost,
   Root,
   ScrollDownButton,
   ScrollUpButton,

@rn-primitives+dropdown-menu+1.2.0.patch

diff --git a/node_modules/@rn-primitives/dropdown-menu/dist/dropdown-menu.d.ts b/node_modules/@rn-primitives/dropdown-menu/dist/dropdown-menu.d.ts
index aee9743..b5b65e4 100644
--- a/node_modules/@rn-primitives/dropdown-menu/dist/dropdown-menu.d.ts
+++ b/node_modules/@rn-primitives/dropdown-menu/dist/dropdown-menu.d.ts
@@ -4,6 +4,7 @@ import { View, Text, LayoutRectangle } from 'react-native';
 import { LayoutPosition } from '@rn-primitives/hooks';
 import * as React from 'react';
 import { PortalProps, TriggerRef } from './index.js';
+import { PortalHost } from "@rn-primitives/portal";
 import './dropdown-menu';
 
 interface IRootContext {
@@ -125,4 +126,4 @@ declare const SubContent: React.ForwardRefExoticComponent<Omit<react_native.Pres
     onKeyUp?: (ev: React.KeyboardEvent) => void;
 } & _rn_primitives_types.ForceMountable & React.RefAttributes<View>>;
 
-export { CheckboxItem, Content, Group, Item, ItemIndicator, Label, Overlay, Portal, RadioGroup, RadioItem, Root, Separator, Sub, SubContent, SubTrigger, Trigger, useRootContext, useSubContext };
+export { CheckboxItem, Content, Group, Item, ItemIndicator, Label, Overlay, Portal, PortalHost, RadioGroup, RadioItem, Root, Separator, Sub, SubContent, SubTrigger, Trigger, useRootContext, useSubContext };
diff --git a/node_modules/@rn-primitives/dropdown-menu/dist/dropdown-menu.js b/node_modules/@rn-primitives/dropdown-menu/dist/dropdown-menu.js
index 1560b2f..194eb4f 100644
--- a/node_modules/@rn-primitives/dropdown-menu/dist/dropdown-menu.js
+++ b/node_modules/@rn-primitives/dropdown-menu/dist/dropdown-menu.js
@@ -39,6 +39,7 @@ __export(dropdown_menu_exports, {
   Label: () => Label,
   Overlay: () => Overlay,
   Portal: () => Portal,
+  PortalHost: () => import_portal.PortalHost,
   RadioGroup: () => RadioGroup,
   RadioItem: () => RadioItem,
   Root: () => Root,
@@ -472,6 +473,7 @@ Content.displayName = "ContentNativeDropdownMenu";
   Label,
   Overlay,
   Portal,
+  PortalHost: import_portal.PortalHost,
   RadioGroup,
   RadioItem,
   Root,
diff --git a/node_modules/@rn-primitives/dropdown-menu/dist/dropdown-menu.mjs b/node_modules/@rn-primitives/dropdown-menu/dist/dropdown-menu.mjs
index 9e28da3..f7b6f04 100644
--- a/node_modules/@rn-primitives/dropdown-menu/dist/dropdown-menu.mjs
+++ b/node_modules/@rn-primitives/dropdown-menu/dist/dropdown-menu.mjs
@@ -6,7 +6,7 @@ import {
   useControllableState,
   useRelativePosition
 } from "@rn-primitives/hooks";
-import { Portal as RNPPortal } from "@rn-primitives/portal";
+import { Portal as RNPPortal, PortalHost } from "@rn-primitives/portal";
 import * as Slot from "@rn-primitives/slot";
 import * as React from "react";
 import {
@@ -430,6 +430,7 @@ export {
   Label,
   Overlay,
   Portal,
+  PortalHost,
   RadioGroup,
   RadioItem,
   Root,

@rn-primitives+popover+1.2.0.patch

diff --git a/node_modules/@rn-primitives/popover/dist/popover.d.ts b/node_modules/@rn-primitives/popover/dist/popover.d.ts
index c02f456..e3ad5c3 100644
--- a/node_modules/@rn-primitives/popover/dist/popover.d.ts
+++ b/node_modules/@rn-primitives/popover/dist/popover.d.ts
@@ -4,6 +4,7 @@ import { View, LayoutRectangle } from 'react-native';
 import { LayoutPosition } from '@rn-primitives/hooks';
 import * as React from 'react';
 import { PortalProps, TriggerRef } from './index.js';
+import { PortalHost } from "@rn-primitives/portal";
 import './popover';
 
 interface IRootContext {
@@ -54,4 +55,4 @@ declare const Close: React.ForwardRefExoticComponent<Omit<react_native.Pressable
     onKeyUp?: (ev: React.KeyboardEvent) => void;
 } & React.RefAttributes<View>>;
 
-export { Close, Content, Overlay, Portal, Root, Trigger, useRootContext };
+export { Close, Content, Overlay, Portal, PortalHost, Root, Trigger, useRootContext };
diff --git a/node_modules/@rn-primitives/popover/dist/popover.js b/node_modules/@rn-primitives/popover/dist/popover.js
index 04eeab6..1063226 100644
--- a/node_modules/@rn-primitives/popover/dist/popover.js
+++ b/node_modules/@rn-primitives/popover/dist/popover.js
@@ -35,6 +35,7 @@ __export(popover_exports, {
   Content: () => Content,
   Overlay: () => Overlay,
   Portal: () => Portal,
+  PortalHost: () => import_portal.PortalHost,
   Root: () => Root,
   Trigger: () => Trigger,
   useRootContext: () => useRootContext
@@ -255,6 +256,7 @@ function onStartShouldSetResponder() {
   Content,
   Overlay,
   Portal,
+  PortalHost: import_portal.PortalHost,
   Root,
   Trigger,
   useRootContext
diff --git a/node_modules/@rn-primitives/popover/dist/popover.mjs b/node_modules/@rn-primitives/popover/dist/popover.mjs
index eabdc04..25529bd 100644
--- a/node_modules/@rn-primitives/popover/dist/popover.mjs
+++ b/node_modules/@rn-primitives/popover/dist/popover.mjs
@@ -2,7 +2,7 @@
 
 // src/popover.tsx
 import { useAugmentedRef, useRelativePosition } from "@rn-primitives/hooks";
-import { Portal as RNPPortal } from "@rn-primitives/portal";
+import { Portal as RNPPortal, PortalHost } from "@rn-primitives/portal";
 import * as Slot from "@rn-primitives/slot";
 import * as React from "react";
 import {
@@ -219,6 +219,7 @@ export {
   Content,
   Overlay,
   Portal,
+  PortalHost,
   Root,
   Trigger,
   useRootContext

2. Configure App.tsx

Update your main App component to include all PortalHosts:

import { PortalHost } from '@rn-primitives/portal';
import { PortalHost as DialogPortalHost } from '@rn-primitives/dialog';
import { PortalHost as SelectPortalHost } from '@rn-primitives/select';
import { PortalHost as PopoverPortalHost } from '@rn-primitives/popover';
import { PortalHost as DropdownMenuPortalHost } from '@rn-primitives/dropdown-menu';

function App() {
  return (
    <SafeAreaProvider>
      <AuthProvider>
        <AppNavigator />
        <PortalHost />
        <DialogPortalHost name="dialog-portal" />
        <SelectPortalHost name="select-portal" />
        <PopoverPortalHost name="popover-portal" />
        <DropdownMenuPortalHost name="dropdown-portal" />
      </AuthProvider>
    </SafeAreaProvider>
  );
}

3. Update UI Components (Optional)

For better isolation, update your UI components to use specific portal names:

// Dialog Component
<DialogPortal hostName={portalHost || 'dialog-portal'}>

// Select Component  
<SelectPrimitive.Portal hostName={portalHost || 'select-portal'}>

// Popover Component
<PopoverPrimitive.Portal hostName={portalHost || 'popover-portal'}>

// DropdownMenu Component
<DropdownMenuPrimitive.Portal hostName={portalHost || 'dropdown-portal'}>

4. Apply Patches

Ensure you have patch-package in your package.json:

{
  "scripts": {
    "postinstall": "patch-package"
  },
  "devDependencies": {
    "patch-package": "^8.0.0"
  }
}

How It Works

  1. Dedicated PortalHosts: Each component type gets its own PortalHost instance
  2. Isolated Stores: Prevents Zustand store conflicts between different Portal components
  3. Proper Communication: Each Portal component communicates with its dedicated PortalHost
  4. Backward Compatibility: Maintains support for custom portalHost props

Benefits

  • ✅ Complete Solution: Works for ALL Portal components (Dialog, Select, Popover, DropdownMenu)
  • ✅ iOS Device Compatible: Fixes the issue on real iOS devices
  • ✅ Scalable: Easy to add more Portal components in the future
  • ✅ Robust: Component isolation prevents interference between different Portal types
  • ✅ Maintainable: Clean separation of concerns

This solution provides complete coverage for the Portal issue on iOS devices with React Native without Expo.

tastafur avatar Sep 17 '25 07:09 tastafur

SelectPrimitive.Portal

Do you have a working example by any chance? I tried all your steps but without any luck.

Component:

const fruits = [
    { label: 'Apple', value: 'apple' },
    { label: 'Banana', value: 'banana' },
    { label: 'Blueberry', value: 'blueberry' },
    { label: 'Grapes', value: 'grapes' },
    { label: 'Pineapple', value: 'pineapple' },
];
const FruitSelector = () => {
    const ref = useRef<TriggerRef>(null);
    const insets = useSafeAreaInsets();
    const contentInsets = {
        top: insets.top,
        bottom: Platform.select({ ios: insets.bottom, android: insets.bottom + 24 }),
        left: 12,
        right: 12,
    };

    // Workaround for rn-primitives/select not opening on mobile
    const onTouchStart = () => {
        ref.current?.open();
    };

    return (
        <>
            {/* <PortalHost name='example-host' /> */}
            {/* <SelectPortalHost name='select-portal' /> */}
            <Select className='max-h-lg'>
                <SelectTrigger ref={ref} onTouchStart={onTouchStart}>
                    <SelectValue placeholder='Select a fruit' />
                </SelectTrigger>
                <SelectContent insets={contentInsets} portalHost='select-portal'>
                    <NativeSelectScrollView>
                        <SelectGroup>
                            <SelectLabel>Fruits</SelectLabel>
                            {fruits.map((fruit) => (
                                <SelectItem key={fruit.value} label={fruit.label} value={fruit.value}>
                                    {fruit.label}
                                </SelectItem>
                            ))}
                        </SelectGroup>
                    </NativeSelectScrollView>
                </SelectContent>
            </Select>
        </>
    );
};

App.tsx

            <SafeAreaProvider>
                <GestureHandlerRootView>
                    <ReactQueryProvider>
                        <SafeAreaView className='flex-1'>
                            <StrictMode>
                                <Component />
                            </StrictMode>
                            <Toaster position='top-center' />
                        </SafeAreaView>
                    </ReactQueryProvider>
                    <SelectPortalHost name='select-portal' />
                    <PortalHost />
                </GestureHandlerRootView>
            </SafeAreaProvider>

Select component has

<SelectPrimitive.Portal hostName={portalHost || 'select-portal'}>

I checked my node_modules and it does contain the correct patches. Anything else I could be missing?

MathiasPawshake avatar Nov 26 '25 22:11 MathiasPawshake