-
Notifications
You must be signed in to change notification settings - Fork 13
fix(theme-provider): callback deps, inline script, and onThemeChange #760
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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]); | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+183
to
+193
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's examine the theme.tsx file to understand the full context
cat -n packages/raystack/components/theme-provider/theme.tsx | head -200Repository: raystack/apsara Length of output: 6726 🏁 Script executed: # Let's also check if there are any tests for this component
find . -type f -name "*.test.*" -o -name "*.spec.*" | grep -i theme | head -20Repository: raystack/apsara Length of output: 139 🏁 Script executed: # Search for onThemeChange usage and callback invocation patterns
rg "onThemeChange" -B 3 -A 3Repository: raystack/apsara Length of output: 2857 🏁 Script executed: # Let's examine the test file to understand existing test coverage
cat -n packages/raystack/components/theme-provider/__tests__/theme-provider.test.tsx | head -300Repository: raystack/apsara Length of output: 10401 🏁 Script executed: # Let's understand the flow better by checking getTheme and getSystemTheme functions
rg "function getTheme|const getTheme|export.*getTheme" -A 10Repository: raystack/apsara Length of output: 886 🏁 Script executed: # Check how resolvedTheme gets initialized and when it changes
rg "setResolvedTheme|resolvedTheme" -B 2 -A 2 | head -80Repository: raystack/apsara Length of output: 6010 🏁 Script executed: # Search for tests related to onThemeChange callback
rg "onThemeChange" packages/raystack/components/theme-provider/__tests__/ -B 5 -A 5Repository: raystack/apsara Length of output: 41 🏁 Script executed: # Let's also look at the getSystemTheme function to understand when resolvedTheme gets populated
rg "function getSystemTheme|const getSystemTheme|export.*getSystemTheme" -A 15Repository: raystack/apsara Length of output: 596 🏁 Script executed: # Check the full test file to see if callback testing exists
wc -l packages/raystack/components/theme-provider/__tests__/theme-provider.test.tsxRepository: raystack/apsara Length of output: 141 🏁 Script executed: # Read the full test file to see all tests, especially any for onThemeChange
cat -n packages/raystack/components/theme-provider/__tests__/theme-provider.test.tsx | tail -100Repository: raystack/apsara Length of output: 3389 🏁 Script executed: # Let's trace through the execution by checking the exact initialization logic
cat -n packages/raystack/components/theme-provider/theme.tsx | sed -n '54-59p; 145-152p; 183-193p'Repository: raystack/apsara Length of output: 111 🏁 Script executed: # Let's trace through the execution by checking the exact initialization logic
cat -n packages/raystack/components/theme-provider/theme.tsx | sed -n '54,59p; 145,152p; 183,193p'Repository: raystack/apsara Length of output: 980 🏁 Script executed: # Now let's create a test scenario to understand the race condition
# Let's trace the exact sequence: what are the initial values and when do they change?
python3 << 'EOF'
print("=== Execution Trace ===")
print("\n1. Initial mount:")
print(" - theme: getTheme(storageKey, defaultTheme)")
print(" * storageKey='theme', defaultTheme='system'")
print(" * localStorage.getItem('theme') returns null (empty storage)")
print(" * So theme = 'system' (fallback)")
print(" - resolvedTheme: getTheme(storageKey)")
print(" * getTheme(storageKey) without fallback")
print(" * localStorage.getItem('theme') returns null")
print(" * So resolvedTheme = undefined (no fallback)")
print()
print("2. During initial render:")
print(" - useEffect at lines 145-152 runs (Listen to System preference)")
print(" * Calls handleMediaQuery(media) immediately")
print(" * handleMediaQuery calls getSystemTheme()")
print(" * getSystemTheme() returns 'dark' or 'light' based on matchMedia")
print(" * Calls setResolvedTheme(resolved)")
print(" * This triggers resolvedTheme state update")
print()
print("3. After resolvedTheme updates:")
print(" - useEffect at lines 183-193 runs again (onThemeChange callback)")
print(" * Dependencies: [theme, resolvedTheme]")
print(" * theme = 'system' (unchanged)")
print(" * resolvedTheme = 'dark' or 'light' (changed!)")
print(" * Check: themeChangeMounted.current = false (first run skips)")
print(" * WAIT: On second run when resolvedTheme changes:")
print(" - themeChangeMounted.current = true (was set in previous effect run)")
print(" - theme = 'system' (truthy)")
print(" - resolved = resolvedTheme (just populated)")
print(" - onThemeChangeRef.current?.('system', 'dark' or 'light')")
print(" - CALLBACK FIRES! Not supposed to fire on initial mount!")
print()
print("=== The Bug ===")
print("When theme='system' and storage is empty:")
print("1. First effect run: skip and set themeChangeMounted=true ✓")
print("2. handleMediaQuery fires and updates resolvedTheme")
print("3. Second effect run (triggered by resolvedTheme change):")
print(" - themeChangeMounted is already true")
print(" - condition skips: NO, because theme changed? NO, theme='system' unchanged")
print(" - But resolvedTheme did change, triggering this effect run!")
print(" - So the callback fires with (theme='system', resolved='dark/light')")
print(" - This is still during initial mount phase, VIOLATION OF CONTRACT!")
EOFRepository: raystack/apsara Length of output: 2034 🏁 Script executed: # Let's verify there's no test coverage for onThemeChange callback behavior
grep -n "onThemeChange" packages/raystack/components/theme-provider/__tests__/theme-provider.test.tsxRepository: raystack/apsara Length of output: 41 🏁 Script executed: # Double-check the contract in types
cat -n packages/raystack/components/theme-provider/types.ts | grep -A 1 -B 1 "onThemeChange"Repository: raystack/apsara Length of output: 316
The callback effect (lines 183-193) runs twice during initial mount: once to skip (setting the flag), then again when The fix is to check whether Proposed fix 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);
- }
+ const resolved = theme === 'system' ? resolvedTheme : theme;
+ if (!theme || !resolved) return;
+
+ if (!themeChangeMounted.current) {
+ themeChangeMounted.current = true;
+ return;
+ }
+
+ onThemeChangeRef.current?.(theme, resolved);
}, [theme, resolvedTheme]);Add a test: render with 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+183
to
+194
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we can call the |
||||||||||||||||||||||||||||||||||||||||||||||
| 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})`; | ||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the |
||||||||||||||||||||||||||||||||||||||||||||||
| } 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})`; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this needed?, on each render the ref is getting updated