Skip to content

Native: Uniwind.setTheme() does not update most component styles (ShadowRegistry race condition) #489

Description

@kilbot

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

  1. Render an app with multiple screens/components using Uniwind className props (e.g., bg-card, bg-popover, text-foreground)
  2. Call Uniwind.setTheme('dark') from a user-initiated action (e.g., a settings screen)
  3. 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)):

  1. UniwindRuntime.onResolveClassNames fires
  2. UniwindStore.reload(runtime) runs synchronously
  3. requestIdleCallback(updateTree) is scheduled
  4. UniwindListener.notify(dependencies) fires, causing React re-renders
  5. Re-rendering components: ref cleanup runs → cancelIdleCallback cancels the pending ShadowRegistry.link()unregisterShadowNode removes the shadow node
  6. New ref callback schedules a NEW requestIdleCallback for ShadowRegistry.link()
  7. 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)
  8. 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

  1. Don't defer ShadowRegistry.link() with requestIdleCallback — if link() runs synchronously in the ref callback, shadow nodes would always be registered when updateShadowTree() runs
  2. 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
  3. 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
  4. Don't call UniwindListener.notify() inside updateTree — notify listeners separately (synchronously after reload) so that re-renders and shadow tree updates don't race

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions