diff --git a/src/hooks/use-window-theme.mjs b/src/hooks/use-window-theme.mjs index 721dfdf91..983368799 100644 --- a/src/hooks/use-window-theme.mjs +++ b/src/hooks/use-window-theme.mjs @@ -10,12 +10,12 @@ export function useWindowTheme() { ) useEffect(() => { if (!window.matchMedia) return + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') const listener = (e) => { setTheme(e.matches ? 'dark' : 'light') } - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', listener) - return () => - window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', listener) + mediaQuery.addEventListener('change', listener) + return () => mediaQuery.removeEventListener('change', listener) }, []) return theme } diff --git a/tests/unit/hooks/use-window-theme.test.mjs b/tests/unit/hooks/use-window-theme.test.mjs new file mode 100644 index 000000000..546b697f8 --- /dev/null +++ b/tests/unit/hooks/use-window-theme.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict' +import { afterEach, test } from 'node:test' +import { JSDOM } from 'jsdom' +import { createElement } from 'react' +import { render, unmountComponentAtNode } from 'react-dom' +import { act } from 'preact/test-utils' +import { useWindowTheme } from '../../../src/hooks/use-window-theme.mjs' + +const globalNames = ['window', 'document', 'Node'] +const originalDescriptors = new Map( + globalNames.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]), +) +let dom + +const ThemeProbe = () => { + useWindowTheme() + return null +} + +const setDOM = () => { + dom = new JSDOM('
') + + for (const name of globalNames) { + Object.defineProperty(globalThis, name, { + value: dom.window[name], + configurable: true, + }) + } +} + +afterEach(() => { + dom?.window.close() + dom = undefined + + for (const [name, descriptor] of originalDescriptors) { + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor) + } else { + delete globalThis[name] + } + } +}) + +test('useWindowTheme removes the listener from the MediaQueryList that registered it', () => { + setDOM() + const mediaQueries = [] + + window.matchMedia = () => { + const listeners = new Set() + const mediaQuery = { + matches: false, + addEventListener(type, listener) { + assert.equal(type, 'change') + listeners.add(listener) + }, + removeEventListener(type, listener) { + assert.equal(type, 'change') + listeners.delete(listener) + }, + listenerCount() { + return listeners.size + }, + } + mediaQueries.push(mediaQuery) + return mediaQuery + } + + const container = document.querySelector('#root') + act(() => render(createElement(ThemeProbe), container)) + + const subscribedMediaQuery = mediaQueries.find( + (mediaQuery) => mediaQuery.listenerCount() === 1, + ) + assert.ok(subscribedMediaQuery) + + act(() => unmountComponentAtNode(container)) + + assert.equal(subscribedMediaQuery.listenerCount(), 0) +})