From 4d0256228d3a22a092f3467f6dd535b143ec8f8e Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Wed, 29 Apr 2026 09:22:18 +0530 Subject: [PATCH] fix(theme-provider): callback deps, inline script, and onThemeChange (#646) - Correct setTheme deps to track storageKey instead of unused forcedTheme. - Add missing enableSystem and applyTheme to handleMediaQuery deps. - Fix inline-script generation in updateDOM: drop the `name + "|| ''"` concat that produced classList.add('') (throws) or setAttribute(n, '') (junk attribute) when a value-map lookup missed; guard the literal path with if(${val}) so the DOM call only runs when the lookup is truthy. - Add onThemeChange prop. Fires on actual theme/resolvedTheme changes, skipping the initial mount. Uses a ref so the callback identity is read at fire time, decoupling effect cadence from consumer render churn. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/theme-provider/theme.tsx | 37 ++++++++++++++++--- .../components/theme-provider/types.ts | 6 ++- 2 files changed, 35 insertions(+), 8 deletions(-) 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; }