From 16463d3bd3de7e27d05decc41cfa97e077976fab Mon Sep 17 00:00:00 2001 From: Lada Kesseler Date: Wed, 9 Sep 2026 17:43:07 +0200 Subject: [PATCH] Fix theme hydration mismatch by reading the theme from the document The hook initialised state from localStorage and matchMedia during the first client render, so the server-rendered sun icon could differ from the client's moon icon and React reported a hydration error on every page. The pre-hydration script already puts the theme class on the html element, so the hook now treats that class as the single source of truth through useSyncExternalStore, rendering light on the server and reading the real theme once hydrated. --- website/app/hooks/useTheme.ts | 34 +++-- website/tests/unit/useTheme.test.ts | 213 ++++++++-------------------- 2 files changed, 72 insertions(+), 175 deletions(-) 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 +})