Description
When calling Uniwind.setTheme() on native (iOS), the vast majority of components do not visually update to the new theme. Only ~2 out of hundreds of shadow nodes get updated by ShadowRegistry.updateShadowTree(). Components that re-render via hooks (useCSSVariable, useUniwind) compute correct styles from UniwindStore.getStyles(), but the visual output does not change.
Web works perfectly — this is native-only.
Environment
uniwind-pro: 1.0.1 (aliased as uniwind)
react-native: 0.83.4
expo: ~55.0.0
- Platform: iOS (iPad, tested on device)
- Architecture: New Architecture (Fabric)
Steps to Reproduce
- Render an app with multiple screens/components using Uniwind className props (e.g.,
bg-card, bg-popover, text-foreground)
- Call
Uniwind.setTheme('dark') from a user-initiated action (e.g., a settings screen)
- Observe that most component backgrounds and text colors remain in the old theme
Expected Behavior
All components should update their styles to reflect the new theme, as they do on web.
Actual Behavior
ShadowRegistry.updateShadowTree(mutations, accentMutations) returns true, but only updates 2 shadow nodes
- The dev warning "Theme transition was cancelled because no shadow nodes were registered" does NOT fire (because
didUpdate is true), even though the update is nearly complete no-op
- Components that explicitly subscribe to theme changes via
useCSSVariable() or useUniwind() DO re-render and getStyles() returns correct theme values, but the visual does not update — the ShadowRegistry's C++ ownership of the shadow node appears to override React Native's style application
Root Cause Analysis
After extensive debugging, I believe the issue is a race condition between ShadowRegistry.link() and ShadowRegistry.updateShadowTree(), both of which are deferred via requestIdleCallback:
1. ShadowRegistry.link() uses requestIdleCallback in the ref callback
In components/native/View.tsx (and Text.tsx), ShadowRegistry.link() is called inside requestIdleCallback within the ref callback:
ref={(ref) => {
// ...
const requestIdleCallbackId = requestIdleCallback(() => {
// ...
ShadowRegistry.link(shadowNodeHandle, ...)
})
return () => {
cancelIdleCallback(requestIdleCallbackId) // ← cancels link on unmount/re-render
unregisterShadowNode(shadowNode, internalHandle)
}
}}
2. updateShadowTree() also uses requestIdleCallback
In components/native/utils/listener.ts:
if (changeSource === RuntimeChangeSource.User) {
requestIdleCallback(updateTree) // updateTree calls ShadowRegistry.updateShadowTree()
}
3. The race condition
When a theme change triggers re-renders (via UniwindListener.notify(dependencies)):
UniwindRuntime.onResolveClassNames fires
UniwindStore.reload(runtime) runs synchronously
requestIdleCallback(updateTree) is scheduled
UniwindListener.notify(dependencies) fires, causing React re-renders
- Re-rendering components: ref cleanup runs →
cancelIdleCallback cancels the pending ShadowRegistry.link() → unregisterShadowNode removes the shadow node
- New ref callback schedules a NEW
requestIdleCallback for ShadowRegistry.link()
updateTree fires → ShadowRegistry.updateShadowTree() runs, but most shadow nodes have been unregistered (step 5) and not yet re-linked (step 6 hasn't fired yet)
- The new
ShadowRegistry.link() calls eventually fire, but updateShadowTree() has already run — so the newly linked nodes never get the theme update
Even for components that DON'T re-render, the initial ShadowRegistry.link() may never have completed if the component re-rendered at any point after mount (since each re-render cancels and re-schedules the idle callback).
4. Evidence
Debugging confirmed:
- At the time
updateShadowTree runs, classNameCount (number of registered shadow nodes) is typically 0-2, when there should be hundreds
getStyles() returns correct theme values during re-renders, proving UniwindStore.reload() worked
- Even when a component re-renders with the correct theme and
getStyles() returns the right styles, the visual doesn't change — suggesting ShadowRegistry's C++ shadow node retains stale styles
Current Workaround
Using useCSSVariable() to subscribe to theme changes and applying backgroundColor as an inline style prop, bypassing the ShadowRegistry pipeline entirely:
import { useCSSVariable } from 'uniwind';
import { Platform, StyleSheet } from 'react-native';
function Card({ className, style, ...props }) {
const bgCard = useCSSVariable('--color-card');
return (
<View
className={cn('bg-card ...', className)}
style={[
Platform.OS !== 'web' ? { backgroundColor: String(bgCard) } : undefined,
style,
]}
{...props}
/>
);
}
This is not viable at scale — it requires patching every component that uses a theme-dependent background or text color.
Possible Fix Directions
- Don't defer
ShadowRegistry.link() with requestIdleCallback — if link() runs synchronously in the ref callback, shadow nodes would always be registered when updateShadowTree() runs
- Don't defer
updateShadowTree() with requestIdleCallback for user-initiated theme changes, or schedule it with a longer delay to ensure all link() calls complete first
- Re-run
updateShadowTree() after link() — if a link() call detects a pending/recent theme change, it could apply the new styles immediately for that node
- Don't call
UniwindListener.notify() inside updateTree — notify listeners separately (synchronously after reload) so that re-renders and shadow tree updates don't race
Description
When calling
Uniwind.setTheme()on native (iOS), the vast majority of components do not visually update to the new theme. Only ~2 out of hundreds of shadow nodes get updated byShadowRegistry.updateShadowTree(). Components that re-render via hooks (useCSSVariable,useUniwind) compute correct styles fromUniwindStore.getStyles(), but the visual output does not change.Web works perfectly — this is native-only.
Environment
uniwind-pro: 1.0.1 (aliased asuniwind)react-native: 0.83.4expo: ~55.0.0Steps to Reproduce
bg-card,bg-popover,text-foreground)Uniwind.setTheme('dark')from a user-initiated action (e.g., a settings screen)Expected Behavior
All components should update their styles to reflect the new theme, as they do on web.
Actual Behavior
ShadowRegistry.updateShadowTree(mutations, accentMutations)returnstrue, but only updates 2 shadow nodesdidUpdateistrue), even though the update is nearly complete no-opuseCSSVariable()oruseUniwind()DO re-render andgetStyles()returns correct theme values, but the visual does not update — the ShadowRegistry's C++ ownership of the shadow node appears to override React Native's style applicationRoot Cause Analysis
After extensive debugging, I believe the issue is a race condition between
ShadowRegistry.link()andShadowRegistry.updateShadowTree(), both of which are deferred viarequestIdleCallback:1.
ShadowRegistry.link()usesrequestIdleCallbackin the ref callbackIn
components/native/View.tsx(andText.tsx),ShadowRegistry.link()is called insiderequestIdleCallbackwithin the ref callback:2.
updateShadowTree()also usesrequestIdleCallbackIn
components/native/utils/listener.ts:3. The race condition
When a theme change triggers re-renders (via
UniwindListener.notify(dependencies)):UniwindRuntime.onResolveClassNamesfiresUniwindStore.reload(runtime)runs synchronouslyrequestIdleCallback(updateTree)is scheduledUniwindListener.notify(dependencies)fires, causing React re-renderscancelIdleCallbackcancels the pendingShadowRegistry.link()→unregisterShadowNoderemoves the shadow noderequestIdleCallbackforShadowRegistry.link()updateTreefires →ShadowRegistry.updateShadowTree()runs, but most shadow nodes have been unregistered (step 5) and not yet re-linked (step 6 hasn't fired yet)ShadowRegistry.link()calls eventually fire, butupdateShadowTree()has already run — so the newly linked nodes never get the theme updateEven for components that DON'T re-render, the initial
ShadowRegistry.link()may never have completed if the component re-rendered at any point after mount (since each re-render cancels and re-schedules the idle callback).4. Evidence
Debugging confirmed:
updateShadowTreeruns,classNameCount(number of registered shadow nodes) is typically 0-2, when there should be hundredsgetStyles()returns correct theme values during re-renders, provingUniwindStore.reload()workedgetStyles()returns the right styles, the visual doesn't change — suggesting ShadowRegistry's C++ shadow node retains stale stylesCurrent Workaround
Using
useCSSVariable()to subscribe to theme changes and applyingbackgroundColoras an inlinestyleprop, bypassing the ShadowRegistry pipeline entirely:This is not viable at scale — it requires patching every component that uses a theme-dependent background or text color.
Possible Fix Directions
ShadowRegistry.link()withrequestIdleCallback— iflink()runs synchronously in the ref callback, shadow nodes would always be registered whenupdateShadowTree()runsupdateShadowTree()withrequestIdleCallbackfor user-initiated theme changes, or schedule it with a longer delay to ensure alllink()calls complete firstupdateShadowTree()afterlink()— if alink()call detects a pending/recent theme change, it could apply the new styles immediately for that nodeUniwindListener.notify()insideupdateTree— notify listeners separately (synchronously afterreload) so that re-renders and shadow tree updates don't race