Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<b5f4608f47743d0e2e9b1db3db551227>>
* @generated SignedSource<<39edde759ee98ddbe1a3723f114ceada>>
*/

/**
Expand Down Expand Up @@ -438,6 +438,12 @@ public object ReactNativeFeatureFlags {
@JvmStatic
public fun fuseboxWebSocketEventsEnabled(): Boolean = accessor.fuseboxWebSocketEventsEnabled()

/**
* 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.
*/
@JvmStatic
public fun keepTimersRunningWhileHostPausedAndroid(): Boolean = accessor.keepTimersRunningWhileHostPausedAndroid()

/**
* 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<d2d170d333194ce2f2a4f1d93321ac6d>>
* @generated SignedSource<<8012f449a876977a418c640e8537aee9>>
*/

/**
Expand Down Expand Up @@ -88,6 +88,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
private var fuseboxFrameRecordingEnabledCache: Boolean? = null
private var fuseboxScreenshotCaptureEnabledCache: Boolean? = null
private var fuseboxWebSocketEventsEnabledCache: Boolean? = null
private var keepTimersRunningWhileHostPausedAndroidCache: Boolean? = null
private var optimizedAnimatedPropUpdatesCache: Boolean? = null
private var overrideBySynchronousMountPropsAtMountingAndroidCache: Boolean? = null
private var perfIssuesEnabledCache: Boolean? = null
Expand Down Expand Up @@ -729,6 +730,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
return cached
}

override fun keepTimersRunningWhileHostPausedAndroid(): Boolean {
var cached = keepTimersRunningWhileHostPausedAndroidCache
if (cached == null) {
cached = ReactNativeFeatureFlagsCxxInterop.keepTimersRunningWhileHostPausedAndroid()
keepTimersRunningWhileHostPausedAndroidCache = cached
}
return cached
}

override fun optimizedAnimatedPropUpdates(): Boolean {
var cached = optimizedAnimatedPropUpdatesCache
if (cached == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<ec3e384af62c0e672da53b57354bca62>>
* @generated SignedSource<<52b31eabb2d0590572c8a8c9a772a158>>
*/

/**
Expand Down Expand Up @@ -164,6 +164,8 @@ public object ReactNativeFeatureFlagsCxxInterop {

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

@DoNotStrip @JvmStatic public external fun keepTimersRunningWhileHostPausedAndroid(): Boolean

@DoNotStrip @JvmStatic public external fun optimizedAnimatedPropUpdates(): Boolean

@DoNotStrip @JvmStatic public external fun overrideBySynchronousMountPropsAtMountingAndroid(): Boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<cd6aa804d1c10b93775adf83419368e3>>
* @generated SignedSource<<931601ba571d0666c5920205cf40eb9f>>
*/

/**
Expand Down Expand Up @@ -159,6 +159,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi

override fun fuseboxWebSocketEventsEnabled(): Boolean = false

override fun keepTimersRunningWhileHostPausedAndroid(): Boolean = false

override fun optimizedAnimatedPropUpdates(): Boolean = false

override fun overrideBySynchronousMountPropsAtMountingAndroid(): Boolean = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<c36b0b2d7173aa78b7cb175658fc89d2>>
* @generated SignedSource<<13cf2ff00b943a9a15d55a6ef105cdcd>>
*/

/**
Expand Down Expand Up @@ -92,6 +92,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
private var fuseboxFrameRecordingEnabledCache: Boolean? = null
private var fuseboxScreenshotCaptureEnabledCache: Boolean? = null
private var fuseboxWebSocketEventsEnabledCache: Boolean? = null
private var keepTimersRunningWhileHostPausedAndroidCache: Boolean? = null
private var optimizedAnimatedPropUpdatesCache: Boolean? = null
private var overrideBySynchronousMountPropsAtMountingAndroidCache: Boolean? = null
private var perfIssuesEnabledCache: Boolean? = null
Expand Down Expand Up @@ -801,6 +802,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
return cached
}

override fun keepTimersRunningWhileHostPausedAndroid(): Boolean {
var cached = keepTimersRunningWhileHostPausedAndroidCache
if (cached == null) {
cached = currentProvider.keepTimersRunningWhileHostPausedAndroid()
accessedFeatureFlags.add("keepTimersRunningWhileHostPausedAndroid")
keepTimersRunningWhileHostPausedAndroidCache = cached
}
return cached
}

override fun optimizedAnimatedPropUpdates(): Boolean {
var cached = optimizedAnimatedPropUpdatesCache
if (cached == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<c5e9b41b76c5580c0a4ba848af4e36a5>>
* @generated SignedSource<<a6af4dfc9df72953497373795991eee9>>
*/

/**
Expand Down Expand Up @@ -159,6 +159,8 @@ public interface ReactNativeFeatureFlagsProvider {

@DoNotStrip public fun fuseboxWebSocketEventsEnabled(): Boolean

@DoNotStrip public fun keepTimersRunningWhileHostPausedAndroid(): Boolean

@DoNotStrip public fun optimizedAnimatedPropUpdates(): Boolean

@DoNotStrip public fun overrideBySynchronousMountPropsAtMountingAndroid(): Boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.facebook.react.common.SystemClock.nanoTime
import com.facebook.react.common.SystemClock.uptimeMillis
import com.facebook.react.common.annotations.internal.LegacyArchitecture
import com.facebook.react.devsupport.interfaces.DevSupportManager
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
import com.facebook.react.jstasks.HeadlessJsTaskContext
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
import java.util.PriorityQueue
Expand Down Expand Up @@ -50,6 +51,11 @@ public open class JavaTimerManager(
private val idleCallbackGuard = Any()
private val timerIdsToTimers: SparseArray<Timer> = SparseArray()
private val isPaused = AtomicBoolean(true)
private val isDestroyed = AtomicBoolean(false)
// Like iOS: timers follow the instance lifetime instead of the host's pause/resume, so JS
// work keeps progressing while no React screen is resumed.
private val keepTimersRunningWhileHostPaused =
ReactNativeFeatureFlags.keepTimersRunningWhileHostPausedAndroid()
private val isRunningTasks = AtomicBoolean(false)
private val timerFrameCallback = TimerFrameCallback()
private val idleFrameCallback = IdleFrameCallback()
Expand All @@ -65,6 +71,15 @@ public open class JavaTimerManager(
init {
reactApplicationContext.addLifecycleEventListener(this)
HeadlessJsTaskContext.getInstance(reactApplicationContext).addTaskEventListener(this)
if (keepTimersRunningWhileHostPaused) {
// The host may never resume (e.g. no React screen is shown), so start timers here.
UiThreadUtil.runOnUiThread {
if (!isDestroyed.get()) {
setChoreographerCallback()
maybeSetChoreographerIdleCallback()
}
}
}
}

override fun onHostPause() {
Expand Down Expand Up @@ -105,10 +120,14 @@ public open class JavaTimerManager(
public open fun onInstanceDestroy() {
HeadlessJsTaskContext.getInstance(reactApplicationContext).removeTaskEventListener(this)
reactApplicationContext.removeLifecycleEventListener(this)
isDestroyed.set(true)
clearFrameCallback()
clearChoreographerIdleCallback()
}

private fun isStopped(): Boolean =
if (keepTimersRunningWhileHostPaused) isDestroyed.get() else isPaused.get()

@LegacyArchitecture
private fun maybeSetChoreographerIdleCallback() {
synchronized(idleCallbackGuard) {
Expand All @@ -120,7 +139,7 @@ public open class JavaTimerManager(

@LegacyArchitecture
private fun maybeIdleCallback() {
if (isPaused.get() && !isRunningTasks.get()) {
if (isStopped() && !isRunningTasks.get()) {
clearFrameCallback()
}
}
Expand All @@ -137,7 +156,7 @@ public open class JavaTimerManager(

private fun clearFrameCallback() {
val headlessJsTaskContext = HeadlessJsTaskContext.getInstance(reactApplicationContext)
if (frameCallbackPosted && isPaused.get() && !headlessJsTaskContext.hasActiveTasks()) {
if (frameCallbackPosted && isStopped() && !headlessJsTaskContext.hasActiveTasks()) {
reactChoreographer.removeFrameCallback(
ReactChoreographer.CallbackType.TIMERS_EVENTS,
timerFrameCallback,
Expand Down Expand Up @@ -286,7 +305,7 @@ public open class JavaTimerManager(

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

private inner class IdleFrameCallback : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
if (isPaused.get() && !isRunningTasks.get()) {
if (isStopped() && !isRunningTasks.get()) {
return
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<1ab2eec7b9c5bdd181ec5fbfd37149ab>>
* @generated SignedSource<<1a0f644a9504d1279e892fc4f9174b10>>
*/

/**
Expand Down Expand Up @@ -447,6 +447,12 @@ class ReactNativeFeatureFlagsJavaProvider
return method(javaProvider_);
}

bool keepTimersRunningWhileHostPausedAndroid() override {
static const auto method =
getReactNativeFeatureFlagsProviderJavaClass()->getMethod<jboolean()>("keepTimersRunningWhileHostPausedAndroid");
return method(javaProvider_);
}

bool optimizedAnimatedPropUpdates() override {
static const auto method =
getReactNativeFeatureFlagsProviderJavaClass()->getMethod<jboolean()>("optimizedAnimatedPropUpdates");
Expand Down Expand Up @@ -959,6 +965,11 @@ bool JReactNativeFeatureFlagsCxxInterop::fuseboxWebSocketEventsEnabled(
return ReactNativeFeatureFlags::fuseboxWebSocketEventsEnabled();
}

bool JReactNativeFeatureFlagsCxxInterop::keepTimersRunningWhileHostPausedAndroid(
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop> /*unused*/) {
return ReactNativeFeatureFlags::keepTimersRunningWhileHostPausedAndroid();
}

bool JReactNativeFeatureFlagsCxxInterop::optimizedAnimatedPropUpdates(
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop> /*unused*/) {
return ReactNativeFeatureFlags::optimizedAnimatedPropUpdates();
Expand Down Expand Up @@ -1334,6 +1345,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() {
makeNativeMethod(
"fuseboxWebSocketEventsEnabled",
JReactNativeFeatureFlagsCxxInterop::fuseboxWebSocketEventsEnabled),
makeNativeMethod(
"keepTimersRunningWhileHostPausedAndroid",
JReactNativeFeatureFlagsCxxInterop::keepTimersRunningWhileHostPausedAndroid),
makeNativeMethod(
"optimizedAnimatedPropUpdates",
JReactNativeFeatureFlagsCxxInterop::optimizedAnimatedPropUpdates),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<2c8f9205f1de55591ede3845ba2fa877>>
* @generated SignedSource<<156a320a8a7e9509922dbf14bdab255e>>
*/

/**
Expand Down Expand Up @@ -234,6 +234,9 @@ class JReactNativeFeatureFlagsCxxInterop
static bool fuseboxWebSocketEventsEnabled(
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);

static bool keepTimersRunningWhileHostPausedAndroid(
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);

static bool optimizedAnimatedPropUpdates(
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* 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.core

import android.os.Looper
import android.view.Choreographer
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.devsupport.interfaces.DevSupportManager
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsDefaults
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
import com.facebook.testutils.shadows.ShadowArguments
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config

@Config(shadows = [ShadowArguments::class])
@RunWith(RobolectricTestRunner::class)
class JavaTimerManagerTest {
private lateinit var reactChoreographer: ReactChoreographer
private lateinit var timerExecutor: JavaScriptTimerExecutor

@Before
fun setUp() {
ReactNativeFeatureFlagsForTests.setUp()
reactChoreographer = mock()
timerExecutor = mock()
}

@After
fun tearDown() {
ReactNativeFeatureFlags.dangerouslyReset()
}

private fun newTimerManager(keepTimersRunningWhileHostPaused: Boolean): JavaTimerManager {
ReactNativeFeatureFlags.dangerouslyReset()
ReactNativeFeatureFlags.override(
object : ReactNativeFeatureFlagsDefaults() {
override fun keepTimersRunningWhileHostPausedAndroid(): Boolean =
keepTimersRunningWhileHostPaused
}
)
val timerManager =
JavaTimerManager(
mock<ReactApplicationContext>(),
timerExecutor,
reactChoreographer,
mock<DevSupportManager>(),
)
shadowOf(Looper.getMainLooper()).idle()
return timerManager
}

private fun postedTimerCallback(): Choreographer.FrameCallback {
val captor = argumentCaptor<Choreographer.FrameCallback>()
verify(reactChoreographer)
.postFrameCallback(eq(ReactChoreographer.CallbackType.TIMERS_EVENTS), captor.capture())
return captor.firstValue
}

@Test
fun `timers run from creation, survive host pause, and stop on instance destroy`() {
val timerManager = newTimerManager(keepTimersRunningWhileHostPaused = true)
timerManager.onHostPause()
timerManager.createTimer(timerId = 1, delay = 0, repeat = false)

val frameCallback = postedTimerCallback()
frameCallback.doFrame(Long.MAX_VALUE)
verify(timerExecutor).callTimers(any())

timerManager.onInstanceDestroy()
timerManager.createTimer(timerId = 2, delay = 0, repeat = false)
frameCallback.doFrame(Long.MAX_VALUE)
verify(timerExecutor).callTimers(any())
}

@Test
fun `timers wait for host resume without the flag`() {
newTimerManager(keepTimersRunningWhileHostPaused = false)

verify(reactChoreographer, never())
.postFrameCallback(eq(ReactChoreographer.CallbackType.TIMERS_EVENTS), any())
}
}
Loading
Loading