diff --git a/apps/www/src/content/docs/theme/overview/props.ts b/apps/www/src/content/docs/theme/overview/props.ts index e76751474..8d89f5f8d 100644 --- a/apps/www/src/content/docs/theme/overview/props.ts +++ b/apps/www/src/content/docs/theme/overview/props.ts @@ -68,6 +68,12 @@ export type ThemeProviderProps = { /** Mapping of theme name to HTML attribute value */ value?: { [themeName: string]: string }; + /** + * 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; + /** React children */ children?: React.ReactNode; }; diff --git a/packages/raystack/components/theme-provider/theme.tsx b/packages/raystack/components/theme-provider/theme.tsx index ae9c2bf93..3a3d7997d 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,13 +48,14 @@ const Theme = ({ nonce, style = 'modern', accentColor = 'indigo', - grayColor = 'gray' + grayColor = 'gray', + onThemeChange }: ThemeProviderProps) => { const [theme, setThemeState] = useState(() => getTheme(storageKey, defaultTheme) ); - const [resolvedTheme, setResolvedTheme] = useState(() => - getTheme(storageKey) + const [resolvedTheme, setResolvedTheme] = useState( + undefined ); const attrs = !value ? themes : Object.values(value); @@ -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 @@ -165,18 +167,41 @@ const Theme = ({ return () => window.removeEventListener('storage', handleStorage); }, [setTheme]); - // Whenever theme or forcedTheme changes, apply it + // Ref-held callback so consumer render churn doesn't drive effect cadence. + const onThemeChangeRef = useRef(onThemeChange); + onThemeChangeRef.current = onThemeChange; + const lastRef = useRef<{ theme: string; resolved: string } | undefined>( + undefined + ); + + // Apply on theme/forcedTheme change, then notify on real changes. useEffect(() => { - // @ts-ignore - applyTheme(forcedTheme ?? theme); - }, [forcedTheme, theme]); + const target = forcedTheme ?? theme; + if (target) applyTheme(target); + + if (!theme) return; + const resolved = + forcedTheme ?? (theme === 'system' ? resolvedTheme : theme); + if (!resolved) return; + + const prev = lastRef.current; + lastRef.current = { theme, resolved }; + + if ( + prev !== undefined && + (prev.theme !== theme || prev.resolved !== resolved) + ) { + onThemeChangeRef.current?.(theme, resolved); + } + }, [forcedTheme, theme, resolvedTheme, applyTheme]); const providerValue = useMemo( () => ({ theme, setTheme, forcedTheme, - resolvedTheme: theme === 'system' ? resolvedTheme : theme, + resolvedTheme: + forcedTheme ?? (theme === 'system' ? resolvedTheme : theme), themes: enableSystem ? [...themes, 'system'] : themes, systemTheme: (enableSystem ? resolvedTheme : undefined) as | 'light' @@ -277,7 +302,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 +318,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..debebb2c7 100644 --- a/packages/raystack/components/theme-provider/types.ts +++ b/packages/raystack/components/theme-provider/types.ts @@ -11,10 +11,10 @@ export interface UseThemeProps { setTheme: (theme: string) => void; /** Active theme name */ theme?: string; - /** 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` */ + /** The actually applied theme. Returns `forcedTheme` when set; otherwise the system preference (`"light"`/`"dark"`) when `theme` is `"system"`; 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; }