From b69ad5299abe15972e7cf870dcf6253e80b6b9e8 Mon Sep 17 00:00:00 2001 From: Ahmed Sbai Date: Sun, 9 Aug 2026 02:13:54 +0200 Subject: [PATCH] Fix crash when setting a percentage borderRadius on Text, TextInput, and ScrollView (Android) Percentage border radii arrive from JS as strings ('50%'), but the borderRadius @ReactPropGroup setters in ReactTextViewManager, PreparedLayoutTextViewManager, ReactTextInputManager, ReactScrollViewManager, ReactHorizontalScrollViewManager, and ReactNestedScrollViewManager still typed the prop as Float, so the reflection-based property updater crashed with "java.lang.String cannot be cast to java.lang.Double": JSApplicationIllegalArgumentException: Error while updating property 'borderRadius' of a view managed by: RCTText Accept a Dynamic and parse it with LengthPercentage.setFromDynamic, completing the migration ReactViewManager received in 0.75 and ReactImageManager in #57795. The Float overloads on the public managers are kept as deprecated pass-throughs for backward compatibility; the two internal managers (PreparedLayoutTextViewManager, ReactNestedScrollViewManager) are migrated outright. Changelog: [ANDROID] [FIXED] - Fix crash when setting a percentage borderRadius on Text, TextInput, and ScrollView --- .../ReactAndroid/api/ReactAndroid.api | 4 + .../ReactHorizontalScrollViewManager.kt | 17 +++- .../scroll/ReactNestedScrollViewManager.kt | 10 +-- .../views/scroll/ReactScrollViewManager.kt | 13 ++- .../text/PreparedLayoutTextViewManager.kt | 11 +-- .../react/views/text/ReactTextViewManager.kt | 19 +++-- .../views/textinput/ReactTextInputManager.kt | 18 +++-- .../views/text/ReactTextViewPropertyTest.kt | 81 +++++++++++++++++++ .../textinput/ReactTextInputPropertyTest.kt | 20 +++++ 9 files changed, 165 insertions(+), 28 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewPropertyTest.kt diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index f69f43bce001..13480ecde1fb 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -5594,6 +5594,7 @@ public class com/facebook/react/views/scroll/ReactHorizontalScrollViewManager : public synthetic fun scrollToEnd (Ljava/lang/Object;Lcom/facebook/react/views/scroll/ReactScrollViewCommandHelper$ScrollToEndCommandData;)V public final fun setBorderColor (Lcom/facebook/react/views/scroll/ReactHorizontalScrollView;ILjava/lang/Integer;)V public final fun setBorderRadius (Lcom/facebook/react/views/scroll/ReactHorizontalScrollView;IF)V + public final fun setBorderRadius (Lcom/facebook/react/views/scroll/ReactHorizontalScrollView;ILcom/facebook/react/bridge/Dynamic;)V public final fun setBorderStyle (Lcom/facebook/react/views/scroll/ReactHorizontalScrollView;Ljava/lang/String;)V public final fun setBorderWidth (Lcom/facebook/react/views/scroll/ReactHorizontalScrollView;IF)V public final fun setBottomFillColor (Lcom/facebook/react/views/scroll/ReactHorizontalScrollView;I)V @@ -5889,6 +5890,7 @@ public class com/facebook/react/views/scroll/ReactScrollViewManager : com/facebo public synthetic fun scrollToEnd (Ljava/lang/Object;Lcom/facebook/react/views/scroll/ReactScrollViewCommandHelper$ScrollToEndCommandData;)V public final fun setBorderColor (Lcom/facebook/react/views/scroll/ReactScrollView;ILjava/lang/Integer;)V public final fun setBorderRadius (Lcom/facebook/react/views/scroll/ReactScrollView;IF)V + public final fun setBorderRadius (Lcom/facebook/react/views/scroll/ReactScrollView;ILcom/facebook/react/bridge/Dynamic;)V public final fun setBorderStyle (Lcom/facebook/react/views/scroll/ReactScrollView;Ljava/lang/String;)V public final fun setBorderWidth (Lcom/facebook/react/views/scroll/ReactScrollView;IF)V public final fun setBottomFillColor (Lcom/facebook/react/views/scroll/ReactScrollView;I)V @@ -6084,6 +6086,7 @@ public class com/facebook/react/views/text/ReactTextViewManager : com/facebook/r public final fun setAndroidHyphenationFrequency (Lcom/facebook/react/views/text/ReactTextView;Ljava/lang/String;)V public final fun setBorderColor (Lcom/facebook/react/views/text/ReactTextView;ILjava/lang/Integer;)V public final fun setBorderRadius (Lcom/facebook/react/views/text/ReactTextView;IF)V + public final fun setBorderRadius (Lcom/facebook/react/views/text/ReactTextView;ILcom/facebook/react/bridge/Dynamic;)V public final fun setBorderStyle (Lcom/facebook/react/views/text/ReactTextView;Ljava/lang/String;)V public final fun setBorderWidth (Lcom/facebook/react/views/text/ReactTextView;IF)V public final fun setDataDetectorType (Lcom/facebook/react/views/text/ReactTextView;Ljava/lang/String;)V @@ -6346,6 +6349,7 @@ public class com/facebook/react/views/textinput/ReactTextInputManager : com/face public final fun setAutoFocus (Lcom/facebook/react/views/textinput/ReactEditText;Z)V public final fun setBorderColor (Lcom/facebook/react/views/textinput/ReactEditText;ILjava/lang/Integer;)V public final fun setBorderRadius (Lcom/facebook/react/views/textinput/ReactEditText;IF)V + public final fun setBorderRadius (Lcom/facebook/react/views/textinput/ReactEditText;ILcom/facebook/react/bridge/Dynamic;)V public final fun setBorderStyle (Lcom/facebook/react/views/textinput/ReactEditText;Ljava/lang/String;)V public final fun setBorderWidth (Lcom/facebook/react/views/textinput/ReactEditText;IF)V public final fun setCaretHidden (Lcom/facebook/react/views/textinput/ReactEditText;Z)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt index bac81c404c1c..b361a03f3e53 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt @@ -282,9 +282,24 @@ constructor(private val fpsListener: FpsListener? = null) : ViewProps.BORDER_BOTTOM_RIGHT_RADIUS, ViewProps.BORDER_BOTTOM_LEFT_RADIUS, ], - defaultFloat = Float.NaN, + ) + public fun setBorderRadius( + view: ReactHorizontalScrollView?, + index: Int, + rawBorderRadius: Dynamic, + ) { + if (view != null) { + val borderRadius = LengthPercentage.setFromDynamic(rawBorderRadius) + setBorderRadius(view, BorderRadiusProp.entries[index], borderRadius) + } + } + + @Deprecated( + "Don't use setBorderRadius(view, index, Float) as it was deprecated in React Native 0.88.0.", ) public fun setBorderRadius(view: ReactHorizontalScrollView?, index: Int, borderRadius: Float) { + // Direct body: DynamicFromObject(Float).asDouble() throws, and setFromDynamic + // would not map NaN back to null like the original Float path did. if (view != null) { val radius = if (borderRadius.isNaN()) null diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollViewManager.kt index 09e25e2d6243..080ab9450c16 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollViewManager.kt @@ -30,7 +30,6 @@ import com.facebook.react.uimanager.BackgroundStyleApplicator.setBorderRadius import com.facebook.react.uimanager.BackgroundStyleApplicator.setBorderStyle import com.facebook.react.uimanager.BackgroundStyleApplicator.setBorderWidth import com.facebook.react.uimanager.LengthPercentage -import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.PixelUtil.dpToPx import com.facebook.react.uimanager.PixelUtil.getDisplayMetricDensity import com.facebook.react.uimanager.PointerEvents.Companion.parsePointerEvents @@ -255,14 +254,11 @@ constructor(private val fpsListener: FpsListener? = null) : ViewProps.BORDER_BOTTOM_RIGHT_RADIUS, ViewProps.BORDER_BOTTOM_LEFT_RADIUS, ], - defaultFloat = Float.NaN, ) - public fun setBorderRadius(view: ReactNestedScrollView?, index: Int, borderRadius: Float) { + public fun setBorderRadius(view: ReactNestedScrollView?, index: Int, rawBorderRadius: Dynamic) { if (view != null) { - val radius = - if (borderRadius.isNaN()) null - else LengthPercentage(borderRadius, LengthPercentageType.POINT) - setBorderRadius(view, BorderRadiusProp.entries[index], radius) + val borderRadius = LengthPercentage.setFromDynamic(rawBorderRadius) + setBorderRadius(view, BorderRadiusProp.entries[index], borderRadius) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt index 2f8df4150b3a..da013d7b0eba 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt @@ -247,9 +247,20 @@ constructor(private val fpsListener: FpsListener? = null) : ViewProps.BORDER_BOTTOM_RIGHT_RADIUS, ViewProps.BORDER_BOTTOM_LEFT_RADIUS, ], - defaultFloat = Float.NaN, + ) + public fun setBorderRadius(view: ReactScrollView?, index: Int, rawBorderRadius: Dynamic) { + if (view != null) { + val borderRadius = LengthPercentage.setFromDynamic(rawBorderRadius) + setBorderRadius(view, BorderRadiusProp.entries[index], borderRadius) + } + } + + @Deprecated( + "Don't use setBorderRadius(view, index, Float) as it was deprecated in React Native 0.88.0.", ) public fun setBorderRadius(view: ReactScrollView?, index: Int, borderRadius: Float) { + // Direct body: DynamicFromObject(Float).asDouble() throws, and setFromDynamic + // would not map NaN back to null like the original Float path did. if (view != null) { val radius = if (borderRadius.isNaN()) null diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextViewManager.kt index b91e3037fb59..15389cea8eee 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextViewManager.kt @@ -13,6 +13,7 @@ import android.text.Spannable import android.text.Spanned import android.view.View import com.facebook.react.R +import com.facebook.react.bridge.Dynamic import com.facebook.react.internal.SystraceSection import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.BackgroundStyleApplicator @@ -20,7 +21,6 @@ import com.facebook.react.uimanager.BaseViewManager import com.facebook.react.uimanager.IViewGroupManager import com.facebook.react.uimanager.LayoutShadowNode import com.facebook.react.uimanager.LengthPercentage -import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.PointerEvents import com.facebook.react.uimanager.ReactStylesDiffMap import com.facebook.react.uimanager.ReferenceStateWrapper @@ -149,13 +149,10 @@ internal class PreparedLayoutTextViewManager : ViewProps.BORDER_BOTTOM_RIGHT_RADIUS, ViewProps.BORDER_BOTTOM_LEFT_RADIUS, ], - defaultFloat = Float.NaN, ) - fun setBorderRadius(view: PreparedLayoutTextView, index: Int, borderRadius: Float): Unit { - val radius = - if (borderRadius.isNaN()) null - else LengthPercentage(borderRadius, LengthPercentageType.POINT) - BackgroundStyleApplicator.setBorderRadius(view, BorderRadiusProp.values()[index], radius) + fun setBorderRadius(view: PreparedLayoutTextView, index: Int, rawBorderRadius: Dynamic): Unit { + val borderRadius = LengthPercentage.setFromDynamic(rawBorderRadius) + BackgroundStyleApplicator.setBorderRadius(view, BorderRadiusProp.values()[index], borderRadius) } @ReactProp(name = "borderStyle") diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt index edcd85130f34..4e05c4daa3e6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt @@ -19,6 +19,7 @@ import android.text.util.Linkify import android.view.Gravity import com.facebook.common.logging.FLog import com.facebook.react.R +import com.facebook.react.bridge.Dynamic import com.facebook.react.common.ReactConstants import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.common.mapbuffer.MapBuffer @@ -335,15 +336,21 @@ public constructor( ViewProps.BORDER_BOTTOM_RIGHT_RADIUS, ViewProps.BORDER_BOTTOM_LEFT_RADIUS, ], - defaultFloat = Float.NaN, + ) + public fun setBorderRadius(view: ReactTextView, index: Int, rawBorderRadius: Dynamic) { + val borderRadius = LengthPercentage.setFromDynamic(rawBorderRadius) + BackgroundStyleApplicator.setBorderRadius(view, BorderRadiusProp.values()[index], borderRadius) + } + + @Deprecated( + "Don't use setBorderRadius(view, index, Float) as it was deprecated in React Native 0.88.0.", ) public fun setBorderRadius(view: ReactTextView, index: Int, borderRadius: Float) { + // Direct body: DynamicFromObject(Float).asDouble() throws, and setFromDynamic + // would not map NaN back to null like the original Float path did. val radius = - if (borderRadius.isNaN()) { - null - } else { - LengthPercentage(borderRadius, LengthPercentageType.POINT) - } + if (borderRadius.isNaN()) null + else LengthPercentage(borderRadius, LengthPercentageType.POINT) BackgroundStyleApplicator.setBorderRadius(view, BorderRadiusProp.values()[index], radius) } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt index 0d58ffdb5ab7..5e40689351ce 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt @@ -802,15 +802,21 @@ public open class ReactTextInputManager public constructor() : ViewProps.BORDER_BOTTOM_RIGHT_RADIUS, ViewProps.BORDER_BOTTOM_LEFT_RADIUS, ], - defaultFloat = Float.NaN, + ) + public fun setBorderRadius(view: ReactEditText, index: Int, rawBorderRadius: Dynamic) { + val borderRadius = LengthPercentage.setFromDynamic(rawBorderRadius) + setBorderRadius(view, BorderRadiusProp.entries[index], borderRadius) + } + + @Deprecated( + "Don't use setBorderRadius(view, index, Float) as it was deprecated in React Native 0.88.0.", ) public fun setBorderRadius(view: ReactEditText, index: Int, borderRadius: Float) { + // Direct body: DynamicFromObject(Float).asDouble() throws, and setFromDynamic + // would not map NaN back to null like the original Float path did. val radius = - if (borderRadius.isNaN()) { - null - } else { - LengthPercentage(borderRadius, LengthPercentageType.POINT) - } + if (borderRadius.isNaN()) null + else LengthPercentage(borderRadius, LengthPercentageType.POINT) setBorderRadius(view, BorderRadiusProp.entries[index], radius) } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewPropertyTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewPropertyTest.kt new file mode 100644 index 000000000000..12adce0d1308 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewPropertyTest.kt @@ -0,0 +1,81 @@ +/* + * 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. + */ + +// TODO T207169925: Migrate CatalystInstance to Reacthost and remove the Suppress("DEPRECATION") +// annotation +@file:Suppress("DEPRECATION") + +package com.facebook.react.views.text + +import android.util.DisplayMetrics +import com.facebook.react.bridge.BridgeReactContext +import com.facebook.react.bridge.CatalystInstance +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.BackgroundStyleApplicator +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.LengthPercentage +import com.facebook.react.uimanager.LengthPercentageType +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.style.BorderRadiusProp +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** Verify view properties are being applied correctly by [ReactTextViewManager] */ +@RunWith(RobolectricTestRunner::class) +class ReactTextViewPropertyTest { + + private lateinit var context: BridgeReactContext + private lateinit var catalystInstanceMock: CatalystInstance + private lateinit var themedContext: ThemedReactContext + private lateinit var manager: ReactTextViewManager + + @Before + fun setup() { + ReactNativeFeatureFlagsForTests.setUp() + context = BridgeReactContext(RuntimeEnvironment.getApplication()) + catalystInstanceMock = createMockCatalystInstance() + context.initializeWithInstance(catalystInstanceMock) + themedContext = ThemedReactContext(context, context, null, -1) + manager = ReactTextViewManager() + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics()) + } + + @After + fun teardown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + + private fun buildStyles(vararg keysAndValues: Any?): ReactStylesDiffMap { + return ReactStylesDiffMap(JavaOnlyMap.of(*keysAndValues)) + } + + @Test + fun testBorderRadius() { + val view = manager.createViewInstance(themedContext) + + // Percentage border radii arrive as strings and must not crash the property updater + manager.updateProperties(view, buildStyles("borderRadius", "50%")) + assertThat(BackgroundStyleApplicator.getBorderRadius(view, BorderRadiusProp.BORDER_RADIUS)) + .isEqualTo(LengthPercentage(50f, LengthPercentageType.PERCENT)) + + manager.updateProperties(view, buildStyles("borderRadius", 10.0)) + assertThat(BackgroundStyleApplicator.getBorderRadius(view, BorderRadiusProp.BORDER_RADIUS)) + .isEqualTo(LengthPercentage(10f, LengthPercentageType.POINT)) + + manager.updateProperties(view, buildStyles("borderRadius", null)) + assertThat(BackgroundStyleApplicator.getBorderRadius(view, BorderRadiusProp.BORDER_RADIUS)) + .isNull() + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt index f7078a7213e6..1c571b4daedb 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt @@ -30,9 +30,13 @@ import com.facebook.react.bridge.CatalystInstance import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.LengthPercentage +import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.ReactStylesDiffMap import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.style.BorderRadiusProp import com.facebook.react.views.text.DefaultStyleValuesUtil.getDefaultTextColorHint import com.facebook.react.views.text.ReactTextUpdate import org.assertj.core.api.Assertions.assertThat @@ -521,6 +525,22 @@ class ReactTextInputPropertyTest { assertThat(checkNotNull(view.text).getSpans(0, view.length(), MarkerSpan::class.java)).isEmpty() } + @Test + fun testBorderRadius() { + // Percentage border radii arrive as strings and must not crash the property updater + manager.updateProperties(view, buildStyles("borderRadius", "50%")) + assertThat(BackgroundStyleApplicator.getBorderRadius(view, BorderRadiusProp.BORDER_RADIUS)) + .isEqualTo(LengthPercentage(50f, LengthPercentageType.PERCENT)) + + manager.updateProperties(view, buildStyles("borderRadius", 10.0)) + assertThat(BackgroundStyleApplicator.getBorderRadius(view, BorderRadiusProp.BORDER_RADIUS)) + .isEqualTo(LengthPercentage(10f, LengthPercentageType.POINT)) + + manager.updateProperties(view, buildStyles("borderRadius", null)) + assertThat(BackgroundStyleApplicator.getBorderRadius(view, BorderRadiusProp.BORDER_RADIUS)) + .isNull() + } + private fun buildStyles(vararg keysAndValues: Any?): ReactStylesDiffMap { return ReactStylesDiffMap(JavaOnlyMap.of(*keysAndValues)) }