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';