diff --git a/website/app/hooks/useTheme.ts b/website/app/hooks/useTheme.ts index 967e7e1..f513a6f 100644 --- a/website/app/hooks/useTheme.ts +++ b/website/app/hooks/useTheme.ts @@ -1,19 +1,17 @@ -import { useState, useEffect, useCallback } from 'react' +import { useCallback, useSyncExternalStore } from 'react' type Theme = 'light' | 'dark' -function getInitialTheme(): Theme { - if (typeof window === 'undefined') { - return 'light' - } +const serverRenderedTheme: Theme = 'light' - const storedTheme = localStorage.getItem('theme') - if (storedTheme === 'light' || storedTheme === 'dark') { - return storedTheme - } +function readThemeFromDocument(): Theme { + return document.documentElement.classList.contains('dark') ? 'dark' : 'light' +} - const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches - return isDarkMode ? 'dark' : 'light' +function subscribeToDocumentTheme(onChange: () => void) { + const observer = new MutationObserver(onChange) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) + return () => observer.disconnect() } function applyThemeToDOM(theme: Theme) { @@ -22,15 +20,15 @@ function applyThemeToDOM(theme: Theme) { } export function useTheme() { - const [theme, setTheme] = useState(getInitialTheme) - - useEffect(() => { - applyThemeToDOM(theme) - }, [theme]) + const theme = useSyncExternalStore( + subscribeToDocumentTheme, + readThemeFromDocument, + () => serverRenderedTheme + ) const toggleTheme = useCallback(() => { const newTheme: Theme = theme === 'light' ? 'dark' : 'light' - setTheme(newTheme) + applyThemeToDOM(newTheme) localStorage.setItem('theme', newTheme) }, [theme]) @@ -38,4 +36,4 @@ export function useTheme() { theme, toggleTheme } -} \ No newline at end of file +} diff --git a/website/tests/unit/useTheme.test.ts b/website/tests/unit/useTheme.test.ts index 58b2242..5fedad8 100644 --- a/website/tests/unit/useTheme.test.ts +++ b/website/tests/unit/useTheme.test.ts @@ -1,207 +1,106 @@ import { renderHook, act } from '@testing-library/react' +import { renderToString } from 'react-dom/server' +import { createElement } from 'react' import { useTheme } from '@/app/hooks/useTheme' -describe('useTheme', () => { - it('returns dark theme when system prefers dark and no stored preference', () => { - const matchMediaNullable = { - matches: true, - addEventListener: () => {}, - removeEventListener: () => {} - } - window.matchMedia = jest.fn().mockReturnValue(matchMediaNullable) - - const localStorageNullable = { - getItem: jest.fn().mockReturnValue(null), - setItem: jest.fn(), - removeItem: jest.fn(), - clear: jest.fn(), - key: jest.fn(), - length: 0 - } - Object.defineProperty(window, 'localStorage', { - value: localStorageNullable, - writable: true - }) +function setDocumentTheme(theme: 'light' | 'dark') { + document.documentElement.classList.remove('light', 'dark') + document.documentElement.classList.add(theme) +} - const { result } = renderHook(() => useTheme()) +function ThemeProbe() { + return createElement('span', null, useTheme().theme) +} - expect(result.current.theme).toBe('dark') +describe('useTheme', () => { + beforeEach(() => { + document.documentElement.classList.remove('light', 'dark') + window.localStorage.clear() }) - it('returns light theme when system prefers light and no stored preference', () => { - const matchMediaNullable = { - matches: false, - addEventListener: () => {}, - removeEventListener: () => {} - } - window.matchMedia = jest.fn().mockReturnValue(matchMediaNullable) - - const localStorageNullable = { - getItem: jest.fn().mockReturnValue(null), - setItem: jest.fn(), - removeItem: jest.fn(), - clear: jest.fn(), - key: jest.fn(), - length: 0 - } - Object.defineProperty(window, 'localStorage', { - value: localStorageNullable, - writable: true - }) + it('reads light theme from the document when the pre-hydration script chose light', () => { + setDocumentTheme('light') const { result } = renderHook(() => useTheme()) expect(result.current.theme).toBe('light') }) - it('returns stored theme preference over system preference', () => { - const matchMediaNullable = { - matches: true, - addEventListener: () => {}, - removeEventListener: () => {} - } - window.matchMedia = jest.fn().mockReturnValue(matchMediaNullable) - - const localStorageNullable = { - getItem: jest.fn().mockReturnValue('light'), - setItem: jest.fn(), - removeItem: jest.fn(), - clear: jest.fn(), - key: jest.fn(), - length: 0 - } - Object.defineProperty(window, 'localStorage', { - value: localStorageNullable, - writable: true - }) + it('reads dark theme from the document when the pre-hydration script chose dark', () => { + setDocumentTheme('dark') const { result } = renderHook(() => useTheme()) - expect(result.current.theme).toBe('light') + expect(result.current.theme).toBe('dark') }) - it('toggleTheme switches from light to dark', () => { - const matchMediaNullable = { - matches: false, - addEventListener: () => {}, - removeEventListener: () => {} - } - window.matchMedia = jest.fn().mockReturnValue(matchMediaNullable) - - const localStorageNullable = { - getItem: jest.fn().mockReturnValue(null), - setItem: jest.fn(), - removeItem: jest.fn(), - clear: jest.fn(), - key: jest.fn(), - length: 0 - } - Object.defineProperty(window, 'localStorage', { - value: localStorageNullable, - writable: true - }) - + it('falls back to light when the document has no theme class', () => { const { result } = renderHook(() => useTheme()) expect(result.current.theme).toBe('light') + }) + + it('renders light on the server so the markup matches the first client render', () => { + setDocumentTheme('dark') - act(() => { + const markup = renderToString(createElement(ThemeProbe)) + + expect(markup).toContain('light') + }) + + it('toggleTheme switches from light to dark', async () => { + setDocumentTheme('light') + const { result } = renderHook(() => useTheme()) + + await act(async () => { result.current.toggleTheme() }) expect(result.current.theme).toBe('dark') }) - it('toggleTheme switches from dark to light', () => { - const matchMediaNullable = { - matches: true, - addEventListener: () => {}, - removeEventListener: () => {} - } - window.matchMedia = jest.fn().mockReturnValue(matchMediaNullable) - - const localStorageNullable = { - getItem: jest.fn().mockReturnValue(null), - setItem: jest.fn(), - removeItem: jest.fn(), - clear: jest.fn(), - key: jest.fn(), - length: 0 - } - Object.defineProperty(window, 'localStorage', { - value: localStorageNullable, - writable: true - }) - + it('toggleTheme switches from dark to light', async () => { + setDocumentTheme('dark') const { result } = renderHook(() => useTheme()) - expect(result.current.theme).toBe('dark') - - act(() => { + await act(async () => { result.current.toggleTheme() }) expect(result.current.theme).toBe('light') }) - it('theme change updates localStorage', () => { - const matchMediaNullable = { - matches: false, - addEventListener: () => {}, - removeEventListener: () => {} - } - window.matchMedia = jest.fn().mockReturnValue(matchMediaNullable) - - const setItemSpy = jest.fn() - const localStorageNullable = { - getItem: jest.fn().mockReturnValue(null), - setItem: setItemSpy, - removeItem: jest.fn(), - clear: jest.fn(), - key: jest.fn(), - length: 0 - } - Object.defineProperty(window, 'localStorage', { - value: localStorageNullable, - writable: true - }) - + it('toggleTheme remembers the choice in localStorage', async () => { + setDocumentTheme('light') const { result } = renderHook(() => useTheme()) - act(() => { + await act(async () => { result.current.toggleTheme() }) - expect(setItemSpy).toHaveBeenCalledWith('theme', 'dark') + expect(window.localStorage.getItem('theme')).toBe('dark') }) - it('theme change applies class to HTML element', () => { - const matchMediaNullable = { - matches: false, - addEventListener: () => {}, - removeEventListener: () => {} - } - window.matchMedia = jest.fn().mockReturnValue(matchMediaNullable) - - const localStorageNullable = { - getItem: jest.fn().mockReturnValue(null), - setItem: jest.fn(), - removeItem: jest.fn(), - clear: jest.fn(), - key: jest.fn(), - length: 0 - } - Object.defineProperty(window, 'localStorage', { - value: localStorageNullable, - writable: true - }) - + it('toggleTheme replaces the theme class on the HTML element', async () => { + setDocumentTheme('light') const { result } = renderHook(() => useTheme()) - act(() => { + await act(async () => { result.current.toggleTheme() }) expect(document.documentElement.classList.contains('dark')).toBe(true) + expect(document.documentElement.classList.contains('light')).toBe(false) + }) + + it('follows theme class changes made outside the hook', async () => { + setDocumentTheme('light') + const { result } = renderHook(() => useTheme()) + + await act(async () => { + setDocumentTheme('dark') + }) + + expect(result.current.theme).toBe('dark') }) -}) \ No newline at end of file +})