From ed755dc8a58ef170d84f660bdde3134491e3f289 Mon Sep 17 00:00:00 2001 From: Im-awesome-Aadi Date: Mon, 9 Feb 2026 00:59:33 +0530 Subject: [PATCH] Changelog: [Android] [Fixed] Reset Hermes timezone cache on system timezone change Summary: When the device timezone changes while the app is running, JavaScript Date still return the local time according to the previous timezone. This happens because Hermes caches timezone information to improve performance. If the device timezone changes during app runtime, local time calculations continue using the old cached timezone. Hermes provides a native API, resetTimezoneCache(), to reset this cached information. This PR adds an internal Android module that calls this API whenever the platform detects a timezone change. Changes: Kotlin: TimeZoneModule Listens for Intent.ACTION_TIMEZONE_CHANGED broadcasts Calls a JNI method to reset Hermes timezone cache Safely registers/unregisters the receiver during Catalyst lifecycle Open for unit testing C++: TimeZoneCache Provides resetNativeHermesTimeZoneCache() JNI method Calls hermes::IHermes::resetTimezoneCache() No-op if runtime pointer is null or runtime is not Hermes Test Plan: Java unit tests Verify broadcast receiver registration/unregistration Verify native reset hook is invoked on timezone change --- .../react/modules/timezone/TimeZoneModule.kt | 83 ++++++++++++ .../facebook/react/shell/MainReactPackage.kt | 6 +- .../src/main/jni/react/jni/CMakeLists.txt | 1 + .../src/main/jni/react/jni/OnLoad-common.cpp | 2 + .../src/main/jni/react/jni/TimeZoneCache.cpp | 32 +++++ .../src/main/jni/react/jni/TimeZoneCache.h | 27 ++++ .../modules/timezone/TimeZoneModuleTest.kt | 128 ++++++++++++++++++ 7 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/timezone/TimeZoneModule.kt create mode 100644 packages/react-native/ReactAndroid/src/main/jni/react/jni/TimeZoneCache.cpp create mode 100644 packages/react-native/ReactAndroid/src/main/jni/react/jni/TimeZoneCache.h create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/timezone/TimeZoneModuleTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/timezone/TimeZoneModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/timezone/TimeZoneModule.kt new file mode 100644 index 000000000000..efbcd2933396 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/timezone/TimeZoneModule.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.modules.timezone + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.util.Log +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.module.annotations.ReactModule + +/** + * Internal module responsible for listening to system timezone changes and + * resetting the Hermes timezone cache when applicable. + * + * This ensures Date behavior remains correct after a device timezone change + * during runtime of app. + */ +@ReactModule(name = TimeZoneModule.NAME) +public open class TimeZoneModule(private val reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + public companion object { + public const val NAME: String = "TimeZoneModule" + } + + private var timeZoneChangeReceiver: BroadcastReceiver? = null + + public override fun getName(): String = NAME + + private external fun resetNativeHermesTimeZoneCache(jsRuntimePtr: Long) + + public override fun initialize() { + super.initialize() + registerTimeZoneChangeReceiver() + } + + private fun registerTimeZoneChangeReceiver() { + if (timeZoneChangeReceiver != null) { + return + } + timeZoneChangeReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + handleTimeZoneChange() + } + } + + reactContext.registerReceiver( + timeZoneChangeReceiver, + IntentFilter(Intent.ACTION_TIMEZONE_CHANGED) + ) + } + + public override fun onCatalystInstanceDestroy() { + timeZoneChangeReceiver?.let { reactContext.unregisterReceiver(it) } + timeZoneChangeReceiver = null + super.onCatalystInstanceDestroy() + } + + protected open fun resetHermesTimeZoneCache(jsRuntimePtr: Long) { + resetNativeHermesTimeZoneCache(jsRuntimePtr) + } + + private fun handleTimeZoneChange() { + try { + val catalystInstance = reactApplicationContext.catalystInstance ?: return + reactApplicationContext.runOnJSQueueThread { + val jsContext = catalystInstance.javaScriptContextHolder.get() + resetHermesTimeZoneCache(jsContext) + } + } catch (e: Exception) { + Log.e(NAME, "Failed to reset Hermes timezone cache on timezone change", e) + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt index 9927bf571536..79ef578c7708 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt @@ -41,6 +41,7 @@ import com.facebook.react.modules.reactdevtoolssettings.ReactDevToolsSettingsMan import com.facebook.react.modules.share.ShareModule import com.facebook.react.modules.sound.SoundManagerModule import com.facebook.react.modules.statusbar.StatusBarModule +import com.facebook.react.modules.timezone.TimeZoneModule import com.facebook.react.modules.toast.ToastModule import com.facebook.react.modules.vibration.VibrationModule import com.facebook.react.modules.websocket.WebSocketModule @@ -91,11 +92,12 @@ import com.facebook.react.views.view.ReactViewManager ShareModule::class, SoundManagerModule::class, StatusBarModule::class, + TimeZoneModule::class, ToastModule::class, VibrationModule::class, WebSocketModule::class, ] -) + ) public class MainReactPackage @JvmOverloads constructor(private val config: MainPackageConfig? = null) : @@ -124,6 +126,7 @@ constructor(private val config: MainPackageConfig? = null) : ShareModule.NAME -> ShareModule(reactContext) StatusBarModule.NAME -> StatusBarModule(reactContext) SoundManagerModule.NAME -> SoundManagerModule(reactContext) + TimeZoneModule.NAME -> TimeZoneModule(reactContext) ToastModule.NAME -> ToastModule(reactContext) VibrationModule.NAME -> VibrationModule(reactContext) WebSocketModule.NAME -> WebSocketModule(reactContext) @@ -265,6 +268,7 @@ constructor(private val config: MainPackageConfig? = null) : ShareModule::class.java, StatusBarModule::class.java, SoundManagerModule::class.java, + TimeZoneModule::class.java, ToastModule::class.java, VibrationModule::class.java, WebSocketModule::class.java, diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/CMakeLists.txt b/packages/react-native/ReactAndroid/src/main/jni/react/jni/CMakeLists.txt index 1e17ddb93db1..820886919749 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/jni/CMakeLists.txt +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/CMakeLists.txt @@ -31,6 +31,7 @@ add_library( OnLoad-common.cpp ReadableNativeArray.cpp ReadableNativeMap.cpp + TimeZoneCache.cpp TransformHelper.cpp WritableNativeArray.cpp WritableNativeMap.cpp diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/OnLoad-common.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/jni/OnLoad-common.cpp index ef069ed7e41d..53660205ccde 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/jni/OnLoad-common.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/OnLoad-common.cpp @@ -14,6 +14,7 @@ #include "TransformHelper.h" #include "WritableNativeArray.h" #include "WritableNativeMap.h" +#include "TimeZoneCache.h" namespace facebook::react { @@ -29,6 +30,7 @@ extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { WritableNativeArray::registerNatives(); WritableNativeMap::registerNatives(); TransformHelper::registerNatives(); + TimeZoneCache::registerNatives(); }); } diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/TimeZoneCache.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/jni/TimeZoneCache.cpp new file mode 100644 index 000000000000..b2a9935e9c21 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/TimeZoneCache.cpp @@ -0,0 +1,32 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "TimeZoneCache.h" +#include + +namespace facebook::react { + +void TimeZoneCache::resetNativeHermesTimeZoneCache(jni::alias_ref /* unused */,jlong jsRuntimePtr) { + if (!jsRuntimePtr) { + return; +} + jsi::Runtime &runtime = *reinterpret_cast(jsRuntimePtr); + auto* hermesAPI = jsi::castInterface(&runtime); + if (!hermesAPI) { + return; + } + hermesAPI->resetTimezoneCache(); +} +void TimeZoneCache::registerNatives() { + registerHybrid({ + makeNativeMethod("resetNativeHermesTimeZoneCache", TimeZoneCache::resetNativeHermesTimeZoneCache), + }); +} + + + +} // namespace facebook::react diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/TimeZoneCache.h b/packages/react-native/ReactAndroid/src/main/jni/react/jni/TimeZoneCache.h new file mode 100644 index 000000000000..a3db987c392e --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/TimeZoneCache.h @@ -0,0 +1,27 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include + +namespace facebook::react { + +class TimeZoneCache : public jni::HybridClass { + public: + static constexpr auto kJavaDescriptor = "Lcom/facebook/react/modules/timezone/TimeZoneModule;"; + + static void registerNatives(); + + static void resetNativeHermesTimeZoneCache(jni::alias_ref /* unused */, jlong jsRuntimePtr); + + ~TimeZoneCache() = default; + + friend HybridBase; +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/timezone/TimeZoneModuleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/timezone/TimeZoneModuleTest.kt new file mode 100644 index 000000000000..941e1fcad384 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/timezone/TimeZoneModuleTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +@file:Suppress("DEPRECATION") + +package com.facebook.react.modules.timezone + +import android.content.BroadcastReceiver +import android.content.Intent +import android.content.IntentFilter +import com.facebook.react.bridge.CatalystInstance +import com.facebook.react.bridge.JavaScriptContextHolder +import com.facebook.react.bridge.ReactApplicationContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class TimeZoneModuleTest { + + private lateinit var reactContext: ReactApplicationContext + private lateinit var catalystInstance: CatalystInstance + private lateinit var module: TestableTimeZoneModule + + // Test subclass to override the native method and avoid UnsatisfiedLinkError + class TestableTimeZoneModule(context: ReactApplicationContext) : TimeZoneModule(context) { + public override fun resetHermesTimeZoneCache(jsRuntimePtr: Long) { + // Do nothing in JVM test + } + } + + @Before + fun setup() { + // Mock ReactApplicationContext and CatalystInstance + reactContext = mock() + catalystInstance = mock() + whenever(reactContext.catalystInstance).thenReturn(catalystInstance) + + // Use the test subclass that overrides the native method + module = spy(TestableTimeZoneModule(reactContext)) + } + + @Test + fun testModuleName() { + assertEquals(TimeZoneModule.NAME, module.name) + } + + @Test + fun testInitializeRegistersReceiver() { + module.initialize() + + // Verify that the private receiver is not null + val receiverField = TimeZoneModule::class.java.getDeclaredField("timeZoneChangeReceiver") + receiverField.isAccessible = true + val receiver = receiverField.get(module) + assertNotNull(receiver) + + // Verify that the receiver was registered with the correct intent filter + verify(reactContext).registerReceiver(any(), any()) + } + + @Test + fun testOnCatalystInstanceDestroyUnregistersReceiver() { + module.initialize() // registers the receiver + + module.onCatalystInstanceDestroy() + + // Verify that the private receiver is cleared + val receiverField = TimeZoneModule::class.java.getDeclaredField("timeZoneChangeReceiver") + receiverField.isAccessible = true + val receiver = receiverField.get(module) + assertNull(receiver) + + // Verify that the receiver was unregistered + verify(reactContext).unregisterReceiver(any()) + } + + @Test + fun testTimeZoneChangeTriggersHermesReset() { + // 1. Mock JavaScriptContextHolder + val jsContextHolder = mock() + whenever(catalystInstance.javaScriptContextHolder).thenReturn(jsContextHolder) + whenever(jsContextHolder.get()).thenReturn(12345L) + + // 2. Mock runOnJSQueueThread to run synchronously + doAnswer { invocation -> + val runnable = invocation.arguments[0] as Runnable + runnable.run() + null + } + .whenever(reactContext) + .runOnJSQueueThread(any()) + + // 3. Initialize the module (registers the receiver) + module.initialize() + + // 4. Access the private receiver via reflection + val receiverField = TimeZoneModule::class.java.getDeclaredField("timeZoneChangeReceiver") + receiverField.isAccessible = true + val receiver = receiverField.get(module) as BroadcastReceiver + + // 5. Simulate timezone change broadcast + receiver.onReceive(reactContext, Intent(Intent.ACTION_TIMEZONE_CHANGED)) + + // 6. Verify that the JS context was accessed + verify(catalystInstance).javaScriptContextHolder + verify(jsContextHolder).get() + + // 7. Verify that resetHermesTimeZoneCache was called with correct JS context + verify(module).resetHermesTimeZoneCache(12345L) + } +}