Skip to content

Commit b0c11aa

Browse files
authored
Merge pull request #321 from forestream/fix-use-theme
Fix use theme
2 parents 6264009 + 0f7bb05 commit b0c11aa

File tree

6 files changed

+524
-479
lines changed

6 files changed

+524
-479
lines changed

.changeset/short-paths-tap.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@devup-ui/react": patch
3+
---
4+
5+
Update useTheme

packages/react/src/hooks/__tests__/use-theme.browser.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,26 @@ beforeEach(() => {
77
describe('useTheme', () => {
88
it('should return theme', async () => {
99
const { useTheme } = await import('../use-theme')
10-
const { result } = renderHook(() => useTheme())
10+
const { result, unmount } = renderHook(() => useTheme())
1111
expect(result.current).toBeNull()
1212

1313
document.documentElement.setAttribute('data-theme', 'dark')
1414
await waitFor(() => {
1515
expect(result.current).toBe('dark')
1616
})
17-
const { result: newResult } = renderHook(() => useTheme())
17+
const { result: newResult, unmount: newUnmount } = renderHook(() =>
18+
useTheme(),
19+
)
1820
expect(newResult.current).toBe('dark')
21+
newUnmount()
22+
unmount()
1923
})
24+
2025
it('should return theme when already set', async () => {
2126
const { useTheme } = await import('../use-theme')
2227
document.documentElement.setAttribute('data-theme', 'dark')
23-
const { result } = renderHook(() => useTheme())
28+
const { result, unmount } = renderHook(() => useTheme())
2429
expect(result.current).toBe('dark')
30+
unmount()
2531
})
2632
})
Lines changed: 9 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,16 @@
11
'use client'
2-
import { useId, useState } from 'react'
32

4-
import type { DevupTheme } from '../types/theme'
5-
import { useSafeEffect } from './use-safe-effect'
3+
import { useSyncExternalStore } from 'react'
64

7-
let observer: null | MutationObserver = null
8-
const setThemeMap: Record<string, React.Dispatch<keyof DevupTheme>> = {}
9-
let globalTheme: keyof DevupTheme | null = null
5+
import { createThemeStore } from '../stores/theme-store'
106

11-
export function useTheme(): keyof DevupTheme | null {
12-
const id = useId()
13-
const [theme, setTheme] = useState<keyof DevupTheme | null>(globalTheme)
14-
useSafeEffect(() => {
15-
if (globalTheme !== null) return
16-
const currentTheme = document.documentElement.getAttribute('data-theme')
17-
if (currentTheme !== null && currentTheme !== theme)
18-
setTheme(currentTheme as keyof DevupTheme)
19-
}, [theme])
20-
useSafeEffect(() => {
21-
const targetNode = document.documentElement
22-
setThemeMap[id] = setTheme
23-
if (!observer) {
24-
observer = new MutationObserver(() => {
25-
const theme = document.documentElement.getAttribute('data-theme')
26-
globalTheme = theme as keyof DevupTheme
27-
for (const key in setThemeMap)
28-
setThemeMap[key](theme as keyof DevupTheme)
29-
})
30-
observer.observe(targetNode, {
31-
attributes: true,
32-
attributeFilter: ['data-theme'],
33-
childList: false,
34-
subtree: false,
35-
characterData: false,
36-
attributeOldValue: false,
37-
characterDataOldValue: false,
38-
})
39-
}
7+
const themeStore = createThemeStore()
408

41-
return () => {
42-
delete setThemeMap[id]
43-
if (observer && Object.keys(setThemeMap).length === 0)
44-
observer.disconnect()
45-
}
46-
}, [id])
9+
export function useTheme() {
10+
const theme = useSyncExternalStore(
11+
themeStore.subscribe,
12+
themeStore.get,
13+
themeStore.get,
14+
)
4715
return theme
4816
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
beforeEach(() => {
2+
vi.resetModules()
3+
})
4+
5+
describe('themeStore', () => {
6+
it('should return themeStore object for browser', async () => {
7+
const { createThemeStore } = await import('../theme-store')
8+
const themeStore = createThemeStore()
9+
expect(themeStore).toBeDefined()
10+
expect(themeStore.get).toEqual(expect.any(Function))
11+
expect(themeStore.set).toEqual(expect.any(Function))
12+
expect(themeStore.subscribe).toEqual(expect.any(Function))
13+
expect(themeStore.get()).toBeNull()
14+
expect(themeStore.set('dark' as any)).toBeUndefined()
15+
expect(themeStore.subscribe(() => {})()).toBeUndefined()
16+
themeStore.subscribe(() => {})
17+
expect(themeStore.set('dark' as any)).toBeUndefined()
18+
})
19+
})
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
'use client'
2+
import type { DevupTheme } from '../types/theme'
3+
4+
type Theme = keyof DevupTheme | null
5+
type StoreChangeEvent = (newTheme: Theme) => void
6+
7+
const initTheme = null
8+
9+
export function createThemeStore() {
10+
if (typeof window === 'undefined')
11+
return {
12+
get: () => initTheme,
13+
set: () => {},
14+
subscribe: () => () => {},
15+
}
16+
17+
const el = document.documentElement
18+
const subscribers: Set<StoreChangeEvent> = new Set()
19+
let theme: Theme = initTheme
20+
const get = () => theme
21+
const set = (newTheme: Theme) => {
22+
theme = newTheme
23+
subscribers.forEach((subscriber) => subscriber(theme))
24+
}
25+
26+
const subscribe = (onStoreChange: StoreChangeEvent) => {
27+
subscribers.add(onStoreChange)
28+
set(el.getAttribute('data-theme') as Theme)
29+
return () => subscribers.delete(onStoreChange)
30+
}
31+
32+
const mo = new MutationObserver((mutations) => {
33+
for (const m of mutations)
34+
if (m.type === 'attributes' && m.target instanceof HTMLElement)
35+
set(m.target.getAttribute('data-theme') as Theme)
36+
})
37+
mo.observe(el, {
38+
attributes: true,
39+
attributeFilter: ['data-theme'],
40+
subtree: false,
41+
})
42+
return {
43+
get,
44+
set,
45+
subscribe,
46+
}
47+
}

0 commit comments

Comments
 (0)