diff --git a/packages/raystack/components/theme-provider/theme.tsx b/packages/raystack/components/theme-provider/theme.tsx index ae9c2bf93..82dfc8ea1 100644 --- a/packages/raystack/components/theme-provider/theme.tsx +++ b/packages/raystack/components/theme-provider/theme.tsx @@ -8,6 +8,7 @@ import { useContext, useEffect, useMemo, + useRef, useState } from 'react'; @@ -47,7 +48,8 @@ const Theme = ({ nonce, style = 'modern', accentColor = 'indigo', - grayColor = 'gray' + grayColor = 'gray', + onThemeChange }: ThemeProviderProps) => { const [theme, setThemeState] = useState(() => getTheme(storageKey, defaultTheme) @@ -124,7 +126,7 @@ const Theme = ({ // Unsupported } }, - [forcedTheme] + [storageKey] ); const handleMediaQuery = useCallback( @@ -136,7 +138,7 @@ const Theme = ({ applyTheme('system'); } }, - [theme, forcedTheme] + [theme, forcedTheme, enableSystem, applyTheme] ); // Always listen to System preference @@ -171,6 +173,25 @@ const Theme = ({ applyTheme(forcedTheme ?? theme); }, [forcedTheme, theme]); + // Fire onThemeChange on actual changes, skipping the initial mount. + // Ref keeps the latest callback without re-firing when consumers pass an inline function. + const onThemeChangeRef = useRef(onThemeChange); + useEffect(() => { + onThemeChangeRef.current = onThemeChange; + }); + const themeChangeMounted = useRef(false); + useEffect(() => { + if (!themeChangeMounted.current) { + themeChangeMounted.current = true; + return; + } + if (theme) { + const resolved = + theme === 'system' && resolvedTheme ? resolvedTheme : theme; + onThemeChangeRef.current?.(theme, resolved); + } + }, [theme, resolvedTheme]); + const providerValue = useMemo( () => ({ theme, @@ -277,7 +298,7 @@ const ThemeScript = memo( setColorScheme = true ) => { const resolvedName = value ? value[name] : name; - const val = literal ? name + `|| ''` : `'${resolvedName}'`; + const val = literal ? name : `'${resolvedName}'`; let text = ''; // MUCH faster to set colorScheme alongside HTML attribute/class @@ -293,13 +314,17 @@ const ThemeScript = memo( } if (attribute === 'class') { - if (literal || resolvedName) { + if (literal) { + text += `if(${val})c.add(${val})`; + } else if (resolvedName) { text += `c.add(${val})`; } else { text += `null`; } } else { - if (resolvedName) { + if (literal) { + text += `if(${val})d[s](n,${val})`; + } else if (resolvedName) { text += `d[s](n,${val})`; } } diff --git a/packages/raystack/components/theme-provider/types.ts b/packages/raystack/components/theme-provider/types.ts index 897aad606..12675a57b 100644 --- a/packages/raystack/components/theme-provider/types.ts +++ b/packages/raystack/components/theme-provider/types.ts @@ -14,7 +14,7 @@ export interface UseThemeProps { /** If `enableSystem` is true and the active theme is "system", this returns whether the system preference resolved to "dark" or "light". Otherwise, identical to `theme` */ resolvedTheme?: string; /** If enableSystem is true, returns the System theme preference ("dark" or "light"), regardless what the active theme is */ - systemTheme?: "dark" | "light"; + systemTheme?: 'dark' | 'light'; } export interface ThemeProviderProps { @@ -33,7 +33,7 @@ export interface ThemeProviderProps { /** Default theme name (for v0.0.12 and lower the default was light). If `enableSystem` is false, the default theme is light */ defaultTheme?: string; /** HTML attribute modified based on the active theme. Accepts `class` and `data-*` (meaning any data attribute, `data-mode`, `data-color`, etc.) */ - attribute?: string | "class"; + attribute?: string | 'class'; /** Mapping of theme name to HTML attribute value. Object where key is the theme name and value is the attribute value */ value?: ValueObject; /** Nonce string to pass to the inline script for CSP headers */ @@ -46,4 +46,6 @@ export interface ThemeProviderProps { accentColor?: 'indigo' | 'orange' | 'mint'; /** Gray color variant for the theme, options are 'gray', 'mauve', or 'slate' */ grayColor?: 'gray' | 'mauve' | 'slate'; + /** Called when the active theme changes. `resolvedTheme` is the actual applied theme (`'light'`/`'dark'` when `theme` is `'system'`). Not fired on initial mount. */ + onThemeChange?: (theme: string, resolvedTheme: string) => void; }