Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/sharp-cars-approve.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion examples/expo-multichain/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ export default function RootLayout() {
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />
{/* 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. */}
<View style={{ position: 'absolute', height: '100%', width: '100%' }}>
<AppKit />
</View>
Expand Down
14 changes: 13 additions & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const path = require('path');

Check warning on line 1 in jest.config.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:path` over `path`.

See more on https://sonarcloud.io/project/issues?id=WalletConnect_web3modal-react-native&issues=AZ1ynPm6tN53tJB8U04U&open=AZ1ynPm6tN53tJB8U04U&pullRequest=549

module.exports = {
preset: 'react-native',
modulePathIgnorePatterns: ['<rootDir>/node_modules', '<rootDir>/lib/'],
Expand All @@ -8,6 +10,16 @@
'^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { configFile: './babel.config.js' }]
},
moduleNameMapper: {
'^@shared-jest-setup$': '<rootDir>/jest-shared-setup.ts'
'^@shared-jest-setup$': '<rootDir>/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')
}
};
6 changes: 4 additions & 2 deletions packages/appkit/jest.config.ts
Original file line number Diff line number Diff line change
@@ -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'
}
};
Expand Down
24 changes: 14 additions & 10 deletions packages/appkit/src/__tests__/hooks/useAppKitTheme.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
132 changes: 132 additions & 0 deletions packages/appkit/src/__tests__/modal/w3m-modal.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => 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) => (
<MockView {...props} testID="mock-card">
{children}
</MockView>
),
Modal: ({ children, testID, visible, contentWrapper: ContentWrapper }: any) => {
if (!visible) {
return null;
}

const content = ContentWrapper ? <ContentWrapper>{children}</ContentWrapper> : children;

return <MockView testID={testID}>{content}</MockView>;
},
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(<AppKit />);

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 <View testID="modal-content-wrapper">{children}</View>;
}

const { getByTestId } = render(<AppKit modalContentWrapper={ModalContentWrapper} />);

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(<AppKit />);

expect(queryByTestId('w3m-modal')).toBeNull();
expect(queryByTestId('mock-card')).toBeNull();
});
});
7 changes: 6 additions & 1 deletion packages/appkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
15 changes: 13 additions & 2 deletions packages/appkit/src/modal/w3m-modal/index.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<AppKitModalContentWrapperProps>;

export interface AppKitProps {
modalContentWrapper?: AppKitModalContentWrapperComponent;
}

export function AppKit({ modalContentWrapper }: AppKitProps) {
const theme = useColorScheme();
const { bottom, top } = useSafeAreaInsets();
const { close } = useInternalAppKit();
Expand Down Expand Up @@ -60,6 +70,7 @@ export function AppKit() {
onRequestClose={handleBackPress}
onBackdropPress={handleModalClose}
testID="w3m-modal"
contentWrapper={modalContentWrapper}
>
<Card style={[styles.card, { paddingBottom: bottom, marginTop: top }]}>
<Header />
Expand Down
6 changes: 4 additions & 2 deletions packages/common/jest.config.ts
Original file line number Diff line number Diff line change
@@ -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'
}
};
Expand Down
6 changes: 4 additions & 2 deletions packages/core/jest.config.ts
Original file line number Diff line number Diff line change
@@ -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'
}
};
Expand Down
6 changes: 4 additions & 2 deletions packages/ui/jest.config.ts
Original file line number Diff line number Diff line change
@@ -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'
}
};
Expand Down
44 changes: 32 additions & 12 deletions packages/ui/src/components/wui-modal/index.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<ModalContentWrapperProps>;

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;
Expand Down Expand Up @@ -111,15 +125,8 @@ export function Modal({ visible, onBackdropPress, onRequestClose, testID, childr
}
}, [modalVisible, translateY, backdropOpacity, height]);

return (
<RNModal
visible={modalVisible}
transparent
animationType="none"
statusBarTranslucent
onRequestClose={onRequestClose}
testID={testID}
>
const modalContent = (
<>
{showBackdrop ? (
<AnimatedPressable
style={[styles.backdrop, { opacity: backdropOpacity }]}
Expand All @@ -130,6 +137,19 @@ export function Modal({ visible, onBackdropPress, onRequestClose, testID, childr
<Animated.View onLayout={onContentLayout}>{children}</Animated.View>
<View style={[styles.bottomBackground, { backgroundColor: Theme['bg-100'] }]} />
</Animated.View>
</>
);

return (
<RNModal
visible={modalVisible}
transparent
animationType="none"
statusBarTranslucent
onRequestClose={onRequestClose}
testID={testID}
>
{ContentWrapper ? <ContentWrapper>{modalContent}</ContentWrapper> : modalContent}
</RNModal>
);
}
Loading
Loading