Skip to content

fix(theme-provider): callback deps, inline script, and onThemeChange#760

Open
paanSinghCoder wants to merge 1 commit intomainfrom
fix/theme-provider-callbacks-and-script
Open

fix(theme-provider): callback deps, inline script, and onThemeChange#760
paanSinghCoder wants to merge 1 commit intomainfrom
fix/theme-provider-callbacks-and-script

Conversation

@paanSinghCoder
Copy link
Copy Markdown
Contributor

@paanSinghCoder paanSinghCoder commented Apr 29, 2026

Description

Addresses items 2, 3, and 5 from #646.

  • Callback depssetTheme now tracks storageKey (was forcedTheme, unused). handleMediaQuery now includes enableSystem and applyTheme. Both fix latent stale-closure bugs that surface only when those props change at runtime.
  • Inline-script fixupdateDOM no longer emits c.add(x[e]|| ''). The literal path is gated with if(${val}) so a missing value-map entry doesn't reach classList.add('') (throws) or setAttribute(n, '') (junk attr).
  • onThemeChange callback — fires on actual theme/resolvedTheme changes, skips initial mount. Held via ref so consumer render churn doesn't over-fire and the callback never goes stale.

Type of Change

  • Bug fix
  • New feature
  • Improvement

How Has This Been Tested?

Manual repro on main vs branch for each fix. Tests TODO — leaving as draft until theme-provider.test.tsx is extended.

Related Issues

Refs #646 (items 2, 3, 5).

🤖 Generated with Claude Code

…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) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 29, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
apsara Ready Ready Preview, Comment Apr 29, 2026 3:54am

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 2026

📝 Walkthrough

Walkthrough

This change adds an onThemeChange callback feature to the theme provider that fires after the initial mount when the theme or resolved theme updates. The callback receives both the raw theme value and the resolved theme (with system theme resolution). Additionally, the change corrects dependency arrays for setTheme and handleMediaQuery hooks to properly include storageKey and relevant flags. The ThemeScript inline script generator is updated to remove unnecessary fallback values and wrap certain operations with conditional guards when the literal flag is used.

Possibly related issues

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes all three main changes: callback dependency fixes, inline script updates, and the new onThemeChange callback feature.
Description check ✅ Passed The description is well-detailed and directly related to the changeset, explaining the callback deps fixes, inline-script fix, and onThemeChange callback implementation with clear context.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@paanSinghCoder paanSinghCoder self-assigned this Apr 29, 2026
@paanSinghCoder paanSinghCoder marked this pull request as ready for review May 1, 2026 04:24
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/raystack/components/theme-provider/theme.tsx`:
- Around line 183-193: The effect handling theme changes currently flips
themeChangeMounted.current on the first run even when theme === 'system' and
resolvedTheme is still undefined, causing onThemeChangeRef.current to fire when
resolvedTheme later populates; update the useEffect (the block referencing
themeChangeMounted, onThemeChangeRef, theme, resolvedTheme) to only treat the
initial run as "mounted" when a stable resolved value exists (i.e., if theme ===
'system' require resolvedTheme to be non-null before setting
themeChangeMounted.current = true), so the callback is not invoked on initial
mount, and add a unit test that renders with defaultTheme="system" and empty
storage asserting onThemeChange is not called on mount but is called on
subsequent user-initiated theme changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: edf4cd10-e5af-479a-ae27-6f4572d173b6

📥 Commits

Reviewing files that changed from the base of the PR and between fddcf49 and 4d02562.

📒 Files selected for processing (2)
  • packages/raystack/components/theme-provider/theme.tsx
  • packages/raystack/components/theme-provider/types.ts

Comment on lines +183 to +193
useEffect(() => {
if (!themeChangeMounted.current) {
themeChangeMounted.current = true;
return;
}
if (theme) {
const resolved =
theme === 'system' && resolvedTheme ? resolvedTheme : theme;
onThemeChangeRef.current?.(theme, resolved);
}
}, [theme, resolvedTheme]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 -200

Repository: 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 -20

Repository: raystack/apsara

Length of output: 139


🏁 Script executed:

# Search for onThemeChange usage and callback invocation patterns
rg "onThemeChange" -B 3 -A 3

Repository: 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 -300

Repository: 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 10

Repository: 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 -80

Repository: 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 5

Repository: 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 15

Repository: 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.tsx

Repository: 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 -100

Repository: 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!")
EOF

Repository: 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.tsx

Repository: 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


onThemeChange fires during initial mount when theme === 'system' and storage is empty.

The callback effect (lines 183-193) runs twice during initial mount: once to skip (setting the flag), then again when resolvedTheme populates via the media query listener. On the second run, the flag is already true, so the callback executes with the resolved system theme, violating the documented contract "Not fired on initial mount."

The fix is to check whether resolved has a value before marking the mount as complete—ensuring the callback only fires after both theme and resolvedTheme are stable.

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 defaultTheme="system", empty storage, and an onThemeChange callback. Verify the callback does not fire on mount, then fires on user-initiated theme changes.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (!themeChangeMounted.current) {
themeChangeMounted.current = true;
return;
}
if (theme) {
const resolved =
theme === 'system' && resolvedTheme ? resolvedTheme : theme;
onThemeChangeRef.current?.(theme, resolved);
}
}, [theme, resolvedTheme]);
useEffect(() => {
const resolved = theme === 'system' ? resolvedTheme : theme;
if (!theme || !resolved) return;
if (!themeChangeMounted.current) {
themeChangeMounted.current = true;
return;
}
onThemeChangeRef.current?.(theme, resolved);
}, [theme, resolvedTheme]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/raystack/components/theme-provider/theme.tsx` around lines 183 -
193, The effect handling theme changes currently flips
themeChangeMounted.current on the first run even when theme === 'system' and
resolvedTheme is still undefined, causing onThemeChangeRef.current to fire when
resolvedTheme later populates; update the useEffect (the block referencing
themeChangeMounted, onThemeChangeRef, theme, resolvedTheme) to only treat the
initial run as "mounted" when a stable resolved value exists (i.e., if theme ===
'system' require resolvedTheme to be non-null before setting
themeChangeMounted.current = true), so the callback is not invoked on initial
mount, and add a unit test that renders with defaultTheme="system" and empty
storage asserting onThemeChange is not called on mount but is called on
subsequent user-initiated theme changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant