Skip to content

Commit af0c98f

Browse files
committed
v0.12.88: rewrite ThemeProvider, lazy colors in useTheme
ThemeProvider: - Eliminate ~4s startup delay by removing eager color computation - Just sets data-theme on <html> and provides context - Add toggleTheme() for convenient light/dark switching useTheme: - Colors are now lazy via Proxy — only computed when accessed - Components using only isDark/setTheme pay zero cost for color computation - Add toggleTheme to return value Docs: - Update useTheme and ThemeProvider docs (en + 4 locales) with toggleTheme and lazy colors note
1 parent 272c7df commit af0c98f

16 files changed

Lines changed: 204 additions & 264 deletions

File tree

packages/asterui/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ description: All notable changes to AsterUI
77

88
All notable changes to AsterUI are documented here.
99

10+
## v0.12.88 (2026-03-21)
11+
12+
### Bug Fixes
13+
14+
- **ThemeProvider**: Rewrote to eliminate ~4 second startup delay. No longer computes theme colors eagerly — just sets `data-theme` on `<html>` and provides context.
15+
- **useTheme**: Theme colors (`colors` property) are now lazy — only computed via canvas when accessed. Components that only need `isDark` or `setTheme` pay no cost for color computation.
16+
17+
### Features
18+
19+
- **ThemeProvider**: Added `toggleTheme()` to context for convenient light/dark switching.
20+
- **useTheme**: Added `toggleTheme` to return value (available when using ThemeProvider).
21+
1022
## v0.12.87 (2026-03-21)
1123

1224
### Bug Fixes

packages/asterui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "asterui",
3-
"version": "0.12.87",
3+
"version": "0.12.88",
44
"description": "React UI component library with DaisyUI",
55
"homepage": "https://asterui.com",
66
"repository": {

packages/asterui/src/hooks/useTheme.ts

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ export interface UseThemeReturn {
1818
isDark: boolean
1919
/** Set the theme. Only available with ThemeProvider. */
2020
setTheme: ((theme: string) => void) | undefined
21-
/** Computed theme colors as hex values */
21+
/** Toggle between light and dark. Only available with ThemeProvider. */
22+
toggleTheme: (() => void) | undefined
23+
/** Computed theme colors as hex values. Lazy — only computed when accessed. */
2224
colors: ThemeColors
2325
/** The system preference. Only available with ThemeProvider. */
2426
systemTheme: 'light' | 'dark' | undefined
2527
}
2628

27-
// Convert any CSS color to hex
29+
// Convert any CSS color to hex via canvas
2830
function colorToHex(color: string): string {
2931
if (typeof document === 'undefined') return '#000000'
3032
const canvas = document.createElement('canvas')
@@ -83,6 +85,32 @@ function getCurrentTheme(): string | null {
8385
return document.documentElement.getAttribute('data-theme')
8486
}
8587

88+
/**
89+
* Create a lazy colors object that only computes hex values when accessed.
90+
* Returns a new object each time so referential identity tracks the dep key.
91+
*/
92+
function createLazyColors(depKey: unknown): ThemeColors {
93+
let cached: ThemeColors | null = null
94+
// depKey is used only to invalidate the closure identity
95+
void depKey
96+
return new Proxy({} as ThemeColors, {
97+
get(_target, prop: string) {
98+
if (!cached) cached = getThemeColors()
99+
return cached[prop as keyof ThemeColors]
100+
},
101+
ownKeys() {
102+
if (!cached) cached = getThemeColors()
103+
return Object.keys(cached)
104+
},
105+
getOwnPropertyDescriptor(_target, prop) {
106+
if (!cached) cached = getThemeColors()
107+
if (prop in cached) {
108+
return { configurable: true, enumerable: true, value: cached[prop as keyof ThemeColors] }
109+
}
110+
},
111+
})
112+
}
113+
86114
/**
87115
* Hook to detect current theme and get computed colors.
88116
*
@@ -93,11 +121,13 @@ function getCurrentTheme(): string | null {
93121
* to isDark and colors based on the current data-theme attribute and
94122
* system preference.
95123
*
124+
* Colors are lazy — only computed (via canvas) when you access them.
125+
* Components that only need isDark/setTheme pay no cost for color computation.
126+
*
96127
* @example
97128
* // With ThemeProvider (full control)
98129
* const { theme, setTheme, resolvedTheme, isDark, colors } = useTheme()
99130
* setTheme('dark')
100-
* setTheme('system')
101131
*
102132
* @example
103133
* // Without ThemeProvider (read-only)
@@ -107,22 +137,22 @@ function getCurrentTheme(): string | null {
107137
export function useTheme(): UseThemeReturn {
108138
const hasProvider = useHasThemeProvider()
109139

110-
// If we have a provider, use its context
111140
if (hasProvider) {
112-
// This is safe because hasProvider is stable after initial render
113141
// eslint-disable-next-line react-hooks/rules-of-hooks
114142
const context = useThemeContext()
143+
// eslint-disable-next-line react-hooks/rules-of-hooks
144+
const lazyColors = useMemo(() => createLazyColors(context.resolvedTheme), [context.resolvedTheme])
115145
return {
116146
theme: context.theme,
117147
resolvedTheme: context.resolvedTheme,
118148
isDark: context.isDark,
119149
setTheme: context.setTheme,
120-
colors: context.colors,
150+
toggleTheme: context.toggleTheme,
151+
colors: lazyColors,
121152
systemTheme: context.systemTheme,
122153
}
123154
}
124155

125-
// Standalone mode - no provider
126156
// eslint-disable-next-line react-hooks/rules-of-hooks
127157
return useThemeStandalone()
128158
}
@@ -131,45 +161,34 @@ export function useTheme(): UseThemeReturn {
131161
* Standalone theme detection (no ThemeProvider)
132162
*/
133163
function useThemeStandalone(): UseThemeReturn {
134-
const [state, setState] = useState<{ isDark: boolean; colors: ThemeColors }>(() => ({
164+
const [state, setState] = useState<{ isDark: boolean; themeKey: string | null }>(() => ({
135165
isDark: false,
136-
colors: getThemeColors(),
166+
themeKey: getCurrentTheme(),
137167
}))
138168

139169
useEffect(() => {
140170
const updateTheme = () => {
141171
const currentTheme = getCurrentTheme()
142172
const systemTheme = getSystemTheme()
143173

144-
// Determine if dark based on data-theme or system preference
145174
let isDark = false
146175
if (currentTheme) {
147176
isDark = DARK_THEMES.has(currentTheme)
148177
} else {
149178
isDark = systemTheme === 'dark'
150179
}
151180

152-
// Double RAF ensures CSS has fully recalculated
153-
requestAnimationFrame(() => {
154-
requestAnimationFrame(() => {
155-
setState({
156-
isDark,
157-
colors: getThemeColors(),
158-
})
159-
})
160-
})
181+
setState({ isDark, themeKey: currentTheme })
161182
}
162183

163184
updateTheme()
164185

165-
// Watch for theme changes via attribute mutation
166186
const observer = new MutationObserver(updateTheme)
167187
observer.observe(document.documentElement, {
168188
attributes: true,
169189
attributeFilter: ['data-theme', 'class']
170190
})
171191

172-
// Watch for system preference changes
173192
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
174193
mediaQuery.addEventListener('change', updateTheme)
175194

@@ -179,14 +198,18 @@ function useThemeStandalone(): UseThemeReturn {
179198
}
180199
}, [])
181200

182-
return useMemo(() => ({
183-
theme: undefined,
184-
resolvedTheme: undefined,
185-
isDark: state.isDark,
186-
setTheme: undefined,
187-
colors: state.colors,
188-
systemTheme: undefined,
189-
}), [state.isDark, state.colors])
201+
return useMemo(() => {
202+
const lazyColors = createLazyColors(state.themeKey)
203+
return {
204+
theme: undefined,
205+
resolvedTheme: undefined,
206+
isDark: state.isDark,
207+
setTheme: undefined,
208+
toggleTheme: undefined,
209+
colors: lazyColors,
210+
systemTheme: undefined,
211+
}
212+
}, [state.isDark, state.themeKey])
190213
}
191214

192215
export default useTheme

packages/asterui/src/providers/ThemeProvider.tsx

Lines changed: 31 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -42,63 +42,14 @@ export interface ThemeContextValue {
4242
isDark: boolean
4343
/** Set the theme */
4444
setTheme: (theme: string) => void
45-
/** Computed theme colors as hex values (for canvas/non-CSS contexts) */
46-
colors: ThemeColors
45+
/** Toggle between light and dark */
46+
toggleTheme: () => void
4747
/** The system preference ("light" or "dark") */
4848
systemTheme: 'light' | 'dark'
4949
}
5050

5151
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined)
5252

53-
// Convert any CSS color to hex
54-
function colorToHex(color: string): string {
55-
if (typeof document === 'undefined') return '#000000'
56-
const canvas = document.createElement('canvas')
57-
canvas.width = canvas.height = 1
58-
const ctx = canvas.getContext('2d')
59-
if (!ctx) return '#000000'
60-
ctx.fillStyle = color
61-
ctx.fillRect(0, 0, 1, 1)
62-
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data
63-
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`
64-
}
65-
66-
function getThemeColors(): ThemeColors {
67-
if (typeof document === 'undefined') {
68-
return {
69-
background: '#ffffff',
70-
foreground: '#000000',
71-
primary: '#6366f1',
72-
primaryContent: '#ffffff',
73-
secondary: '#f000b8',
74-
accent: '#37cdbe',
75-
info: '#3abff8',
76-
success: '#36d399',
77-
warning: '#fbbd23',
78-
error: '#f87272',
79-
}
80-
}
81-
82-
const style = getComputedStyle(document.documentElement)
83-
const getColor = (varName: string, fallback: string): string => {
84-
const value = style.getPropertyValue(varName).trim()
85-
return value ? colorToHex(value) : fallback
86-
}
87-
88-
return {
89-
background: getColor('--color-base-100', '#ffffff'),
90-
foreground: getColor('--color-base-content', '#000000'),
91-
primary: getColor('--color-primary', '#6366f1'),
92-
primaryContent: getColor('--color-primary-content', '#ffffff'),
93-
secondary: getColor('--color-secondary', '#f000b8'),
94-
accent: getColor('--color-accent', '#37cdbe'),
95-
info: getColor('--color-info', '#3abff8'),
96-
success: getColor('--color-success', '#36d399'),
97-
warning: getColor('--color-warning', '#fbbd23'),
98-
error: getColor('--color-error', '#f87272'),
99-
}
100-
}
101-
10253
function getSystemTheme(): 'light' | 'dark' {
10354
if (typeof window === 'undefined') return 'light'
10455
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
@@ -130,86 +81,71 @@ export function ThemeProvider({
13081
darkTheme = 'dark',
13182
isDarkTheme,
13283
}: ThemeProviderProps) {
133-
// Initialize theme from storage or default
84+
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme)
85+
13486
const [theme, setThemeState] = useState<string>(() => {
135-
const stored = getStoredTheme(storageKey)
136-
return stored || defaultTheme
87+
return getStoredTheme(storageKey) || defaultTheme
13788
})
13889

139-
// Track system preference
140-
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme)
141-
142-
// Resolve the actual theme
14390
const resolvedTheme = useMemo(() => {
14491
if (theme === 'system') {
14592
return systemTheme === 'dark' ? darkTheme : lightTheme
14693
}
14794
return theme
14895
}, [theme, systemTheme, lightTheme, darkTheme])
14996

150-
// Determine if dark
15197
const isDark = useMemo(() => {
15298
if (isDarkTheme) return isDarkTheme(resolvedTheme)
15399
return DARK_THEMES.has(resolvedTheme)
154100
}, [resolvedTheme, isDarkTheme])
155101

156-
// Track colors (updated after theme applies)
157-
const [colors, setColors] = useState<ThemeColors>(getThemeColors)
158-
159-
// Set theme function
160-
const setTheme = useCallback((newTheme: string) => {
161-
setThemeState(newTheme)
162-
storeTheme(storageKey, newTheme)
163-
}, [storageKey])
164-
165-
// Apply theme to document
102+
// Set data-theme on <html>
166103
useEffect(() => {
167104
if (typeof document === 'undefined') return
168105
document.documentElement.setAttribute('data-theme', resolvedTheme)
169-
170-
// Double RAF ensures CSS has fully recalculated after attribute change
171-
requestAnimationFrame(() => {
172-
requestAnimationFrame(() => {
173-
setColors(getThemeColors())
174-
})
175-
})
176106
}, [resolvedTheme])
177107

178108
// Listen for system preference changes
179109
useEffect(() => {
180110
if (typeof window === 'undefined') return
181-
182-
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
183-
const handleChange = (e: MediaQueryListEvent) => {
184-
setSystemTheme(e.matches ? 'dark' : 'light')
185-
}
186-
187-
mediaQuery.addEventListener('change', handleChange)
188-
return () => mediaQuery.removeEventListener('change', handleChange)
111+
const mq = window.matchMedia('(prefers-color-scheme: dark)')
112+
const handler = (e: MediaQueryListEvent) => setSystemTheme(e.matches ? 'dark' : 'light')
113+
mq.addEventListener('change', handler)
114+
return () => mq.removeEventListener('change', handler)
189115
}, [])
190116

191117
// Listen for storage changes (cross-tab sync)
192118
useEffect(() => {
193119
if (!storageKey || typeof window === 'undefined') return
194-
195-
const handleStorage = (e: StorageEvent) => {
120+
const handler = (e: StorageEvent) => {
196121
if (e.key === storageKey && e.newValue) {
197122
setThemeState(e.newValue)
198123
}
199124
}
125+
window.addEventListener('storage', handler)
126+
return () => window.removeEventListener('storage', handler)
127+
}, [storageKey])
200128

201-
window.addEventListener('storage', handleStorage)
202-
return () => window.removeEventListener('storage', handleStorage)
129+
const setTheme = useCallback((t: string) => {
130+
setThemeState(t)
131+
storeTheme(storageKey, t)
203132
}, [storageKey])
204133

134+
const toggleTheme = useCallback(() => {
135+
setThemeState((current) => {
136+
const resolved = current === 'system'
137+
? (systemTheme === 'dark' ? darkTheme : lightTheme)
138+
: current
139+
const currentIsDark = isDarkTheme ? isDarkTheme(resolved) : DARK_THEMES.has(resolved)
140+
const next = currentIsDark ? lightTheme : darkTheme
141+
storeTheme(storageKey, next)
142+
return next
143+
})
144+
}, [systemTheme, lightTheme, darkTheme, isDarkTheme, storageKey])
145+
205146
const value = useMemo<ThemeContextValue>(() => ({
206-
theme,
207-
resolvedTheme,
208-
isDark,
209-
setTheme,
210-
colors,
211-
systemTheme,
212-
}), [theme, resolvedTheme, isDark, setTheme, colors, systemTheme])
147+
theme, resolvedTheme, isDark, setTheme, toggleTheme, systemTheme,
148+
}), [theme, resolvedTheme, isDark, setTheme, toggleTheme, systemTheme])
213149

214150
return (
215151
<ThemeContext.Provider value={value}>

packages/docs/src/content/docs/es/hooks/usetheme.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ Ver [ThemeProvider](/es/providers/themeprovider) para las props del proveedor y
9393
| `resolvedTheme` | `string \| undefined` | El tema aplicado después de resolver `'system'`. Solo con ThemeProvider. |
9494
| `isDark` | `boolean` | `true` si el modo oscuro está activo. |
9595
| `setTheme` | `(theme: string) => void \| undefined` | Función para cambiar el tema. Solo con ThemeProvider. |
96-
| `colors` | `ThemeColors` | Colores del tema calculados como valores hex. |
96+
| `toggleTheme` | `() => void \| undefined` | Alternar entre tema claro y oscuro. Solo con ThemeProvider. |
97+
| `colors` | `ThemeColors` | Colores del tema calculados como valores hex. Lazy — solo se calculan cuando se acceden. |
9798
| `systemTheme` | `'light' \| 'dark' \| undefined` | La preferencia del sistema. Solo con ThemeProvider. |
9899

99100
### ThemeColors

0 commit comments

Comments
 (0)