fix: ensure colorSchemeObservable always updates to preserve manual o… by tonichiga · Pull Request #1776 · nativewind/nativewind · GitHub
Skip to content

fix: ensure colorSchemeObservable always updates to preserve manual o…#1776

Open
tonichiga wants to merge 1 commit intonativewind:v4from
tonichiga:fix/color-scheme-observable-always-update
Open

fix: ensure colorSchemeObservable always updates to preserve manual o…#1776
tonichiga wants to merge 1 commit intonativewind:v4from
tonichiga:fix/color-scheme-observable-always-update

Conversation

@tonichiga
Copy link
Copy Markdown

fix(appearance): preserve manual colorScheme override in production builds

Summary

Fix setColorScheme() not persisting after app reload (OTA update) on iOS when using darkMode: "class" in NativeWind.


Problem

When a React Native app using NativeWind 4.x with darkMode: "class" receives an OTA update and reloads, the manually set color scheme (e.g. "dark") is silently discarded and the app falls back to the system appearance.

Preconditions

  • iOS device with light system theme
  • App with user-selected dark theme stored in persistent storage (e.g. MMKV)
  • OTA update triggers app JS bundle reload

Observed behavior

App reloads → displays light theme despite user preference being "dark".

Expected behavior

App reloads → displays dark theme as stored in user preferences.


Root Cause

appearance-observables.ts maintains two separate observables:

Observable Purpose
colorSchemeObservable Manual override set via colorScheme.set()
systemColorScheme System value from Appearance.addChangeListener

The colorScheme.get() method returns colorSchemeObservable.get() ?? systemColorScheme.get().

The bug: colorScheme.set(value) called Appearance.setColorScheme(value) (native side) but only updated colorSchemeObservable inside a NODE_ENV === "test" guard — meaning in production builds, colorSchemeObservable was never set and always remained undefined.

// Before (broken in production)
colorScheme.set(value) {
    appearance.setColorScheme(value); // native call
    if (process.env.NODE_ENV === "test") {
        colorSchemeObservable.set(...); // ← never runs in production
    }
}

The get() fallback then always resolved to systemColorScheme, which during OTA reload is briefly reset to the system value by the AppState "active" listener:

appStateListener = appState.addEventListener("change", (type) => {
  if (type === "active") {
    const colorScheme = appearance.getColorScheme() ?? "light"; // returns system "light"
    exports.systemColorScheme.set(colorScheme); // ← overwrites everything
  }
});

Result: colorSchemeObservable = undefined, systemColorScheme = "light" → app renders in light mode.

Why it worked on Android: Android does not fire an AppState "active" transition during OTA JS reload the same way iOS does, so systemColorScheme was not reset at the wrong moment.

Why timeouts didn't help: The issue is not a race condition — colorSchemeObservable was structurally never populated in production, regardless of timing.


Fix

Remove the NODE_ENV === "test" guard so colorSchemeObservable is always updated when colorScheme.set() is called. This makes the manual override survive any subsequent systemColorScheme changes triggered by Appearance or AppState events.

// After (correct)
colorScheme.set(value) {
    appearance.setColorScheme(value);
    // Always update so manual override is preserved regardless of environment
    colorSchemeObservable.set(value === "system" ? undefined : value);
}

Note: "system" maps to undefined (not "light") so that get() correctly falls through to the real-time systemColorScheme observable instead of being hardcoded.

Diff

- if (process.env.NODE_ENV === "test") {
-     colorSchemeObservable.set(value === "system" ? "light" : value);
- }
+ // Always update colorSchemeObservable so the manual override is preserved
+ // even when iOS resets the native appearance during OTA reload.
+ colorSchemeObservable.set(value === "system" ? undefined : value);

Testing

Scenario Before After
App cold start, dark theme saved ✅ Works ✅ Works
OTA update reload, dark theme saved, system = light ❌ Resets to light ✅ Stays dark
User switches theme via UI ✅ Works ✅ Works
darkMode: "media" config N/A (throws before reaching this code) N/A
Android, any scenario ✅ Works ✅ Works

Affected versions

Reproduced on react-native-css-interop@0.2.3. Likely present in all prior versions with the same guard.

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