diff --git a/.changeset/late-queens-talk.md b/.changeset/late-queens-talk.md new file mode 100644 index 00000000..f92de52b --- /dev/null +++ b/.changeset/late-queens-talk.md @@ -0,0 +1,13 @@ +--- +'@reown/appkit-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-ui-react-native': patch +'@reown/appkit-wagmi-react-native': patch +--- + +fix: refactors the theme management logic to introduce a clearer separation between system theme and user-defined default theme diff --git a/packages/appkit/src/__tests__/hooks/useAppKitTheme.test.tsx b/packages/appkit/src/__tests__/hooks/useAppKitTheme.test.tsx index e2340c65..cfc5ea8d 100644 --- a/packages/appkit/src/__tests__/hooks/useAppKitTheme.test.tsx +++ b/packages/appkit/src/__tests__/hooks/useAppKitTheme.test.tsx @@ -21,10 +21,10 @@ jest.mock('valtio', () => ({ jest.mock('@reown/appkit-core-react-native', () => ({ ThemeController: { state: { - themeMode: undefined, + themeMode: 'light', themeVariables: {} }, - setThemeMode: jest.fn(), + setDefaultThemeMode: jest.fn(), setThemeVariables: jest.fn() } })); @@ -42,7 +42,7 @@ describe('useAppKitTheme', () => { jest.clearAllMocks(); // Reset ThemeController state ThemeController.state = { - themeMode: undefined, + themeMode: 'light', themeVariables: {} }; }); @@ -61,7 +61,7 @@ describe('useAppKitTheme', () => { it('should return initial theme state', () => { const { result } = renderHook(() => useAppKitTheme(), { wrapper }); - expect(result.current.themeMode).toBeUndefined(); + expect(result.current.themeMode).toBe('light'); expect(result.current.themeVariables).toStrictEqual({}); }); @@ -99,24 +99,24 @@ describe('useAppKitTheme', () => { expect(result.current.themeVariables).toEqual(themeVariables); }); - it('should call ThemeController.setThemeMode when setThemeMode is called', () => { + it('should call ThemeController.setDefaultThemeMode when setThemeMode is called', () => { const { result } = renderHook(() => useAppKitTheme(), { wrapper }); act(() => { result.current.setThemeMode('dark'); }); - expect(ThemeController.setThemeMode).toHaveBeenCalledWith('dark'); + expect(ThemeController.setDefaultThemeMode).toHaveBeenCalledWith('dark'); }); - it('should call ThemeController.setThemeMode with undefined', () => { + it('should call ThemeController.setDefaultThemeMode with undefined', () => { const { result } = renderHook(() => useAppKitTheme(), { wrapper }); act(() => { result.current.setThemeMode(undefined); }); - expect(ThemeController.setThemeMode).toHaveBeenCalledWith(undefined); + expect(ThemeController.setDefaultThemeMode).toHaveBeenCalledWith(undefined); }); it('should call ThemeController.setThemeVariables when setThemeVariables is called', () => { @@ -172,10 +172,10 @@ describe('useAppKitTheme', () => { 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); + expect(ThemeController.setDefaultThemeMode).toHaveBeenCalledTimes(3); + expect(ThemeController.setDefaultThemeMode).toHaveBeenNthCalledWith(1, 'dark'); + expect(ThemeController.setDefaultThemeMode).toHaveBeenNthCalledWith(2, 'light'); + expect(ThemeController.setDefaultThemeMode).toHaveBeenNthCalledWith(3, undefined); }); it('should handle multiple setThemeVariables calls', () => { diff --git a/packages/appkit/src/hooks/useAppKitTheme.ts b/packages/appkit/src/hooks/useAppKitTheme.ts index 9e5b71a1..99e4e8fa 100644 --- a/packages/appkit/src/hooks/useAppKitTheme.ts +++ b/packages/appkit/src/hooks/useAppKitTheme.ts @@ -8,8 +8,8 @@ import { useAppKitContext } from './useAppKitContext'; * Interface representing the result of the useAppKitTheme hook */ export interface UseAppKitThemeReturn { - /** The current theme mode ('dark' or 'light'), or undefined if using system default */ - themeMode?: ThemeMode; + /** The current theme mode ('dark' or 'light') */ + themeMode: ThemeMode; /** The current theme variables, currently only supports 'accent' color */ themeVariables: ThemeVariables; /** Function to set the theme mode */ @@ -63,11 +63,12 @@ export interface UseAppKitThemeReturn { */ export function useAppKitTheme(): UseAppKitThemeReturn { useAppKitContext(); + const { themeMode, themeVariables } = useSnapshot(ThemeController.state); const stableFunctions = useMemo( () => ({ - setThemeMode: ThemeController.setThemeMode.bind(ThemeController), + setThemeMode: ThemeController.setDefaultThemeMode.bind(ThemeController), setThemeVariables: ThemeController.setThemeVariables.bind(ThemeController) }), [] diff --git a/packages/appkit/src/modal/w3m-modal/index.tsx b/packages/appkit/src/modal/w3m-modal/index.tsx index 2ffd2a5e..4ea75fa0 100644 --- a/packages/appkit/src/modal/w3m-modal/index.tsx +++ b/packages/appkit/src/modal/w3m-modal/index.tsx @@ -23,7 +23,7 @@ export function AppKit() { const { bottom, top } = useSafeAreaInsets(); const { close } = useInternalAppKit(); const { open } = useSnapshot(ModalController.state); - const { themeMode, themeVariables, defaultThemeMode } = useSnapshot(ThemeController.state); + const { themeMode, themeVariables } = useSnapshot(ThemeController.state); const { projectId } = useSnapshot(OptionsController.state); const handleBackPress = () => { @@ -35,10 +35,8 @@ export function AppKit() { }; useEffect(() => { - if (theme && !defaultThemeMode) { - ThemeController.setThemeMode(theme); - } - }, [theme, defaultThemeMode]); + ThemeController.setSystemThemeMode(theme ?? undefined); + }, [theme]); const prefetch = useCallback(async () => { await ApiController.prefetch(); diff --git a/packages/core/src/__tests__/controllers/ThemeController.test.ts b/packages/core/src/__tests__/controllers/ThemeController.test.ts index c2d3e06d..c7f35fa4 100644 --- a/packages/core/src/__tests__/controllers/ThemeController.test.ts +++ b/packages/core/src/__tests__/controllers/ThemeController.test.ts @@ -1,82 +1,112 @@ -import { Appearance } from 'react-native'; import { ThemeController } from '../../index'; -// Mock react-native Appearance -jest.mock('react-native', () => ({ - Appearance: { - getColorScheme: jest.fn() - } -})); - -const mockedAppearance = Appearance as jest.Mocked; - // -- Tests -------------------------------------------------------------------- describe('ThemeController', () => { beforeEach(() => { - // Reset state before each test - ThemeController.setThemeMode(); - ThemeController.setDefaultThemeMode(); - ThemeController.setThemeVariables(); + // Reset state + ThemeController.setDefaultThemeMode(undefined); + ThemeController.setSystemThemeMode(); + ThemeController.setThemeVariables(undefined); jest.clearAllMocks(); }); describe('initial state', () => { it('should have valid default state', () => { - expect(ThemeController.state.themeMode).toBeDefined(); - expect(ThemeController.state.defaultThemeMode).toBeUndefined(); - expect(ThemeController.state.themeVariables).toEqual({}); + const state = ThemeController.state; + expect(state.themeMode).toBeDefined(); + expect(state.defaultThemeMode).toBeUndefined(); + expect(state.themeVariables).toEqual({}); }); }); - describe('setThemeMode', () => { - it('should set theme mode to light', () => { - ThemeController.setThemeMode('light'); - expect(ThemeController.state.themeMode).toBe('light'); + describe('setDefaultThemeMode', () => { + it('should set default theme mode to light', () => { + ThemeController.setDefaultThemeMode('light'); + const state = ThemeController.state; + expect(state.defaultThemeMode).toBe('light'); }); - it('should set theme mode to dark', () => { - ThemeController.setThemeMode('dark'); - expect(ThemeController.state.themeMode).toBe('dark'); + it('should set default theme mode to dark', () => { + ThemeController.setDefaultThemeMode('dark'); + const state = ThemeController.state; + expect(state.defaultThemeMode).toBe('dark'); }); - it('should fall back to system theme when undefined and system is dark', () => { - mockedAppearance.getColorScheme.mockReturnValue('dark'); - ThemeController.setThemeMode(); - expect(ThemeController.state.themeMode).toBe('dark'); - expect(mockedAppearance.getColorScheme).toHaveBeenCalled(); + it('should clear default theme mode when set to undefined', () => { + ThemeController.setDefaultThemeMode('dark'); + ThemeController.setDefaultThemeMode(undefined); + const state = ThemeController.state; + expect(state.defaultThemeMode).toBeUndefined(); }); - it('should fall back to system theme when undefined and system is light', () => { - mockedAppearance.getColorScheme.mockReturnValue('light'); - ThemeController.setThemeMode(); - expect(ThemeController.state.themeMode).toBe('light'); - expect(mockedAppearance.getColorScheme).toHaveBeenCalled(); + it('should update default theme mode from light to dark', () => { + ThemeController.setDefaultThemeMode('light'); + ThemeController.setDefaultThemeMode('dark'); + const state = ThemeController.state; + expect(state.defaultThemeMode).toBe('dark'); }); + }); - it('should default to light when system returns null', () => { - mockedAppearance.getColorScheme.mockReturnValue(null); - ThemeController.setThemeMode(); - expect(ThemeController.state.themeMode).toBe('light'); + describe('setSystemThemeMode', () => { + it('should set system theme mode to dark', () => { + ThemeController.setSystemThemeMode('dark'); + const state = ThemeController.state; + expect(state.systemThemeMode).toBe('dark'); + }); + + it('should set system theme mode to light', () => { + ThemeController.setSystemThemeMode('light'); + const state = ThemeController.state; + expect(state.systemThemeMode).toBe('light'); + }); + + it('should default to light when called without arguments', () => { + ThemeController.setSystemThemeMode(); + const state = ThemeController.state; + expect(state.systemThemeMode).toBe('light'); + }); + + it('should update system theme mode from light to dark', () => { + ThemeController.setSystemThemeMode('light'); + ThemeController.setSystemThemeMode('dark'); + const state = ThemeController.state; + expect(state.systemThemeMode).toBe('dark'); }); }); - describe('setDefaultThemeMode', () => { - it('should set default theme mode and apply it', () => { + describe('theme mode precedence', () => { + it('should have both system and default theme modes set independently', () => { + ThemeController.setSystemThemeMode('light'); ThemeController.setDefaultThemeMode('dark'); - expect(ThemeController.state.defaultThemeMode).toBe('dark'); - expect(ThemeController.state.themeMode).toBe('dark'); + const state = ThemeController.state; + expect(state.systemThemeMode).toBe('light'); + expect(state.defaultThemeMode).toBe('dark'); }); - it('should set default theme mode to light and apply it', () => { + it('should allow default theme mode to be cleared while system remains', () => { + ThemeController.setSystemThemeMode('dark'); ThemeController.setDefaultThemeMode('light'); - expect(ThemeController.state.defaultThemeMode).toBe('light'); + ThemeController.setDefaultThemeMode(undefined); + const state = ThemeController.state; + expect(state.systemThemeMode).toBe('dark'); + expect(state.defaultThemeMode).toBeUndefined(); + }); + it('should derive themeMode with correct priority: defaultThemeMode > systemThemeMode > light', () => { + // Initially, with no values set, should default to 'light' + ThemeController.setDefaultThemeMode(undefined); + ThemeController.setSystemThemeMode(); expect(ThemeController.state.themeMode).toBe('light'); - }); - it('should set default theme mode to undefined and fall back to system', () => { - mockedAppearance.getColorScheme.mockReturnValue('dark'); - ThemeController.setDefaultThemeMode(); - expect(ThemeController.state.defaultThemeMode).toBeUndefined(); + // When only systemThemeMode is set, themeMode should follow it + ThemeController.setSystemThemeMode('dark'); + expect(ThemeController.state.themeMode).toBe('dark'); + + // When defaultThemeMode is set, it takes priority over systemThemeMode + ThemeController.setDefaultThemeMode('light'); + expect(ThemeController.state.themeMode).toBe('light'); + + // When defaultThemeMode is cleared, falls back to systemThemeMode + ThemeController.setDefaultThemeMode(undefined); expect(ThemeController.state.themeMode).toBe('dark'); }); }); @@ -85,7 +115,8 @@ describe('ThemeController', () => { it('should set theme variables', () => { const variables = { accent: '#000000' }; ThemeController.setThemeVariables(variables); - expect(ThemeController.state.themeVariables).toEqual(variables); + const state = ThemeController.state; + expect(state.themeVariables).toEqual(variables); }); it('should override existing theme variables', () => { @@ -94,8 +125,9 @@ describe('ThemeController', () => { ThemeController.setThemeVariables(initialVariables); ThemeController.setThemeVariables(newVariables); + const state = ThemeController.state; - expect(ThemeController.state.themeVariables).toEqual({ + expect(state.themeVariables).toEqual({ accent: '#FFFFFF' }); }); @@ -103,15 +135,18 @@ describe('ThemeController', () => { it('should reset theme variables when undefined', () => { const variables = { accent: '#000000' }; ThemeController.setThemeVariables(variables); - expect(ThemeController.state.themeVariables).toEqual(variables); + let state = ThemeController.state; + expect(state.themeVariables).toEqual(variables); ThemeController.setThemeVariables(undefined); - expect(ThemeController.state.themeVariables).toEqual({}); + state = ThemeController.state; + expect(state.themeVariables).toEqual({}); }); it('should handle empty object', () => { ThemeController.setThemeVariables({}); - expect(ThemeController.state.themeVariables).toEqual({}); + const state = ThemeController.state; + expect(state.themeVariables).toEqual({}); }); }); @@ -134,9 +169,10 @@ describe('ThemeController', () => { describe('state immutability', () => { it('should maintain state reference but update values', () => { const stateRef = ThemeController.state; - ThemeController.setThemeMode('dark'); + ThemeController.setDefaultThemeMode('dark'); expect(ThemeController.state).toBe(stateRef); - expect(ThemeController.state.themeMode).toBe('dark'); + const state = ThemeController.state; + expect(state.defaultThemeMode).toBe('dark'); }); }); }); diff --git a/packages/core/src/controllers/ThemeController.ts b/packages/core/src/controllers/ThemeController.ts index c64a5507..9d8d2d2c 100644 --- a/packages/core/src/controllers/ThemeController.ts +++ b/packages/core/src/controllers/ThemeController.ts @@ -1,40 +1,39 @@ -import { Appearance } from 'react-native'; import { proxy, subscribe as sub } from 'valtio'; import type { ThemeMode, ThemeVariables } from '@reown/appkit-common-react-native'; // -- Types --------------------------------------------- // export interface ThemeControllerState { - themeMode?: ThemeMode; + themeMode: ThemeMode; + systemThemeMode?: ThemeMode; defaultThemeMode?: ThemeMode; themeVariables: ThemeVariables; } // -- State --------------------------------------------- // -const state = proxy({ - themeMode: undefined, - defaultThemeMode: undefined, - themeVariables: {} +const state = proxy({ + systemThemeMode: undefined as ThemeMode | undefined, + defaultThemeMode: undefined as ThemeMode | undefined, + themeVariables: {} as ThemeVariables, + get themeMode(): ThemeMode { + // eslint-disable-next-line valtio/avoid-this-in-proxy -- using `this` for sibling property access in getters is the recommended valtio pattern for computed properties + return this.defaultThemeMode ?? this.systemThemeMode ?? 'light'; + } }); // -- Controller ---------------------------------------- // export const ThemeController = { - state, + state: state as ThemeControllerState, subscribe(callback: (newState: ThemeControllerState) => void) { return sub(state, () => callback(state)); }, - setThemeMode(themeMode?: ThemeControllerState['themeMode']) { - if (!themeMode) { - state.themeMode = (Appearance.getColorScheme() ?? 'light') as ThemeMode; - } else { - state.themeMode = themeMode; - } + setSystemThemeMode(systemThemeMode?: ThemeControllerState['systemThemeMode']) { + state.systemThemeMode = systemThemeMode ?? 'light'; }, setDefaultThemeMode(themeMode?: ThemeControllerState['defaultThemeMode']) { state.defaultThemeMode = themeMode; - this.setThemeMode(themeMode); }, setThemeVariables(themeVariables?: ThemeControllerState['themeVariables']) { diff --git a/packages/ui/src/context/ThemeContext.tsx b/packages/ui/src/context/ThemeContext.tsx index 5282c0bd..69154e46 100644 --- a/packages/ui/src/context/ThemeContext.tsx +++ b/packages/ui/src/context/ThemeContext.tsx @@ -1,11 +1,10 @@ -import { useColorScheme } from 'react-native'; import { createContext, useContext, useMemo, type ReactNode } from 'react'; import type { ThemeMode, ThemeVariables } from '@reown/appkit-common-react-native'; import { DarkTheme, LightTheme, getAccentColors } from '../utils/ThemeUtil'; type ThemeContextType = { - themeMode?: ThemeMode; + themeMode: ThemeMode; themeVariables?: ThemeVariables; }; @@ -13,7 +12,7 @@ export const ThemeContext = createContext(undefine interface ThemeProviderProps { children: ReactNode; - themeMode?: ThemeMode; + themeMode: ThemeMode; themeVariables?: ThemeVariables; } @@ -25,11 +24,9 @@ export function ThemeProvider({ children, themeMode, themeVariables }: ThemeProv export function useTheme() { const context = useContext(ThemeContext); - const scheme = useColorScheme(); return useMemo(() => { - // If the theme mode is not set, use the system color scheme - const themeMode = context?.themeMode ?? scheme; + const themeMode = context?.themeMode ?? 'light'; const themeVariables = context?.themeVariables ?? {}; let Theme = themeMode === 'dark' ? DarkTheme : LightTheme; @@ -42,5 +39,5 @@ export function useTheme() { } return Theme; - }, [context?.themeMode, context?.themeVariables, scheme]); + }, [context?.themeMode, context?.themeVariables]); }