From 4d0256228d3a22a092f3467f6dd535b143ec8f8e Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Wed, 29 Apr 2026 09:22:18 +0530 Subject: [PATCH 1/7] 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; } From 12cb393c99af75bd35d82475afaf0c390d9b8042 Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Tue, 5 May 2026 08:56:55 +0530 Subject: [PATCH 2/7] fix(theme): optimize theme change handling and callback execution --- .../components/theme-provider/theme.tsx | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/raystack/components/theme-provider/theme.tsx b/packages/raystack/components/theme-provider/theme.tsx index 82dfc8ea1..ddf9dc418 100644 --- a/packages/raystack/components/theme-provider/theme.tsx +++ b/packages/raystack/components/theme-provider/theme.tsx @@ -167,30 +167,23 @@ 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 themeChangeMounted = useRef(false); + + // Apply on theme/forcedTheme change, then notify (skipping initial mount). useEffect(() => { // @ts-ignore 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) { + if (themeChangeMounted.current && theme) { const resolved = theme === 'system' && resolvedTheme ? resolvedTheme : theme; onThemeChangeRef.current?.(theme, resolved); } - }, [theme, resolvedTheme]); + themeChangeMounted.current = true; + }, [forcedTheme, theme, resolvedTheme]); const providerValue = useMemo( () => ({ From 4f58c0cf313daed224cf5515073fbdb1265ae76e Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Tue, 5 May 2026 11:25:32 +0530 Subject: [PATCH 3/7] fix(theme-provider): add onThemeChange callback to docs --- apps/www/src/content/docs/theme/overview/props.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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; }; From bba39648307b9a99d0f734369bb61fe923695baf Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Tue, 5 May 2026 11:47:44 +0530 Subject: [PATCH 4/7] fix(theme): resolve comment --- .../components/theme-provider/theme.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/raystack/components/theme-provider/theme.tsx b/packages/raystack/components/theme-provider/theme.tsx index ddf9dc418..66eb67fa7 100644 --- a/packages/raystack/components/theme-provider/theme.tsx +++ b/packages/raystack/components/theme-provider/theme.tsx @@ -170,19 +170,24 @@ const Theme = ({ // Ref-held callback so consumer render churn doesn't drive effect cadence. const onThemeChangeRef = useRef(onThemeChange); onThemeChangeRef.current = onThemeChange; - const themeChangeMounted = useRef(false); + const lastResolvedRef = useRef(undefined); - // Apply on theme/forcedTheme change, then notify (skipping initial mount). + // Apply on theme/forcedTheme change, then notify on real resolved-theme changes. useEffect(() => { // @ts-ignore applyTheme(forcedTheme ?? theme); - if (themeChangeMounted.current && theme) { - const resolved = - theme === 'system' && resolvedTheme ? resolvedTheme : theme; + if (!theme) return; + const resolved = theme === 'system' ? resolvedTheme : theme; + if (!resolved) return; + + if ( + lastResolvedRef.current !== undefined && + lastResolvedRef.current !== resolved + ) { onThemeChangeRef.current?.(theme, resolved); } - themeChangeMounted.current = true; + lastResolvedRef.current = resolved; }, [forcedTheme, theme, resolvedTheme]); const providerValue = useMemo( From 71545eb41736c7277e41147754e4b18f7e97c784 Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Tue, 5 May 2026 11:58:48 +0530 Subject: [PATCH 5/7] fix(theme): improve theme resolution logic in Theme component --- packages/raystack/components/theme-provider/theme.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/raystack/components/theme-provider/theme.tsx b/packages/raystack/components/theme-provider/theme.tsx index 66eb67fa7..591ea4d8f 100644 --- a/packages/raystack/components/theme-provider/theme.tsx +++ b/packages/raystack/components/theme-provider/theme.tsx @@ -178,7 +178,8 @@ const Theme = ({ applyTheme(forcedTheme ?? theme); if (!theme) return; - const resolved = theme === 'system' ? resolvedTheme : theme; + const resolved = + forcedTheme ?? (theme === 'system' ? resolvedTheme : theme); if (!resolved) return; if ( From 4e859c5d03984e2e4ae684764090429a1d833abf Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Tue, 5 May 2026 12:02:22 +0530 Subject: [PATCH 6/7] fix(theme-provider): enhance theme change handling and update documentation for resolvedTheme --- .../components/theme-provider/theme.tsx | 23 +++++++++++-------- .../components/theme-provider/types.ts | 2 +- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/raystack/components/theme-provider/theme.tsx b/packages/raystack/components/theme-provider/theme.tsx index 591ea4d8f..e2841f448 100644 --- a/packages/raystack/components/theme-provider/theme.tsx +++ b/packages/raystack/components/theme-provider/theme.tsx @@ -170,33 +170,38 @@ const Theme = ({ // Ref-held callback so consumer render churn doesn't drive effect cadence. const onThemeChangeRef = useRef(onThemeChange); onThemeChangeRef.current = onThemeChange; - const lastResolvedRef = useRef(undefined); + const lastRef = useRef<{ theme: string; resolved: string } | undefined>( + undefined + ); - // Apply on theme/forcedTheme change, then notify on real resolved-theme changes. + // Apply on theme/forcedTheme change, then notify on real changes. useEffect(() => { - // @ts-ignore - applyTheme(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 ( - lastResolvedRef.current !== undefined && - lastResolvedRef.current !== resolved + prev !== undefined && + (prev.theme !== theme || prev.resolved !== resolved) ) { onThemeChangeRef.current?.(theme, resolved); } - lastResolvedRef.current = resolved; - }, [forcedTheme, theme, resolvedTheme]); + }, [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' diff --git a/packages/raystack/components/theme-provider/types.ts b/packages/raystack/components/theme-provider/types.ts index 12675a57b..debebb2c7 100644 --- a/packages/raystack/components/theme-provider/types.ts +++ b/packages/raystack/components/theme-provider/types.ts @@ -11,7 +11,7 @@ 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'; From 417cedd88a8e97fdf0effc150b749955b4d8a959 Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Tue, 5 May 2026 12:10:28 +0530 Subject: [PATCH 7/7] fix(theme): update resolvedTheme initialization to use explicit type --- packages/raystack/components/theme-provider/theme.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/raystack/components/theme-provider/theme.tsx b/packages/raystack/components/theme-provider/theme.tsx index e2841f448..3a3d7997d 100644 --- a/packages/raystack/components/theme-provider/theme.tsx +++ b/packages/raystack/components/theme-provider/theme.tsx @@ -54,8 +54,8 @@ const Theme = ({ 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);