diff --git a/.changeset/spotty-foxes-fry.md b/.changeset/spotty-foxes-fry.md new file mode 100644 index 00000000..4d0fcbc3 --- /dev/null +++ b/.changeset/spotty-foxes-fry.md @@ -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 diff --git a/package.json b/package.json index d9127d17..5f2bd7c8 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "build:gallery": "turbo run build:gallery", "lint": "eslint . --ext .js,.jsx,.ts,.tsx", "prettier": "prettier --check .", - "test": "turbo build && turbo run test --parallel", + "test": "turbo run test --parallel", "clean": "turbo clean && rm -rf node_modules && (watchman watch-del-all || true)", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\" --ignore-path .gitignore", "playwright:test": "cd apps/native && yarn playwright:test", diff --git a/packages/appkit/babel.config.js b/packages/appkit/babel.config.js new file mode 100644 index 00000000..6403cbe5 --- /dev/null +++ b/packages/appkit/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['module:metro-react-native-babel-preset'] +}; diff --git a/packages/appkit/jest-setup.ts b/packages/appkit/jest-setup.ts new file mode 100644 index 00000000..58fc8174 --- /dev/null +++ b/packages/appkit/jest-setup.ts @@ -0,0 +1,2 @@ +// Import shared setup +import '@shared-jest-setup'; diff --git a/packages/appkit/jest.config.ts b/packages/appkit/jest.config.ts new file mode 100644 index 00000000..f2fb133e --- /dev/null +++ b/packages/appkit/jest.config.ts @@ -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; diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 2c5f7c45..d9a53621 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -9,6 +9,7 @@ "scripts": { "build": "bob build", "clean": "rm -rf lib", + "test": "jest", "lint": "eslint . --ext .js,.jsx,.ts,.tsx" }, "files": [ diff --git a/packages/appkit/src/AppKitContext.tsx b/packages/appkit/src/AppKitContext.tsx index 2d90df28..5f1d64c4 100644 --- a/packages/appkit/src/AppKitContext.tsx +++ b/packages/appkit/src/AppKitContext.tsx @@ -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; } diff --git a/packages/appkit/src/__tests__/hooks/useAppKitTheme.test.tsx b/packages/appkit/src/__tests__/hooks/useAppKitTheme.test.tsx new file mode 100644 index 00000000..e2340c65 --- /dev/null +++ b/packages/appkit/src/__tests__/hooks/useAppKitTheme.test.tsx @@ -0,0 +1,195 @@ +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 Appearance +jest.mock('react-native', () => ({ + Appearance: { + getColorScheme: jest.fn().mockReturnValue('light') + } +})); + +// 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 {children}; + }; + + 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).toStrictEqual({}); + }); + + 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: 'dark', + 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); + }); +}); diff --git a/packages/appkit/src/hooks/useAccount.ts b/packages/appkit/src/hooks/useAccount.ts index a3a30e87..02fd7ec9 100644 --- a/packages/appkit/src/hooks/useAccount.ts +++ b/packages/appkit/src/hooks/useAccount.ts @@ -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 @@ -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, diff --git a/packages/appkit/src/hooks/useAppKit.ts b/packages/appkit/src/hooks/useAppKit.ts index 910b26ed..2dff2dc1 100644 --- a/packages/appkit/src/hooks/useAppKit.ts +++ b/packages/appkit/src/hooks/useAppKit.ts @@ -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 ( + * + *