diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a3d872..83db76e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ ### Fixed - EscrowForm now rejects self-escrow (beneficiary === depositor) and arbiter addresses that match the depositor or beneficiary (#23) +- `useTheme` now subscribes to live OS theme changes when set to + `'system'`, instead of reading `matchMedia(...).matches` once and + going stale until `theme` itself changes (#31) ### Removed - Removed the orphaned `useStellarAccount` hook: it had no call sites, diff --git a/src/hooks/useMediaQuery.test.ts b/src/hooks/useMediaQuery.test.ts new file mode 100644 index 0000000..dc11ce6 --- /dev/null +++ b/src/hooks/useMediaQuery.test.ts @@ -0,0 +1,86 @@ +import { renderHook, act } from '@testing-library/react' +import { describe, it, expect, afterEach } from 'vitest' +import { useMediaQuery, useIsDarkMode } from './useMediaQuery' + +function createMockMatchMedia(initialMatches: boolean) { + let matches = initialMatches + const listeners = new Set<(e: MediaQueryListEvent) => void>() + + const mql = { + get matches() { + return matches + }, + media: '', + onchange: null, + addEventListener: (_: 'change', handler: (e: MediaQueryListEvent) => void) => { + listeners.add(handler) + }, + removeEventListener: (_: 'change', handler: (e: MediaQueryListEvent) => void) => { + listeners.delete(handler) + }, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + } + + return { + matchMedia: () => mql as unknown as MediaQueryList, + fireChange: (nextMatches: boolean) => { + matches = nextMatches + const event = { matches: nextMatches } as MediaQueryListEvent + listeners.forEach((handler) => handler(event)) + }, + } +} + +describe('useMediaQuery / useIsDarkMode', () => { + const originalMatchMedia = window.matchMedia + + afterEach(() => { + window.matchMedia = originalMatchMedia + }) + + it('returns the initial matches value synchronously', () => { + const mock = createMockMatchMedia(true) + window.matchMedia = mock.matchMedia + + const { result } = renderHook(() => useMediaQuery('(prefers-color-scheme: dark)')) + + expect(result.current).toBe(true) + }) + + it('updates when the underlying media query fires a change event', () => { + const mock = createMockMatchMedia(false) + window.matchMedia = mock.matchMedia + + const { result } = renderHook(() => useMediaQuery('(prefers-color-scheme: dark)')) + expect(result.current).toBe(false) + + act(() => { + mock.fireChange(true) + }) + + expect(result.current).toBe(true) + }) + + it('removes its change listener on unmount', () => { + const mock = createMockMatchMedia(false) + window.matchMedia = mock.matchMedia + + const { unmount } = renderHook(() => useMediaQuery('(prefers-color-scheme: dark)')) + unmount() + + // Firing a change after unmount should not throw (no dangling listener + // calling setState on an unmounted component). + expect(() => mock.fireChange(true)).not.toThrow() + }) + + it('useIsDarkMode reflects the prefers-color-scheme: dark query', () => { + const mock = createMockMatchMedia(true) + window.matchMedia = mock.matchMedia + + const { result } = renderHook(() => useIsDarkMode()) + + expect(result.current).toBe(true) + }) +}) diff --git a/src/hooks/useTheme.test.ts b/src/hooks/useTheme.test.ts new file mode 100644 index 0000000..0590090 --- /dev/null +++ b/src/hooks/useTheme.test.ts @@ -0,0 +1,109 @@ +import { renderHook, act } from '@testing-library/react' +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { useTheme } from './useTheme' + +function createMockMatchMedia(initialMatches: boolean) { + let matches = initialMatches + const listeners = new Set<(e: MediaQueryListEvent) => void>() + + const mql = { + get matches() { + return matches + }, + media: '(prefers-color-scheme: dark)', + onchange: null, + addEventListener: (_: 'change', handler: (e: MediaQueryListEvent) => void) => { + listeners.add(handler) + }, + removeEventListener: (_: 'change', handler: (e: MediaQueryListEvent) => void) => { + listeners.delete(handler) + }, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + } + + return { + matchMedia: () => mql as unknown as MediaQueryList, + fireChange: (nextMatches: boolean) => { + matches = nextMatches + const event = { matches: nextMatches } as MediaQueryListEvent + listeners.forEach((handler) => handler(event)) + }, + } +} + +describe('useTheme', () => { + const originalMatchMedia = window.matchMedia + + beforeEach(() => { + localStorage.clear() + document.documentElement.classList.remove('dark') + }) + + afterEach(() => { + window.matchMedia = originalMatchMedia + localStorage.clear() + document.documentElement.classList.remove('dark') + }) + + it('reacts live to an OS theme change while theme is "system", with no change to theme itself', () => { + const mock = createMockMatchMedia(false) + window.matchMedia = mock.matchMedia + + const { result } = renderHook(() => useTheme()) + expect(result.current.theme).toBe('system') + expect(document.documentElement.classList.contains('dark')).toBe(false) + + act(() => { + mock.fireChange(true) + }) + + expect(document.documentElement.classList.contains('dark')).toBe(true) + expect(result.current.theme).toBe('system') + }) + + it('toggles the dark class back off when the OS switches back to light', () => { + const mock = createMockMatchMedia(true) + window.matchMedia = mock.matchMedia + + renderHook(() => useTheme()) + expect(document.documentElement.classList.contains('dark')).toBe(true) + + act(() => { + mock.fireChange(false) + }) + + expect(document.documentElement.classList.contains('dark')).toBe(false) + }) + + it('does not react to OS theme changes when explicitly set to "light"', () => { + localStorage.setItem('ss-theme', JSON.stringify('light')) + const mock = createMockMatchMedia(false) + window.matchMedia = mock.matchMedia + + renderHook(() => useTheme()) + expect(document.documentElement.classList.contains('dark')).toBe(false) + + act(() => { + mock.fireChange(true) + }) + + expect(document.documentElement.classList.contains('dark')).toBe(false) + }) + + it('does not react to OS theme changes when explicitly set to "dark"', () => { + localStorage.setItem('ss-theme', JSON.stringify('dark')) + const mock = createMockMatchMedia(true) + window.matchMedia = mock.matchMedia + + renderHook(() => useTheme()) + expect(document.documentElement.classList.contains('dark')).toBe(true) + + act(() => { + mock.fireChange(false) + }) + + expect(document.documentElement.classList.contains('dark')).toBe(true) + }) +}) diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index b3c84a4..ce01093 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -1,13 +1,15 @@ import { useLocalStorage } from './useLocalStorage' +import { useIsDarkMode } from './useMediaQuery' import { useEffect } from 'react' type Theme = 'light' | 'dark' | 'system' export function useTheme() { const [theme, setTheme] = useLocalStorage('ss-theme', 'system') + const systemIsDark = useIsDarkMode() useEffect(() => { const root = document.documentElement if (theme === 'dark') root.classList.add('dark') else if (theme === 'light') root.classList.remove('dark') - else root.classList.toggle('dark', matchMedia('(prefers-color-scheme: dark)').matches) - }, [theme]) + else root.classList.toggle('dark', systemIsDark) + }, [theme, systemIsDark]) return { theme, setTheme } }