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
34 changes: 16 additions & 18 deletions website/app/hooks/useTheme.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -22,20 +20,20 @@ function applyThemeToDOM(theme: Theme) {
}

export function useTheme() {
const [theme, setTheme] = useState<Theme>(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])

return {
theme,
toggleTheme
}
}
}
213 changes: 56 additions & 157 deletions website/tests/unit/useTheme.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
Loading