Skip to content

Commit 6567eda

Browse files
zeyapfacebook-github-bot
authored andcommitted
Add feature flag to keep Android timers running while the host is paused
Summary: On Android, `JavaTimerManager` stops JS timers on `onHostPause` and only restarts them on `onHostResume` or while a headless JS task is active. When the app keeps using the JS runtime without a resumed React screen, pending `setTimeout`s and Promise-driven work that depends on them stall until a React screen resumes. On iOS, `RCTTiming` does not depend on host lifecycle: timers keep firing until the instance is invalidated. Add `keepTimersRunningWhileHostPausedAndroid` (default `false`). When enabled, `JavaTimerManager`: - starts the timer frame callback when it is created, since the host may never resume; - stops timers only once the instance is destroyed (tracked by a new `isDestroyed`), instead of when the host is paused. Changelog: [Internal] Differential Revision: D126948111
1 parent 57cc022 commit 6567eda

22 files changed

Lines changed: 287 additions & 52 deletions

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<b5f4608f47743d0e2e9b1db3db551227>>
7+
* @generated SignedSource<<39edde759ee98ddbe1a3723f114ceada>>
88
*/
99

1010
/**
@@ -438,6 +438,12 @@ public object ReactNativeFeatureFlags {
438438
@JvmStatic
439439
public fun fuseboxWebSocketEventsEnabled(): Boolean = accessor.fuseboxWebSocketEventsEnabled()
440440

441+
/**
442+
* Keep JS timers running on Android while the React host is paused, stopping them only when the React instance is destroyed. Matches iOS, where timers do not depend on host lifecycle.
443+
*/
444+
@JvmStatic
445+
public fun keepTimersRunningWhileHostPausedAndroid(): Boolean = accessor.keepTimersRunningWhileHostPausedAndroid()
446+
441447
/**
442448
* When enabled, uses optimized platform-specific paths to apply animated props synchronously. On Android, this uses a batched int/double buffer protocol with a single JNI call. Other platforms apply the props of each view through the existing synchronous update.
443449
*/

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<d2d170d333194ce2f2a4f1d93321ac6d>>
7+
* @generated SignedSource<<8012f449a876977a418c640e8537aee9>>
88
*/
99

1010
/**
@@ -88,6 +88,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
8888
private var fuseboxFrameRecordingEnabledCache: Boolean? = null
8989
private var fuseboxScreenshotCaptureEnabledCache: Boolean? = null
9090
private var fuseboxWebSocketEventsEnabledCache: Boolean? = null
91+
private var keepTimersRunningWhileHostPausedAndroidCache: Boolean? = null
9192
private var optimizedAnimatedPropUpdatesCache: Boolean? = null
9293
private var overrideBySynchronousMountPropsAtMountingAndroidCache: Boolean? = null
9394
private var perfIssuesEnabledCache: Boolean? = null
@@ -729,6 +730,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
729730
return cached
730731
}
731732

733+
override fun keepTimersRunningWhileHostPausedAndroid(): Boolean {
734+
var cached = keepTimersRunningWhileHostPausedAndroidCache
735+
if (cached == null) {
736+
cached = ReactNativeFeatureFlagsCxxInterop.keepTimersRunningWhileHostPausedAndroid()
737+
keepTimersRunningWhileHostPausedAndroidCache = cached
738+
}
739+
return cached
740+
}
741+
732742
override fun optimizedAnimatedPropUpdates(): Boolean {
733743
var cached = optimizedAnimatedPropUpdatesCache
734744
if (cached == null) {

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<ec3e384af62c0e672da53b57354bca62>>
7+
* @generated SignedSource<<52b31eabb2d0590572c8a8c9a772a158>>
88
*/
99

1010
/**
@@ -164,6 +164,8 @@ public object ReactNativeFeatureFlagsCxxInterop {
164164

165165
@DoNotStrip @JvmStatic public external fun fuseboxWebSocketEventsEnabled(): Boolean
166166

167+
@DoNotStrip @JvmStatic public external fun keepTimersRunningWhileHostPausedAndroid(): Boolean
168+
167169
@DoNotStrip @JvmStatic public external fun optimizedAnimatedPropUpdates(): Boolean
168170

169171
@DoNotStrip @JvmStatic public external fun overrideBySynchronousMountPropsAtMountingAndroid(): Boolean

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<cd6aa804d1c10b93775adf83419368e3>>
7+
* @generated SignedSource<<931601ba571d0666c5920205cf40eb9f>>
88
*/
99

1010
/**
@@ -159,6 +159,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi
159159

160160
override fun fuseboxWebSocketEventsEnabled(): Boolean = false
161161

162+
override fun keepTimersRunningWhileHostPausedAndroid(): Boolean = false
163+
162164
override fun optimizedAnimatedPropUpdates(): Boolean = false
163165

164166
override fun overrideBySynchronousMountPropsAtMountingAndroid(): Boolean = true

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<c36b0b2d7173aa78b7cb175658fc89d2>>
7+
* @generated SignedSource<<13cf2ff00b943a9a15d55a6ef105cdcd>>
88
*/
99

1010
/**
@@ -92,6 +92,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
9292
private var fuseboxFrameRecordingEnabledCache: Boolean? = null
9393
private var fuseboxScreenshotCaptureEnabledCache: Boolean? = null
9494
private var fuseboxWebSocketEventsEnabledCache: Boolean? = null
95+
private var keepTimersRunningWhileHostPausedAndroidCache: Boolean? = null
9596
private var optimizedAnimatedPropUpdatesCache: Boolean? = null
9697
private var overrideBySynchronousMountPropsAtMountingAndroidCache: Boolean? = null
9798
private var perfIssuesEnabledCache: Boolean? = null
@@ -801,6 +802,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
801802
return cached
802803
}
803804

805+
override fun keepTimersRunningWhileHostPausedAndroid(): Boolean {
806+
var cached = keepTimersRunningWhileHostPausedAndroidCache
807+
if (cached == null) {
808+
cached = currentProvider.keepTimersRunningWhileHostPausedAndroid()
809+
accessedFeatureFlags.add("keepTimersRunningWhileHostPausedAndroid")
810+
keepTimersRunningWhileHostPausedAndroidCache = cached
811+
}
812+
return cached
813+
}
814+
804815
override fun optimizedAnimatedPropUpdates(): Boolean {
805816
var cached = optimizedAnimatedPropUpdatesCache
806817
if (cached == null) {

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<c5e9b41b76c5580c0a4ba848af4e36a5>>
7+
* @generated SignedSource<<a6af4dfc9df72953497373795991eee9>>
88
*/
99

1010
/**
@@ -159,6 +159,8 @@ public interface ReactNativeFeatureFlagsProvider {
159159

160160
@DoNotStrip public fun fuseboxWebSocketEventsEnabled(): Boolean
161161

162+
@DoNotStrip public fun keepTimersRunningWhileHostPausedAndroid(): Boolean
163+
162164
@DoNotStrip public fun optimizedAnimatedPropUpdates(): Boolean
163165

164166
@DoNotStrip public fun overrideBySynchronousMountPropsAtMountingAndroid(): Boolean

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import com.facebook.react.common.SystemClock.nanoTime
2020
import com.facebook.react.common.SystemClock.uptimeMillis
2121
import com.facebook.react.common.annotations.internal.LegacyArchitecture
2222
import com.facebook.react.devsupport.interfaces.DevSupportManager
23+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
2324
import com.facebook.react.jstasks.HeadlessJsTaskContext
2425
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
2526
import java.util.PriorityQueue
@@ -50,6 +51,11 @@ public open class JavaTimerManager(
5051
private val idleCallbackGuard = Any()
5152
private val timerIdsToTimers: SparseArray<Timer> = SparseArray()
5253
private val isPaused = AtomicBoolean(true)
54+
private val isDestroyed = AtomicBoolean(false)
55+
// Like iOS: timers follow the instance lifetime instead of the host's pause/resume, so JS
56+
// work keeps progressing while no React screen is resumed.
57+
private val keepTimersRunningWhileHostPaused =
58+
ReactNativeFeatureFlags.keepTimersRunningWhileHostPausedAndroid()
5359
private val isRunningTasks = AtomicBoolean(false)
5460
private val timerFrameCallback = TimerFrameCallback()
5561
private val idleFrameCallback = IdleFrameCallback()
@@ -65,6 +71,15 @@ public open class JavaTimerManager(
6571
init {
6672
reactApplicationContext.addLifecycleEventListener(this)
6773
HeadlessJsTaskContext.getInstance(reactApplicationContext).addTaskEventListener(this)
74+
if (keepTimersRunningWhileHostPaused) {
75+
// The host may never resume (e.g. no React screen is shown), so start timers here.
76+
UiThreadUtil.runOnUiThread {
77+
if (!isDestroyed.get()) {
78+
setChoreographerCallback()
79+
maybeSetChoreographerIdleCallback()
80+
}
81+
}
82+
}
6883
}
6984

7085
override fun onHostPause() {
@@ -105,10 +120,14 @@ public open class JavaTimerManager(
105120
public open fun onInstanceDestroy() {
106121
HeadlessJsTaskContext.getInstance(reactApplicationContext).removeTaskEventListener(this)
107122
reactApplicationContext.removeLifecycleEventListener(this)
123+
isDestroyed.set(true)
108124
clearFrameCallback()
109125
clearChoreographerIdleCallback()
110126
}
111127

128+
private fun isStopped(): Boolean =
129+
if (keepTimersRunningWhileHostPaused) isDestroyed.get() else isPaused.get()
130+
112131
@LegacyArchitecture
113132
private fun maybeSetChoreographerIdleCallback() {
114133
synchronized(idleCallbackGuard) {
@@ -120,7 +139,7 @@ public open class JavaTimerManager(
120139

121140
@LegacyArchitecture
122141
private fun maybeIdleCallback() {
123-
if (isPaused.get() && !isRunningTasks.get()) {
142+
if (isStopped() && !isRunningTasks.get()) {
124143
clearFrameCallback()
125144
}
126145
}
@@ -137,7 +156,7 @@ public open class JavaTimerManager(
137156

138157
private fun clearFrameCallback() {
139158
val headlessJsTaskContext = HeadlessJsTaskContext.getInstance(reactApplicationContext)
140-
if (frameCallbackPosted && isPaused.get() && !headlessJsTaskContext.hasActiveTasks()) {
159+
if (frameCallbackPosted && isStopped() && !headlessJsTaskContext.hasActiveTasks()) {
141160
reactChoreographer.removeFrameCallback(
142161
ReactChoreographer.CallbackType.TIMERS_EVENTS,
143162
timerFrameCallback,
@@ -286,7 +305,7 @@ public open class JavaTimerManager(
286305

287306
/** Calls all timers that have expired since the last time this frame callback was called. */
288307
override fun doFrame(frameTimeNanos: Long) {
289-
if (isPaused.get() && !isRunningTasks.get()) {
308+
if (isStopped() && !isRunningTasks.get()) {
290309
return
291310
}
292311
val frameTimeMillis = frameTimeNanos / 1000000
@@ -318,7 +337,7 @@ public open class JavaTimerManager(
318337

319338
private inner class IdleFrameCallback : Choreographer.FrameCallback {
320339
override fun doFrame(frameTimeNanos: Long) {
321-
if (isPaused.get() && !isRunningTasks.get()) {
340+
if (isStopped() && !isRunningTasks.get()) {
322341
return
323342
}
324343

‎packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<1ab2eec7b9c5bdd181ec5fbfd37149ab>>
7+
* @generated SignedSource<<1a0f644a9504d1279e892fc4f9174b10>>
88
*/
99

1010
/**
@@ -447,6 +447,12 @@ class ReactNativeFeatureFlagsJavaProvider
447447
return method(javaProvider_);
448448
}
449449

450+
bool keepTimersRunningWhileHostPausedAndroid() override {
451+
static const auto method =
452+
getReactNativeFeatureFlagsProviderJavaClass()->getMethod<jboolean()>("keepTimersRunningWhileHostPausedAndroid");
453+
return method(javaProvider_);
454+
}
455+
450456
bool optimizedAnimatedPropUpdates() override {
451457
static const auto method =
452458
getReactNativeFeatureFlagsProviderJavaClass()->getMethod<jboolean()>("optimizedAnimatedPropUpdates");
@@ -959,6 +965,11 @@ bool JReactNativeFeatureFlagsCxxInterop::fuseboxWebSocketEventsEnabled(
959965
return ReactNativeFeatureFlags::fuseboxWebSocketEventsEnabled();
960966
}
961967

968+
bool JReactNativeFeatureFlagsCxxInterop::keepTimersRunningWhileHostPausedAndroid(
969+
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop> /*unused*/) {
970+
return ReactNativeFeatureFlags::keepTimersRunningWhileHostPausedAndroid();
971+
}
972+
962973
bool JReactNativeFeatureFlagsCxxInterop::optimizedAnimatedPropUpdates(
963974
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop> /*unused*/) {
964975
return ReactNativeFeatureFlags::optimizedAnimatedPropUpdates();
@@ -1334,6 +1345,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() {
13341345
makeNativeMethod(
13351346
"fuseboxWebSocketEventsEnabled",
13361347
JReactNativeFeatureFlagsCxxInterop::fuseboxWebSocketEventsEnabled),
1348+
makeNativeMethod(
1349+
"keepTimersRunningWhileHostPausedAndroid",
1350+
JReactNativeFeatureFlagsCxxInterop::keepTimersRunningWhileHostPausedAndroid),
13371351
makeNativeMethod(
13381352
"optimizedAnimatedPropUpdates",
13391353
JReactNativeFeatureFlagsCxxInterop::optimizedAnimatedPropUpdates),

‎packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<2c8f9205f1de55591ede3845ba2fa877>>
7+
* @generated SignedSource<<156a320a8a7e9509922dbf14bdab255e>>
88
*/
99

1010
/**
@@ -234,6 +234,9 @@ class JReactNativeFeatureFlagsCxxInterop
234234
static bool fuseboxWebSocketEventsEnabled(
235235
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
236236

237+
static bool keepTimersRunningWhileHostPausedAndroid(
238+
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
239+
237240
static bool optimizedAnimatedPropUpdates(
238241
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
239242

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.modules.core
9+
10+
import android.os.Looper
11+
import android.view.Choreographer
12+
import com.facebook.react.bridge.ReactApplicationContext
13+
import com.facebook.react.devsupport.interfaces.DevSupportManager
14+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
15+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsDefaults
16+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
17+
import com.facebook.testutils.shadows.ShadowArguments
18+
import org.junit.After
19+
import org.junit.Before
20+
import org.junit.Test
21+
import org.junit.runner.RunWith
22+
import org.mockito.kotlin.any
23+
import org.mockito.kotlin.argumentCaptor
24+
import org.mockito.kotlin.eq
25+
import org.mockito.kotlin.mock
26+
import org.mockito.kotlin.never
27+
import org.mockito.kotlin.verify
28+
import org.robolectric.RobolectricTestRunner
29+
import org.robolectric.Shadows.shadowOf
30+
import org.robolectric.annotation.Config
31+
32+
@Config(shadows = [ShadowArguments::class])
33+
@RunWith(RobolectricTestRunner::class)
34+
class JavaTimerManagerTest {
35+
private lateinit var reactChoreographer: ReactChoreographer
36+
private lateinit var timerExecutor: JavaScriptTimerExecutor
37+
38+
@Before
39+
fun setUp() {
40+
ReactNativeFeatureFlagsForTests.setUp()
41+
reactChoreographer = mock()
42+
timerExecutor = mock()
43+
}
44+
45+
@After
46+
fun tearDown() {
47+
ReactNativeFeatureFlags.dangerouslyReset()
48+
}
49+
50+
private fun newTimerManager(keepTimersRunningWhileHostPaused: Boolean): JavaTimerManager {
51+
ReactNativeFeatureFlags.dangerouslyReset()
52+
ReactNativeFeatureFlags.override(
53+
object : ReactNativeFeatureFlagsDefaults() {
54+
override fun keepTimersRunningWhileHostPausedAndroid(): Boolean =
55+
keepTimersRunningWhileHostPaused
56+
}
57+
)
58+
val timerManager =
59+
JavaTimerManager(
60+
mock<ReactApplicationContext>(),
61+
timerExecutor,
62+
reactChoreographer,
63+
mock<DevSupportManager>(),
64+
)
65+
shadowOf(Looper.getMainLooper()).idle()
66+
return timerManager
67+
}
68+
69+
private fun postedTimerCallback(): Choreographer.FrameCallback {
70+
val captor = argumentCaptor<Choreographer.FrameCallback>()
71+
verify(reactChoreographer)
72+
.postFrameCallback(eq(ReactChoreographer.CallbackType.TIMERS_EVENTS), captor.capture())
73+
return captor.firstValue
74+
}
75+
76+
@Test
77+
fun `timers run from creation, survive host pause, and stop on instance destroy`() {
78+
val timerManager = newTimerManager(keepTimersRunningWhileHostPaused = true)
79+
timerManager.onHostPause()
80+
timerManager.createTimer(timerId = 1, delay = 0, repeat = false)
81+
82+
val frameCallback = postedTimerCallback()
83+
frameCallback.doFrame(Long.MAX_VALUE)
84+
verify(timerExecutor).callTimers(any())
85+
86+
timerManager.onInstanceDestroy()
87+
timerManager.createTimer(timerId = 2, delay = 0, repeat = false)
88+
frameCallback.doFrame(Long.MAX_VALUE)
89+
verify(timerExecutor).callTimers(any())
90+
}
91+
92+
@Test
93+
fun `timers wait for host resume without the flag`() {
94+
newTimerManager(keepTimersRunningWhileHostPaused = false)
95+
96+
verify(reactChoreographer, never())
97+
.postFrameCallback(eq(ReactChoreographer.CallbackType.TIMERS_EVENTS), any())
98+
}
99+
}

0 commit comments

Comments
 (0)