Skip to content

Commit 0876643

Browse files
feat(android): add unstable_Immediate UpdateMode for synchronous state updates (#57164)
Summary: Currently we support [unstable_Immediate](https://github.com/facebook/react-native/blob/8915341694db15d97236758a0716215129221bfe/packages/react-native/ReactCommon/react/renderer/core/ConcreteState.h#L76) mode on `updateState` for iOS. This allows us to dispatch [synchronous](https://github.com/expo/expo/blob/9bb3eec52fd94da3c4ed18dc602e01000a3e8c7d/packages/expo-modules-core/ios/Fabric/ExpoFabricViewObjC.mm#L176) shadow node size updates in Expo UI SwiftUI (communicating SwiftUI children size to Yoga. [Host](https://docs.expo.dev/versions/latest/sdk/ui/swift-ui/host/) for more details). Since it is not enabled on android, it leads to layout shift when mixing Compose and React Native components as Compose tree currently dispatches size information [asynchronously](https://github.com/expo/expo/blob/e1f6dc9c188ab057f31234f480487fe29835ced2/packages/expo-ui/android/src/main/java/expo/modules/ui/HostView.kt#L202) to the shadow node. Here, the video demonstrates the asynchronous vs synchronous `updateState` usage (before and after applying the changes in this PR). Repro [link](https://github.com/intergalacticspacehighway/expo-ui-compose-flicker/) https://github.com/user-attachments/assets/53d7471d-7a85-4487-be91-6a7e2e280fc6 ## More: After enabling the `update_immediate`, I noticed the flicker was still happening. The issue is that update layout instruction triggers a follow up instruction when we set the shadow node size from Expo UI which gets [skipped](https://github.com/facebook/react-native/blob/8915341694db15d97236758a0716215129221bfe/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountItemDispatcher.kt#L87) to the next frame. This is handled on iOS using `_followUpTransactionRequired` flag [here](https://github.com/facebook/react-native/blob/8915341694db15d97236758a0716215129221bfe/packages/react-native/React/Fabric/Mounting/RCTMountingManager.mm#L240). This seems to be added on iOS for similar reasons [here](5f0435f) when dispatching an instruction can lead to a follow-up instruction when synchronous update is triggered. So we also handle the sync follow up transaction in this PR using similar approach to iOS's. ## Changelog: [ANDROID] [ADDED] - Add `unstable_Immediate` UpdateMode support in `updateState`. <!-- Help reviewers and the release process by writing your own changelog entry. Pick one each for the category and type tags: For more details, see: https://reactnative.dev/contributing/changelogs-in-pull-requests Pull Request resolved: #57164 Test Plan: - Tested the changes in the [repro](https://github.com/intergalacticspacehighway/expo-ui-compose-flicker/). Also tested RN Tester, it should be a non breaking change. - Added unit tests to test the follow up re-entrancy event dispatch. Reviewed By: javache Differential Revision: D120962440 Pulled By: jasozh fbshipit-source-id: 2f9387bbdd591745728256c4ed65ee40f7dc6f7c
1 parent 99fca5f commit 0876643

33 files changed

Lines changed: 498 additions & 120 deletions

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/StateWrapperImpl.kt‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,18 @@ import com.facebook.proguard.annotations.DoNotStripAny
1414
import com.facebook.react.bridge.NativeMap
1515
import com.facebook.react.bridge.ReadableNativeMap
1616
import com.facebook.react.bridge.WritableMap
17+
import com.facebook.react.common.annotations.UnstableReactNativeAPI
1718
import com.facebook.react.common.mapbuffer.ReadableMapBuffer
1819
import com.facebook.react.uimanager.ReferenceStateWrapper
20+
import com.facebook.react.uimanager.StateWrapper
1921

2022
/**
2123
* This class holds reference to the C++ EventEmitter object. Instances of this class are created on
2224
* the Bindings.cpp, where the pointer to the C++ event emitter is set.
2325
*/
2426
@SuppressLint("MissingNativeLoadLibrary")
2527
@DoNotStripAny
28+
@OptIn(UnstableReactNativeAPI::class)
2629
internal class StateWrapperImpl private constructor() : HybridClassBase(), ReferenceStateWrapper {
2730

2831
private external fun initHybrid()
@@ -33,7 +36,7 @@ internal class StateWrapperImpl private constructor() : HybridClassBase(), Refer
3336

3437
private external fun getStateDataReferenceImpl(): Any?
3538

36-
external fun updateStateImpl(map: NativeMap)
39+
external fun updateStateImpl(map: NativeMap, updateMode: Int)
3740

3841
override val stateDataMapBuffer: ReadableMapBuffer?
3942
get() {
@@ -67,11 +70,15 @@ internal class StateWrapperImpl private constructor() : HybridClassBase(), Refer
6770
}
6871

6972
override fun updateState(map: WritableMap) {
73+
updateState(map, StateWrapper.UpdateMode.Asynchronous)
74+
}
75+
76+
override fun updateState(map: WritableMap, updateMode: StateWrapper.UpdateMode) {
7077
if (!isValid) {
7178
FLog.e(TAG, "Race between StateWrapperImpl destruction and updateState")
7279
return
7380
}
74-
updateStateImpl(map as NativeMap)
81+
updateStateImpl(map as NativeMap, updateMode.value)
7582
}
7683

7784
override fun destroyState() {

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountItemDispatcher.kt‎

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ internal class MountItemDispatcher(
5858
}
5959

6060
private var inDispatch: Boolean = false
61+
private var followUpDispatchRequired: Boolean = false
6162
var batchedExecutionTime: Long = 0L
6263
private set
6364

@@ -110,24 +111,29 @@ internal class MountItemDispatcher(
110111
@UiThread
111112
@ThreadConfined(UI)
112113
fun tryDispatchMountItems() {
113-
// If we're already dispatching, don't reenter.
114-
// Reentrance can potentially happen a lot on Android in Fabric because `updateState` from the
115-
// mounting layer causes mount items to be dispatched synchronously. We want to 1) make sure we
116-
// don't reenter in those cases, but 2) still execute those queued instructions synchronously.
117-
// This is a pretty blunt tool, but we might not have better options since we really don't want
118-
// to execute anything out-of-order.
114+
// If we're already dispatching, don't reenter but signal that a follow-up dispatch is
115+
// needed. This follows the same pattern as iOS's RCTMountingManager::initiateTransaction,
116+
// which uses _followUpTransactionRequired flag to ensure mount items
117+
// enqueued during dispatch (e.g., from synchronous state updates triggered by view layout)
118+
// are processed in the same frame rather than deferred to the next one.
119119
if (inDispatch) {
120+
if (ReactNativeFeatureFlags.enableFollowUpMountItemDispatchAndroid()) {
121+
followUpDispatchRequired = true
122+
}
120123
return
121124
}
122125

123-
inDispatch = true
126+
do {
127+
followUpDispatchRequired = false
128+
inDispatch = true
124129

125-
try {
126-
dispatchMountItems()
127-
} finally {
128-
// Clean up after running dispatchMountItems - even if an exception was thrown
129-
inDispatch = false
130-
}
130+
try {
131+
dispatchMountItems()
132+
} finally {
133+
// Clean up after running dispatchMountItems - even if an exception was thrown
134+
inDispatch = false
135+
}
136+
} while (followUpDispatchRequired)
131137

132138
// We call didDispatchMountItems regardless of whether we actually dispatched anything, since
133139
// NativeAnimatedModule relies on this for executing any animations that may have been

‎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<<f8fad51e8b31224b04d7933da8affb3a>>
88
*/
99

1010
/**
@@ -204,6 +204,12 @@ public object ReactNativeFeatureFlags {
204204
@JvmStatic
205205
public fun enableFlexboxAutoMinSizeInStrictMode(): Boolean = accessor.enableFlexboxAutoMinSizeInStrictMode()
206206

207+
/**
208+
* When mount items are queued while MountItemDispatcher is already dispatching on Android (e.g. from a synchronous state update), dispatch them in the same frame instead of deferring them to the next one.
209+
*/
210+
@JvmStatic
211+
public fun enableFollowUpMountItemDispatchAndroid(): Boolean = accessor.enableFollowUpMountItemDispatchAndroid()
212+
207213
/**
208214
* Enables font scale changes updating layout for measurable nodes.
209215
*/

‎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<<846a8685f662d66dbcdaa4324c772d51>>
88
*/
99

1010
/**
@@ -49,6 +49,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
4949
private var enableFabricCommitBranchingMergeOnMainThreadCache: Boolean? = null
5050
private var enableFabricLogsCache: Boolean? = null
5151
private var enableFlexboxAutoMinSizeInStrictModeCache: Boolean? = null
52+
private var enableFollowUpMountItemDispatchAndroidCache: Boolean? = null
5253
private var enableFontScaleChangesUpdatingLayoutCache: Boolean? = null
5354
private var enableIOSCompressedTextFrameAdjustmentCache: Boolean? = null
5455
private var enableIOSTextBaselineOffsetPerLineCache: Boolean? = null
@@ -378,6 +379,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
378379
return cached
379380
}
380381

382+
override fun enableFollowUpMountItemDispatchAndroid(): Boolean {
383+
var cached = enableFollowUpMountItemDispatchAndroidCache
384+
if (cached == null) {
385+
cached = ReactNativeFeatureFlagsCxxInterop.enableFollowUpMountItemDispatchAndroid()
386+
enableFollowUpMountItemDispatchAndroidCache = cached
387+
}
388+
return cached
389+
}
390+
381391
override fun enableFontScaleChangesUpdatingLayout(): Boolean {
382392
var cached = enableFontScaleChangesUpdatingLayoutCache
383393
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<<9ccf3958e1db2db295ba73dc371b5185>>
88
*/
99

1010
/**
@@ -86,6 +86,8 @@ public object ReactNativeFeatureFlagsCxxInterop {
8686

8787
@DoNotStrip @JvmStatic public external fun enableFlexboxAutoMinSizeInStrictMode(): Boolean
8888

89+
@DoNotStrip @JvmStatic public external fun enableFollowUpMountItemDispatchAndroid(): Boolean
90+
8991
@DoNotStrip @JvmStatic public external fun enableFontScaleChangesUpdatingLayout(): Boolean
9092

9193
@DoNotStrip @JvmStatic public external fun enableIOSCompressedTextFrameAdjustment(): 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<<0a04c5e0a0b418f2efd4b253646ae7c1>>
88
*/
99

1010
/**
@@ -81,6 +81,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi
8181

8282
override fun enableFlexboxAutoMinSizeInStrictMode(): Boolean = false
8383

84+
override fun enableFollowUpMountItemDispatchAndroid(): Boolean = false
85+
8486
override fun enableFontScaleChangesUpdatingLayout(): Boolean = true
8587

8688
override fun enableIOSCompressedTextFrameAdjustment(): Boolean = false

‎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<<861063ef7d217992d3cecd410e25d885>>
88
*/
99

1010
/**
@@ -53,6 +53,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
5353
private var enableFabricCommitBranchingMergeOnMainThreadCache: Boolean? = null
5454
private var enableFabricLogsCache: Boolean? = null
5555
private var enableFlexboxAutoMinSizeInStrictModeCache: Boolean? = null
56+
private var enableFollowUpMountItemDispatchAndroidCache: Boolean? = null
5657
private var enableFontScaleChangesUpdatingLayoutCache: Boolean? = null
5758
private var enableIOSCompressedTextFrameAdjustmentCache: Boolean? = null
5859
private var enableIOSTextBaselineOffsetPerLineCache: Boolean? = null
@@ -411,6 +412,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
411412
return cached
412413
}
413414

415+
override fun enableFollowUpMountItemDispatchAndroid(): Boolean {
416+
var cached = enableFollowUpMountItemDispatchAndroidCache
417+
if (cached == null) {
418+
cached = currentProvider.enableFollowUpMountItemDispatchAndroid()
419+
accessedFeatureFlags.add("enableFollowUpMountItemDispatchAndroid")
420+
enableFollowUpMountItemDispatchAndroidCache = cached
421+
}
422+
return cached
423+
}
424+
414425
override fun enableFontScaleChangesUpdatingLayout(): Boolean {
415426
var cached = enableFontScaleChangesUpdatingLayoutCache
416427
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<<5fa21c808df68be4c007469040fbca2a>>
88
*/
99

1010
/**
@@ -81,6 +81,8 @@ public interface ReactNativeFeatureFlagsProvider {
8181

8282
@DoNotStrip public fun enableFlexboxAutoMinSizeInStrictMode(): Boolean
8383

84+
@DoNotStrip public fun enableFollowUpMountItemDispatchAndroid(): Boolean
85+
8486
@DoNotStrip public fun enableFontScaleChangesUpdatingLayout(): Boolean
8587

8688
@DoNotStrip public fun enableIOSCompressedTextFrameAdjustment(): Boolean

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/StateWrapper.kt‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package com.facebook.react.uimanager
99

1010
import com.facebook.react.bridge.ReadableNativeMap
1111
import com.facebook.react.bridge.WritableMap
12+
import com.facebook.react.common.annotations.UnstableReactNativeAPI
1213
import com.facebook.react.common.mapbuffer.ReadableMapBuffer
1314

1415
/**
@@ -17,6 +18,15 @@ import com.facebook.react.common.mapbuffer.ReadableMapBuffer
1718
* by calling updateState, which communicates state back to the C++ layer.
1819
*/
1920
public interface StateWrapper {
21+
/**
22+
* Maps to EventQueue::UpdateMode in C++. Controls how state updates are flushed (Async or Sync).
23+
*/
24+
@UnstableReactNativeAPI
25+
public enum class UpdateMode(public val value: Int) {
26+
Asynchronous(0),
27+
unstable_Immediate(1),
28+
}
29+
2030
/**
2131
* Get a ReadableMapBuffer object from the C++ layer, which is a K/V map of short keys to values.
2232
*
@@ -35,6 +45,12 @@ public interface StateWrapper {
3545
*/
3646
public fun updateState(map: WritableMap)
3747

48+
/**
49+
* Pass a map of values back to the C++ layer. updateMode controls whether the update is queued
50+
* asynchronously or flushed immediately.
51+
*/
52+
@UnstableReactNativeAPI public fun updateState(map: WritableMap, updateMode: UpdateMode)
53+
3854
/**
3955
* Mark state as unused and clean up in Java and in native. This should be called as early as
4056
* possible when you know a StateWrapper will no longer be used. If there's ANY chance of it being

‎packages/react-native/ReactAndroid/src/main/jni/react/fabric/StateWrapperImpl.cpp‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#include <fbjni/fbjni.h>
1010
#include <react/featureflags/ReactNativeFeatureFlags.h>
1111
#include <react/jni/ReadableNativeMap.h>
12+
#include <react/renderer/core/EventQueue.h>
1213
#include <react/renderer/mapbuffer/MapBuffer.h>
1314
#include <react/renderer/mapbuffer/MapBufferBuilder.h>
1415

@@ -50,12 +51,13 @@ jni::local_ref<jobject> StateWrapperImpl::getStateDataReferenceImpl() {
5051
return nullptr;
5152
}
5253

53-
void StateWrapperImpl::updateStateImpl(NativeMap* map) {
54+
void StateWrapperImpl::updateStateImpl(NativeMap* map, jint updateMode) {
5455
if (state_) {
5556
// Get folly::dynamic from map
5657
auto dynamicMap = map->consume();
5758
// Set state
58-
state_->updateState(std::move(dynamicMap));
59+
state_->updateState(
60+
std::move(dynamicMap), static_cast<EventQueue::UpdateMode>(updateMode));
5961
}
6062
}
6163

0 commit comments

Comments
 (0)