From 47df773df2e382407a8740ca5077e2915daa4a5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20B=C5=82oniarz?= Date: Tue, 15 Sep 2026 15:58:39 +0200 Subject: [PATCH 1/2] Batch Android animated prop updates into one JNI call per surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: With the batched delegate from the previous PR, Android applied the backend's synchronous updates with one JNI call and one `ReadableNativeMap` per view per frame. When `optimizedAnimatedPropUpdates` is on, a surface's updates now go to the UI thread in a single call: `FabricMountingManager::synchronouslyUpdateAnimatedProps` encodes them into int and double buffers, and `BatchedAnimatedPropsMountItem` decodes and applies them. This continues the encoder and mount item from #56468. Views that are not mounted yet are still queued. A view is packed only when every prop it carries has a command. Unsupported props, null resets, platform colors, malformed values and typed props send the whole view through the existing `packAnimatedProps` serializer as a map instead. The commands cover the props Reanimated applies synchronously on Android, following the parity review in https://github.com/react/react-native/pull/56468#discussion_r3225982756. Angles are converted to radians in C++, and radii and translations keep their px or percent units. Because the decoder produces `JavaOnlyArray` transforms, `TransformHelper`'s Kotlin conversion now applies a uniform scale to Z as well as X and Y, matching the native conversion when scale is combined with 3D rotation. On a low-end Android phone (4× Cortex-A53) with 12 views animating opacity, UI-thread time per frame's synchronous batch dropped from about 6.5 ms to 3.4 ms. Changelog: [Android][Performance] - Batch synchronous animated prop updates when `optimizedAnimatedPropUpdates` is enabled Test Plan: - JVM: `BatchedAnimatedPropsMountItemTest`, `MountingManagerAnimatedPropsTest`, `MatrixMathHelperTest` and `SkewMatrixHelperTest` pass. The scale regression test fails without the `TransformHelper` change. - Native: the encoder, registry and `RawProps` tests pass on Android. The numeric-color fallback test fails without the checked conversion. - The Android arm64 release build succeeds. C++ API snapshots regenerated. Not built for iOS. --- .../react/fabric/FabricUIManager.java | 22 + .../react/fabric/mounting/MountingManager.kt | 10 + .../BatchedAnimatedPropsMountItem.kt | 274 ++++++++++++ .../react/uimanager/TransformHelper.kt | 1 + .../fabric/AnimatedPropBufferEncoder.cpp | 393 ++++++++++++++++++ .../react/fabric/AnimatedPropBufferEncoder.h | 24 ++ .../react/fabric/FabricMountingManager.cpp | 29 ++ .../jni/react/fabric/FabricMountingManager.h | 4 + .../react/fabric/FabricUIManagerBinding.cpp | 7 + .../jni/react/fabric/FabricUIManagerBinding.h | 2 + .../react/renderer/core/RawProps.h | 6 + 11 files changed, 772 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/BatchedAnimatedPropsMountItem.kt create mode 100644 packages/react-native/ReactAndroid/src/main/jni/react/fabric/AnimatedPropBufferEncoder.cpp create mode 100644 packages/react-native/ReactAndroid/src/main/jni/react/fabric/AnimatedPropBufferEncoder.h diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index 3fe95a4595db..04c8e7a1a09b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java @@ -62,6 +62,7 @@ import com.facebook.react.fabric.mounting.MountingManager; import com.facebook.react.fabric.mounting.SurfaceMountingManager; import com.facebook.react.fabric.mounting.mountitems.BatchMountItem; +import com.facebook.react.fabric.mounting.mountitems.BatchedAnimatedPropsMountItem; import com.facebook.react.fabric.mounting.mountitems.DispatchCommandMountItem; import com.facebook.react.fabric.mounting.mountitems.MountItem; import com.facebook.react.fabric.mounting.mountitems.MountItemFactory; @@ -837,6 +838,27 @@ public void synchronouslyUpdateViewOnUIThread(final int reactTag, final Readable ReactMarkerConstants.FABRIC_UPDATE_UI_MAIN_THREAD_END, null, commitNumber); } + /** Called from C++ via JNI with a batch encoded by AnimatedPropBufferEncoder.cpp. */ + @SuppressLint("NotInvokedPrivateMethod") + @SuppressWarnings("unused") + @UiThread + @ThreadConfined(UI) + private void synchronouslyUpdateViewBatch( + int[] intBuffer, double[] doubleBuffer, Object[] rawPropsBuffer) { + UiThreadUtil.assertOnUiThread(); + + int commitNumber = mCurrentSynchronousCommitNumber++; + ReactMarker.logFabricMarker( + ReactMarkerConstants.FABRIC_UPDATE_UI_MAIN_THREAD_START, null, commitNumber); + + new BatchedAnimatedPropsMountItem( + intBuffer, doubleBuffer, rawPropsBuffer, mMountItemDispatcher) + .execute(mMountingManager); + + ReactMarker.logFabricMarker( + ReactMarkerConstants.FABRIC_UPDATE_UI_MAIN_THREAD_END, null, commitNumber); + } + /** Called from C++ via JNI. */ @SuppressLint("NotInvokedPrivateMethod") @SuppressWarnings("unused") diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountingManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountingManager.kt index fc1f576da33d..72b717d243da 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountingManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountingManager.kt @@ -268,6 +268,16 @@ internal class MountingManager( getSurfaceManagerForView(reactTag)?.storeSynchronousMountPropsOverride(reactTag, props) } + /** Returns false when no surface owns [reactTag] yet, so the caller can queue the update. */ + @UiThread + fun updateAnimatedPropsSynchronously(reactTag: Int, props: ReadableMap): Boolean { + assertOnUiThread() + val surfaceMountingManager = getSurfaceManagerForView(reactTag) ?: return false + surfaceMountingManager.storeSynchronousMountPropsOverride(reactTag, props) + surfaceMountingManager.updatePropsSynchronously(reactTag, props) + return true + } + @UiThread fun updatePropsSynchronously(reactTag: Int, props: ReadableMap?) { assertOnUiThread() diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/BatchedAnimatedPropsMountItem.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/BatchedAnimatedPropsMountItem.kt new file mode 100644 index 000000000000..21807a3ade44 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/BatchedAnimatedPropsMountItem.kt @@ -0,0 +1,274 @@ +/* + * 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.fabric.mounting.mountitems + +import android.view.View +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.fabric.FabricUIManager.IS_DEVELOPMENT_ENVIRONMENT +import com.facebook.react.fabric.mounting.MountItemDispatcher +import com.facebook.react.fabric.mounting.MountingManager +import com.facebook.react.uimanager.ViewProps + +internal class BatchedAnimatedPropsMountItem( + private val intBuffer: IntArray, + private val doubleBuffer: DoubleArray, + private val rawPropsBuffer: Array, + private val mountItemDispatcher: MountItemDispatcher, +) : MountItem { + + override fun execute(mountingManager: MountingManager) { + decode { viewTag, props -> + try { + if (!mountingManager.updateAnimatedPropsSynchronously(viewTag, props)) { + mountItemDispatcher.addMountItem(SynchronousMountItem(viewTag, props)) + } + } catch (ex: Exception) { + // Match SynchronousMountItem's handling of view-manager failures. + } + } + } + + internal fun decode(apply: (Int, ReadableMap) -> Unit) { + var intIdx = 0 + var doubleIdx = 0 + var rawIdx = 0 + while (intIdx < intBuffer.size) { + val command = intBuffer[intIdx++] + require(command == CMD_START_OF_VIEW) { "Expected start of animated view, got $command" } + val viewTag = intBuffer[intIdx++] + + if (intBuffer[intIdx] == CMD_RAW_PROPS) { + require(intBuffer[intIdx + 1] == CMD_END_OF_VIEW) { "Raw props must be the whole view" } + intIdx += 2 + apply(viewTag, rawPropsBuffer[rawIdx++] as ReadableMap) + continue + } + + val props = JavaOnlyMap() + while (true) { + val cmd = intBuffer[intIdx++] + if (cmd == CMD_END_OF_VIEW) { + break + } + + when (cmd) { + CMD_OPACITY, + CMD_ELEVATION, + CMD_Z_INDEX, + CMD_SHADOW_OPACITY, + CMD_SHADOW_RADIUS, + CMD_OUTLINE_OFFSET, + CMD_OUTLINE_WIDTH -> props.putDouble(commandToString(cmd), doubleBuffer[doubleIdx++]) + CMD_BACKGROUND_COLOR, + CMD_COLOR, + CMD_TINT_COLOR, + CMD_PLACEHOLDER_TEXT_COLOR, + CMD_SHADOW_COLOR, + CMD_BORDER_COLOR, + CMD_BORDER_TOP_COLOR, + CMD_BORDER_BOTTOM_COLOR, + CMD_BORDER_LEFT_COLOR, + CMD_BORDER_RIGHT_COLOR, + CMD_BORDER_START_COLOR, + CMD_BORDER_END_COLOR, + CMD_BORDER_BLOCK_COLOR, + CMD_BORDER_BLOCK_START_COLOR, + CMD_BORDER_BLOCK_END_COLOR, + CMD_OUTLINE_COLOR -> props.putInt(commandToString(cmd), intBuffer[intIdx++]) + CMD_BORDER_RADIUS, + CMD_BORDER_TOP_LEFT_RADIUS, + CMD_BORDER_TOP_RIGHT_RADIUS, + CMD_BORDER_TOP_START_RADIUS, + CMD_BORDER_TOP_END_RADIUS, + CMD_BORDER_BOTTOM_LEFT_RADIUS, + CMD_BORDER_BOTTOM_RIGHT_RADIUS, + CMD_BORDER_BOTTOM_START_RADIUS, + CMD_BORDER_BOTTOM_END_RADIUS, + CMD_BORDER_START_START_RADIUS, + CMD_BORDER_START_END_RADIUS, + CMD_BORDER_END_START_RADIUS, + CMD_BORDER_END_END_RADIUS -> + putLength( + props, + commandToString(cmd), + intBuffer[intIdx++], + doubleBuffer[doubleIdx++], + ) + CMD_START_OF_TRANSFORM -> { + val transform = JavaOnlyArray() + while (true) { + val transformCmd = intBuffer[intIdx++] + if (transformCmd == CMD_END_OF_TRANSFORM) { + break + } + val name = transformCommandToString(transformCmd) + val entry = JavaOnlyMap() + when (transformCmd) { + CMD_TRANSLATE_X, + CMD_TRANSLATE_Y -> + putLength(entry, name, intBuffer[intIdx++], doubleBuffer[doubleIdx++]) + CMD_MATRIX -> { + val matrix = JavaOnlyArray() + repeat(16) { matrix.pushDouble(doubleBuffer[doubleIdx++]) } + entry.putArray(name, matrix) + } + // Angles are in radians. + else -> entry.putDouble(name, doubleBuffer[doubleIdx++]) + } + transform.pushMap(entry) + } + props.putArray(ViewProps.TRANSFORM, transform) + } + else -> error("Unknown animated prop command: $cmd") + } + } + + apply(viewTag, props) + } + require(doubleIdx == doubleBuffer.size && rawIdx == rawPropsBuffer.size) { + "Unused values in animated props buffer" + } + } + + override fun toString(): String { + if (!IS_DEVELOPMENT_ENVIRONMENT) return "BATCHED UPDATE PROPS " + val updates = mutableListOf() + decode { tag, props -> updates.add("[$tag]: ${props.toHashMap()}") } + return "BATCHED UPDATE PROPS ${updates.joinToString()}" + } + + override fun getSurfaceId(): Int = View.NO_ID + + companion object { + // Keep command values in sync with AnimatedPropBufferEncoder.cpp. + private const val CMD_START_OF_VIEW = 1 + private const val CMD_START_OF_TRANSFORM = 2 + private const val CMD_END_OF_TRANSFORM = 3 + private const val CMD_END_OF_VIEW = 4 + private const val CMD_RAW_PROPS = 5 + private const val CMD_OPACITY = 10 + private const val CMD_ELEVATION = 11 + private const val CMD_Z_INDEX = 12 + private const val CMD_SHADOW_OPACITY = 13 + private const val CMD_SHADOW_RADIUS = 14 + private const val CMD_BACKGROUND_COLOR = 15 + private const val CMD_COLOR = 16 + private const val CMD_TINT_COLOR = 17 + private const val CMD_PLACEHOLDER_TEXT_COLOR = 18 + private const val CMD_SHADOW_COLOR = 19 + private const val CMD_BORDER_RADIUS = 20 + private const val CMD_BORDER_TOP_LEFT_RADIUS = 21 + private const val CMD_BORDER_TOP_RIGHT_RADIUS = 22 + private const val CMD_BORDER_TOP_START_RADIUS = 23 + private const val CMD_BORDER_TOP_END_RADIUS = 24 + private const val CMD_BORDER_BOTTOM_LEFT_RADIUS = 25 + private const val CMD_BORDER_BOTTOM_RIGHT_RADIUS = 26 + private const val CMD_BORDER_BOTTOM_START_RADIUS = 27 + private const val CMD_BORDER_BOTTOM_END_RADIUS = 28 + private const val CMD_BORDER_START_START_RADIUS = 29 + private const val CMD_BORDER_START_END_RADIUS = 30 + private const val CMD_BORDER_END_START_RADIUS = 31 + private const val CMD_BORDER_END_END_RADIUS = 32 + private const val CMD_BORDER_COLOR = 40 + private const val CMD_BORDER_TOP_COLOR = 41 + private const val CMD_BORDER_BOTTOM_COLOR = 42 + private const val CMD_BORDER_LEFT_COLOR = 43 + private const val CMD_BORDER_RIGHT_COLOR = 44 + private const val CMD_BORDER_START_COLOR = 45 + private const val CMD_BORDER_END_COLOR = 46 + private const val CMD_BORDER_BLOCK_COLOR = 47 + private const val CMD_BORDER_BLOCK_START_COLOR = 48 + private const val CMD_BORDER_BLOCK_END_COLOR = 49 + private const val CMD_OUTLINE_COLOR = 50 + private const val CMD_OUTLINE_OFFSET = 51 + private const val CMD_OUTLINE_WIDTH = 52 + private const val CMD_TRANSLATE_X = 100 + private const val CMD_TRANSLATE_Y = 101 + private const val CMD_SCALE = 102 + private const val CMD_SCALE_X = 103 + private const val CMD_SCALE_Y = 104 + private const val CMD_ROTATE = 105 + private const val CMD_ROTATE_X = 106 + private const val CMD_ROTATE_Y = 107 + private const val CMD_ROTATE_Z = 108 + private const val CMD_SKEW_X = 109 + private const val CMD_SKEW_Y = 110 + private const val CMD_MATRIX = 111 + private const val CMD_PERSPECTIVE = 112 + private const val CMD_UNIT_PX = 202 + private const val CMD_UNIT_PERCENT = 203 + + private fun putLength(map: JavaOnlyMap, name: String, unit: Int, value: Double) = + when (unit) { + CMD_UNIT_PX -> map.putDouble(name, value) + CMD_UNIT_PERCENT -> map.putString(name, "$value%") + else -> error("Unknown length unit: $unit") + } + + private fun commandToString(command: Int): String = + when (command) { + CMD_OPACITY -> ViewProps.OPACITY + CMD_ELEVATION -> ViewProps.ELEVATION + CMD_Z_INDEX -> ViewProps.Z_INDEX + CMD_SHADOW_OPACITY -> "shadowOpacity" + CMD_SHADOW_RADIUS -> "shadowRadius" + CMD_BACKGROUND_COLOR -> ViewProps.BACKGROUND_COLOR + CMD_COLOR -> ViewProps.COLOR + CMD_TINT_COLOR -> "tintColor" + CMD_PLACEHOLDER_TEXT_COLOR -> "placeholderTextColor" + CMD_SHADOW_COLOR -> ViewProps.SHADOW_COLOR + CMD_BORDER_RADIUS -> ViewProps.BORDER_RADIUS + CMD_BORDER_TOP_LEFT_RADIUS -> ViewProps.BORDER_TOP_LEFT_RADIUS + CMD_BORDER_TOP_RIGHT_RADIUS -> ViewProps.BORDER_TOP_RIGHT_RADIUS + CMD_BORDER_TOP_START_RADIUS -> ViewProps.BORDER_TOP_START_RADIUS + CMD_BORDER_TOP_END_RADIUS -> ViewProps.BORDER_TOP_END_RADIUS + CMD_BORDER_BOTTOM_LEFT_RADIUS -> ViewProps.BORDER_BOTTOM_LEFT_RADIUS + CMD_BORDER_BOTTOM_RIGHT_RADIUS -> ViewProps.BORDER_BOTTOM_RIGHT_RADIUS + CMD_BORDER_BOTTOM_START_RADIUS -> ViewProps.BORDER_BOTTOM_START_RADIUS + CMD_BORDER_BOTTOM_END_RADIUS -> ViewProps.BORDER_BOTTOM_END_RADIUS + CMD_BORDER_START_START_RADIUS -> ViewProps.BORDER_START_START_RADIUS + CMD_BORDER_START_END_RADIUS -> ViewProps.BORDER_START_END_RADIUS + CMD_BORDER_END_START_RADIUS -> ViewProps.BORDER_END_START_RADIUS + CMD_BORDER_END_END_RADIUS -> ViewProps.BORDER_END_END_RADIUS + CMD_BORDER_COLOR -> ViewProps.BORDER_COLOR + CMD_BORDER_TOP_COLOR -> ViewProps.BORDER_TOP_COLOR + CMD_BORDER_BOTTOM_COLOR -> ViewProps.BORDER_BOTTOM_COLOR + CMD_BORDER_LEFT_COLOR -> ViewProps.BORDER_LEFT_COLOR + CMD_BORDER_RIGHT_COLOR -> ViewProps.BORDER_RIGHT_COLOR + CMD_BORDER_START_COLOR -> ViewProps.BORDER_START_COLOR + CMD_BORDER_END_COLOR -> ViewProps.BORDER_END_COLOR + CMD_BORDER_BLOCK_COLOR -> ViewProps.BORDER_BLOCK_COLOR + CMD_BORDER_BLOCK_START_COLOR -> ViewProps.BORDER_BLOCK_START_COLOR + CMD_BORDER_BLOCK_END_COLOR -> ViewProps.BORDER_BLOCK_END_COLOR + CMD_OUTLINE_COLOR -> ViewProps.OUTLINE_COLOR + CMD_OUTLINE_OFFSET -> ViewProps.OUTLINE_OFFSET + CMD_OUTLINE_WIDTH -> ViewProps.OUTLINE_WIDTH + else -> error("Unknown animated prop command: $command") + } + + private fun transformCommandToString(command: Int): String = + when (command) { + CMD_TRANSLATE_X -> ViewProps.TRANSLATE_X + CMD_TRANSLATE_Y -> ViewProps.TRANSLATE_Y + CMD_SCALE -> "scale" + CMD_SCALE_X -> ViewProps.SCALE_X + CMD_SCALE_Y -> ViewProps.SCALE_Y + CMD_ROTATE -> "rotate" + CMD_ROTATE_X -> "rotateX" + CMD_ROTATE_Y -> "rotateY" + CMD_ROTATE_Z -> "rotateZ" + CMD_SKEW_X -> "skewX" + CMD_SKEW_Y -> "skewY" + CMD_MATRIX -> "matrix" + CMD_PERSPECTIVE -> "perspective" + else -> error("Unknown animated prop command: $command") + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TransformHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TransformHelper.kt index 8c713b98fbec..189c3ae53111 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TransformHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TransformHelper.kt @@ -141,6 +141,7 @@ public object TransformHelper { val scale = transform.getDouble(transformType) MatrixMathHelper.applyScaleX(helperMatrix, scale) MatrixMathHelper.applyScaleY(helperMatrix, scale) + MatrixMathHelper.applyScaleZ(helperMatrix, scale) } "scaleX" -> MatrixMathHelper.applyScaleX(helperMatrix, transform.getDouble(transformType)) "scaleY" -> MatrixMathHelper.applyScaleY(helperMatrix, transform.getDouble(transformType)) diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/AnimatedPropBufferEncoder.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/AnimatedPropBufferEncoder.cpp new file mode 100644 index 000000000000..a619b78b31d7 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/AnimatedPropBufferEncoder.cpp @@ -0,0 +1,393 @@ +/* + * 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 "AnimatedPropBufferEncoder.h" + +#include +#include +#include +#include +#include +#include + +namespace facebook::react { + +namespace { + +// A view is START_OF_VIEW, tag, its props, END_OF_VIEW. Props take their values +// from the int and double buffers in order. A view with a prop that has no +// command is RAW_PROPS instead, taking the next map of the raw props buffer. +// Keep in sync with BatchedAnimatedPropsMountItem.kt on the Java side. + +constexpr int CMD_START_OF_VIEW = 1; +constexpr int CMD_START_OF_TRANSFORM = 2; +constexpr int CMD_END_OF_TRANSFORM = 3; +constexpr int CMD_END_OF_VIEW = 4; +constexpr int CMD_RAW_PROPS = 5; + +constexpr int CMD_OPACITY = 10; +constexpr int CMD_ELEVATION = 11; +constexpr int CMD_Z_INDEX = 12; +constexpr int CMD_SHADOW_OPACITY = 13; +constexpr int CMD_SHADOW_RADIUS = 14; + +constexpr int CMD_BACKGROUND_COLOR = 15; +constexpr int CMD_COLOR = 16; +constexpr int CMD_TINT_COLOR = 17; +constexpr int CMD_PLACEHOLDER_TEXT_COLOR = 18; +constexpr int CMD_SHADOW_COLOR = 19; + +constexpr int CMD_BORDER_RADIUS = 20; +constexpr int CMD_BORDER_TOP_LEFT_RADIUS = 21; +constexpr int CMD_BORDER_TOP_RIGHT_RADIUS = 22; +constexpr int CMD_BORDER_TOP_START_RADIUS = 23; +constexpr int CMD_BORDER_TOP_END_RADIUS = 24; +constexpr int CMD_BORDER_BOTTOM_LEFT_RADIUS = 25; +constexpr int CMD_BORDER_BOTTOM_RIGHT_RADIUS = 26; +constexpr int CMD_BORDER_BOTTOM_START_RADIUS = 27; +constexpr int CMD_BORDER_BOTTOM_END_RADIUS = 28; +constexpr int CMD_BORDER_START_START_RADIUS = 29; +constexpr int CMD_BORDER_START_END_RADIUS = 30; +constexpr int CMD_BORDER_END_START_RADIUS = 31; +constexpr int CMD_BORDER_END_END_RADIUS = 32; + +constexpr int CMD_BORDER_COLOR = 40; +constexpr int CMD_BORDER_TOP_COLOR = 41; +constexpr int CMD_BORDER_BOTTOM_COLOR = 42; +constexpr int CMD_BORDER_LEFT_COLOR = 43; +constexpr int CMD_BORDER_RIGHT_COLOR = 44; +constexpr int CMD_BORDER_START_COLOR = 45; +constexpr int CMD_BORDER_END_COLOR = 46; +constexpr int CMD_BORDER_BLOCK_COLOR = 47; +constexpr int CMD_BORDER_BLOCK_START_COLOR = 48; +constexpr int CMD_BORDER_BLOCK_END_COLOR = 49; + +constexpr int CMD_OUTLINE_COLOR = 50; +constexpr int CMD_OUTLINE_OFFSET = 51; +constexpr int CMD_OUTLINE_WIDTH = 52; + +constexpr int CMD_TRANSFORM_TRANSLATE_X = 100; +constexpr int CMD_TRANSFORM_TRANSLATE_Y = 101; +constexpr int CMD_TRANSFORM_SCALE = 102; +constexpr int CMD_TRANSFORM_SCALE_X = 103; +constexpr int CMD_TRANSFORM_SCALE_Y = 104; +constexpr int CMD_TRANSFORM_ROTATE = 105; +constexpr int CMD_TRANSFORM_ROTATE_X = 106; +constexpr int CMD_TRANSFORM_ROTATE_Y = 107; +constexpr int CMD_TRANSFORM_ROTATE_Z = 108; +constexpr int CMD_TRANSFORM_SKEW_X = 109; +constexpr int CMD_TRANSFORM_SKEW_Y = 110; +constexpr int CMD_TRANSFORM_MATRIX = 111; +constexpr int CMD_TRANSFORM_PERSPECTIVE = 112; + +constexpr int CMD_UNIT_PX = 202; +constexpr int CMD_UNIT_PERCENT = 203; + +std::optional propNameToCommand(const std::string& name) { + static const std::unordered_map kMap = { + {"opacity", CMD_OPACITY}, + {"elevation", CMD_ELEVATION}, + {"zIndex", CMD_Z_INDEX}, + {"shadowOpacity", CMD_SHADOW_OPACITY}, + {"shadowRadius", CMD_SHADOW_RADIUS}, + {"backgroundColor", CMD_BACKGROUND_COLOR}, + {"color", CMD_COLOR}, + {"tintColor", CMD_TINT_COLOR}, + {"placeholderTextColor", CMD_PLACEHOLDER_TEXT_COLOR}, + {"shadowColor", CMD_SHADOW_COLOR}, + {"borderRadius", CMD_BORDER_RADIUS}, + {"borderTopLeftRadius", CMD_BORDER_TOP_LEFT_RADIUS}, + {"borderTopRightRadius", CMD_BORDER_TOP_RIGHT_RADIUS}, + {"borderTopStartRadius", CMD_BORDER_TOP_START_RADIUS}, + {"borderTopEndRadius", CMD_BORDER_TOP_END_RADIUS}, + {"borderBottomLeftRadius", CMD_BORDER_BOTTOM_LEFT_RADIUS}, + {"borderBottomRightRadius", CMD_BORDER_BOTTOM_RIGHT_RADIUS}, + {"borderBottomStartRadius", CMD_BORDER_BOTTOM_START_RADIUS}, + {"borderBottomEndRadius", CMD_BORDER_BOTTOM_END_RADIUS}, + {"borderStartStartRadius", CMD_BORDER_START_START_RADIUS}, + {"borderStartEndRadius", CMD_BORDER_START_END_RADIUS}, + {"borderEndStartRadius", CMD_BORDER_END_START_RADIUS}, + {"borderEndEndRadius", CMD_BORDER_END_END_RADIUS}, + {"borderColor", CMD_BORDER_COLOR}, + {"borderTopColor", CMD_BORDER_TOP_COLOR}, + {"borderBottomColor", CMD_BORDER_BOTTOM_COLOR}, + {"borderLeftColor", CMD_BORDER_LEFT_COLOR}, + {"borderRightColor", CMD_BORDER_RIGHT_COLOR}, + {"borderStartColor", CMD_BORDER_START_COLOR}, + {"borderEndColor", CMD_BORDER_END_COLOR}, + {"borderBlockColor", CMD_BORDER_BLOCK_COLOR}, + {"borderBlockStartColor", CMD_BORDER_BLOCK_START_COLOR}, + {"borderBlockEndColor", CMD_BORDER_BLOCK_END_COLOR}, + {"outlineColor", CMD_OUTLINE_COLOR}, + {"outlineOffset", CMD_OUTLINE_OFFSET}, + {"outlineWidth", CMD_OUTLINE_WIDTH}, + {"transform", CMD_START_OF_TRANSFORM}, + }; + auto it = kMap.find(name); + if (it == kMap.end()) { + return std::nullopt; + } + return it->second; +} + +std::optional transformNameToCommand(const std::string& name) { + static const std::unordered_map kMap = { + {"translateX", CMD_TRANSFORM_TRANSLATE_X}, + {"translateY", CMD_TRANSFORM_TRANSLATE_Y}, + {"scale", CMD_TRANSFORM_SCALE}, + {"scaleX", CMD_TRANSFORM_SCALE_X}, + {"scaleY", CMD_TRANSFORM_SCALE_Y}, + {"rotate", CMD_TRANSFORM_ROTATE}, + {"rotateX", CMD_TRANSFORM_ROTATE_X}, + {"rotateY", CMD_TRANSFORM_ROTATE_Y}, + {"rotateZ", CMD_TRANSFORM_ROTATE_Z}, + {"skewX", CMD_TRANSFORM_SKEW_X}, + {"skewY", CMD_TRANSFORM_SKEW_Y}, + {"matrix", CMD_TRANSFORM_MATRIX}, + {"perspective", CMD_TRANSFORM_PERSPECTIVE}, + }; + auto it = kMap.find(name); + if (it == kMap.end()) { + return std::nullopt; + } + return it->second; +} + +bool packLength( + const folly::dynamic& value, + std::vector& intBuffer, + std::vector& doubleBuffer) { + if (value.isNumber()) { + intBuffer.push_back(CMD_UNIT_PX); + doubleBuffer.push_back(value.asDouble()); + return true; + } + if (!value.isString() || !value.getString().ends_with("%")) { + return false; + } + const auto& text = value.getString(); + auto percent = folly::tryTo(text.substr(0, text.size() - 1)); + if (!percent.hasValue()) { + return false; + } + intBuffer.push_back(CMD_UNIT_PERCENT); + doubleBuffer.push_back(percent.value()); + return true; +} + +std::optional toRadians(const folly::dynamic& value) { + if (value.isNumber()) { + return value.asDouble(); + } + if (!value.isString()) { + return std::nullopt; + } + const auto& text = value.getString(); + bool isDegrees = text.ends_with("deg"); + if (!isDegrees && !text.ends_with("rad")) { + return std::nullopt; + } + auto angle = folly::tryTo(text.substr(0, text.size() - 3)); + if (!angle.hasValue()) { + return std::nullopt; + } + return isDegrees ? angle.value() * std::numbers::pi / 180 : angle.value(); +} + +bool packTransformToBuffers( + const folly::dynamic& transform, + std::vector& intBuffer, + std::vector& doubleBuffer) { + if (!transform.isArray()) { + return false; + } + intBuffer.push_back(CMD_START_OF_TRANSFORM); + for (const auto& item : transform) { + if (!item.isObject() || item.size() != 1) { + return false; + } + const auto& [name, value] = *item.items().begin(); + auto cmd = transformNameToCommand(name.getString()); + if (!cmd.has_value()) { + return false; + } + intBuffer.push_back(cmd.value()); + switch (cmd.value()) { + case CMD_TRANSFORM_SCALE: + case CMD_TRANSFORM_SCALE_X: + case CMD_TRANSFORM_SCALE_Y: + case CMD_TRANSFORM_PERSPECTIVE: + if (!value.isNumber()) { + return false; + } + doubleBuffer.push_back(value.asDouble()); + break; + case CMD_TRANSFORM_TRANSLATE_X: + case CMD_TRANSFORM_TRANSLATE_Y: + if (!packLength(value, intBuffer, doubleBuffer)) { + return false; + } + break; + case CMD_TRANSFORM_ROTATE: + case CMD_TRANSFORM_ROTATE_X: + case CMD_TRANSFORM_ROTATE_Y: + case CMD_TRANSFORM_ROTATE_Z: + case CMD_TRANSFORM_SKEW_X: + case CMD_TRANSFORM_SKEW_Y: { + auto radians = toRadians(value); + if (!radians.has_value()) { + return false; + } + doubleBuffer.push_back(radians.value()); + break; + } + case CMD_TRANSFORM_MATRIX: + if (!value.isArray() || value.size() != 16) { + return false; + } + for (const auto& element : value) { + if (!element.isNumber()) { + return false; + } + doubleBuffer.push_back(element.asDouble()); + } + break; + default: + return false; + } + } + intBuffer.push_back(CMD_END_OF_TRANSFORM); + return true; +} + +bool packDynamicEntryToBuffers( + const std::string& key, + const folly::dynamic& value, + std::vector& intBuffer, + std::vector& doubleBuffer) { + auto cmd = propNameToCommand(key); + if (!cmd.has_value()) { + return false; + } + + switch (cmd.value()) { + case CMD_OPACITY: + case CMD_ELEVATION: + case CMD_Z_INDEX: + case CMD_SHADOW_OPACITY: + case CMD_SHADOW_RADIUS: + case CMD_OUTLINE_OFFSET: + case CMD_OUTLINE_WIDTH: + if (!value.isNumber()) { + return false; + } + intBuffer.push_back(cmd.value()); + doubleBuffer.push_back(value.asDouble()); + return true; + + case CMD_BACKGROUND_COLOR: + case CMD_COLOR: + case CMD_TINT_COLOR: + case CMD_PLACEHOLDER_TEXT_COLOR: + case CMD_SHADOW_COLOR: + case CMD_BORDER_COLOR: + case CMD_BORDER_TOP_COLOR: + case CMD_BORDER_BOTTOM_COLOR: + case CMD_BORDER_LEFT_COLOR: + case CMD_BORDER_RIGHT_COLOR: + case CMD_BORDER_START_COLOR: + case CMD_BORDER_END_COLOR: + case CMD_BORDER_BLOCK_COLOR: + case CMD_BORDER_BLOCK_START_COLOR: + case CMD_BORDER_BLOCK_END_COLOR: + case CMD_OUTLINE_COLOR: { + if (!value.isNumber()) { + return false; + } + auto color = value.isInt() ? folly::tryTo(value.getInt()) + : folly::tryTo(value.getDouble()); + if (!color.hasValue()) { + return false; + } + intBuffer.push_back(cmd.value()); + intBuffer.push_back(static_cast(color.value())); + return true; + } + + case CMD_BORDER_RADIUS: + case CMD_BORDER_TOP_LEFT_RADIUS: + case CMD_BORDER_TOP_RIGHT_RADIUS: + case CMD_BORDER_TOP_START_RADIUS: + case CMD_BORDER_TOP_END_RADIUS: + case CMD_BORDER_BOTTOM_LEFT_RADIUS: + case CMD_BORDER_BOTTOM_RIGHT_RADIUS: + case CMD_BORDER_BOTTOM_START_RADIUS: + case CMD_BORDER_BOTTOM_END_RADIUS: + case CMD_BORDER_START_START_RADIUS: + case CMD_BORDER_START_END_RADIUS: + case CMD_BORDER_END_START_RADIUS: + case CMD_BORDER_END_END_RADIUS: + intBuffer.push_back(cmd.value()); + return packLength(value, intBuffer, doubleBuffer); + + case CMD_START_OF_TRANSFORM: + return packTransformToBuffers(value, intBuffer, doubleBuffer); + + default: + return false; + } +} + +bool packAnimatedPropsToBuffers( + const AnimatedProps& animatedProps, + std::vector& intBuffer, + std::vector& doubleBuffer) { + if (!animatedProps.props.empty()) { + return false; + } + if (animatedProps.rawProps) { + std::optional converted; + const auto* rawProps = animatedProps.rawProps->getDynamic(); + if (rawProps == nullptr) { + rawProps = &converted.emplace(animatedProps.rawProps->toDynamic()); + } + for (const auto& [key, value] : rawProps->items()) { + if (!packDynamicEntryToBuffers( + key.getString(), value, intBuffer, doubleBuffer)) { + return false; + } + } + } + return true; +} + +} // namespace + +AnimatedPropsBuffer encodeAnimatedProps( + const std::unordered_map& updates) { + AnimatedPropsBuffer buffer; + buffer.ints.reserve(updates.size() * 4); + buffer.doubles.reserve(updates.size()); + for (const auto& [tag, animatedProps] : updates) { + buffer.ints.push_back(CMD_START_OF_VIEW); + buffer.ints.push_back(tag); + auto intSize = buffer.ints.size(); + auto doubleSize = buffer.doubles.size(); + if (!packAnimatedPropsToBuffers( + animatedProps, buffer.ints, buffer.doubles)) { + buffer.ints.resize(intSize); + buffer.doubles.resize(doubleSize); + buffer.ints.push_back(CMD_RAW_PROPS); + buffer.rawProps.push_back( + animationbackend::packAnimatedProps(animatedProps)); + } + buffer.ints.push_back(CMD_END_OF_VIEW); + } + return buffer; +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/AnimatedPropBufferEncoder.h b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/AnimatedPropBufferEncoder.h new file mode 100644 index 000000000000..316c910aa358 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/AnimatedPropBufferEncoder.h @@ -0,0 +1,24 @@ +/* + * 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 +#include +#include + +namespace facebook::react { + +struct AnimatedPropsBuffer { + std::vector ints; + std::vector doubles; + std::vector rawProps; +}; + +AnimatedPropsBuffer encodeAnimatedProps(const std::unordered_map &updates); + +} // namespace facebook::react diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.cpp index f1e15694da63..4be462f7c934 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.cpp @@ -7,6 +7,7 @@ #include "FabricMountingManager.h" +#include "AnimatedPropBufferEncoder.h" #include "EventEmitterWrapper.h" #include "MountItem.h" #include "StateWrapperImpl.h" @@ -1246,6 +1247,34 @@ void FabricMountingManager::synchronouslyUpdateViewOnUIThread( synchronouslyUpdateViewOnUIThreadJNI(javaUIManager_, viewTag, propsMap); } +void FabricMountingManager::synchronouslyUpdateAnimatedProps( + const std::unordered_map& updates) { + TraceSection s( + "FabricMountingManager::synchronouslyUpdateAnimatedProps", + "viewCount", + updates.size()); + static auto synchronouslyUpdateAnimatedPropsJNI = + JFabricUIManager::javaClassStatic() + ->getMethod)>( + "synchronouslyUpdateViewBatch"); + auto buffer = encodeAnimatedProps(updates); + auto ints = jni::JArrayInt::newArray(buffer.ints.size()); + ints->setRegion( + 0, static_cast(buffer.ints.size()), buffer.ints.data()); + auto doubles = jni::JArrayDouble::newArray(buffer.doubles.size()); + doubles->setRegion( + 0, static_cast(buffer.doubles.size()), buffer.doubles.data()); + auto rawProps = jni::JArrayClass::newArray(buffer.rawProps.size()); + for (size_t i = 0; i < buffer.rawProps.size(); i++) { + rawProps->setElement( + i, + ReadableNativeMap::newObjectCxxArgs(std::move(buffer.rawProps[i])) + .get()); + } + synchronouslyUpdateAnimatedPropsJNI( + javaUIManager_, ints.get(), doubles.get(), rawProps.get()); +} + void FabricMountingManager::captureViewSnapshot(Tag tag, SurfaceId surfaceId) { static auto captureViewSnapshotJNI = JFabricUIManager::javaClassStatic()->getMethod( diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.h b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.h index 3d31c58db7eb..4067098e5c09 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.h @@ -17,6 +17,8 @@ namespace facebook::react { +struct AnimatedProps; + class MountingTransaction; struct ShadowView; @@ -84,6 +86,8 @@ class FabricMountingManager final { void synchronouslyUpdateViewOnUIThread(Tag viewTag, const folly::dynamic &props); + void synchronouslyUpdateAnimatedProps(const std::unordered_map &updates); + void captureViewSnapshot(Tag tag, SurfaceId surfaceId); void setViewSnapshot(Tag sourceTag, Tag targetTag, SurfaceId surfaceId); diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp index 2d2a2f3d37f4..d5c154f1e6f1 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp @@ -863,6 +863,13 @@ void FabricUIManagerBinding::schedulerShouldSynchronouslyUpdateViewOnUIThread( } } +void FabricUIManagerBinding::schedulerShouldSynchronouslyUpdateAnimatedProps( + const std::unordered_map& updates) { + if (ReactNativeFeatureFlags::cxxNativeAnimatedEnabled() && mountingManager_) { + mountingManager_->synchronouslyUpdateAnimatedProps(updates); + } +} + void FabricUIManagerBinding::schedulerDidUpdateShadowTree( const std::unordered_map& /*tagToProps*/) { // no-op diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.h b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.h index ea4b928357ec..97229f08fdcf 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.h @@ -115,6 +115,8 @@ class FabricUIManagerBinding : public jni::HybridClass, void schedulerShouldSynchronouslyUpdateViewOnUIThread(Tag tag, const folly::dynamic &props) override; + void schedulerShouldSynchronouslyUpdateAnimatedProps(const std::unordered_map &updates) override; + void schedulerDidUpdateShadowTree(const std::unordered_map &tagToProps) override; void schedulerDidCaptureViewSnapshot(Tag tag, SurfaceId surfaceId) override; diff --git a/packages/react-native/ReactCommon/react/renderer/core/RawProps.h b/packages/react-native/ReactCommon/react/renderer/core/RawProps.h index 8afc6f7ec890..8685ec6f575d 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/RawProps.h +++ b/packages/react-native/ReactCommon/react/renderer/core/RawProps.h @@ -83,6 +83,12 @@ class RawProps final { */ folly::dynamic toDynamic(const std::function &filterObjectKeys = nullptr) const; + // The pointer is valid while this RawProps owns its dynamic source. + const folly::dynamic *getDynamic() const noexcept + { + return mode_ == Mode::Dynamic ? &dynamic_ : nullptr; + } + /* * Returns `true` if the object is empty. * Empty `RawProps` does not have any stored data. From 2dfe6737fdf0226080e637ec62b8e992d2172f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20B=C5=82oniarz?= Date: Tue, 15 Sep 2026 15:58:39 +0200 Subject: [PATCH 2/2] Batch Android animated prop updates into one JNI call per surface: tests and scaffolding Tests and C++ API snapshots for the change in the parent commit. --- .../test/AnimatedPropBufferEncoderTest.cpp | 401 ++++++++++++++++++ .../BatchedAnimatedPropsMountItemTest.kt | 358 ++++++++++++++++ .../MountingManagerAnimatedPropsTest.kt | 99 +++++ .../renderer/core/tests/RawPropsTest.cpp | 23 + .../api-snapshots/ReactAndroidDebugCxx.api | 9 + .../api-snapshots/ReactAndroidNewarchCxx.api | 9 + .../api-snapshots/ReactAndroidReleaseCxx.api | 9 + .../api-snapshots/ReactAppleDebugCxx.api | 1 + .../api-snapshots/ReactAppleNewarchCxx.api | 1 + .../api-snapshots/ReactAppleReleaseCxx.api | 1 + .../api-snapshots/ReactCommonDebugCxx.api | 1 + .../api-snapshots/ReactCommonNewarchCxx.api | 1 + .../api-snapshots/ReactCommonReleaseCxx.api | 1 + 13 files changed, 914 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/main/jni/react/fabric/test/AnimatedPropBufferEncoderTest.cpp create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/BatchedAnimatedPropsMountItemTest.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/MountingManagerAnimatedPropsTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/test/AnimatedPropBufferEncoderTest.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/test/AnimatedPropBufferEncoderTest.cpp new file mode 100644 index 000000000000..4e18b43b7c3d --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/test/AnimatedPropBufferEncoderTest.cpp @@ -0,0 +1,401 @@ +/* + * 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 +#include +#include +#include +#include +#include +#include + +namespace facebook::react { + +namespace { + +// Protocol codes read by BatchedAnimatedPropsMountItem. +enum Command : int { + CMD_START_OF_VIEW = 1, + CMD_START_OF_TRANSFORM = 2, + CMD_END_OF_TRANSFORM = 3, + CMD_END_OF_VIEW = 4, + CMD_RAW_PROPS = 5, + CMD_OPACITY = 10, + CMD_BACKGROUND_COLOR = 15, + CMD_PLACEHOLDER_TEXT_COLOR = 18, + CMD_SHADOW_COLOR = 19, + CMD_BORDER_RADIUS = 20, + CMD_BORDER_BLOCK_COLOR = 47, + CMD_BORDER_BLOCK_START_COLOR = 48, + CMD_BORDER_BLOCK_END_COLOR = 49, + CMD_OUTLINE_COLOR = 50, + CMD_OUTLINE_OFFSET = 51, + CMD_OUTLINE_WIDTH = 52, + CMD_TRANSFORM_TRANSLATE_X = 100, + CMD_TRANSFORM_ROTATE = 105, + CMD_TRANSFORM_SKEW_Y = 110, + CMD_UNIT_PX = 202, + CMD_UNIT_PERCENT = 203, +}; + +} // namespace + +TEST( + AnimatedPropBufferEncoderTest, + encodesDynamicOpacityAndTransformWithoutConsumingInput) { + folly::dynamic rawProps = folly::dynamic::object("opacity", 0.25)( + "transform", + folly::dynamic::array( + folly::dynamic::object("translateX", 10), + folly::dynamic::object("scale", 2))); + std::unordered_map updates; + updates.emplace(10, AnimatedProps{{}, std::make_unique(rawProps)}); + auto buffer = encodeAnimatedProps(updates); + EXPECT_EQ(updates.at(10).rawProps->toDynamic(), rawProps); + EXPECT_EQ(buffer.ints[0], CMD_START_OF_VIEW); + EXPECT_EQ(buffer.ints[1], 10); + EXPECT_EQ(buffer.ints.back(), CMD_END_OF_VIEW); + EXPECT_TRUE(buffer.rawProps.empty()); + auto again = encodeAnimatedProps(updates); + EXPECT_EQ(buffer.ints, again.ints); + EXPECT_EQ(buffer.doubles, again.doubles); +} + +TEST(AnimatedPropBufferEncoderTest, copiesFallbackPropsWithoutConsumingInput) { + folly::dynamic rawProps = folly::dynamic::object("opacity", nullptr)( + "transform", + folly::dynamic::array(folly::dynamic::object("futureTransform", 1)))( + "shadowOffset", folly::dynamic::object("width", 2)("height", 3)); + std::unordered_map updates; + updates.emplace(10, AnimatedProps{{}, std::make_unique(rawProps)}); + auto buffer = encodeAnimatedProps(updates); + EXPECT_EQ(updates.at(10).rawProps->toDynamic(), rawProps); + ASSERT_EQ(buffer.rawProps.size(), 1); + EXPECT_EQ(buffer.rawProps[0], rawProps); + EXPECT_EQ( + buffer.ints, + (std::vector{ + CMD_START_OF_VIEW, 10, CMD_RAW_PROPS, CMD_END_OF_VIEW})); + EXPECT_TRUE(buffer.doubles.empty()); +} + +TEST(AnimatedPropBufferEncoderTest, convertsJSIProps) { + auto runtime = hermes::makeHermesRuntime(); + auto object = jsi::Object(*runtime); + object.setProperty(*runtime, "opacity", 0.5); + std::unordered_map updates; + updates.emplace( + 10, + AnimatedProps{ + {}, + std::make_unique(*runtime, jsi::Value(*runtime, object))}); + auto buffer = encodeAnimatedProps(updates); + EXPECT_EQ( + buffer.ints, + (std::vector{CMD_START_OF_VIEW, 10, CMD_OPACITY, CMD_END_OF_VIEW})); + EXPECT_EQ(buffer.doubles, (std::vector{0.5})); + EXPECT_TRUE(buffer.rawProps.empty()); +} + +TEST(AnimatedPropBufferEncoderTest, convertsJSIFallbackWithoutConsumingInput) { + auto runtime = hermes::makeHermesRuntime(); + auto object = jsi::Object(*runtime); + object.setProperty(*runtime, "testID", "animated-view"); + std::unordered_map updates; + updates.emplace( + 10, + AnimatedProps{ + {}, + std::make_unique(*runtime, jsi::Value(*runtime, object))}); + auto buffer = encodeAnimatedProps(updates); + folly::dynamic expected = folly::dynamic::object("testID", "animated-view"); + ASSERT_EQ(buffer.rawProps.size(), 1); + EXPECT_EQ(buffer.rawProps[0], expected); + EXPECT_EQ(updates.at(10).rawProps->toDynamic(), expected); +} + +namespace { +AnimatedPropsBuffer encodeRawProps(const folly::dynamic& props) { + std::unordered_map updates; + updates.emplace(10, AnimatedProps{{}, std::make_unique(props)}); + return encodeAnimatedProps(updates); +} +} // namespace + +TEST(AnimatedPropBufferEncoderTest, encodesIntegerRadiusAndTranslation) { + auto radius = encodeRawProps(folly::dynamic::object("borderRadius", 4)); + EXPECT_EQ( + radius.ints, + (std::vector{ + CMD_START_OF_VIEW, + 10, + CMD_BORDER_RADIUS, + CMD_UNIT_PX, + CMD_END_OF_VIEW})); + EXPECT_EQ(radius.doubles, (std::vector{4})); + EXPECT_TRUE(radius.rawProps.empty()); + auto transform = encodeRawProps( + folly::dynamic::object( + "transform", + folly::dynamic::array(folly::dynamic::object("translateX", 10)))); + EXPECT_EQ( + transform.ints, + (std::vector{ + CMD_START_OF_VIEW, + 10, + CMD_START_OF_TRANSFORM, + CMD_TRANSFORM_TRANSLATE_X, + CMD_UNIT_PX, + CMD_END_OF_TRANSFORM, + CMD_END_OF_VIEW})); + EXPECT_EQ(transform.doubles, (std::vector{10})); + EXPECT_TRUE(transform.rawProps.empty()); +} + +TEST(AnimatedPropBufferEncoderTest, encodesPercentAndConvertsAnglesToRadians) { + folly::dynamic props = folly::dynamic::object( + "transform", + folly::dynamic::array( + folly::dynamic::object("translateX", "12.345678901%"), + folly::dynamic::object("rotate", "45.123456789deg"), + folly::dynamic::object("skewY", "0.123456789rad"))); + auto buffer = encodeRawProps(props); + EXPECT_EQ( + buffer.ints, + (std::vector{ + CMD_START_OF_VIEW, + 10, + CMD_START_OF_TRANSFORM, + CMD_TRANSFORM_TRANSLATE_X, + CMD_UNIT_PERCENT, + CMD_TRANSFORM_ROTATE, + CMD_TRANSFORM_SKEW_Y, + CMD_END_OF_TRANSFORM, + CMD_END_OF_VIEW})); + EXPECT_EQ( + buffer.doubles, + (std::vector{ + 12.345678901, 45.123456789 * std::numbers::pi / 180, 0.123456789})); + EXPECT_TRUE(buffer.rawProps.empty()); +} + +TEST(AnimatedPropBufferEncoderTest, sendsWholeViewAsRawPropsOnUnsupportedProp) { + folly::dynamic props = folly::dynamic::object("opacity", 0.5)( + "transform", + folly::dynamic::array( + folly::dynamic::object("scale", 2), + folly::dynamic::object("translateX", "auto"))); + auto buffer = encodeRawProps(props); + EXPECT_EQ( + buffer.ints, + (std::vector{ + CMD_START_OF_VIEW, 10, CMD_RAW_PROPS, CMD_END_OF_VIEW})); + EXPECT_TRUE(buffer.doubles.empty()); + ASSERT_EQ(buffer.rawProps.size(), 1); + EXPECT_EQ(buffer.rawProps[0], props); +} + +TEST(AnimatedPropBufferEncoderTest, preservesUnsupportedPropsAndResets) { + folly::dynamic props = + folly::dynamic::object("opacity", nullptr)("backgroundColor", nullptr)( + "transformOrigin", folly::dynamic::array("50%", 10, 0))( + "filter", + folly::dynamic::array(folly::dynamic::object("brightness", 0.5))); + auto buffer = encodeRawProps(props); + EXPECT_EQ( + buffer.ints, + (std::vector{ + CMD_START_OF_VIEW, 10, CMD_RAW_PROPS, CMD_END_OF_VIEW})); + ASSERT_EQ(buffer.rawProps.size(), 1); + EXPECT_EQ(buffer.rawProps[0], props); +} + +TEST(AnimatedPropBufferEncoderTest, rollsBackInvalidRadiusAndMatrix) { + for (const auto& props : std::vector{ + folly::dynamic::object("borderRadius", "wrong"), + folly::dynamic::object( + "transform", + folly::dynamic::array( + folly::dynamic::object( + "matrix", folly::dynamic::array(1, 2, 3))))}) { + auto buffer = encodeRawProps(props); + EXPECT_EQ( + buffer.ints, + (std::vector{ + CMD_START_OF_VIEW, 10, CMD_RAW_PROPS, CMD_END_OF_VIEW})); + EXPECT_TRUE(buffer.doubles.empty()); + ASSERT_EQ(buffer.rawProps.size(), 1); + EXPECT_EQ(buffer.rawProps[0], props); + } +} + +TEST(AnimatedPropBufferEncoderTest, encodesColorWithoutSignLoss) { + auto buffer = encodeRawProps( + folly::dynamic::object("backgroundColor", int64_t{0xff112233})); + EXPECT_EQ( + buffer.ints, + (std::vector{ + CMD_START_OF_VIEW, + 10, + CMD_BACKGROUND_COLOR, + static_cast(0xff112233), + CMD_END_OF_VIEW})); + EXPECT_TRUE(buffer.rawProps.empty()); +} + +TEST(AnimatedPropBufferEncoderTest, encodesAdditionalSynchronousColors) { + for (const auto& [name, command] : std::vector>{ + {"placeholderTextColor", CMD_PLACEHOLDER_TEXT_COLOR}, + {"shadowColor", CMD_SHADOW_COLOR}, + {"borderBlockColor", CMD_BORDER_BLOCK_COLOR}, + {"borderBlockStartColor", CMD_BORDER_BLOCK_START_COLOR}, + {"borderBlockEndColor", CMD_BORDER_BLOCK_END_COLOR}, + {"outlineColor", CMD_OUTLINE_COLOR}}) { + SCOPED_TRACE(name); + for (const auto& color : std::vector{ + int64_t{0xff112233}, double{0xff112233}, int64_t{-15654349}}) { + auto buffer = encodeRawProps(folly::dynamic::object(name, color)); + EXPECT_EQ( + buffer.ints, + (std::vector{ + CMD_START_OF_VIEW, + 10, + command, + static_cast(0xff112233), + CMD_END_OF_VIEW})); + EXPECT_TRUE(buffer.doubles.empty()); + EXPECT_TRUE(buffer.rawProps.empty()); + } + for (const auto& color : std::vector{ + nullptr, + folly::dynamic::object( + "resource_paths", + folly::dynamic::array("?attr/colorAccent"))}) { + folly::dynamic props = folly::dynamic::object(name, color); + auto buffer = encodeRawProps(props); + EXPECT_EQ( + buffer.ints, + (std::vector{ + CMD_START_OF_VIEW, 10, CMD_RAW_PROPS, CMD_END_OF_VIEW})); + EXPECT_TRUE(buffer.doubles.empty()); + ASSERT_EQ(buffer.rawProps.size(), 1); + EXPECT_EQ(buffer.rawProps[0], props); + } + } +} + +TEST(AnimatedPropBufferEncoderTest, encodesDoubleColor) { + auto buffer = encodeRawProps( + folly::dynamic::object("backgroundColor", double{0xff112233})); + EXPECT_EQ( + buffer.ints, + (std::vector{ + CMD_START_OF_VIEW, + 10, + CMD_BACKGROUND_COLOR, + static_cast(0xff112233), + CMD_END_OF_VIEW})); + EXPECT_TRUE(buffer.rawProps.empty()); +} + +TEST( + AnimatedPropBufferEncoderTest, + preservesUnsupportedNumericColorsInFallback) { + for (double color : + {-16777215.5, + 1.5, + std::numeric_limits::lowest(), + std::numeric_limits::max(), + -std::numeric_limits::infinity(), + std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}) { + SCOPED_TRACE(color); + auto buffer = encodeRawProps( + folly::dynamic::object("opacity", 0.5)("backgroundColor", color)); + EXPECT_EQ( + buffer.ints, + (std::vector{ + CMD_START_OF_VIEW, 10, CMD_RAW_PROPS, CMD_END_OF_VIEW})); + EXPECT_TRUE(buffer.doubles.empty()); + ASSERT_EQ(buffer.rawProps.size(), 1); + EXPECT_EQ(buffer.rawProps[0]["opacity"], 0.5); + auto decodedColor = buffer.rawProps[0]["backgroundColor"].asDouble(); + if (std::isnan(color)) { + EXPECT_TRUE(std::isnan(decodedColor)); + } else { + EXPECT_EQ(decodedColor, color); + } + } +} + +TEST(AnimatedPropBufferEncoderTest, encodesOutlineDimensions) { + for (const auto& [name, command] : std::vector>{ + {"outlineOffset", CMD_OUTLINE_OFFSET}, + {"outlineWidth", CMD_OUTLINE_WIDTH}}) { + SCOPED_TRACE(name); + for (const auto& value : std::vector{-2, 1.25}) { + auto buffer = encodeRawProps(folly::dynamic::object(name, value)); + EXPECT_EQ( + buffer.ints, + (std::vector{CMD_START_OF_VIEW, 10, command, CMD_END_OF_VIEW})); + EXPECT_EQ(buffer.doubles, (std::vector{value.asDouble()})); + EXPECT_TRUE(buffer.rawProps.empty()); + } + for (const auto& value : std::vector{nullptr, "10%"}) { + folly::dynamic props = folly::dynamic::object(name, value); + auto buffer = encodeRawProps(props); + EXPECT_EQ( + buffer.ints, + (std::vector{ + CMD_START_OF_VIEW, 10, CMD_RAW_PROPS, CMD_END_OF_VIEW})); + EXPECT_TRUE(buffer.doubles.empty()); + ASSERT_EQ(buffer.rawProps.size(), 1); + EXPECT_EQ(buffer.rawProps[0], props); + } + } +} + +TEST(AnimatedPropBufferEncoderTest, typedPropsOverrideRawPropsInFallback) { + AnimatedPropsBuilder builder; + builder.setShadowOffset(Size{2, 3}); + auto props = builder.get(); + props.rawProps = std::make_unique( + folly::dynamic::object("shadowOffset", nullptr)("opacity", 0.5)); + std::unordered_map updates; + updates.emplace(10, std::move(props)); + auto buffer = encodeAnimatedProps(updates); + EXPECT_EQ( + buffer.ints, + (std::vector{ + CMD_START_OF_VIEW, 10, CMD_RAW_PROPS, CMD_END_OF_VIEW})); + EXPECT_TRUE(buffer.doubles.empty()); + ASSERT_EQ(buffer.rawProps.size(), 1); + folly::dynamic expected = folly::dynamic::object("opacity", 0.5)( + "shadowOffset", folly::dynamic::object("width", 2)("height", 3)); + EXPECT_EQ(buffer.rawProps[0], expected); +} + +TEST(AnimatedPropBufferEncoderTest, sendsTypedPropsAsRawProps) { + AnimatedPropsBuilder builder; + builder.setOpacity(0.75); + auto props = builder.get(); + props.rawProps = + std::make_unique(folly::dynamic::object("opacity", 0.25)); + std::unordered_map updates; + updates.emplace(10, std::move(props)); + auto buffer = encodeAnimatedProps(updates); + EXPECT_EQ( + buffer.ints, + (std::vector{ + CMD_START_OF_VIEW, 10, CMD_RAW_PROPS, CMD_END_OF_VIEW})); + EXPECT_TRUE(buffer.doubles.empty()); + ASSERT_EQ(buffer.rawProps.size(), 1); + folly::dynamic expected = folly::dynamic::object("opacity", 0.75); + EXPECT_EQ(buffer.rawProps[0], expected); +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/BatchedAnimatedPropsMountItemTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/BatchedAnimatedPropsMountItemTest.kt new file mode 100644 index 000000000000..f6c1d767da62 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/BatchedAnimatedPropsMountItemTest.kt @@ -0,0 +1,358 @@ +/* + * 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.fabric + +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.bridge.ReactNativeJNISoLoader +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.fabric.mounting.MountItemDispatcher +import com.facebook.react.fabric.mounting.MountingManager +import com.facebook.react.fabric.mounting.mountitems.BatchedAnimatedPropsMountItem +import com.facebook.react.uimanager.MatrixMathHelper +import com.facebook.react.uimanager.TransformHelper +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.within +import org.junit.Test +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class BatchedAnimatedPropsMountItemTest { + private fun decode( + ints: IntArray, + doubles: DoubleArray, + rawProps: Array = emptyArray(), + ): List> { + val updates = mutableListOf>() + BatchedAnimatedPropsMountItem(ints, doubles, rawProps, mock()).decode { tag, props -> + updates.add(tag to props) + } + return updates + } + + @Test + fun decodesOpacityPerView() { + val updates = + decode( + intArrayOf( + CMD_START_OF_VIEW, + 11, + CMD_OPACITY, + CMD_END_OF_VIEW, + CMD_START_OF_VIEW, + 12, + CMD_OPACITY, + CMD_END_OF_VIEW), + doubleArrayOf(0.25, 0.75)) + + assertThat(updates.map { it.first }).containsExactly(11, 12) + assertThat(updates[0].second.getDouble("opacity")).isEqualTo(0.25) + assertThat(updates[1].second.getDouble("opacity")).isEqualTo(0.75) + } + + @Test + fun decodesAdditionalColorProps() { + val color = 0xff112233.toInt() + for ((command, name) in + listOf( + CMD_PLACEHOLDER_TEXT_COLOR to "placeholderTextColor", + CMD_SHADOW_COLOR to "shadowColor", + CMD_BORDER_BLOCK_COLOR to "borderBlockColor", + CMD_BORDER_BLOCK_START_COLOR to "borderBlockStartColor", + CMD_BORDER_BLOCK_END_COLOR to "borderBlockEndColor", + CMD_OUTLINE_COLOR to "outlineColor", + )) { + val props = + decode(intArrayOf(CMD_START_OF_VIEW, 7, command, color, CMD_END_OF_VIEW), doubleArrayOf()) + .single() + .second + assertThat(props.getInt(name)).describedAs(name).isEqualTo(color) + } + } + + @Test + fun decodesOutlinePropsAcrossViews() { + val updates = + decode( + intArrayOf( + CMD_START_OF_VIEW, + 7, + CMD_OUTLINE_OFFSET, + CMD_OUTLINE_WIDTH, + CMD_END_OF_VIEW, + CMD_START_OF_VIEW, + 8, + CMD_OPACITY, + CMD_END_OF_VIEW), + doubleArrayOf(-2.0, 1.25, 0.5)) + + assertThat(updates.map { it.first }).containsExactly(7, 8) + val props = updates[0].second + assertThat(props.getDouble("outlineOffset")).isEqualTo(-2.0) + assertThat(props.getDouble("outlineWidth")).isEqualTo(1.25) + assertThat(updates[1].second.getDouble("opacity")).isEqualTo(0.5) + } + + @Test + fun decodesTransformOpsInOrder() { + val updates = + decode( + intArrayOf( + CMD_START_OF_VIEW, + 7, + CMD_START_OF_TRANSFORM, + CMD_TRANSLATE_X, + CMD_UNIT_PX, + CMD_ROTATE, + CMD_SCALE, + CMD_END_OF_TRANSFORM, + CMD_END_OF_VIEW), + doubleArrayOf(10.0, 1.5, 2.0)) + + val transforms = checkNotNull(updates.single().second.getArray("transform")) + assertThat(transforms.size()).isEqualTo(3) + assertThat(transforms.getMap(0)?.getDouble("translateX")).isEqualTo(10.0) + assertThat(transforms.getMap(1)?.getDouble("rotate")).isEqualTo(1.5) + assertThat(transforms.getMap(2)?.getDouble("scale")).isEqualTo(2.0) + } + + @Test + fun decodesMatrixOpWithSixteenValues() { + val matrix = DoubleArray(16) { it.toDouble() } + val updates = + decode( + intArrayOf( + CMD_START_OF_VIEW, + 7, + CMD_START_OF_TRANSFORM, + CMD_MATRIX, + CMD_END_OF_TRANSFORM, + CMD_END_OF_VIEW), + matrix) + + val transforms = checkNotNull(updates.single().second.getArray("transform")) + val decoded = checkNotNull(transforms.getMap(0)?.getArray("matrix")) + assertThat(decoded.size()).isEqualTo(16) + assertThat(decoded.getDouble(15)).isEqualTo(15.0) + } + + @Test + fun preservesScaleWithThreeDimensionalRotations() { + mockStatic(ReactNativeJNISoLoader::class.java).use { + for ((rotation, axis) in listOf(CMD_ROTATE_X to 0, CMD_ROTATE_Y to 1)) { + val props = + decode( + intArrayOf( + CMD_START_OF_VIEW, + 7, + CMD_START_OF_TRANSFORM, + CMD_PERSPECTIVE, + CMD_SCALE, + rotation, + CMD_END_OF_TRANSFORM, + CMD_END_OF_VIEW, + ), + doubleArrayOf(200.0, 2.0, Math.PI / 4), + ) + .single() + .second + val matrix = DoubleArray(16) + TransformHelper.processTransform( + checkNotNull(props.getArray("transform")), + matrix, + 0f, + 0f, + null, + ) + val decomposition = MatrixMathHelper.MatrixDecompositionContext() + MatrixMathHelper.decomposeMatrix(matrix, decomposition) + + for (scale in decomposition.scale) { + assertThat(scale).isCloseTo(2.0, within(0.000001)) + } + assertThat(decomposition.rotationDegrees[axis]).isEqualTo(45.0) + } + } + } + + @Test + fun passesRawPropsThroughAsTheWholeView() { + val raw = object : ReadableMap by JavaOnlyMap.of("translateX", 4.0, "opacity", null) {} + val updates = + decode( + intArrayOf( + CMD_START_OF_VIEW, + 7, + CMD_RAW_PROPS, + CMD_END_OF_VIEW, + CMD_START_OF_VIEW, + 8, + CMD_OPACITY, + CMD_END_OF_VIEW), + doubleArrayOf(0.5), + arrayOf(raw)) + + assertThat(updates[0]).isEqualTo(7 to raw) + assertThat(updates[1].second.getDouble("opacity")).isEqualTo(0.5) + } + + @Test + fun rejectsUnknownEntries() { + org.junit.Assert.assertThrows(IllegalStateException::class.java) { + decode(intArrayOf(CMD_START_OF_VIEW, 7, UNKNOWN_COMMAND, CMD_END_OF_VIEW), doubleArrayOf()) + } + } + + @Test + fun decodesRadiusAndTranslationUnits() { + val props = + decode( + intArrayOf( + CMD_START_OF_VIEW, + 7, + CMD_BORDER_RADIUS, + CMD_UNIT_PX, + CMD_BORDER_TOP_LEFT_RADIUS, + CMD_UNIT_PERCENT, + CMD_START_OF_TRANSFORM, + CMD_TRANSLATE_X, + CMD_UNIT_PX, + CMD_TRANSLATE_Y, + CMD_UNIT_PERCENT, + CMD_END_OF_TRANSFORM, + CMD_END_OF_VIEW), + doubleArrayOf(4.0, 12.5, 10.0, 25.0), + ) + .single() + .second + assertThat(props.getDouble("borderRadius")).isEqualTo(4.0) + assertThat(props.getString("borderTopLeftRadius")).isEqualTo("12.5%") + val transform = checkNotNull(props.getArray("transform")) + assertThat(transform.getMap(0)?.getDouble("translateX")).isEqualTo(10.0) + assertThat(transform.getMap(1)?.getString("translateY")).isEqualTo("25.0%") + } + + @Test + fun rejectsMalformedCommandsAndUnits() { + for (ints in + listOf( + intArrayOf(UNKNOWN_COMMAND), + intArrayOf(CMD_START_OF_VIEW, 7, CMD_BORDER_RADIUS, UNKNOWN_UNIT, CMD_END_OF_VIEW), + intArrayOf( + CMD_START_OF_VIEW, + 7, + CMD_START_OF_TRANSFORM, + CMD_TRANSLATE_X, + UNKNOWN_UNIT, + CMD_END_OF_TRANSFORM, + CMD_END_OF_VIEW), + intArrayOf( + CMD_START_OF_VIEW, + 7, + CMD_START_OF_TRANSFORM, + UNKNOWN_TRANSFORM_COMMAND, + CMD_END_OF_TRANSFORM, + CMD_END_OF_VIEW), + intArrayOf( + CMD_START_OF_VIEW, + 7, + CMD_START_OF_TRANSFORM, + CMD_MATRIX, + CMD_END_OF_TRANSFORM, + CMD_END_OF_VIEW), + intArrayOf(CMD_START_OF_VIEW, 7, CMD_RAW_PROPS, CMD_OPACITY, CMD_END_OF_VIEW), + )) { + org.junit.Assert.assertThrows(RuntimeException::class.java) { + decode(ints, doubleArrayOf(1.0)) + } + } + } + + @Test + fun queuesMissingViewsAndContinuesTheBatch() { + val manager = mock() + val dispatcher = mock() + val second = JavaOnlyMap.of("opacity", 0.75) + whenever(manager.updateAnimatedPropsSynchronously(12, second)).thenReturn(true) + BatchedAnimatedPropsMountItem( + intArrayOf( + CMD_START_OF_VIEW, + 11, + CMD_OPACITY, + CMD_END_OF_VIEW, + CMD_START_OF_VIEW, + 12, + CMD_OPACITY, + CMD_END_OF_VIEW), + doubleArrayOf(0.25, 0.75), + emptyArray(), + dispatcher, + ) + .execute(manager) + verify(dispatcher).addMountItem(org.mockito.kotlin.any()) + verify(manager).updateAnimatedPropsSynchronously(12, second) + } + + @Test + fun continuesAfterOneViewManagerFails() { + val manager = mock() + val dispatcher = mock() + whenever(manager.updateAnimatedPropsSynchronously(11, JavaOnlyMap.of("opacity", 0.25))) + .thenThrow(IllegalStateException("view failure")) + BatchedAnimatedPropsMountItem( + intArrayOf( + CMD_START_OF_VIEW, + 11, + CMD_OPACITY, + CMD_END_OF_VIEW, + CMD_START_OF_VIEW, + 12, + CMD_OPACITY, + CMD_END_OF_VIEW), + doubleArrayOf(0.25, 0.75), + emptyArray(), + dispatcher, + ) + .execute(manager) + verify(manager).updateAnimatedPropsSynchronously(12, JavaOnlyMap.of("opacity", 0.75)) + } + + private companion object { + const val CMD_START_OF_VIEW = 1 + const val CMD_START_OF_TRANSFORM = 2 + const val CMD_END_OF_TRANSFORM = 3 + const val CMD_END_OF_VIEW = 4 + const val CMD_RAW_PROPS = 5 + const val CMD_OPACITY = 10 + const val CMD_PLACEHOLDER_TEXT_COLOR = 18 + const val CMD_SHADOW_COLOR = 19 + const val CMD_BORDER_RADIUS = 20 + const val CMD_BORDER_TOP_LEFT_RADIUS = 21 + const val CMD_BORDER_BLOCK_COLOR = 47 + const val CMD_BORDER_BLOCK_START_COLOR = 48 + const val CMD_BORDER_BLOCK_END_COLOR = 49 + const val CMD_OUTLINE_COLOR = 50 + const val CMD_OUTLINE_OFFSET = 51 + const val CMD_OUTLINE_WIDTH = 52 + const val CMD_TRANSLATE_X = 100 + const val CMD_TRANSLATE_Y = 101 + const val CMD_SCALE = 102 + const val CMD_ROTATE = 105 + const val CMD_ROTATE_X = 106 + const val CMD_ROTATE_Y = 107 + const val CMD_MATRIX = 111 + const val CMD_PERSPECTIVE = 112 + const val CMD_UNIT_PX = 202 + const val CMD_UNIT_PERCENT = 203 + + const val UNKNOWN_COMMAND = 9 + const val UNKNOWN_TRANSFORM_COMMAND = 999 + const val UNKNOWN_UNIT = 999 + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/MountingManagerAnimatedPropsTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/MountingManagerAnimatedPropsTest.kt new file mode 100644 index 000000000000..f9f4f59e5795 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/MountingManagerAnimatedPropsTest.kt @@ -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. + */ + +@file:Suppress("DEPRECATION") + +package com.facebook.react.fabric + +import com.facebook.react.ReactRootView +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.bridge.ReactTestHelper +import com.facebook.react.fabric.mounting.MountingManager +import com.facebook.react.fabric.mounting.SurfaceMountingManager +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewManager +import com.facebook.react.uimanager.ViewManagerRegistry +import com.facebook.react.views.view.ReactViewManager +import com.facebook.testutils.shadows.ShadowNativeLoader +import com.facebook.testutils.shadows.ShadowNativeMap +import com.facebook.testutils.shadows.ShadowReadableNativeArray +import com.facebook.testutils.shadows.ShadowReadableNativeMap +import com.facebook.testutils.shadows.ShadowSoLoader +import com.facebook.testutils.shadows.ShadowWritableNativeArray +import com.facebook.testutils.shadows.ShadowWritableNativeMap +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config( + shadows = + [ + ShadowSoLoader::class, + ShadowNativeLoader::class, + ShadowNativeMap::class, + ShadowWritableNativeMap::class, + ShadowReadableNativeMap::class, + ShadowWritableNativeArray::class, + ShadowReadableNativeArray::class, + ], +) +class MountingManagerAnimatedPropsTest { + private lateinit var mountingManager: MountingManager + private lateinit var themedReactContext: ThemedReactContext + private val surfaceId = 1 + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + val reactContext = ReactTestHelper.createCatalystContextForTest() + themedReactContext = ThemedReactContext(reactContext, reactContext, null, -1) + mountingManager = + MountingManager(ViewManagerRegistry(listOf>(ReactViewManager())), {}) + } + + private fun startSurfaceWithView(tag: Int): SurfaceMountingManager { + mountingManager.startSurface(surfaceId, themedReactContext, ReactRootView(themedReactContext)) + val smm = mountingManager.getSurfaceManagerEnforced(surfaceId, "test") + smm.preallocateView("RCTView", tag, JavaOnlyMap.of(), null, true) + smm.addViewAt(surfaceId, tag, 0) + return smm + } + + @Test + fun appliesPropsToExistingView() { + val smm = startSurfaceWithView(42) + + val applied = + mountingManager.updateAnimatedPropsSynchronously(42, JavaOnlyMap.of("opacity", 0.3)) + + assertThat(applied).isTrue() + assertThat(smm.getView(42).alpha).isEqualTo(0.3f) + } + + @Test + fun reportsUnknownTag() { + startSurfaceWithView(42) + + assertThat(mountingManager.updateAnimatedPropsSynchronously(7, JavaOnlyMap.of("opacity", 0.3))) + .isFalse() + } + + @Test + fun storedValueOverridesStaleMountUpdate() { + val smm = startSurfaceWithView(42) + mountingManager.updateAnimatedPropsSynchronously(42, JavaOnlyMap.of("opacity", 0.3)) + + smm.updateProps(42, JavaOnlyMap.of("opacity", 1.0)) + + assertThat(smm.getView(42).alpha).isEqualTo(0.3f) + } +} diff --git a/packages/react-native/ReactCommon/react/renderer/core/tests/RawPropsTest.cpp b/packages/react-native/ReactCommon/react/renderer/core/tests/RawPropsTest.cpp index caff5c160c32..b23760236f21 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/tests/RawPropsTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/core/tests/RawPropsTest.cpp @@ -20,6 +20,29 @@ using namespace facebook; using namespace facebook::react; +TEST(RawPropsTest, dynamicSourceIsBorrowed) { + const auto rawProps = RawProps(folly::dynamic::object("opacity", 0.5)); + const auto* dynamic = rawProps.getDynamic(); + ASSERT_NE(dynamic, nullptr); + EXPECT_EQ(dynamic, rawProps.getDynamic()); + EXPECT_EQ(*dynamic, rawProps.toDynamic()); +} + +TEST(RawPropsTest, emptyPropsHaveNoDynamicSource) { + EXPECT_EQ(RawProps().getDynamic(), nullptr); + EXPECT_EQ(RawProps(nullptr).getDynamic(), nullptr); +} + +TEST(RawPropsTest, jsiPropsHaveNoDynamicSource) { + auto runtime = facebook::hermes::makeHermesRuntime(); + auto object = jsi::Object(*runtime); + object.setProperty(*runtime, "opacity", 0.5); + const auto rawProps = RawProps(*runtime, jsi::Value(*runtime, object)); + EXPECT_EQ(rawProps.getDynamic(), nullptr); + folly::dynamic expected = folly::dynamic::object("opacity", 0.5); + EXPECT_EQ(rawProps.toDynamic(), expected); +} + class PropsSingleFloat : public Props { public: PropsSingleFloat() = default; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index d24683a5d5b5..a233a78d244f 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -865,6 +865,7 @@ double facebook::react::getCPUTimeNanos(); double facebook::react::interpolate(double inputValue, double inputMin, double inputMax, double outputMin, double outputMax, std::string_view extrapolateLeft, std::string_view extrapolateRight); double facebook::react::round(double value) noexcept; facebook::jsi::Value facebook::react::createPromiseAsJSIValue(facebook::jsi::Runtime& rt, facebook::react::PromiseSetupFunctionType&& func); +facebook::react::AnimatedPropsBuffer facebook::react::encodeAnimatedProps(const std::unordered_map& updates); facebook::react::Color facebook::react::hostPlatformColorFromComponents(facebook::react::ColorComponents components); facebook::react::Color facebook::react::hostPlatformColorFromRGBA(uint8_t r, uint8_t g, uint8_t b, uint8_t a); facebook::react::ColorComponents facebook::react::colorComponentsFromColor(facebook::react::SharedColor color); @@ -2401,6 +2402,7 @@ class facebook::react::FabricMountingManager { public void sendAccessibilityEvent(const facebook::react::ShadowView& shadowView, const std::string& eventType); public void setIsJSResponder(const facebook::react::ShadowView& shadowView, bool isJSResponder, bool blockNativeResponder); public void setViewSnapshot(facebook::react::Tag sourceTag, facebook::react::Tag targetTag, facebook::react::SurfaceId surfaceId); + public void synchronouslyUpdateAnimatedProps(const std::unordered_map& updates); public void synchronouslyUpdateViewOnUIThread(facebook::react::Tag viewTag, const folly::dynamic& props); public ~FabricMountingManager(); } @@ -4260,6 +4262,7 @@ class facebook::react::RawProps { public bool isEmpty() const noexcept; public const facebook::react::RawValue* at(const char* name) const noexcept; public const facebook::react::RawValue* at(const char* name, const char* prefix, const char* suffix) const noexcept; + public const folly::dynamic* getDynamic() const noexcept; public facebook::react::RawProps& operator=(const facebook::react::RawProps& other) noexcept = delete; public facebook::react::RawProps& operator=(facebook::react::RawProps&& other) noexcept = delete; public folly::dynamic toDynamic(const std::function& filterObjectKeys = nullptr) const; @@ -6784,6 +6787,12 @@ struct facebook::react::AnimatedProps { public std::vector> props; } +struct facebook::react::AnimatedPropsBuffer { + public std::vector doubles; + public std::vector rawProps; + public std::vector ints; +} + struct facebook::react::AnimatedPropsBuilder { public facebook::react::AnimatedProps get(); public std::unique_ptr rawProps; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api index 96f11569470d..1138238b6f7b 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -864,6 +864,7 @@ double facebook::react::getCPUTimeNanos(); double facebook::react::interpolate(double inputValue, double inputMin, double inputMax, double outputMin, double outputMax, std::string_view extrapolateLeft, std::string_view extrapolateRight); double facebook::react::round(double value) noexcept; facebook::jsi::Value facebook::react::createPromiseAsJSIValue(facebook::jsi::Runtime& rt, facebook::react::PromiseSetupFunctionType&& func); +facebook::react::AnimatedPropsBuffer facebook::react::encodeAnimatedProps(const std::unordered_map& updates); facebook::react::Color facebook::react::hostPlatformColorFromComponents(facebook::react::ColorComponents components); facebook::react::Color facebook::react::hostPlatformColorFromRGBA(uint8_t r, uint8_t g, uint8_t b, uint8_t a); facebook::react::ColorComponents facebook::react::colorComponentsFromColor(facebook::react::SharedColor color); @@ -2384,6 +2385,7 @@ class facebook::react::FabricMountingManager { public void sendAccessibilityEvent(const facebook::react::ShadowView& shadowView, const std::string& eventType); public void setIsJSResponder(const facebook::react::ShadowView& shadowView, bool isJSResponder, bool blockNativeResponder); public void setViewSnapshot(facebook::react::Tag sourceTag, facebook::react::Tag targetTag, facebook::react::SurfaceId surfaceId); + public void synchronouslyUpdateAnimatedProps(const std::unordered_map& updates); public void synchronouslyUpdateViewOnUIThread(facebook::react::Tag viewTag, const folly::dynamic& props); public ~FabricMountingManager(); } @@ -4104,6 +4106,7 @@ class facebook::react::RawProps { public bool isEmpty() const noexcept; public const facebook::react::RawValue* at(const char* name) const noexcept; public const facebook::react::RawValue* at(const char* name, const char* prefix, const char* suffix) const noexcept; + public const folly::dynamic* getDynamic() const noexcept; public facebook::react::RawProps& operator=(const facebook::react::RawProps& other) noexcept = delete; public facebook::react::RawProps& operator=(facebook::react::RawProps&& other) noexcept = delete; public folly::dynamic toDynamic(const std::function& filterObjectKeys = nullptr) const; @@ -6594,6 +6597,12 @@ struct facebook::react::AnimatedProps { public std::vector> props; } +struct facebook::react::AnimatedPropsBuffer { + public std::vector doubles; + public std::vector rawProps; + public std::vector ints; +} + struct facebook::react::AnimatedPropsBuilder { public facebook::react::AnimatedProps get(); public std::unique_ptr rawProps; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index bcc9d69640dd..d633789807bb 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -865,6 +865,7 @@ double facebook::react::getCPUTimeNanos(); double facebook::react::interpolate(double inputValue, double inputMin, double inputMax, double outputMin, double outputMax, std::string_view extrapolateLeft, std::string_view extrapolateRight); double facebook::react::round(double value) noexcept; facebook::jsi::Value facebook::react::createPromiseAsJSIValue(facebook::jsi::Runtime& rt, facebook::react::PromiseSetupFunctionType&& func); +facebook::react::AnimatedPropsBuffer facebook::react::encodeAnimatedProps(const std::unordered_map& updates); facebook::react::Color facebook::react::hostPlatformColorFromComponents(facebook::react::ColorComponents components); facebook::react::Color facebook::react::hostPlatformColorFromRGBA(uint8_t r, uint8_t g, uint8_t b, uint8_t a); facebook::react::ColorComponents facebook::react::colorComponentsFromColor(facebook::react::SharedColor color); @@ -2399,6 +2400,7 @@ class facebook::react::FabricMountingManager { public void sendAccessibilityEvent(const facebook::react::ShadowView& shadowView, const std::string& eventType); public void setIsJSResponder(const facebook::react::ShadowView& shadowView, bool isJSResponder, bool blockNativeResponder); public void setViewSnapshot(facebook::react::Tag sourceTag, facebook::react::Tag targetTag, facebook::react::SurfaceId surfaceId); + public void synchronouslyUpdateAnimatedProps(const std::unordered_map& updates); public void synchronouslyUpdateViewOnUIThread(facebook::react::Tag viewTag, const folly::dynamic& props); public ~FabricMountingManager(); } @@ -4257,6 +4259,7 @@ class facebook::react::RawProps { public bool isEmpty() const noexcept; public const facebook::react::RawValue* at(const char* name) const noexcept; public const facebook::react::RawValue* at(const char* name, const char* prefix, const char* suffix) const noexcept; + public const folly::dynamic* getDynamic() const noexcept; public facebook::react::RawProps& operator=(const facebook::react::RawProps& other) noexcept = delete; public facebook::react::RawProps& operator=(facebook::react::RawProps&& other) noexcept = delete; public folly::dynamic toDynamic(const std::function& filterObjectKeys = nullptr) const; @@ -6775,6 +6778,12 @@ struct facebook::react::AnimatedProps { public std::vector> props; } +struct facebook::react::AnimatedPropsBuffer { + public std::vector doubles; + public std::vector rawProps; + public std::vector ints; +} + struct facebook::react::AnimatedPropsBuilder { public facebook::react::AnimatedProps get(); public std::unique_ptr rawProps; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 53991b89c0c2..8dd5edcda290 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -6428,6 +6428,7 @@ class facebook::react::RawProps { public bool isEmpty() const noexcept; public const facebook::react::RawValue* at(const char* name) const noexcept; public const facebook::react::RawValue* at(const char* name, const char* prefix, const char* suffix) const noexcept; + public const folly::dynamic* getDynamic() const noexcept; public facebook::react::RawProps& operator=(const facebook::react::RawProps& other) noexcept = delete; public facebook::react::RawProps& operator=(facebook::react::RawProps&& other) noexcept = delete; public folly::dynamic toDynamic(const std::function& filterObjectKeys = nullptr) const; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index b1e8f49542ad..1819dfe3b52c 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -6304,6 +6304,7 @@ class facebook::react::RawProps { public bool isEmpty() const noexcept; public const facebook::react::RawValue* at(const char* name) const noexcept; public const facebook::react::RawValue* at(const char* name, const char* prefix, const char* suffix) const noexcept; + public const folly::dynamic* getDynamic() const noexcept; public facebook::react::RawProps& operator=(const facebook::react::RawProps& other) noexcept = delete; public facebook::react::RawProps& operator=(facebook::react::RawProps&& other) noexcept = delete; public folly::dynamic toDynamic(const std::function& filterObjectKeys = nullptr) const; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 4c6f5a4a4685..ab171200ceb8 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -6425,6 +6425,7 @@ class facebook::react::RawProps { public bool isEmpty() const noexcept; public const facebook::react::RawValue* at(const char* name) const noexcept; public const facebook::react::RawValue* at(const char* name, const char* prefix, const char* suffix) const noexcept; + public const folly::dynamic* getDynamic() const noexcept; public facebook::react::RawProps& operator=(const facebook::react::RawProps& other) noexcept = delete; public facebook::react::RawProps& operator=(facebook::react::RawProps&& other) noexcept = delete; public folly::dynamic toDynamic(const std::function& filterObjectKeys = nullptr) const; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api index 87a4c9ac4bb1..c878ab945208 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api @@ -2831,6 +2831,7 @@ class facebook::react::RawProps { public bool isEmpty() const noexcept; public const facebook::react::RawValue* at(const char* name) const noexcept; public const facebook::react::RawValue* at(const char* name, const char* prefix, const char* suffix) const noexcept; + public const folly::dynamic* getDynamic() const noexcept; public facebook::react::RawProps& operator=(const facebook::react::RawProps& other) noexcept = delete; public facebook::react::RawProps& operator=(facebook::react::RawProps&& other) noexcept = delete; public folly::dynamic toDynamic(const std::function& filterObjectKeys = nullptr) const; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api index 87569996e1ad..75c1654006e3 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api @@ -2715,6 +2715,7 @@ class facebook::react::RawProps { public bool isEmpty() const noexcept; public const facebook::react::RawValue* at(const char* name) const noexcept; public const facebook::react::RawValue* at(const char* name, const char* prefix, const char* suffix) const noexcept; + public const folly::dynamic* getDynamic() const noexcept; public facebook::react::RawProps& operator=(const facebook::react::RawProps& other) noexcept = delete; public facebook::react::RawProps& operator=(facebook::react::RawProps&& other) noexcept = delete; public folly::dynamic toDynamic(const std::function& filterObjectKeys = nullptr) const; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api index cfb7cfadb968..6c3f59647833 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api @@ -2828,6 +2828,7 @@ class facebook::react::RawProps { public bool isEmpty() const noexcept; public const facebook::react::RawValue* at(const char* name) const noexcept; public const facebook::react::RawValue* at(const char* name, const char* prefix, const char* suffix) const noexcept; + public const folly::dynamic* getDynamic() const noexcept; public facebook::react::RawProps& operator=(const facebook::react::RawProps& other) noexcept = delete; public facebook::react::RawProps& operator=(facebook::react::RawProps&& other) noexcept = delete; public folly::dynamic toDynamic(const std::function& filterObjectKeys = nullptr) const;