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/late-queens-talk.md
Original file line number Diff line number Diff line change
@@ -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
24 changes: 12 additions & 12 deletions packages/appkit/src/__tests__/hooks/useAppKitTheme.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}));
Expand All @@ -42,7 +42,7 @@ describe('useAppKitTheme', () => {
jest.clearAllMocks();
// Reset ThemeController state
ThemeController.state = {
themeMode: undefined,
themeMode: 'light',
themeVariables: {}
};
});
Expand All @@ -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({});
});

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
7 changes: 4 additions & 3 deletions packages/appkit/src/hooks/useAppKitTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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)
}),
[]
Expand Down
8 changes: 3 additions & 5 deletions packages/appkit/src/modal/w3m-modal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -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();
Expand Down
148 changes: 92 additions & 56 deletions packages/core/src/__tests__/controllers/ThemeController.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Appearance>;

// -- 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');
});
});
Expand All @@ -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', () => {
Expand All @@ -94,24 +125,28 @@ describe('ThemeController', () => {

ThemeController.setThemeVariables(initialVariables);
ThemeController.setThemeVariables(newVariables);
const state = ThemeController.state;

expect(ThemeController.state.themeVariables).toEqual({
expect(state.themeVariables).toEqual({
accent: '#FFFFFF'
});
});

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({});
});
});

Expand All @@ -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');
});
});
});
Loading