diff --git a/.changeset/sharp-cars-approve.md b/.changeset/sharp-cars-approve.md
new file mode 100644
index 00000000..5df25ad1
--- /dev/null
+++ b/.changeset/sharp-cars-approve.md
@@ -0,0 +1,13 @@
+---
+'@reown/appkit-bitcoin-react-native': patch
+'@reown/appkit-coinbase-react-native': patch
+'@reown/appkit-common-react-native': patch
+'@reown/appkit-core-react-native': patch
+'@reown/appkit-ethers-react-native': patch
+'@reown/appkit-react-native': patch
+'@reown/appkit-solana-react-native': patch
+'@reown/appkit-ui-react-native': patch
+'@reown/appkit-wagmi-react-native': patch
+---
+
+fix: add modalContentWrapper prop to work around Expo Router modal layering issues
diff --git a/examples/expo-multichain/app/_layout.tsx b/examples/expo-multichain/app/_layout.tsx
index 6119a864..95d4e518 100644
--- a/examples/expo-multichain/app/_layout.tsx
+++ b/examples/expo-multichain/app/_layout.tsx
@@ -96,7 +96,9 @@ export default function RootLayout() {
- {/* This is a workaround for the Android modal issue. https://github.com/expo/expo/issues/32991#issuecomment-2489620459 */}
+ {/* Mount AppKit once in the root layout to avoid Android Expo Router modal layering issues.
+ If your app already uses react-native-screens and transparentModal still hides the modal,
+ pass modalContentWrapper={FullWindowOverlay} here. */}
diff --git a/jest.config.ts b/jest.config.ts
index 98a89709..5d12cd6d 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -1,3 +1,5 @@
+const path = require('path');
+
module.exports = {
preset: 'react-native',
modulePathIgnorePatterns: ['/node_modules', '/lib/'],
@@ -8,6 +10,16 @@ module.exports = {
'^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { configFile: './babel.config.js' }]
},
moduleNameMapper: {
- '^@shared-jest-setup$': '/jest-shared-setup.ts'
+ '^@shared-jest-setup$': '/jest-shared-setup.ts',
+ '^@reown/appkit-react-native$': path.join(__dirname, 'packages/appkit/src/index.ts'),
+ '^@reown/appkit-bitcoin-react-native$': path.join(__dirname, 'packages/bitcoin/src/index.tsx'),
+ '^@reown/appkit-coinbase-react-native$': path.join(__dirname, 'packages/coinbase/src/index.ts'),
+ '^@reown/appkit-common-react-native$': path.join(__dirname, 'packages/common/src/index.ts'),
+ '^@reown/appkit-core-react-native$': path.join(__dirname, 'packages/core/src/index.ts'),
+ '^@reown/appkit-ethers-react-native$': path.join(__dirname, 'packages/ethers/src/index.tsx'),
+ '^@reown/appkit-react-native-cli$': path.join(__dirname, 'packages/cli/src/index.ts'),
+ '^@reown/appkit-solana-react-native$': path.join(__dirname, 'packages/solana/src/index.ts'),
+ '^@reown/appkit-ui-react-native$': path.join(__dirname, 'packages/ui/src/index.ts'),
+ '^@reown/appkit-wagmi-react-native$': path.join(__dirname, 'packages/wagmi/src/index.tsx')
}
};
diff --git a/packages/appkit/jest.config.ts b/packages/appkit/jest.config.ts
index f2fb133e..93551e06 100644
--- a/packages/appkit/jest.config.ts
+++ b/packages/appkit/jest.config.ts
@@ -1,8 +1,10 @@
+const rootConfig = require('../../jest.config');
+
const appkitConfig = {
- ...require('../../jest.config'),
+ ...rootConfig,
setupFilesAfterEnv: ['./jest-setup.ts'],
- // Override the moduleNameMapper to use the correct path from the package
moduleNameMapper: {
+ ...rootConfig.moduleNameMapper,
'^@shared-jest-setup$': '../../jest-shared-setup.ts'
}
};
diff --git a/packages/appkit/src/__tests__/hooks/useAppKitTheme.test.tsx b/packages/appkit/src/__tests__/hooks/useAppKitTheme.test.tsx
index cfc5ea8d..37a912d6 100644
--- a/packages/appkit/src/__tests__/hooks/useAppKitTheme.test.tsx
+++ b/packages/appkit/src/__tests__/hooks/useAppKitTheme.test.tsx
@@ -18,16 +18,20 @@ jest.mock('valtio', () => ({
}));
// Mock ThemeController
-jest.mock('@reown/appkit-core-react-native', () => ({
- ThemeController: {
- state: {
- themeMode: 'light',
- themeVariables: {}
- },
- setDefaultThemeMode: jest.fn(),
- setThemeVariables: jest.fn()
- }
-}));
+jest.mock(
+ '@reown/appkit-core-react-native',
+ () => ({
+ ThemeController: {
+ state: {
+ themeMode: 'light',
+ themeVariables: {}
+ },
+ setDefaultThemeMode: jest.fn(),
+ setThemeVariables: jest.fn()
+ }
+ }),
+ { virtual: true }
+);
describe('useAppKitTheme', () => {
const mockAppKit = {} as AppKit;
diff --git a/packages/appkit/src/__tests__/modal/w3m-modal.test.tsx b/packages/appkit/src/__tests__/modal/w3m-modal.test.tsx
new file mode 100644
index 00000000..1adac9d9
--- /dev/null
+++ b/packages/appkit/src/__tests__/modal/w3m-modal.test.tsx
@@ -0,0 +1,132 @@
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import { View } from 'react-native';
+
+const mockClose = jest.fn();
+const mockPrefetch = jest.fn().mockResolvedValue(undefined);
+const mockSendEvent = jest.fn();
+const modalState = {
+ open: true
+};
+
+jest.mock('valtio', () => ({
+ useSnapshot: jest.fn((state: Record) => state)
+}));
+
+jest.mock('react-native-safe-area-context', () => ({
+ useSafeAreaInsets: jest.fn(() => ({ top: 0, bottom: 0 }))
+}));
+
+jest.mock('../../AppKitContext', () => ({
+ useInternalAppKit: jest.fn(() => ({
+ close: mockClose
+ }))
+}));
+
+jest.mock('../../partials/w3m-header', () => ({
+ Header: () => 'Header'
+}));
+
+jest.mock('../../partials/w3m-snackbar', () => ({
+ Snackbar: () => 'Snackbar'
+}));
+
+jest.mock('../../modal/w3m-router', () => ({
+ AppKitRouter: () => 'Router'
+}));
+
+jest.mock(
+ '@reown/appkit-ui-react-native',
+ () => {
+ const { View: MockView } = require('react-native');
+
+ return {
+ Card: ({ children, ...props }: any) => (
+
+ {children}
+
+ ),
+ Modal: ({ children, testID, visible, contentWrapper: ContentWrapper }: any) => {
+ if (!visible) {
+ return null;
+ }
+
+ const content = ContentWrapper ? {children} : children;
+
+ return {content};
+ },
+ ThemeProvider: ({ children }: any) => <>{children}>
+ };
+ },
+ { virtual: true }
+);
+
+jest.mock(
+ '@reown/appkit-core-react-native',
+ () => ({
+ ApiController: {
+ prefetch: mockPrefetch
+ },
+ EventsController: {
+ sendEvent: mockSendEvent
+ },
+ ModalController: {
+ state: modalState
+ },
+ OptionsController: {
+ state: {
+ projectId: 'project-id'
+ }
+ },
+ RouterController: {
+ state: {
+ history: ['Connect']
+ },
+ goBack: jest.fn()
+ },
+ ThemeController: {
+ state: {
+ themeMode: 'light',
+ themeVariables: {}
+ },
+ setSystemThemeMode: jest.fn()
+ }
+ }),
+ { virtual: true }
+);
+
+const { AppKit } = require('../../modal/w3m-modal');
+
+describe('AppKit modal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ modalState.open = true;
+ });
+
+ it('renders the modal without a wrapper', () => {
+ const { getByTestId, queryByTestId } = render();
+
+ expect(getByTestId('w3m-modal')).toBeTruthy();
+ expect(queryByTestId('modal-content-wrapper')).toBeNull();
+ });
+
+ it('wraps modal content when modalContentWrapper is provided', () => {
+ function ModalContentWrapper({ children }: { children: React.ReactNode }) {
+ return {children};
+ }
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('modal-content-wrapper')).toBeTruthy();
+ expect(getByTestId('w3m-modal')).toBeTruthy();
+ });
+
+ it('does not render modal content when closed', () => {
+ modalState.open = false;
+
+ const { queryByTestId } = render();
+
+ expect(queryByTestId('w3m-modal')).toBeNull();
+ expect(queryByTestId('mock-card')).toBeNull();
+ });
+});
diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts
index cef464c0..2b1bf9aa 100644
--- a/packages/appkit/src/index.ts
+++ b/packages/appkit/src/index.ts
@@ -14,7 +14,12 @@ export {
NetworkButton as NetworkButton,
type NetworkButtonProps as NetworkButtonProps
} from './modal/w3m-network-button';
-export { AppKit } from './modal/w3m-modal';
+export {
+ AppKit,
+ type AppKitProps,
+ type AppKitModalContentWrapperComponent,
+ type AppKitModalContentWrapperProps
+} from './modal/w3m-modal';
/********** Types **********/
export type * from '@reown/appkit-core-react-native';
diff --git a/packages/appkit/src/modal/w3m-modal/index.tsx b/packages/appkit/src/modal/w3m-modal/index.tsx
index 4ea75fa0..ff5bd8a0 100644
--- a/packages/appkit/src/modal/w3m-modal/index.tsx
+++ b/packages/appkit/src/modal/w3m-modal/index.tsx
@@ -1,5 +1,5 @@
import { useSnapshot } from 'valtio';
-import { useCallback, useEffect } from 'react';
+import { useCallback, useEffect, type ComponentType, type ReactNode } from 'react';
import { useColorScheme } from 'react-native';
import { Card, Modal, ThemeProvider } from '@reown/appkit-ui-react-native';
import {
@@ -18,7 +18,17 @@ import { Snackbar } from '../../partials/w3m-snackbar';
import { useInternalAppKit } from '../../AppKitContext';
import styles from './styles';
-export function AppKit() {
+export interface AppKitModalContentWrapperProps {
+ children: ReactNode;
+}
+
+export type AppKitModalContentWrapperComponent = ComponentType;
+
+export interface AppKitProps {
+ modalContentWrapper?: AppKitModalContentWrapperComponent;
+}
+
+export function AppKit({ modalContentWrapper }: AppKitProps) {
const theme = useColorScheme();
const { bottom, top } = useSafeAreaInsets();
const { close } = useInternalAppKit();
@@ -60,6 +70,7 @@ export function AppKit() {
onRequestClose={handleBackPress}
onBackdropPress={handleModalClose}
testID="w3m-modal"
+ contentWrapper={modalContentWrapper}
>
diff --git a/packages/common/jest.config.ts b/packages/common/jest.config.ts
index 1704bb3e..daca28c6 100644
--- a/packages/common/jest.config.ts
+++ b/packages/common/jest.config.ts
@@ -1,8 +1,10 @@
+const rootConfig = require('../../jest.config');
+
const commonConfig = {
- ...require('../../jest.config'),
+ ...rootConfig,
setupFilesAfterEnv: ['./jest-setup.ts'],
- // Override the moduleNameMapper to use the correct path from the package
moduleNameMapper: {
+ ...rootConfig.moduleNameMapper,
'^@shared-jest-setup$': '../../jest-shared-setup.ts'
}
};
diff --git a/packages/core/jest.config.ts b/packages/core/jest.config.ts
index 21e9e0b9..2666b21e 100644
--- a/packages/core/jest.config.ts
+++ b/packages/core/jest.config.ts
@@ -1,8 +1,10 @@
+const rootConfig = require('../../jest.config');
+
const coreConfig = {
- ...require('../../jest.config'),
+ ...rootConfig,
setupFilesAfterEnv: ['./jest-setup.ts'],
- // Override the moduleNameMapper to use the correct path from the package
moduleNameMapper: {
+ ...rootConfig.moduleNameMapper,
'^@shared-jest-setup$': '../../jest-shared-setup.ts'
}
};
diff --git a/packages/ui/jest.config.ts b/packages/ui/jest.config.ts
index 2892ee1d..aeface90 100644
--- a/packages/ui/jest.config.ts
+++ b/packages/ui/jest.config.ts
@@ -1,8 +1,10 @@
+const rootConfig = require('../../jest.config');
+
const uiConfig = {
- ...require('../../jest.config'),
+ ...rootConfig,
setupFilesAfterEnv: ['./jest-setup.ts'],
- // Override the moduleNameMapper to use the correct path from the package
moduleNameMapper: {
+ ...rootConfig.moduleNameMapper,
'^@shared-jest-setup$': '../../jest-shared-setup.ts'
}
};
diff --git a/packages/ui/src/components/wui-modal/index.tsx b/packages/ui/src/components/wui-modal/index.tsx
index d814cfce..3ee44228 100644
--- a/packages/ui/src/components/wui-modal/index.tsx
+++ b/packages/ui/src/components/wui-modal/index.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useRef, useState, type ComponentType, type ReactNode } from 'react';
import {
useWindowDimensions,
Modal as RNModal,
@@ -14,13 +14,27 @@ export type ModalProps = Pick<
RNModalProps,
'visible' | 'onDismiss' | 'testID' | 'onRequestClose'
> & {
- children: React.ReactNode;
+ children: ReactNode;
onBackdropPress?: () => void;
+ contentWrapper?: ModalContentWrapperComponent;
};
+export interface ModalContentWrapperProps {
+ children: ReactNode;
+}
+
+export type ModalContentWrapperComponent = ComponentType;
+
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
-export function Modal({ visible, onBackdropPress, onRequestClose, testID, children }: ModalProps) {
+export function Modal({
+ visible,
+ onBackdropPress,
+ onRequestClose,
+ testID,
+ children,
+ contentWrapper: ContentWrapper
+}: ModalProps) {
const { height } = useWindowDimensions();
const Theme = useTheme();
const backdropOpacity = useRef(new Animated.Value(0)).current;
@@ -111,15 +125,8 @@ export function Modal({ visible, onBackdropPress, onRequestClose, testID, childr
}
}, [modalVisible, translateY, backdropOpacity, height]);
- return (
-
+ const modalContent = (
+ <>
{showBackdrop ? (
{children}
+ >
+ );
+
+ return (
+
+ {ContentWrapper ? {modalContent} : modalContent}
);
}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index f2e8efa4..4de0945d 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -50,7 +50,12 @@ export { ListTransaction, type ListTransactionProps } from './composites/wui-lis
export { ListWallet, type ListWalletProps } from './composites/wui-list-wallet';
export { Logo, type LogoProps } from './composites/wui-logo';
export { LogoSelect, type LogoSelectProps } from './composites/wui-logo-select';
-export { Modal, type ModalProps } from './components/wui-modal';
+export {
+ Modal,
+ type ModalContentWrapperComponent,
+ type ModalContentWrapperProps,
+ type ModalProps
+} from './components/wui-modal';
export { NetworkButton, type NetworkButtonProps } from './composites/wui-network-button';
export { NetworkImage, type NetworkImageProps } from './composites/wui-network-image';
export { NumericKeyboard, type NumericKeyboardProps } from './composites/wui-numeric-keyboard';