Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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/spotty-foxes-fry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@reown/appkit-react-native': patch
'@reown/appkit-ui-react-native': patch
'@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-solana-react-native': patch
'@reown/appkit-wagmi-react-native': patch
---

chore: added useAppKitTheme hook
3 changes: 3 additions & 0 deletions packages/appkit/babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
presets: ['module:metro-react-native-babel-preset']
};
2 changes: 2 additions & 0 deletions packages/appkit/jest-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Import shared setup
import '@shared-jest-setup';
9 changes: 9 additions & 0 deletions packages/appkit/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const appkitConfig = {
...require('../../jest.config'),
setupFilesAfterEnv: ['./jest-setup.ts'],
// Override the moduleNameMapper to use the correct path from the package
moduleNameMapper: {
'^@shared-jest-setup$': '../../jest-shared-setup.ts'
}
};
module.exports = appkitConfig;
1 change: 1 addition & 0 deletions packages/appkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"scripts": {
"build": "bob build",
"clean": "rm -rf lib",
"test": "jest",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx"
},
"files": [
Expand Down
2 changes: 1 addition & 1 deletion packages/appkit/src/AppKitContext.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { createContext, useContext, useMemo, type ReactNode } from 'react';
import { AppKit } from './AppKit';

interface AppKitContextType {
export interface AppKitContextType {
appKit: AppKit | null;
}

Expand Down
188 changes: 188 additions & 0 deletions packages/appkit/src/__tests__/hooks/useAppKitTheme.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { renderHook, act } from '@testing-library/react-native';
import React from 'react';
import { useAppKitTheme } from '../../hooks/useAppKitTheme';
import { ThemeController } from '@reown/appkit-core-react-native';
import { type AppKitContextType, AppKitContext } from '../../AppKitContext';
import type { AppKit } from '../../AppKit';

// Mock valtio
jest.mock('valtio', () => ({
useSnapshot: jest.fn(state => state)
}));

// Mock ThemeController
jest.mock('@reown/appkit-core-react-native', () => ({
ThemeController: {
state: {
themeMode: undefined,
themeVariables: {}
},
setThemeMode: jest.fn(),
setThemeVariables: jest.fn()
}
}));

describe('useAppKitTheme', () => {
const mockAppKit = {} as AppKit;

const wrapper = ({ children }: { children: React.ReactNode }) => {
const contextValue: AppKitContextType = { appKit: mockAppKit };

return <AppKitContext.Provider value={contextValue}>{children}</AppKitContext.Provider>;
};

beforeEach(() => {
jest.clearAllMocks();
// Reset ThemeController state
ThemeController.state = {
themeMode: undefined,
themeVariables: {}
};
});

it('should throw error when used outside AppKitProvider', () => {
// Suppress console.error for this test
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

expect(() => {
renderHook(() => useAppKitTheme());
}).toThrow('AppKit instance is not yet available in context.');

consoleSpy.mockRestore();
});

it('should return initial theme state', () => {
const { result } = renderHook(() => useAppKitTheme(), { wrapper });

expect(result.current.themeMode).toBeUndefined();
expect(result.current.themeVariables).toEqual({});
});

it('should return dark theme mode when set', () => {
ThemeController.state = {
themeMode: 'dark',
themeVariables: {}
};

const { result } = renderHook(() => useAppKitTheme(), { wrapper });

expect(result.current.themeMode).toBe('dark');
});

it('should return light theme mode when set', () => {
ThemeController.state = {
themeMode: 'light',
themeVariables: {}
};

const { result } = renderHook(() => useAppKitTheme(), { wrapper });

expect(result.current.themeMode).toBe('light');
});

it('should return theme variables when set', () => {
const themeVariables = { accent: '#00BB7F' };
ThemeController.state = {
themeMode: undefined,
themeVariables
};

const { result } = renderHook(() => useAppKitTheme(), { wrapper });

expect(result.current.themeVariables).toEqual(themeVariables);
});

it('should call ThemeController.setThemeMode when setThemeMode is called', () => {
const { result } = renderHook(() => useAppKitTheme(), { wrapper });

act(() => {
result.current.setThemeMode('dark');
});

expect(ThemeController.setThemeMode).toHaveBeenCalledWith('dark');
});

it('should call ThemeController.setThemeMode with undefined', () => {
const { result } = renderHook(() => useAppKitTheme(), { wrapper });

act(() => {
result.current.setThemeMode(undefined);
});

expect(ThemeController.setThemeMode).toHaveBeenCalledWith(undefined);
});

it('should call ThemeController.setThemeVariables when setThemeVariables is called', () => {
const { result } = renderHook(() => useAppKitTheme(), { wrapper });
const themeVariables = { accent: '#FF5733' };

act(() => {
result.current.setThemeVariables(themeVariables);
});

expect(ThemeController.setThemeVariables).toHaveBeenCalledWith(themeVariables);
});

it('should call ThemeController.setThemeVariables with undefined', () => {
const { result } = renderHook(() => useAppKitTheme(), { wrapper });

act(() => {
result.current.setThemeVariables(undefined);
});

expect(ThemeController.setThemeVariables).toHaveBeenCalledWith(undefined);
});

it('should return stable function references', () => {
const { result } = renderHook(() => useAppKitTheme(), { wrapper });

const firstSetThemeMode = result.current.setThemeMode;
const firstSetThemeVariables = result.current.setThemeVariables;

// Functions should be stable (same reference)
expect(result.current.setThemeMode).toBe(firstSetThemeMode);
expect(result.current.setThemeVariables).toBe(firstSetThemeVariables);
});

it('should update theme mode and variables together', () => {
ThemeController.state = {
themeMode: 'dark',
themeVariables: { accent: '#00BB7F' }
};

const { result } = renderHook(() => useAppKitTheme(), { wrapper });

expect(result.current.themeMode).toBe('dark');
expect(result.current.themeVariables).toEqual({ accent: '#00BB7F' });
});

it('should handle multiple setThemeMode calls', () => {
const { result } = renderHook(() => useAppKitTheme(), { wrapper });

act(() => {
result.current.setThemeMode('dark');
result.current.setThemeMode('light');
result.current.setThemeMode(undefined);
});

expect(ThemeController.setThemeMode).toHaveBeenCalledTimes(3);
expect(ThemeController.setThemeMode).toHaveBeenNthCalledWith(1, 'dark');
expect(ThemeController.setThemeMode).toHaveBeenNthCalledWith(2, 'light');
expect(ThemeController.setThemeMode).toHaveBeenNthCalledWith(3, undefined);
});

it('should handle multiple setThemeVariables calls', () => {
const { result } = renderHook(() => useAppKitTheme(), { wrapper });
const variables1 = { accent: '#00BB7F' };
const variables2 = { accent: '#FF5733' };

act(() => {
result.current.setThemeVariables(variables1);
result.current.setThemeVariables(variables2);
});

expect(ThemeController.setThemeVariables).toHaveBeenCalledTimes(2);
expect(ThemeController.setThemeVariables).toHaveBeenNthCalledWith(1, variables1);
expect(ThemeController.setThemeVariables).toHaveBeenNthCalledWith(2, variables2);
});
});
4 changes: 2 additions & 2 deletions packages/appkit/src/hooks/useAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import {
} from '@reown/appkit-core-react-native';
import { useMemo } from 'react';
import { useSnapshot } from 'valtio';
import { useAppKit } from './useAppKit';
import type { AccountType, AppKitNetwork } from '@reown/appkit-common-react-native';
import { useAppKitContext } from './useAppKitContext';

/**
* Represents a blockchain account with its associated metadata
Expand Down Expand Up @@ -64,7 +64,7 @@ export interface Account {
* @throws Will log errors via LogController if account parsing fails
*/
export function useAccount() {
useAppKit(); // Use the hook for checks
useAppKitContext();

const {
activeAddress: address,
Expand Down
57 changes: 45 additions & 12 deletions packages/appkit/src/hooks/useAppKit.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,60 @@
import { useContext, useMemo } from 'react';
import { useMemo } from 'react';
import type { ChainNamespace } from '@reown/appkit-common-react-native';

import type { AppKit } from '../AppKit';
import { AppKitContext } from '../AppKitContext';
import { useAppKitContext } from './useAppKitContext';

/**
* Interface representing the return value of the useAppKit hook
*/
interface UseAppKitReturn {
/** Function to open the AppKit modal with optional view configuration */
open: AppKit['open'];
/** Function to close the AppKit modal */
close: AppKit['close'];
/** Function to disconnect the wallet, optionally scoped to a specific namespace */
disconnect: (namespace?: ChainNamespace) => void;
/** Function to switch to a different network */
switchNetwork: AppKit['switchNetwork'];
}

/**
* Hook to access core AppKit functionality for controlling the modal
*
* @remarks
* This hook provides access to the main AppKit instance methods for opening/closing
* the modal, disconnecting wallets, and switching networks. All functions are memoized
* and properly bound to ensure stable references across renders.
*
* @returns {UseAppKitReturn} An object containing:
* - `open`: Opens the AppKit modal, optionally with a specific view
* - `close`: Closes the AppKit modal
* - `disconnect`: Disconnects the current wallet connection (optionally for a specific namespace)
* - `switchNetwork`: Switches to a different blockchain network
*
* @throws {Error} If used outside of an AppKitProvider
* @throws {Error} If AppKit instance is not available in context
*
* @example
* ```tsx
* function MyComponent() {
* const { open, close, disconnect, switchNetwork } = useAppKit();
*
* return (
* <View>
* <Button onPress={() => open()} title="Connect Wallet" />
* <Button onPress={() => disconnect()} title="Disconnect" />
* <Button
* onPress={() => switchNetwork('eip155:1')}
* title="Switch to Ethereum"
* />
* </View>
* );
* }
* ```
*/
export const useAppKit = (): UseAppKitReturn => {
const context = useContext(AppKitContext);

if (context === undefined) {
throw new Error('useAppKit must be used within an AppKitProvider');
}

if (!context.appKit) {
// This might happen if the provider is rendered before AppKit is initialized
throw new Error('AppKit instance is not yet available in context.');
}
const context = useAppKitContext();

const stableFunctions = useMemo(() => {
if (!context.appKit) {
Expand Down
43 changes: 43 additions & 0 deletions packages/appkit/src/hooks/useAppKitContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useContext } from 'react';

import { AppKitContext, type AppKitContextType } from '../AppKitContext';

/**
* Hook to access the AppKit context
*
* @remarks
* This is an internal hook used by other AppKit hooks to ensure they're used within
* the AppKitProvider. You typically don't need to use this hook directly - use the
* higher-level hooks like `useAppKit`, `useAccount`, `useAppKitTheme`, etc. instead.
*
* @returns {AppKitContextType} The AppKit context containing the AppKit instance
*
* @throws {Error} If used outside of an AppKitProvider
* @throws {Error} If the AppKit instance is not yet available in context
*
* @internal
*
* @example
* ```tsx
* // This is typically used internally by other hooks
* function MyCustomHook() {
* const context = useAppKitContext();
* // Use context.appKit...
* }
* ```
*/

export const useAppKitContext = (): AppKitContextType => {
const context = useContext(AppKitContext);

if (context === undefined) {
throw new Error('useAppKit must be used within an AppKitProvider');
}

if (!context.appKit) {
// This might happen if the provider is rendered before AppKit is initialized
throw new Error('AppKit instance is not yet available in context.');
}

return context;
};
Loading