react-native: Appearance addChangeListener handler is called when app goes to background with wrong color scheme

Description

A handler function of Appearance.addChangeListener is triggered when the app goes to the background. It also has a wrong colorScheme value.

React Native version:

System:
    OS: macOS 10.15.1
    CPU: (4) x64 Intel(R) Core(TM) i7-5557U CPU @ 3.10GHz
    Memory: 92.24 MB / 16.00 GB
    Shell: 5.7.1 - /bin/zsh
  Binaries:
    Node: 10.13.0 - /usr/local/bin/node
    Yarn: 1.12.1 - /usr/local/bin/yarn
    npm: 6.9.0 - /usr/local/bin/npm
    Watchman: Not Found
  Managers:
    CocoaPods: 1.8.4 - /usr/local/bin/pod
  SDKs:
    iOS SDK:
      Platforms: iOS 13.2, DriverKit 19.0, macOS 10.15, tvOS 13.2, watchOS 6.1
    Android SDK:
      API Levels: 23, 25, 26, 27, 28, 29
      Build Tools: 27.0.3, 28.0.2, 28.0.3, 29.0.2
      System Images: android-23 | Google APIs Intel x86 Atom, android-27 | Google Play Intel x86 Atom, android-28 | Google APIs Intel x86 Atom, android-28 | Google Play Intel x86 Atom, android-29 | Google APIs Intel x86 Atom
      Android NDK: Not Found
  IDEs:
    Android Studio: 3.5 AI-191.8026.42.35.5791312
    Xcode: 11.2.1/11B500 - /usr/bin/xcodebuild
  Languages:
    Python: 2.7.15 - /usr/local/bin/python
  npmPackages:
    @react-native-community/cli: Not Found
    react: 16.11.0 => 16.11.0 
    react-native: 0.62.1 => 0.62.1 
  npmGlobalPackages:
    *react-native*: Not Found

Steps To Reproduce

  1. Register Appearance.addChangeListener at the root of the app (I use it with react-native-navigation).
Appearance.addChangeListener(({ colorScheme }) => {
  console.log(colorScheme);
});
  1. Move the app to the background.

Expected Results

No theme changed.

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Reactions: 47
  • Comments: 47 (3 by maintainers)

Commits related to this issue

Most upvoted comments

+1, having the same issue!

Dump question, isn’t it the easiest way to just call Appearance.getColorScheme() when the Listener fires? Works well for me without a cumbersome timer…

  const initAppearanceListener = () => {
    const listener: Appearance.AppearanceListener = ({ colorScheme } /* <-- ignore */) => {
      setColorScheme(Appearance.getColorScheme());
    };
    Appearance.addChangeListener(listener);
    return () => Appearance.removeChangeListener(listener);
  };

Not stale

2 years passed and this bug still haunted me every other day

+1 The issue still persists in RN0.69+

@Macrow your workaround helped. thank you

here it is as a hook to use in lieu of the rn useColorScheme hook. I also used setTimeout instead of throttle

import { Appearance, ColorSchemeName } from 'react-native';
import { useEffect, useRef, useState } from 'react';

export default function useColorScheme(delay = 500): NonNullable<ColorSchemeName> {
  const [colorScheme, setColorScheme] = useState(Appearance.getColorScheme());

  let timeout = useRef<NodeJS.Timeout | null>(null).current;

  useEffect(() => {
    Appearance.addChangeListener(onColorSchemeChange);

    return () => {
      resetCurrentTimeout();
      Appearance.removeChangeListener(onColorSchemeChange);
    };
  }, []);

  function onColorSchemeChange(preferences: Appearance.AppearancePreferences) {
    resetCurrentTimeout();

    timeout = setTimeout(() => {
      setColorScheme(preferences.colorScheme);
    }, delay);
  }

  function resetCurrentTimeout() {
    if (timeout) {
      clearTimeout(timeout);
    }
  }

  return colorScheme as NonNullable<ColorSchemeName>;
}

The theme change in App Switcher is not applied immediately, and response is slow when entering foreground, but it is resolved.

But I want it to be solved in react-native.

import { useEffect, useMemo, useState } from 'react';
import { AppState, AppStateStatus, ColorSchemeName, useColorScheme as _useColorScheme } from 'react-native';
import { useSubscription } from 'use-subscription';

export const useAppState = (): AppStateStatus => {
  const subscription = useMemo(
    () => ({
      getCurrentValue: () => AppState.currentState,
      subscribe: (callback: () => void) => {
        const appStateSubscription = AppState.addEventListener('change', callback);
        return () => appStateSubscription.remove();
      },
    }),
    [],
  );
  return useSubscription(subscription);
};

export const useColorScheme = (): NonNullable<ColorSchemeName> => {
  const colorScheme = _useColorScheme() as NonNullable<ColorSchemeName>;
  const [currentScheme, setCurrentScheme] = useState(colorScheme);
  const appState = useAppState();

  useEffect(() => {
    if (appState === 'active') {
      setCurrentScheme(colorScheme);
    }
  }, [appState, colorScheme]);

  return currentScheme;
};

Dump question, isn’t it the easiest way to just call Appearance.getColorScheme() when the Listener fires? Works well for me without a cumbersome timer…

  const initAppearanceListener = () => {
    const listener: Appearance.AppearanceListener = ({ colorScheme } /* <-- ignore */) => {
      setColorScheme(Appearance.getColorScheme());
    };
    Appearance.addChangeListener(listener);
    return () => Appearance.removeChangeListener(listener);
  };

Sorry, but as I say in the following link, it keeps happening very randomly with your workaround:

https://github.com/expo/expo/issues/10815#issuecomment-897325894

I have the same problem on iOS, and I found expo/react-native-appearance have this issue too, I don’t know if it was my mistake.

I fix it by Lodash temporary, I don’t find a better way.

import _ from 'lodash';

useEffect(() => {
    const handleColorModeChange = async (preferences: Appearance.AppearancePreferences) => {
      console.log(preferences.colorScheme);
      // do your job ....
    };

    // delay 1 second to handle change
    Appearance.addChangeListener(_.throttle(handleColorModeChange, 1000, {
      leading: false,
      trailing: true
    }));

    return () => {
      Appearance.removeChangeListener(handleColorModeChange);
    };
  }, []);

The only way to 100.00% fix the flashing as of right now seems to be what @komik966 suggested in https://github.com/facebook/react-native/issues/28525#issuecomment-907805294: refactor every color with DynamicColorIOS, which I assume passes the color values for all appearances down to the OS and let the OS pick the right color value for the color mode, instead of listening for color mode changes and swapping the color in React Native.

I have refactored my project with DynamicColorIOS and now the app also transitions very nicely when the color mode changes, but it requires a lot of additional work as DynamicColorIOS is only supported in iOS and libraries like React Navigation also don’t fully support it (https://github.com/react-navigation/react-navigation/issues/10891), so such refactoring may not be practical for large complex projects.

https://user-images.githubusercontent.com/31050761/193649332-f2211749-d1f7-461b-8a8a-1b474bdda38b.mp4

Conclusion:

Use DynamicColorIOS, it will:

  • get rid of UI flashing effect when switching to background
  • properly update app switcher snapshot

Definitely still seeing this. Moved to expo/react-native-appearance for now.

that’s great! Given that there are a couple of reports of the issue being fixed let’s close this. If you have any further issues I would recommend opening a new issue with a proper repro.

wow how is this bug still not fixed. i remember having this same bug in flutter, but there the issue was tagged, reproed, triaged, and fixed in less than 12 hours.

anyway, thanks for the many and varied workarounds.

this worked for me:

import { useEffect, useState } from "react";
import { Appearance, ColorSchemeName } from "react-native";
import debounce from "lodash/debounce";

// This hook is from https://github.com/facebook/react-native/issues/28525
export function useColorScheme(): NonNullable<ColorSchemeName> {
  const [colorScheme, setColorScheme] = useState(Appearance.getColorScheme());

  const initAppearanceListener = () => {
    const listener: Appearance.AppearanceListener = debounce(
      () => {
        setColorScheme(Appearance.getColorScheme());
      },
      200,
      { leading: false, trailing: true }
    );
    const changeListener = Appearance.addChangeListener(listener);
    return () => changeListener.remove();
  };

  useEffect(() => {
    initAppearanceListener();
  }, []);

  return colorScheme as NonNullable<ColorSchemeName>;
}

https://reactnative.dev/docs/usecolorscheme This hook could help someone.

Experiencing flashing with useColorScheme hook as well…

“react-native”: “0.70.4”,

This is still happening in React Native 0.66.1. I’m not using any listeners, just the useColorScheme hook.

If I go to the device’s home screen and open the app again (without closing entirely), the app flashes for a second with dark appearance although the system scheme is set as light.

Results of my investigation:

This happens, because RN subscribes to changes inside traitCollectionDidChange. iOS toggles to both light and dark schemes to make light and dark snapshot of the application. More here: https://stackoverflow.com/a/63372828 It confirms @monomichael 's suggestion.

https://github.com/facebook/react-native/blob/b51a99c73cc4fbcbb03c97f92b7f7f166493538f/React/CoreModules/RCTAppearance.mm#L120-L124

https://github.com/facebook/react-native/blob/b51a99c73cc4fbcbb03c97f92b7f7f166493538f/React/Base/RCTRootView.m#L363-L373

Here is described problem about invalid snapshot in app switcher https://stackoverflow.com/questions/60667186/is-there-any-method-to-achieve-true-dark-mode-in-react-native-for-ios

Conclusion:

Use DynamicColorIOS, it will:

  • get rid of UI flashing effect when switching to background
  • properly update app switcher snapshot

The problem is on iOS only. When backgrounding the app, perhaps due to a bug on iOS 13 the user interface style changes to the opposite color scheme and then back to the current color scheme immediately afterwards. The best solution is to debounce the notification calls by 10ms like they did on react-native-appearance.

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0,01 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
    [[NSNotificationCenter defaultCenter] postNotificationName:RCTUserInterfaceStyleDidChangeNotification
                                                        object:self
                                                      userInfo:@{
                                                        RCTUserInterfaceStyleDidChangeNotificationTraitCollectionKey: self.traitCollection,
                                                      }];
  });

This issue broke our Facebook login because we were rebooting the app to load dark mode theme mid login when the Facebook app took focus to do OAuth. Pretty difficult to troubleshoot.

Dump question, isn’t it the easiest way to just call Appearance.getColorScheme() when the Listener fires? Works well for me without a cumbersome timer…

This worked for me, just ignoring the argument passed into the function

This was fixed for me on 0.73.5! 🎉

I’m using version 0.73.4 (and finally so far) it seems to be working properly too 🙌

My workaround (https://github.com/ds300/patch-package use for applying changes)

diff --git a/node_modules/react-native/React/CoreModules/RCTAppearance.mm b/node_modules/react-native/React/CoreModules/RCTAppearance.mm
index 17535a9..e7fd131 100644
--- a/node_modules/react-native/React/CoreModules/RCTAppearance.mm
+++ b/node_modules/react-native/React/CoreModules/RCTAppearance.mm
@@ -106,9 +106,8 @@ RCT_EXPORT_METHOD(setColorScheme : (NSString *)style)
 
 RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSString *, getColorScheme)
 {
-  if (_currentColorScheme == nil) {
-    _currentColorScheme = RCTColorSchemePreference(nil);
-  }
+  _currentColorScheme = RCTColorSchemePreference(nil);
+
   return _currentColorScheme;
 }

⚠️ PAY ATTENTION: These changes can create new bugs!

react-native+0.72.3.patch

2023, latest version, and this bug is still there. My (workaround) solution is using lodash.debounce with leading = false.

I solved it finally using AppState 🎉. Note: I am using NativeWind. Please adjust the code according to your needs.

import { useColorScheme as useNativeWindColorScheme } from 'nativewind';
import { AppState, useColorScheme } from 'react-native';

const Root = () => {
	const currentColorScheme = useColorScheme();
	const { colorScheme, setColorScheme } = useNativeWindColorScheme();
	const isDarkTheme = colorScheme === GlobalSchemeEnum.DARK;
	const LightTheme = { dark: !isDarkTheme, colors: ThemeNavigationLightColors };
	const DarkTheme = { dark: isDarkTheme, colors: ThemeNavigationDarkColors };
	const theme = isDarkTheme ? DarkTheme : LightTheme;

	useEffect(() => {
		const subscription = AppState.addEventListener('change', (state) => {
			const isActive = state === 'active';
			if (!isActive) return;
			currentColorScheme && setColorScheme(currentColorScheme);
		});
		return () => subscription.remove();
	}, [colorScheme, setColorScheme, currentColorScheme]);

	{/* Navigation */}
	<NavigationContainer theme={theme}>
		<App />		
	</NavigationContainer>
};

AppRegistry.registerComponent(appName, () => Root);

Let me know if it doesn’t work!

@komik966 Very glad that you asked! I thought it would, and it almost did, but there were two minor cases where the DynamicColorIOS colors were not updated correctly in the app switcher, specifically:

  1. the custom border color I set for React Navigation header and tab bar
  2. the custom border color I set for NativeBase Input and Select components every single NativeBase component

Not sure if I suspect that it might be because borderColors are being handled differently, since every instance of borderColor is not updating correctly but all my other colors and backgroundColors are being handled perfectly fine in both React Navigation and NativeBase.

https://user-images.githubusercontent.com/31050761/193877709-07496d89-63e2-40e8-a407-c0b7c9d9859e.MP4

At the end, I chose to use the same color for the borders under both light and dark mode to avoid the flashing when going to the app switcher.

I started having this problem. The current version that is the problem is “react-native”:“0.66.4”, There was no such problem in version “react-native”: “0.64.2”,

Thanks @jasonaibrahim, I really didn’t want to fool with this nonsense today. That makes it easy! 🙌

With this code in my App.js, I am seeing the extra calls but not the flash back and forth between themes:

  const systemScheme = useColorScheme();
  const themeObject = systemScheme === 'light' ? light : dark;

  const toggleTheme = useCallback((colorScheme) => {
    const statusBarTheme = colorScheme === 'light' ? 'dark' : 'light';
    StatusBar.setBarStyle(`${statusBarTheme}-content`);
  }, []);

  useEffect(() => {
    Appearance.addChangeListener(({ colorScheme }) => {
      toggleTheme(colorScheme);
    });
    return () => {
      Appearance.removeChangeListener();
    };
  }, [toggleTheme]);

Hope that helps someone.

Here is a repo: https://github.com/rosskhanas/react-native-appearance-bug

Once the app goes to the background it logs 2 rows - 2 different values to the console - light, and dark.