From 4a037427d07aaff9be8d035c809d989e43e38c02 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Thu, 23 Apr 2026 10:38:55 +0100 Subject: [PATCH 01/11] Add per-platform redBoxV2 feature flags Cherry-pick of d6ed561f521 with feature flag files regenerated for the 0.83-stable branch. --- .../featureflags/ReactNativeFeatureFlags.kt | 14 +++- .../ReactNativeFeatureFlagsCxxAccessor.kt | 22 ++++- .../ReactNativeFeatureFlagsCxxInterop.kt | 6 +- .../ReactNativeFeatureFlagsDefaults.kt | 6 +- .../ReactNativeFeatureFlagsLocalAccessor.kt | 24 +++++- .../ReactNativeFeatureFlagsProvider.kt | 6 +- .../JReactNativeFeatureFlagsCxxInterop.cpp | 30 ++++++- .../JReactNativeFeatureFlagsCxxInterop.h | 8 +- .../featureflags/ReactNativeFeatureFlags.cpp | 10 ++- .../featureflags/ReactNativeFeatureFlags.h | 12 ++- .../ReactNativeFeatureFlagsAccessor.cpp | 80 ++++++++++++++----- .../ReactNativeFeatureFlagsAccessor.h | 8 +- .../ReactNativeFeatureFlagsDefaults.h | 10 ++- .../ReactNativeFeatureFlagsDynamicProvider.h | 20 ++++- .../ReactNativeFeatureFlagsProvider.h | 4 +- .../NativeReactNativeFeatureFlags.cpp | 12 ++- .../NativeReactNativeFeatureFlags.h | 6 +- .../ReactNativeFeatureFlags.config.js | 22 +++++ .../featureflags/ReactNativeFeatureFlags.js | 12 ++- .../specs/NativeReactNativeFeatureFlags.js | 4 +- 20 files changed, 275 insertions(+), 41 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt index a39034bc983d..c793cbd6a7cb 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<26dfce17f8149372c967e83a1e55b497>> */ /** @@ -426,6 +426,18 @@ public object ReactNativeFeatureFlags { @JvmStatic public fun preventShadowTreeCommitExhaustion(): Boolean = accessor.preventShadowTreeCommitExhaustion() + /** + * Use the redesigned RedBox error overlay on Android, styled to match the LogBox visual language. + */ + @JvmStatic + public fun redBoxV2Android(): Boolean = accessor.redBoxV2Android() + + /** + * Use the redesigned RedBox error overlay on iOS, styled to match the LogBox visual language. + */ + @JvmStatic + public fun redBoxV2IOS(): Boolean = accessor.redBoxV2IOS() + /** * Function used to enable / disable Pressibility from using W3C Pointer Events for its hover callbacks */ diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt index 33180deb5859..977e01fe3828 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<7523b02820a1f77c0a851ed051770f51>> */ /** @@ -86,6 +86,8 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces private var perfMonitorV2EnabledCache: Boolean? = null private var preparedTextCacheSizeCache: Double? = null private var preventShadowTreeCommitExhaustionCache: Boolean? = null + private var redBoxV2AndroidCache: Boolean? = null + private var redBoxV2IOSCache: Boolean? = null private var shouldPressibilityUseW3CPointerEventsForHoverCache: Boolean? = null private var shouldTriggerResponderTransferOnScrollAndroidCache: Boolean? = null private var skipActivityIdentityAssertionOnHostPauseCache: Boolean? = null @@ -702,6 +704,24 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces return cached } + override fun redBoxV2Android(): Boolean { + var cached = redBoxV2AndroidCache + if (cached == null) { + cached = ReactNativeFeatureFlagsCxxInterop.redBoxV2Android() + redBoxV2AndroidCache = cached + } + return cached + } + + override fun redBoxV2IOS(): Boolean { + var cached = redBoxV2IOSCache + if (cached == null) { + cached = ReactNativeFeatureFlagsCxxInterop.redBoxV2IOS() + redBoxV2IOSCache = cached + } + return cached + } + override fun shouldPressibilityUseW3CPointerEventsForHover(): Boolean { var cached = shouldPressibilityUseW3CPointerEventsForHoverCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt index 6de1a23ce55f..432580556ccb 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<0fa0febd40941c466222d1af82f677cf>> */ /** @@ -160,6 +160,10 @@ public object ReactNativeFeatureFlagsCxxInterop { @DoNotStrip @JvmStatic public external fun preventShadowTreeCommitExhaustion(): Boolean + @DoNotStrip @JvmStatic public external fun redBoxV2Android(): Boolean + + @DoNotStrip @JvmStatic public external fun redBoxV2IOS(): Boolean + @DoNotStrip @JvmStatic public external fun shouldPressibilityUseW3CPointerEventsForHover(): Boolean @DoNotStrip @JvmStatic public external fun shouldTriggerResponderTransferOnScrollAndroid(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt index de0380341563..a47d3c56b0aa 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<9158a8f1750ec5e9e530399fe09f52cf>> */ /** @@ -155,6 +155,10 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi override fun preventShadowTreeCommitExhaustion(): Boolean = false + override fun redBoxV2Android(): Boolean = false + + override fun redBoxV2IOS(): Boolean = false + override fun shouldPressibilityUseW3CPointerEventsForHover(): Boolean = false override fun shouldTriggerResponderTransferOnScrollAndroid(): Boolean = false diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt index f116734c24d8..3493f5a089c3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<38e86d50298e5c4199d456f709ac13fc>> + * @generated SignedSource<<9feb91e31a7d54e4b3f7737fb524800b>> */ /** @@ -90,6 +90,8 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc private var perfMonitorV2EnabledCache: Boolean? = null private var preparedTextCacheSizeCache: Double? = null private var preventShadowTreeCommitExhaustionCache: Boolean? = null + private var redBoxV2AndroidCache: Boolean? = null + private var redBoxV2IOSCache: Boolean? = null private var shouldPressibilityUseW3CPointerEventsForHoverCache: Boolean? = null private var shouldTriggerResponderTransferOnScrollAndroidCache: Boolean? = null private var skipActivityIdentityAssertionOnHostPauseCache: Boolean? = null @@ -772,6 +774,26 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc return cached } + override fun redBoxV2Android(): Boolean { + var cached = redBoxV2AndroidCache + if (cached == null) { + cached = currentProvider.redBoxV2Android() + accessedFeatureFlags.add("redBoxV2Android") + redBoxV2AndroidCache = cached + } + return cached + } + + override fun redBoxV2IOS(): Boolean { + var cached = redBoxV2IOSCache + if (cached == null) { + cached = currentProvider.redBoxV2IOS() + accessedFeatureFlags.add("redBoxV2IOS") + redBoxV2IOSCache = cached + } + return cached + } + override fun shouldPressibilityUseW3CPointerEventsForHover(): Boolean { var cached = shouldPressibilityUseW3CPointerEventsForHoverCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt index 415136b6b9c1..440cb2e33041 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<5425bfe01cb2aaecc61b65b8c2f7dad0>> */ /** @@ -155,6 +155,10 @@ public interface ReactNativeFeatureFlagsProvider { @DoNotStrip public fun preventShadowTreeCommitExhaustion(): Boolean + @DoNotStrip public fun redBoxV2Android(): Boolean + + @DoNotStrip public fun redBoxV2IOS(): Boolean + @DoNotStrip public fun shouldPressibilityUseW3CPointerEventsForHover(): Boolean @DoNotStrip public fun shouldTriggerResponderTransferOnScrollAndroid(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp index c13b3e0a783d..e594a269b411 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<986de1b799ad8bc22a323365aef3533b>> */ /** @@ -435,6 +435,18 @@ class ReactNativeFeatureFlagsJavaProvider return method(javaProvider_); } + bool redBoxV2Android() override { + static const auto method = + getReactNativeFeatureFlagsProviderJavaClass()->getMethod("redBoxV2Android"); + return method(javaProvider_); + } + + bool redBoxV2IOS() override { + static const auto method = + getReactNativeFeatureFlagsProviderJavaClass()->getMethod("redBoxV2IOS"); + return method(javaProvider_); + } + bool shouldPressibilityUseW3CPointerEventsForHover() override { static const auto method = getReactNativeFeatureFlagsProviderJavaClass()->getMethod("shouldPressibilityUseW3CPointerEventsForHover"); @@ -895,6 +907,16 @@ bool JReactNativeFeatureFlagsCxxInterop::preventShadowTreeCommitExhaustion( return ReactNativeFeatureFlags::preventShadowTreeCommitExhaustion(); } +bool JReactNativeFeatureFlagsCxxInterop::redBoxV2Android( + facebook::jni::alias_ref /*unused*/) { + return ReactNativeFeatureFlags::redBoxV2Android(); +} + +bool JReactNativeFeatureFlagsCxxInterop::redBoxV2IOS( + facebook::jni::alias_ref /*unused*/) { + return ReactNativeFeatureFlags::redBoxV2IOS(); +} + bool JReactNativeFeatureFlagsCxxInterop::shouldPressibilityUseW3CPointerEventsForHover( facebook::jni::alias_ref /*unused*/) { return ReactNativeFeatureFlags::shouldPressibilityUseW3CPointerEventsForHover(); @@ -1229,6 +1251,12 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() { makeNativeMethod( "preventShadowTreeCommitExhaustion", JReactNativeFeatureFlagsCxxInterop::preventShadowTreeCommitExhaustion), + makeNativeMethod( + "redBoxV2Android", + JReactNativeFeatureFlagsCxxInterop::redBoxV2Android), + makeNativeMethod( + "redBoxV2IOS", + JReactNativeFeatureFlagsCxxInterop::redBoxV2IOS), makeNativeMethod( "shouldPressibilityUseW3CPointerEventsForHover", JReactNativeFeatureFlagsCxxInterop::shouldPressibilityUseW3CPointerEventsForHover), diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h index 6764c07f6ac8..5ec92f849388 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<554bd02cd328ba462d4e703b653d6cdd>> + * @generated SignedSource<> */ /** @@ -228,6 +228,12 @@ class JReactNativeFeatureFlagsCxxInterop static bool preventShadowTreeCommitExhaustion( facebook::jni::alias_ref); + static bool redBoxV2Android( + facebook::jni::alias_ref); + + static bool redBoxV2IOS( + facebook::jni::alias_ref); + static bool shouldPressibilityUseW3CPointerEventsForHover( facebook::jni::alias_ref); diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp index 733c9708c31d..2ad9364942d5 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<20973c91d9d7e791598083c0cee5b934>> + * @generated SignedSource<<9ceab23bb1cb8d569411222993ef9f1e>> */ /** @@ -290,6 +290,14 @@ bool ReactNativeFeatureFlags::preventShadowTreeCommitExhaustion() { return getAccessor().preventShadowTreeCommitExhaustion(); } +bool ReactNativeFeatureFlags::redBoxV2Android() { + return getAccessor().redBoxV2Android(); +} + +bool ReactNativeFeatureFlags::redBoxV2IOS() { + return getAccessor().redBoxV2IOS(); +} + bool ReactNativeFeatureFlags::shouldPressibilityUseW3CPointerEventsForHover() { return getAccessor().shouldPressibilityUseW3CPointerEventsForHover(); } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h index 5bbb0dd5ebc5..6014288cf3f2 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<26b7504c2446d37c6540609a620854cb>> */ /** @@ -369,6 +369,16 @@ class ReactNativeFeatureFlags { */ RN_EXPORT static bool preventShadowTreeCommitExhaustion(); + /** + * Use the redesigned RedBox error overlay on Android, styled to match the LogBox visual language. + */ + RN_EXPORT static bool redBoxV2Android(); + + /** + * Use the redesigned RedBox error overlay on iOS, styled to match the LogBox visual language. + */ + RN_EXPORT static bool redBoxV2IOS(); + /** * Function used to enable / disable Pressibility from using W3C Pointer Events for its hover callbacks */ diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp index 8c89226787ab..38bd360ac2fc 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<3c510c97c279768dbf3bfd5c2b2c1903>> + * @generated SignedSource<<71a8a1e9b86beb41e91532bc07831dba>> */ /** @@ -1217,6 +1217,42 @@ bool ReactNativeFeatureFlagsAccessor::preventShadowTreeCommitExhaustion() { return flagValue.value(); } +bool ReactNativeFeatureFlagsAccessor::redBoxV2Android() { + auto flagValue = redBoxV2Android_.load(); + + if (!flagValue.has_value()) { + // This block is not exclusive but it is not necessary. + // If multiple threads try to initialize the feature flag, we would only + // be accessing the provider multiple times but the end state of this + // instance and the returned flag value would be the same. + + markFlagAsAccessed(66, "redBoxV2Android"); + + flagValue = currentProvider_->redBoxV2Android(); + redBoxV2Android_ = flagValue; + } + + return flagValue.value(); +} + +bool ReactNativeFeatureFlagsAccessor::redBoxV2IOS() { + auto flagValue = redBoxV2IOS_.load(); + + if (!flagValue.has_value()) { + // This block is not exclusive but it is not necessary. + // If multiple threads try to initialize the feature flag, we would only + // be accessing the provider multiple times but the end state of this + // instance and the returned flag value would be the same. + + markFlagAsAccessed(67, "redBoxV2IOS"); + + flagValue = currentProvider_->redBoxV2IOS(); + redBoxV2IOS_ = flagValue; + } + + return flagValue.value(); +} + bool ReactNativeFeatureFlagsAccessor::shouldPressibilityUseW3CPointerEventsForHover() { auto flagValue = shouldPressibilityUseW3CPointerEventsForHover_.load(); @@ -1226,7 +1262,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldPressibilityUseW3CPointerEventsForHo // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(66, "shouldPressibilityUseW3CPointerEventsForHover"); + markFlagAsAccessed(68, "shouldPressibilityUseW3CPointerEventsForHover"); flagValue = currentProvider_->shouldPressibilityUseW3CPointerEventsForHover(); shouldPressibilityUseW3CPointerEventsForHover_ = flagValue; @@ -1244,7 +1280,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldTriggerResponderTransferOnScrollAndr // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(67, "shouldTriggerResponderTransferOnScrollAndroid"); + markFlagAsAccessed(69, "shouldTriggerResponderTransferOnScrollAndroid"); flagValue = currentProvider_->shouldTriggerResponderTransferOnScrollAndroid(); shouldTriggerResponderTransferOnScrollAndroid_ = flagValue; @@ -1262,7 +1298,7 @@ bool ReactNativeFeatureFlagsAccessor::skipActivityIdentityAssertionOnHostPause() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(68, "skipActivityIdentityAssertionOnHostPause"); + markFlagAsAccessed(70, "skipActivityIdentityAssertionOnHostPause"); flagValue = currentProvider_->skipActivityIdentityAssertionOnHostPause(); skipActivityIdentityAssertionOnHostPause_ = flagValue; @@ -1280,7 +1316,7 @@ bool ReactNativeFeatureFlagsAccessor::sweepActiveTouchOnChildNativeGesturesAndro // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(69, "sweepActiveTouchOnChildNativeGesturesAndroid"); + markFlagAsAccessed(71, "sweepActiveTouchOnChildNativeGesturesAndroid"); flagValue = currentProvider_->sweepActiveTouchOnChildNativeGesturesAndroid(); sweepActiveTouchOnChildNativeGesturesAndroid_ = flagValue; @@ -1298,7 +1334,7 @@ bool ReactNativeFeatureFlagsAccessor::traceTurboModulePromiseRejectionsOnAndroid // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(70, "traceTurboModulePromiseRejectionsOnAndroid"); + markFlagAsAccessed(72, "traceTurboModulePromiseRejectionsOnAndroid"); flagValue = currentProvider_->traceTurboModulePromiseRejectionsOnAndroid(); traceTurboModulePromiseRejectionsOnAndroid_ = flagValue; @@ -1316,7 +1352,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommit( // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(71, "updateRuntimeShadowNodeReferencesOnCommit"); + markFlagAsAccessed(73, "updateRuntimeShadowNodeReferencesOnCommit"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommit(); updateRuntimeShadowNodeReferencesOnCommit_ = flagValue; @@ -1334,7 +1370,7 @@ bool ReactNativeFeatureFlagsAccessor::useAlwaysAvailableJSErrorHandling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(72, "useAlwaysAvailableJSErrorHandling"); + markFlagAsAccessed(74, "useAlwaysAvailableJSErrorHandling"); flagValue = currentProvider_->useAlwaysAvailableJSErrorHandling(); useAlwaysAvailableJSErrorHandling_ = flagValue; @@ -1352,7 +1388,7 @@ bool ReactNativeFeatureFlagsAccessor::useFabricInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(73, "useFabricInterop"); + markFlagAsAccessed(75, "useFabricInterop"); flagValue = currentProvider_->useFabricInterop(); useFabricInterop_ = flagValue; @@ -1370,7 +1406,7 @@ bool ReactNativeFeatureFlagsAccessor::useNativeEqualsInNativeReadableArrayAndroi // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(74, "useNativeEqualsInNativeReadableArrayAndroid"); + markFlagAsAccessed(76, "useNativeEqualsInNativeReadableArrayAndroid"); flagValue = currentProvider_->useNativeEqualsInNativeReadableArrayAndroid(); useNativeEqualsInNativeReadableArrayAndroid_ = flagValue; @@ -1388,7 +1424,7 @@ bool ReactNativeFeatureFlagsAccessor::useNativeTransformHelperAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(75, "useNativeTransformHelperAndroid"); + markFlagAsAccessed(77, "useNativeTransformHelperAndroid"); flagValue = currentProvider_->useNativeTransformHelperAndroid(); useNativeTransformHelperAndroid_ = flagValue; @@ -1406,7 +1442,7 @@ bool ReactNativeFeatureFlagsAccessor::useNativeViewConfigsInBridgelessMode() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(76, "useNativeViewConfigsInBridgelessMode"); + markFlagAsAccessed(78, "useNativeViewConfigsInBridgelessMode"); flagValue = currentProvider_->useNativeViewConfigsInBridgelessMode(); useNativeViewConfigsInBridgelessMode_ = flagValue; @@ -1424,7 +1460,7 @@ bool ReactNativeFeatureFlagsAccessor::useOptimizedEventBatchingOnAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(77, "useOptimizedEventBatchingOnAndroid"); + markFlagAsAccessed(79, "useOptimizedEventBatchingOnAndroid"); flagValue = currentProvider_->useOptimizedEventBatchingOnAndroid(); useOptimizedEventBatchingOnAndroid_ = flagValue; @@ -1442,7 +1478,7 @@ bool ReactNativeFeatureFlagsAccessor::useRawPropsJsiValue() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(78, "useRawPropsJsiValue"); + markFlagAsAccessed(80, "useRawPropsJsiValue"); flagValue = currentProvider_->useRawPropsJsiValue(); useRawPropsJsiValue_ = flagValue; @@ -1460,7 +1496,7 @@ bool ReactNativeFeatureFlagsAccessor::useShadowNodeStateOnClone() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(79, "useShadowNodeStateOnClone"); + markFlagAsAccessed(81, "useShadowNodeStateOnClone"); flagValue = currentProvider_->useShadowNodeStateOnClone(); useShadowNodeStateOnClone_ = flagValue; @@ -1478,7 +1514,7 @@ bool ReactNativeFeatureFlagsAccessor::useSharedAnimatedBackend() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(80, "useSharedAnimatedBackend"); + markFlagAsAccessed(82, "useSharedAnimatedBackend"); flagValue = currentProvider_->useSharedAnimatedBackend(); useSharedAnimatedBackend_ = flagValue; @@ -1496,7 +1532,7 @@ bool ReactNativeFeatureFlagsAccessor::useTraitHiddenOnAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(81, "useTraitHiddenOnAndroid"); + markFlagAsAccessed(83, "useTraitHiddenOnAndroid"); flagValue = currentProvider_->useTraitHiddenOnAndroid(); useTraitHiddenOnAndroid_ = flagValue; @@ -1514,7 +1550,7 @@ bool ReactNativeFeatureFlagsAccessor::useTurboModuleInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(82, "useTurboModuleInterop"); + markFlagAsAccessed(84, "useTurboModuleInterop"); flagValue = currentProvider_->useTurboModuleInterop(); useTurboModuleInterop_ = flagValue; @@ -1532,7 +1568,7 @@ bool ReactNativeFeatureFlagsAccessor::useTurboModules() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(83, "useTurboModules"); + markFlagAsAccessed(85, "useTurboModules"); flagValue = currentProvider_->useTurboModules(); useTurboModules_ = flagValue; @@ -1550,7 +1586,7 @@ double ReactNativeFeatureFlagsAccessor::viewCullingOutsetRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(84, "viewCullingOutsetRatio"); + markFlagAsAccessed(86, "viewCullingOutsetRatio"); flagValue = currentProvider_->viewCullingOutsetRatio(); viewCullingOutsetRatio_ = flagValue; @@ -1568,7 +1604,7 @@ double ReactNativeFeatureFlagsAccessor::virtualViewHysteresisRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(85, "virtualViewHysteresisRatio"); + markFlagAsAccessed(87, "virtualViewHysteresisRatio"); flagValue = currentProvider_->virtualViewHysteresisRatio(); virtualViewHysteresisRatio_ = flagValue; @@ -1586,7 +1622,7 @@ double ReactNativeFeatureFlagsAccessor::virtualViewPrerenderRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(86, "virtualViewPrerenderRatio"); + markFlagAsAccessed(88, "virtualViewPrerenderRatio"); flagValue = currentProvider_->virtualViewPrerenderRatio(); virtualViewPrerenderRatio_ = flagValue; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h index 38ba562f5a0f..d25312631085 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<7286fc61200f969c22910f37180ad481>> */ /** @@ -98,6 +98,8 @@ class ReactNativeFeatureFlagsAccessor { bool perfMonitorV2Enabled(); double preparedTextCacheSize(); bool preventShadowTreeCommitExhaustion(); + bool redBoxV2Android(); + bool redBoxV2IOS(); bool shouldPressibilityUseW3CPointerEventsForHover(); bool shouldTriggerResponderTransferOnScrollAndroid(); bool skipActivityIdentityAssertionOnHostPause(); @@ -130,7 +132,7 @@ class ReactNativeFeatureFlagsAccessor { std::unique_ptr currentProvider_; bool wasOverridden_; - std::array, 87> accessedFeatureFlags_; + std::array, 89> accessedFeatureFlags_; std::atomic> commonTestFlag_; std::atomic> cdpInteractionMetricsEnabled_; @@ -198,6 +200,8 @@ class ReactNativeFeatureFlagsAccessor { std::atomic> perfMonitorV2Enabled_; std::atomic> preparedTextCacheSize_; std::atomic> preventShadowTreeCommitExhaustion_; + std::atomic> redBoxV2Android_; + std::atomic> redBoxV2IOS_; std::atomic> shouldPressibilityUseW3CPointerEventsForHover_; std::atomic> shouldTriggerResponderTransferOnScrollAndroid_; std::atomic> skipActivityIdentityAssertionOnHostPause_; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h index b4924ba5601c..9637406a1230 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<0118a824a779b337399cb008d2c40af2>> */ /** @@ -291,6 +291,14 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider { return false; } + bool redBoxV2Android() override { + return false; + } + + bool redBoxV2IOS() override { + return false; + } + bool shouldPressibilityUseW3CPointerEventsForHover() override { return false; } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h index 22046ddd4b1b..7a514048cd27 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<6e8731d9d7642d65b9b707741ef71873>> + * @generated SignedSource<<1434eae86af219c2c989aade2e59968d>> */ /** @@ -639,6 +639,24 @@ class ReactNativeFeatureFlagsDynamicProvider : public ReactNativeFeatureFlagsDef return ReactNativeFeatureFlagsDefaults::preventShadowTreeCommitExhaustion(); } + bool redBoxV2Android() override { + auto value = values_["redBoxV2Android"]; + if (!value.isNull()) { + return value.getBool(); + } + + return ReactNativeFeatureFlagsDefaults::redBoxV2Android(); + } + + bool redBoxV2IOS() override { + auto value = values_["redBoxV2IOS"]; + if (!value.isNull()) { + return value.getBool(); + } + + return ReactNativeFeatureFlagsDefaults::redBoxV2IOS(); + } + bool shouldPressibilityUseW3CPointerEventsForHover() override { auto value = values_["shouldPressibilityUseW3CPointerEventsForHover"]; if (!value.isNull()) { diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h index 8dd58d390fc8..03e94cc2819d 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<886710691ddc524f90129b6921cb2c2a>> + * @generated SignedSource<<990b3d661a81316160046f01bba5e683>> */ /** @@ -91,6 +91,8 @@ class ReactNativeFeatureFlagsProvider { virtual bool perfMonitorV2Enabled() = 0; virtual double preparedTextCacheSize() = 0; virtual bool preventShadowTreeCommitExhaustion() = 0; + virtual bool redBoxV2Android() = 0; + virtual bool redBoxV2IOS() = 0; virtual bool shouldPressibilityUseW3CPointerEventsForHover() = 0; virtual bool shouldTriggerResponderTransferOnScrollAndroid() = 0; virtual bool skipActivityIdentityAssertionOnHostPause() = 0; diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp index de0b91d4a8d8..4775d8414926 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<884b5382bd771ceb400f15ab4069bc75>> + * @generated SignedSource<<1e23ab5dd844fae335eb7bfc2953794a>> */ /** @@ -374,6 +374,16 @@ bool NativeReactNativeFeatureFlags::preventShadowTreeCommitExhaustion( return ReactNativeFeatureFlags::preventShadowTreeCommitExhaustion(); } +bool NativeReactNativeFeatureFlags::redBoxV2Android( + jsi::Runtime& /*runtime*/) { + return ReactNativeFeatureFlags::redBoxV2Android(); +} + +bool NativeReactNativeFeatureFlags::redBoxV2IOS( + jsi::Runtime& /*runtime*/) { + return ReactNativeFeatureFlags::redBoxV2IOS(); +} + bool NativeReactNativeFeatureFlags::shouldPressibilityUseW3CPointerEventsForHover( jsi::Runtime& /*runtime*/) { return ReactNativeFeatureFlags::shouldPressibilityUseW3CPointerEventsForHover(); diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h index d09128160d48..84163536acf5 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<41e6644bf2ceca861bfb1e491c912c1d>> + * @generated SignedSource<<6d3c44622ffc5392e446a19ce48b1e61>> */ /** @@ -168,6 +168,10 @@ class NativeReactNativeFeatureFlags bool preventShadowTreeCommitExhaustion(jsi::Runtime& runtime); + bool redBoxV2Android(jsi::Runtime& runtime); + + bool redBoxV2IOS(jsi::Runtime& runtime); + bool shouldPressibilityUseW3CPointerEventsForHover(jsi::Runtime& runtime); bool shouldTriggerResponderTransferOnScrollAndroid(jsi::Runtime& runtime); diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index b4d2ccb9d132..ce6f9ea037c0 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -751,6 +751,28 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'experimental', }, + redBoxV2Android: { + defaultValue: false, + metadata: { + dateAdded: '2026-03-25', + description: + 'Use the redesigned RedBox error overlay on Android, styled to match the LogBox visual language.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, + redBoxV2IOS: { + defaultValue: false, + metadata: { + dateAdded: '2026-03-25', + description: + 'Use the redesigned RedBox error overlay on iOS, styled to match the LogBox visual language.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, shouldPressibilityUseW3CPointerEventsForHover: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index 69a23d5f9851..cffd6e62d16e 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<075c9fd7af1eab0ffdde75a05daa8cac>> + * @generated SignedSource<<67f332fc91b30e629da8919f82044079>> * @flow strict * @noformat */ @@ -116,6 +116,8 @@ export type ReactNativeFeatureFlags = $ReadOnly<{ perfMonitorV2Enabled: Getter, preparedTextCacheSize: Getter, preventShadowTreeCommitExhaustion: Getter, + redBoxV2Android: Getter, + redBoxV2IOS: Getter, shouldPressibilityUseW3CPointerEventsForHover: Getter, shouldTriggerResponderTransferOnScrollAndroid: Getter, skipActivityIdentityAssertionOnHostPause: Getter, @@ -482,6 +484,14 @@ export const preparedTextCacheSize: Getter = createNativeFlagGetter('pre * Enables a new mechanism in ShadowTree to prevent problems caused by multiple threads trying to commit concurrently. If a thread tries to commit a few times unsuccessfully, it will acquire a lock and try again. */ export const preventShadowTreeCommitExhaustion: Getter = createNativeFlagGetter('preventShadowTreeCommitExhaustion', false); +/** + * Use the redesigned RedBox error overlay on Android, styled to match the LogBox visual language. + */ +export const redBoxV2Android: Getter = createNativeFlagGetter('redBoxV2Android', false); +/** + * Use the redesigned RedBox error overlay on iOS, styled to match the LogBox visual language. + */ +export const redBoxV2IOS: Getter = createNativeFlagGetter('redBoxV2IOS', false); /** * Function used to enable / disable Pressibility from using W3C Pointer Events for its hover callbacks */ diff --git a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js index 9295169694d7..cd5d006a30d3 100644 --- a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<76ee0fb7c942f84965cece244a891d7f>> * @flow strict * @noformat */ @@ -91,6 +91,8 @@ export interface Spec extends TurboModule { +perfMonitorV2Enabled?: () => boolean; +preparedTextCacheSize?: () => number; +preventShadowTreeCommitExhaustion?: () => boolean; + +redBoxV2Android?: () => boolean; + +redBoxV2IOS?: () => boolean; +shouldPressibilityUseW3CPointerEventsForHover?: () => boolean; +shouldTriggerResponderTransferOnScrollAndroid?: () => boolean; +skipActivityIdentityAssertionOnHostPause?: () => boolean; From 94a7b9ca25f970ef685b7050aae605917e33269a Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Tue, 21 Apr 2026 12:54:35 -0700 Subject: [PATCH 02/11] Extract RCTRedBoxController from RCTRedBox.mm (#56509) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/56509 Moves the `RCTRedBoxController` class, previously inlined in `RCTRedBox.mm`, into its own implementation and header files (`RCTRedBoxController.mm`, `RCTRedBoxController+Internal.h`). An upcoming diff will fork these files for the experimental RedBox 2.0. Changelog: [Internal] Reviewed By: christophpurrer Differential Revision: D101484586 fbshipit-source-id: 0b0c80caa790ee5286c48adfce5c22d22bef47b6 --- .../React/CoreModules/RCTRedBox.mm | 441 +---------------- .../RCTRedBoxController+Internal.h | 41 ++ .../React/CoreModules/RCTRedBoxController.mm | 447 ++++++++++++++++++ 3 files changed, 489 insertions(+), 440 deletions(-) create mode 100644 packages/react-native/React/CoreModules/RCTRedBoxController+Internal.h create mode 100644 packages/react-native/React/CoreModules/RCTRedBoxController.mm diff --git a/packages/react-native/React/CoreModules/RCTRedBox.mm b/packages/react-native/React/CoreModules/RCTRedBox.mm index fb057b969214..b216df078262 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox.mm +++ b/packages/react-native/React/CoreModules/RCTRedBox.mm @@ -17,450 +17,11 @@ #import #import -#import - #import "CoreModulesPlugins.h" +#import "RCTRedBoxController+Internal.h" #if RCT_DEV_MENU -@class RCTRedBoxController; - -@interface UIButton (RCTRedBox) - -@property (nonatomic) RCTRedBoxButtonPressHandler rct_handler; - -- (void)rct_addBlock:(RCTRedBoxButtonPressHandler)handler forControlEvents:(UIControlEvents)controlEvents; - -@end - -@implementation UIButton (RCTRedBox) - -- (RCTRedBoxButtonPressHandler)rct_handler -{ - return objc_getAssociatedObject(self, @selector(rct_handler)); -} - -- (void)setRct_handler:(RCTRedBoxButtonPressHandler)rct_handler -{ - objc_setAssociatedObject(self, @selector(rct_handler), rct_handler, OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -- (void)rct_callBlock -{ - if (self.rct_handler) { - self.rct_handler(); - } -} - -- (void)rct_addBlock:(RCTRedBoxButtonPressHandler)handler forControlEvents:(UIControlEvents)controlEvents -{ - self.rct_handler = handler; - [self addTarget:self action:@selector(rct_callBlock) forControlEvents:controlEvents]; -} - -@end - -@protocol RCTRedBoxControllerActionDelegate - -- (void)redBoxController:(RCTRedBoxController *)redBoxController openStackFrameInEditor:(RCTJSStackFrame *)stackFrame; -- (void)reloadFromRedBoxController:(RCTRedBoxController *)redBoxController; -- (void)loadExtraDataViewController; - -@end - -@interface RCTRedBoxController : UIViewController -@property (nonatomic, weak) id actionDelegate; -@end - -@implementation RCTRedBoxController { - UITableView *_stackTraceTableView; - NSString *_lastErrorMessage; - NSArray *_lastStackTrace; - NSArray *_customButtonTitles; - NSArray *_customButtonHandlers; - int _lastErrorCookie; -} - -- (instancetype)initWithCustomButtonTitles:(NSArray *)customButtonTitles - customButtonHandlers:(NSArray *)customButtonHandlers -{ - if (self = [super init]) { - _lastErrorCookie = -1; - _customButtonTitles = customButtonTitles; - _customButtonHandlers = customButtonHandlers; - } - - return self; -} - -- (void)viewDidLoad -{ - [super viewDidLoad]; - self.view.backgroundColor = [UIColor blackColor]; - - const CGFloat buttonHeight = 60; - - CGRect detailsFrame = self.view.bounds; - detailsFrame.size.height -= buttonHeight + (double)[self bottomSafeViewHeight]; - - _stackTraceTableView = [[UITableView alloc] initWithFrame:detailsFrame style:UITableViewStylePlain]; - _stackTraceTableView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - _stackTraceTableView.delegate = self; - _stackTraceTableView.dataSource = self; - _stackTraceTableView.backgroundColor = [UIColor clearColor]; - _stackTraceTableView.separatorColor = [UIColor colorWithWhite:1 alpha:0.3]; - _stackTraceTableView.separatorStyle = UITableViewCellSeparatorStyleNone; - _stackTraceTableView.indicatorStyle = UIScrollViewIndicatorStyleWhite; - [self.view addSubview:_stackTraceTableView]; - -#if TARGET_OS_SIMULATOR || TARGET_OS_MACCATALYST - NSString *reloadText = @"Reload\n(\u2318R)"; - NSString *dismissText = @"Dismiss\n(ESC)"; - NSString *copyText = @"Copy\n(\u2325\u2318C)"; - NSString *extraText = @"Extra Info\n(\u2318E)"; -#else - NSString *reloadText = @"Reload JS"; - NSString *dismissText = @"Dismiss"; - NSString *copyText = @"Copy"; - NSString *extraText = @"Extra Info"; -#endif - - UIButton *dismissButton = [self redBoxButton:dismissText - accessibilityIdentifier:@"redbox-dismiss" - selector:@selector(dismiss) - block:nil]; - UIButton *reloadButton = [self redBoxButton:reloadText - accessibilityIdentifier:@"redbox-reload" - selector:@selector(reload) - block:nil]; - UIButton *copyButton = [self redBoxButton:copyText - accessibilityIdentifier:@"redbox-copy" - selector:@selector(copyStack) - block:nil]; - UIButton *extraButton = [self redBoxButton:extraText - accessibilityIdentifier:@"redbox-extra" - selector:@selector(showExtraDataViewController) - block:nil]; - - [NSLayoutConstraint activateConstraints:@[ - [dismissButton.heightAnchor constraintEqualToConstant:buttonHeight], - [reloadButton.heightAnchor constraintEqualToConstant:buttonHeight], - [copyButton.heightAnchor constraintEqualToConstant:buttonHeight], - [extraButton.heightAnchor constraintEqualToConstant:buttonHeight] - ]]; - - UIStackView *buttonStackView = [[UIStackView alloc] init]; - buttonStackView.translatesAutoresizingMaskIntoConstraints = NO; - buttonStackView.axis = UILayoutConstraintAxisHorizontal; - buttonStackView.distribution = UIStackViewDistributionFillEqually; - buttonStackView.alignment = UIStackViewAlignmentTop; - buttonStackView.backgroundColor = [UIColor colorWithRed:0.1 green:0.1 blue:0.1 alpha:1]; - - [buttonStackView addArrangedSubview:dismissButton]; - [buttonStackView addArrangedSubview:reloadButton]; - [buttonStackView addArrangedSubview:copyButton]; - [buttonStackView addArrangedSubview:extraButton]; - - [self.view addSubview:buttonStackView]; - - [NSLayoutConstraint activateConstraints:@[ - [buttonStackView.heightAnchor constraintEqualToConstant:buttonHeight + [self bottomSafeViewHeight]], - [buttonStackView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], - [buttonStackView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], - [buttonStackView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor] - ]]; - - for (NSUInteger i = 0; i < [_customButtonTitles count]; i++) { - UIButton *button = [self redBoxButton:_customButtonTitles[i] - accessibilityIdentifier:@"" - selector:nil - block:_customButtonHandlers[i]]; - [button.heightAnchor constraintEqualToConstant:buttonHeight].active = YES; - [buttonStackView addArrangedSubview:button]; - } - - UIView *topBorder = [[UIView alloc] init]; - topBorder.translatesAutoresizingMaskIntoConstraints = NO; - topBorder.backgroundColor = [UIColor colorWithRed:0.70 green:0.70 blue:0.70 alpha:1.0]; - [topBorder.heightAnchor constraintEqualToConstant:1].active = YES; - - [self.view addSubview:topBorder]; - - [NSLayoutConstraint activateConstraints:@[ - [topBorder.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], - [topBorder.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], - [topBorder.bottomAnchor constraintEqualToAnchor:buttonStackView.topAnchor], - ]]; -} - -- (UIButton *)redBoxButton:(NSString *)title - accessibilityIdentifier:(NSString *)accessibilityIdentifier - selector:(SEL)selector - block:(RCTRedBoxButtonPressHandler)block -{ - UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom]; - button.autoresizingMask = - UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleRightMargin; - button.accessibilityIdentifier = accessibilityIdentifier; - button.titleLabel.font = [UIFont systemFontOfSize:13]; - button.titleLabel.lineBreakMode = NSLineBreakByWordWrapping; - button.titleLabel.textAlignment = NSTextAlignmentCenter; - button.backgroundColor = [UIColor colorWithRed:0.1 green:0.1 blue:0.1 alpha:1]; - [button setTitle:title forState:UIControlStateNormal]; - [button setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; - [button setTitleColor:[UIColor colorWithWhite:1 alpha:0.5] forState:UIControlStateHighlighted]; - if (selector) { - [button addTarget:self action:selector forControlEvents:UIControlEventTouchUpInside]; - } else if (block) { - [button rct_addBlock:block forControlEvents:UIControlEventTouchUpInside]; - } - return button; -} - -- (NSInteger)bottomSafeViewHeight -{ -#if TARGET_OS_MACCATALYST - return 0; -#else - return RCTKeyWindow().safeAreaInsets.bottom; -#endif -} - -RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder) - -- (NSString *)stripAnsi:(NSString *)text -{ - NSError *error = nil; - NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"\\x1b\\[[0-9;]*m" - options:NSRegularExpressionCaseInsensitive - error:&error]; - return [regex stringByReplacingMatchesInString:text options:0 range:NSMakeRange(0, [text length]) withTemplate:@""]; -} - -- (void)showErrorMessage:(NSString *)message - withStack:(NSArray *)stack - isUpdate:(BOOL)isUpdate - errorCookie:(int)errorCookie -{ - // Remove ANSI color codes from the message - NSString *messageWithoutAnsi = [self stripAnsi:message]; - - BOOL isRootViewControllerPresented = self.presentingViewController != nil; - // Show if this is a new message, or if we're updating the previous message - BOOL isNew = !isRootViewControllerPresented && !isUpdate; - BOOL isUpdateForSameMessage = !isNew && - (isRootViewControllerPresented && isUpdate && - ((errorCookie == -1 && [_lastErrorMessage isEqualToString:messageWithoutAnsi]) || - (errorCookie == _lastErrorCookie))); - if (isNew || isUpdateForSameMessage) { - _lastStackTrace = stack; - // message is displayed using UILabel, which is unable to render text of - // unlimited length, so we truncate it - _lastErrorMessage = [messageWithoutAnsi substringToIndex:MIN((NSUInteger)10000, messageWithoutAnsi.length)]; - _lastErrorCookie = errorCookie; - - [_stackTraceTableView reloadData]; - - if (!isRootViewControllerPresented) { - [_stackTraceTableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0] - atScrollPosition:UITableViewScrollPositionTop - animated:NO]; - [RCTKeyWindow().rootViewController presentViewController:self animated:YES completion:nil]; - } - } -} - -- (void)dismiss -{ - [self dismissViewControllerAnimated:YES completion:nil]; -} - -- (void)reload -{ - if (_actionDelegate != nil) { - [_actionDelegate reloadFromRedBoxController:self]; - } else { - // In bridgeless mode `RCTRedBox` gets deallocated, we need to notify listeners anyway. - RCTTriggerReloadCommandListeners(@"Redbox"); - [self dismiss]; - } -} - -- (void)showExtraDataViewController -{ - [_actionDelegate loadExtraDataViewController]; -} - -- (void)copyStack -{ - NSMutableString *fullStackTrace; - - if (_lastErrorMessage != nil) { - fullStackTrace = [_lastErrorMessage mutableCopy]; - [fullStackTrace appendString:@"\n\n"]; - } else { - fullStackTrace = [NSMutableString string]; - } - - for (RCTJSStackFrame *stackFrame in _lastStackTrace) { - [fullStackTrace appendString:[NSString stringWithFormat:@"%@\n", stackFrame.methodName]]; - if (stackFrame.file) { - [fullStackTrace appendFormat:@" %@\n", [self formatFrameSource:stackFrame]]; - } - } - UIPasteboard *pb = [UIPasteboard generalPasteboard]; - [pb setString:fullStackTrace]; -} - -- (NSString *)formatFrameSource:(RCTJSStackFrame *)stackFrame -{ - NSString *fileName = RCTNilIfNull(stackFrame.file) ? [stackFrame.file lastPathComponent] : @""; - NSString *lineInfo = [NSString stringWithFormat:@"%@:%lld", fileName, (long long)stackFrame.lineNumber]; - - if (stackFrame.column != 0) { - lineInfo = [lineInfo stringByAppendingFormat:@":%lld", (long long)stackFrame.column]; - } - return lineInfo; -} - -#pragma mark - TableView - -- (NSInteger)numberOfSectionsInTableView:(__unused UITableView *)tableView -{ - return 2; -} - -- (NSInteger)tableView:(__unused UITableView *)tableView numberOfRowsInSection:(NSInteger)section -{ - return section == 0 ? 1 : _lastStackTrace.count; -} - -- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath -{ - if (indexPath.section == 0) { - UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"msg-cell"]; - return [self reuseCell:cell forErrorMessage:_lastErrorMessage]; - } - UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell"]; - NSUInteger index = indexPath.row; - RCTJSStackFrame *stackFrame = _lastStackTrace[index]; - return [self reuseCell:cell forStackFrame:stackFrame]; -} - -- (UITableViewCell *)reuseCell:(UITableViewCell *)cell forErrorMessage:(NSString *)message -{ - if (!cell) { - cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"msg-cell"]; - cell.textLabel.accessibilityIdentifier = @"redbox-error"; - cell.textLabel.textColor = [UIColor whiteColor]; - - // Prefer a monofont for formatting messages that were designed - // to be displayed in a terminal. - cell.textLabel.font = [UIFont monospacedSystemFontOfSize:14 weight:UIFontWeightBold]; - - cell.textLabel.lineBreakMode = NSLineBreakByWordWrapping; - cell.textLabel.numberOfLines = 0; - cell.detailTextLabel.textColor = [UIColor whiteColor]; - cell.backgroundColor = [UIColor colorWithRed:0.82 green:0.10 blue:0.15 alpha:1.0]; - cell.selectionStyle = UITableViewCellSelectionStyleNone; - } - - cell.textLabel.text = message; - - return cell; -} - -- (UITableViewCell *)reuseCell:(UITableViewCell *)cell forStackFrame:(RCTJSStackFrame *)stackFrame -{ - if (!cell) { - cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"cell"]; - cell.textLabel.font = [UIFont fontWithName:@"Menlo-Regular" size:14]; - cell.textLabel.lineBreakMode = NSLineBreakByCharWrapping; - cell.textLabel.numberOfLines = 2; - cell.detailTextLabel.textColor = [UIColor colorWithRed:0.70 green:0.70 blue:0.70 alpha:1.0]; - cell.detailTextLabel.font = [UIFont fontWithName:@"Menlo-Regular" size:11]; - cell.detailTextLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; - cell.backgroundColor = [UIColor clearColor]; - cell.selectedBackgroundView = [UIView new]; - cell.selectedBackgroundView.backgroundColor = [UIColor colorWithWhite:0 alpha:0.2]; - } - - cell.textLabel.text = stackFrame.methodName ?: @"(unnamed method)"; - if (stackFrame.file) { - cell.detailTextLabel.text = [self formatFrameSource:stackFrame]; - } else { - cell.detailTextLabel.text = @""; - } - cell.textLabel.textColor = stackFrame.collapse ? [UIColor lightGrayColor] : [UIColor whiteColor]; - cell.detailTextLabel.textColor = stackFrame.collapse ? [UIColor colorWithRed:0.50 green:0.50 blue:0.50 alpha:1.0] - : [UIColor colorWithRed:0.70 green:0.70 blue:0.70 alpha:1.0]; - return cell; -} - -- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath -{ - if (indexPath.section == 0) { - NSMutableParagraphStyle *paragraphStyle = [[NSParagraphStyle defaultParagraphStyle] mutableCopy]; - paragraphStyle.lineBreakMode = NSLineBreakByWordWrapping; - - NSDictionary *attributes = - @{NSFontAttributeName : [UIFont boldSystemFontOfSize:16], NSParagraphStyleAttributeName : paragraphStyle}; - CGRect boundingRect = - [_lastErrorMessage boundingRectWithSize:CGSizeMake(tableView.frame.size.width - 30, CGFLOAT_MAX) - options:NSStringDrawingUsesLineFragmentOrigin - attributes:attributes - context:nil]; - return ceil(boundingRect.size.height) + 40; - } else { - return 50; - } -} - -- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath -{ - if (indexPath.section == 1) { - NSUInteger row = indexPath.row; - RCTJSStackFrame *stackFrame = _lastStackTrace[row]; - [_actionDelegate redBoxController:self openStackFrameInEditor:stackFrame]; - } - [tableView deselectRowAtIndexPath:indexPath animated:YES]; -} - -#pragma mark - Key commands - -- (NSArray *)keyCommands -{ - // NOTE: We could use RCTKeyCommands for this, but since - // we control this window, we can use the standard, non-hacky - // mechanism instead - - return @[ - // Dismiss red box - [UIKeyCommand keyCommandWithInput:UIKeyInputEscape modifierFlags:0 action:@selector(dismiss)], - - // Reload - [UIKeyCommand keyCommandWithInput:@"r" modifierFlags:UIKeyModifierCommand action:@selector(reload)], - - // Copy = Cmd-Option C since Cmd-C in the simulator copies the pasteboard from - // the simulator to the desktop pasteboard. - [UIKeyCommand keyCommandWithInput:@"c" - modifierFlags:UIKeyModifierCommand | UIKeyModifierAlternate - action:@selector(copyStack)], - - // Extra data - [UIKeyCommand keyCommandWithInput:@"e" - modifierFlags:UIKeyModifierCommand - action:@selector(showExtraDataViewController)] - ]; -} - -- (BOOL)canBecomeFirstResponder -{ - return YES; -} - -@end - @interface RCTRedBox () < RCTInvalidating, RCTRedBoxControllerActionDelegate, diff --git a/packages/react-native/React/CoreModules/RCTRedBoxController+Internal.h b/packages/react-native/React/CoreModules/RCTRedBoxController+Internal.h new file mode 100644 index 000000000000..c8e333c49dea --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTRedBoxController+Internal.h @@ -0,0 +1,41 @@ +/* + * 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. + */ + +#import + +#import "RCTRedBox.h" + +#if RCT_DEV_MENU + +@class RCTJSStackFrame; +@class RCTRedBoxController; + +@protocol RCTRedBoxControllerActionDelegate + +- (void)redBoxController:(RCTRedBoxController *)redBoxController openStackFrameInEditor:(RCTJSStackFrame *)stackFrame; +- (void)reloadFromRedBoxController:(RCTRedBoxController *)redBoxController; +- (void)loadExtraDataViewController; + +@end + +@interface RCTRedBoxController : UIViewController + +@property (nonatomic, weak) id actionDelegate; + +- (instancetype)initWithCustomButtonTitles:(NSArray *)customButtonTitles + customButtonHandlers:(NSArray *)customButtonHandlers; + +- (void)showErrorMessage:(NSString *)message + withStack:(NSArray *)stack + isUpdate:(BOOL)isUpdate + errorCookie:(int)errorCookie; + +- (void)dismiss; + +@end + +#endif diff --git a/packages/react-native/React/CoreModules/RCTRedBoxController.mm b/packages/react-native/React/CoreModules/RCTRedBoxController.mm new file mode 100644 index 000000000000..8278444f3a6b --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTRedBoxController.mm @@ -0,0 +1,447 @@ +/* + * 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. + */ + +#import "RCTRedBoxController+Internal.h" + +#import +#import +#import +#import + +#import + +#if RCT_DEV_MENU + +@interface UIButton (RCTRedBox) + +@property (nonatomic) RCTRedBoxButtonPressHandler rct_handler; + +- (void)rct_addBlock:(RCTRedBoxButtonPressHandler)handler forControlEvents:(UIControlEvents)controlEvents; + +@end + +@implementation UIButton (RCTRedBox) + +- (RCTRedBoxButtonPressHandler)rct_handler +{ + return objc_getAssociatedObject(self, @selector(rct_handler)); +} + +- (void)setRct_handler:(RCTRedBoxButtonPressHandler)rct_handler +{ + objc_setAssociatedObject(self, @selector(rct_handler), rct_handler, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +- (void)rct_callBlock +{ + if (self.rct_handler) { + self.rct_handler(); + } +} + +- (void)rct_addBlock:(RCTRedBoxButtonPressHandler)handler forControlEvents:(UIControlEvents)controlEvents +{ + self.rct_handler = handler; + [self addTarget:self action:@selector(rct_callBlock) forControlEvents:controlEvents]; +} + +@end + +@implementation RCTRedBoxController { + UITableView *_stackTraceTableView; + NSString *_lastErrorMessage; + NSArray *_lastStackTrace; + NSArray *_customButtonTitles; + NSArray *_customButtonHandlers; + int _lastErrorCookie; +} + +- (instancetype)initWithCustomButtonTitles:(NSArray *)customButtonTitles + customButtonHandlers:(NSArray *)customButtonHandlers +{ + if (self = [super init]) { + _lastErrorCookie = -1; + _customButtonTitles = customButtonTitles; + _customButtonHandlers = customButtonHandlers; + } + + return self; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + self.view.backgroundColor = [UIColor blackColor]; + + const CGFloat buttonHeight = 60; + + CGRect detailsFrame = self.view.bounds; + detailsFrame.size.height -= buttonHeight + (double)[self bottomSafeViewHeight]; + + _stackTraceTableView = [[UITableView alloc] initWithFrame:detailsFrame style:UITableViewStylePlain]; + _stackTraceTableView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + _stackTraceTableView.delegate = self; + _stackTraceTableView.dataSource = self; + _stackTraceTableView.backgroundColor = [UIColor clearColor]; +#if !TARGET_OS_TV + _stackTraceTableView.separatorColor = [UIColor colorWithWhite:1 alpha:0.3]; + _stackTraceTableView.separatorStyle = UITableViewCellSeparatorStyleNone; +#endif + _stackTraceTableView.indicatorStyle = UIScrollViewIndicatorStyleWhite; + [self.view addSubview:_stackTraceTableView]; + +#if TARGET_OS_SIMULATOR || TARGET_OS_MACCATALYST + NSString *reloadText = @"Reload\n(\u2318R)"; + NSString *dismissText = @"Dismiss\n(ESC)"; + NSString *copyText = @"Copy\n(\u2325\u2318C)"; + NSString *extraText = @"Extra Info\n(\u2318E)"; +#else + NSString *reloadText = @"Reload JS"; + NSString *dismissText = @"Dismiss"; + NSString *copyText = @"Copy"; + NSString *extraText = @"Extra Info"; +#endif + + UIButton *dismissButton = [self redBoxButton:dismissText + accessibilityIdentifier:@"redbox-dismiss" + selector:@selector(dismiss) + block:nil]; + UIButton *reloadButton = [self redBoxButton:reloadText + accessibilityIdentifier:@"redbox-reload" + selector:@selector(reload) + block:nil]; + UIButton *copyButton = [self redBoxButton:copyText + accessibilityIdentifier:@"redbox-copy" + selector:@selector(copyStack) + block:nil]; + UIButton *extraButton = [self redBoxButton:extraText + accessibilityIdentifier:@"redbox-extra" + selector:@selector(showExtraDataViewController) + block:nil]; + + [NSLayoutConstraint activateConstraints:@[ + [dismissButton.heightAnchor constraintEqualToConstant:buttonHeight], + [reloadButton.heightAnchor constraintEqualToConstant:buttonHeight], + [copyButton.heightAnchor constraintEqualToConstant:buttonHeight], + [extraButton.heightAnchor constraintEqualToConstant:buttonHeight] + ]]; + + UIStackView *buttonStackView = [[UIStackView alloc] init]; + buttonStackView.translatesAutoresizingMaskIntoConstraints = NO; + buttonStackView.axis = UILayoutConstraintAxisHorizontal; + buttonStackView.distribution = UIStackViewDistributionFillEqually; + buttonStackView.alignment = UIStackViewAlignmentTop; + buttonStackView.backgroundColor = [UIColor colorWithRed:0.1 green:0.1 blue:0.1 alpha:1]; + + [buttonStackView addArrangedSubview:dismissButton]; + [buttonStackView addArrangedSubview:reloadButton]; + [buttonStackView addArrangedSubview:copyButton]; + [buttonStackView addArrangedSubview:extraButton]; + + [self.view addSubview:buttonStackView]; + + [NSLayoutConstraint activateConstraints:@[ + [buttonStackView.heightAnchor constraintEqualToConstant:buttonHeight + [self bottomSafeViewHeight]], + [buttonStackView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [buttonStackView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [buttonStackView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor] + ]]; + + for (NSUInteger i = 0; i < [_customButtonTitles count]; i++) { + UIButton *button = [self redBoxButton:_customButtonTitles[i] + accessibilityIdentifier:@"" + selector:nil + block:_customButtonHandlers[i]]; + [button.heightAnchor constraintEqualToConstant:buttonHeight].active = YES; + [buttonStackView addArrangedSubview:button]; + } + + UIView *topBorder = [[UIView alloc] init]; + topBorder.translatesAutoresizingMaskIntoConstraints = NO; + topBorder.backgroundColor = [UIColor colorWithRed:0.70 green:0.70 blue:0.70 alpha:1.0]; + [topBorder.heightAnchor constraintEqualToConstant:1].active = YES; + + [self.view addSubview:topBorder]; + + [NSLayoutConstraint activateConstraints:@[ + [topBorder.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [topBorder.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [topBorder.bottomAnchor constraintEqualToAnchor:buttonStackView.topAnchor], + ]]; +} + +- (UIButton *)redBoxButton:(NSString *)title + accessibilityIdentifier:(NSString *)accessibilityIdentifier + selector:(SEL)selector + block:(RCTRedBoxButtonPressHandler)block +{ + UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom]; + button.autoresizingMask = + UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleRightMargin; + button.accessibilityIdentifier = accessibilityIdentifier; + button.titleLabel.font = [UIFont systemFontOfSize:13]; + button.titleLabel.lineBreakMode = NSLineBreakByWordWrapping; + button.titleLabel.textAlignment = NSTextAlignmentCenter; + button.backgroundColor = [UIColor colorWithRed:0.1 green:0.1 blue:0.1 alpha:1]; + [button setTitle:title forState:UIControlStateNormal]; + [button setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; + [button setTitleColor:[UIColor colorWithWhite:1 alpha:0.5] forState:UIControlStateHighlighted]; + if (selector) { + [button addTarget:self action:selector forControlEvents:UIControlEventTouchUpInside]; + } else if (block) { + [button rct_addBlock:block forControlEvents:UIControlEventTouchUpInside]; + } + return button; +} + +- (NSInteger)bottomSafeViewHeight +{ +#if TARGET_OS_MACCATALYST + return 0; +#else + return RCTKeyWindow().safeAreaInsets.bottom; +#endif +} + +RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder) + +- (NSString *)stripAnsi:(NSString *)text +{ + NSError *error = nil; + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"\\x1b\\[[0-9;]*m" + options:NSRegularExpressionCaseInsensitive + error:&error]; + return [regex stringByReplacingMatchesInString:text options:0 range:NSMakeRange(0, [text length]) withTemplate:@""]; +} + +- (void)showErrorMessage:(NSString *)message + withStack:(NSArray *)stack + isUpdate:(BOOL)isUpdate + errorCookie:(int)errorCookie +{ + // Remove ANSI color codes from the message + NSString *messageWithoutAnsi = [self stripAnsi:message]; + + BOOL isRootViewControllerPresented = self.presentingViewController != nil; + // Show if this is a new message, or if we're updating the previous message + BOOL isNew = !isRootViewControllerPresented && !isUpdate; + BOOL isUpdateForSameMessage = !isNew && + (isRootViewControllerPresented && isUpdate && + ((errorCookie == -1 && [_lastErrorMessage isEqualToString:messageWithoutAnsi]) || + (errorCookie == _lastErrorCookie))); + if (isNew || isUpdateForSameMessage) { + _lastStackTrace = stack; + // message is displayed using UILabel, which is unable to render text of + // unlimited length, so we truncate it + _lastErrorMessage = [messageWithoutAnsi substringToIndex:MIN((NSUInteger)10000, messageWithoutAnsi.length)]; + _lastErrorCookie = errorCookie; + + [_stackTraceTableView reloadData]; + + if (!isRootViewControllerPresented) { + [_stackTraceTableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0] + atScrollPosition:UITableViewScrollPositionTop + animated:NO]; + [RCTKeyWindow().rootViewController presentViewController:self animated:YES completion:nil]; + } + } +} + +- (void)dismiss +{ + [self dismissViewControllerAnimated:YES completion:nil]; +} + +- (void)reload +{ + if (_actionDelegate != nil) { + [_actionDelegate reloadFromRedBoxController:self]; + } else { + // In bridgeless mode `RCTRedBox` gets deallocated, we need to notify listeners anyway. + RCTTriggerReloadCommandListeners(@"Redbox"); + [self dismiss]; + } +} + +- (void)showExtraDataViewController +{ + [_actionDelegate loadExtraDataViewController]; +} + +- (void)copyStack +{ + NSMutableString *fullStackTrace; + + if (_lastErrorMessage != nil) { + fullStackTrace = [_lastErrorMessage mutableCopy]; + [fullStackTrace appendString:@"\n\n"]; + } else { + fullStackTrace = [NSMutableString string]; + } + + for (RCTJSStackFrame *stackFrame in _lastStackTrace) { + [fullStackTrace appendString:[NSString stringWithFormat:@"%@\n", stackFrame.methodName]]; + if (stackFrame.file) { + [fullStackTrace appendFormat:@" %@\n", [self formatFrameSource:stackFrame]]; + } + } +#if !TARGET_OS_TV + UIPasteboard *pb = [UIPasteboard generalPasteboard]; + [pb setString:fullStackTrace]; +#endif +} + +- (NSString *)formatFrameSource:(RCTJSStackFrame *)stackFrame +{ + NSString *fileName = RCTNilIfNull(stackFrame.file) ? [stackFrame.file lastPathComponent] : @""; + NSString *lineInfo = [NSString stringWithFormat:@"%@:%lld", fileName, (long long)stackFrame.lineNumber]; + + if (stackFrame.column != 0) { + lineInfo = [lineInfo stringByAppendingFormat:@":%lld", (long long)stackFrame.column]; + } + return lineInfo; +} + +#pragma mark - TableView + +- (NSInteger)numberOfSectionsInTableView:(__unused UITableView *)tableView +{ + return 2; +} + +- (NSInteger)tableView:(__unused UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + return section == 0 ? 1 : _lastStackTrace.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath.section == 0) { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"msg-cell"]; + return [self reuseCell:cell forErrorMessage:_lastErrorMessage]; + } + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell"]; + NSUInteger index = indexPath.row; + RCTJSStackFrame *stackFrame = _lastStackTrace[index]; + return [self reuseCell:cell forStackFrame:stackFrame]; +} + +- (UITableViewCell *)reuseCell:(UITableViewCell *)cell forErrorMessage:(NSString *)message +{ + if (!cell) { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"msg-cell"]; + cell.textLabel.accessibilityIdentifier = @"redbox-error"; + cell.textLabel.textColor = [UIColor whiteColor]; + + // Prefer a monofont for formatting messages that were designed + // to be displayed in a terminal. + cell.textLabel.font = [UIFont monospacedSystemFontOfSize:14 weight:UIFontWeightBold]; + + cell.textLabel.lineBreakMode = NSLineBreakByWordWrapping; + cell.textLabel.numberOfLines = 0; + cell.detailTextLabel.textColor = [UIColor whiteColor]; + cell.backgroundColor = [UIColor colorWithRed:0.82 green:0.10 blue:0.15 alpha:1.0]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + } + + cell.textLabel.text = message; + + return cell; +} + +- (UITableViewCell *)reuseCell:(UITableViewCell *)cell forStackFrame:(RCTJSStackFrame *)stackFrame +{ + if (!cell) { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"cell"]; + cell.textLabel.font = [UIFont fontWithName:@"Menlo-Regular" size:14]; + cell.textLabel.lineBreakMode = NSLineBreakByCharWrapping; + cell.textLabel.numberOfLines = 2; + cell.detailTextLabel.textColor = [UIColor colorWithRed:0.70 green:0.70 blue:0.70 alpha:1.0]; + cell.detailTextLabel.font = [UIFont fontWithName:@"Menlo-Regular" size:11]; + cell.detailTextLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; + cell.backgroundColor = [UIColor clearColor]; + cell.selectedBackgroundView = [UIView new]; + cell.selectedBackgroundView.backgroundColor = [UIColor colorWithWhite:0 alpha:0.2]; + } + + cell.textLabel.text = stackFrame.methodName ?: @"(unnamed method)"; + if (stackFrame.file) { + cell.detailTextLabel.text = [self formatFrameSource:stackFrame]; + } else { + cell.detailTextLabel.text = @""; + } + cell.textLabel.textColor = stackFrame.collapse ? [UIColor lightGrayColor] : [UIColor whiteColor]; + cell.detailTextLabel.textColor = stackFrame.collapse ? [UIColor colorWithRed:0.50 green:0.50 blue:0.50 alpha:1.0] + : [UIColor colorWithRed:0.70 green:0.70 blue:0.70 alpha:1.0]; + return cell; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath.section == 0) { + NSMutableParagraphStyle *paragraphStyle = [[NSParagraphStyle defaultParagraphStyle] mutableCopy]; + paragraphStyle.lineBreakMode = NSLineBreakByWordWrapping; + + NSDictionary *attributes = + @{NSFontAttributeName : [UIFont boldSystemFontOfSize:16], NSParagraphStyleAttributeName : paragraphStyle}; + CGRect boundingRect = + [_lastErrorMessage boundingRectWithSize:CGSizeMake(tableView.frame.size.width - 30, CGFLOAT_MAX) + options:NSStringDrawingUsesLineFragmentOrigin + attributes:attributes + context:nil]; + return ceil(boundingRect.size.height) + 40; + } else { + return 50; + } +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath.section == 1) { + NSUInteger row = indexPath.row; + RCTJSStackFrame *stackFrame = _lastStackTrace[row]; + [_actionDelegate redBoxController:self openStackFrameInEditor:stackFrame]; + } + [tableView deselectRowAtIndexPath:indexPath animated:YES]; +} + +#pragma mark - Key commands + +- (NSArray *)keyCommands +{ + // NOTE: We could use RCTKeyCommands for this, but since + // we control this window, we can use the standard, non-hacky + // mechanism instead + + return @[ + // Dismiss red box + [UIKeyCommand keyCommandWithInput:UIKeyInputEscape modifierFlags:0 action:@selector(dismiss)], + + // Reload + [UIKeyCommand keyCommandWithInput:@"r" modifierFlags:UIKeyModifierCommand action:@selector(reload)], + + // Copy = Cmd-Option C since Cmd-C in the simulator copies the pasteboard from + // the simulator to the desktop pasteboard. + [UIKeyCommand keyCommandWithInput:@"c" + modifierFlags:UIKeyModifierCommand | UIKeyModifierAlternate + action:@selector(copyStack)], + + // Extra data + [UIKeyCommand keyCommandWithInput:@"e" + modifierFlags:UIKeyModifierCommand + action:@selector(showExtraDataViewController)] + ]; +} + +- (BOOL)canBecomeFirstResponder +{ + return YES; +} + +@end + +#endif From 2bf12aa0ea05d5df6795dfd2013dde6539b1f881 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 22 Apr 2026 07:56:36 -0700 Subject: [PATCH 03/11] Add LogBox-styled error overlay (#56550) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/56550 Replace the legacy red-on-black RedBox design with a LogBox-inspired design for RedBox 2.0: charcoal background, salmon header bar, structured call stack, 3-button footer, full-screen view with no animated transition. Gated behind `redBoxV2IOS`. This diff is just for the low-hanging fruit - setting up the split implementation and borrowing the broad visual style of LogBox. Further up this stack we will port more functionality and improve on this baseline. Changelog: [Internal] Reviewed By: cipolleschi Differential Revision: D98115368 fbshipit-source-id: 7c14137c94c812f42b6af0c376762e068cfcdaaa --- packages/react-native/Package.swift | 2 +- .../React/CoreModules/RCTRedBox+Internal.h | 36 ++ .../React/CoreModules/RCTRedBox.mm | 30 +- .../RCTRedBox2Controller+Internal.h | 31 ++ .../React/CoreModules/RCTRedBox2Controller.mm | 498 ++++++++++++++++++ .../RCTRedBoxController+Internal.h | 14 +- .../CoreModules/React-CoreModules.podspec | 1 + 7 files changed, 589 insertions(+), 23 deletions(-) create mode 100644 packages/react-native/React/CoreModules/RCTRedBox+Internal.h create mode 100644 packages/react-native/React/CoreModules/RCTRedBox2Controller+Internal.h create mode 100644 packages/react-native/React/CoreModules/RCTRedBox2Controller.mm diff --git a/packages/react-native/Package.swift b/packages/react-native/Package.swift index d9f6481c8d34..aa2245923e5d 100644 --- a/packages/react-native/Package.swift +++ b/packages/react-native/Package.swift @@ -380,7 +380,7 @@ let reactCoreModules = RNTarget( name: .reactCoreModules, path: "React/CoreModules", excludedPaths: ["PlatformStubs/RCTStatusBarManager.mm"], - dependencies: [.reactNativeDependencies, .jsi, .yoga, .reactTurboModuleCore] + dependencies: [.reactNativeDependencies, .jsi, .yoga, .reactTurboModuleCore, .reactFeatureFlags] ) /// React-runtimeCore.podspec diff --git a/packages/react-native/React/CoreModules/RCTRedBox+Internal.h b/packages/react-native/React/CoreModules/RCTRedBox+Internal.h new file mode 100644 index 000000000000..4e68766b39ee --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTRedBox+Internal.h @@ -0,0 +1,36 @@ +/* + * 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. + */ + +#import +#import + +#if RCT_DEV_MENU + +@class RCTJSStackFrame; + +@protocol RCTRedBoxControllerActionDelegate + +- (void)redBoxController:(UIViewController *)redBoxController openStackFrameInEditor:(RCTJSStackFrame *)stackFrame; +- (void)reloadFromRedBoxController:(UIViewController *)redBoxController; +- (void)loadExtraDataViewController; + +@end + +@protocol RCTRedBoxControlling + +@property (nonatomic, weak) id actionDelegate; + +- (void)showErrorMessage:(NSString *)message + withStack:(NSArray *)stack + isUpdate:(BOOL)isUpdate + errorCookie:(int)errorCookie; + +- (void)dismiss; + +@end + +#endif diff --git a/packages/react-native/React/CoreModules/RCTRedBox.mm b/packages/react-native/React/CoreModules/RCTRedBox.mm index b216df078262..f0630767607b 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox.mm +++ b/packages/react-native/React/CoreModules/RCTRedBox.mm @@ -16,8 +16,11 @@ #import #import #import +#import #import "CoreModulesPlugins.h" +#import "RCTRedBox+Internal.h" +#import "RCTRedBox2Controller+Internal.h" #import "RCTRedBoxController+Internal.h" #if RCT_DEV_MENU @@ -30,7 +33,7 @@ @interface RCTRedBox () < @end @implementation RCTRedBox { - RCTRedBoxController *_controller; + id _controller; NSMutableArray> *_errorCustomizers; RCTRedBoxExtraDataViewController *_extraDataViewController; NSMutableArray *_customButtonTitles; @@ -178,14 +181,20 @@ - (void)showErrorMessage:(NSString *)message [[self->_moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"collectRedBoxExtraData" body:nil]; #pragma clang diagnostic pop - if (!self->_controller) { - self->_controller = [[RCTRedBoxController alloc] initWithCustomButtonTitles:self->_customButtonTitles - customButtonHandlers:self->_customButtonHandlers]; - self->_controller.actionDelegate = self; - } RCTErrorInfo *errorInfo = [[RCTErrorInfo alloc] initWithErrorMessage:message stack:stack]; errorInfo = [self _customizeError:errorInfo]; + + if (self->_controller == nullptr) { + if (facebook::react::ReactNativeFeatureFlags::redBoxV2IOS()) { + self->_controller = [[RCTRedBox2Controller alloc] initWithCustomButtonTitles:self->_customButtonTitles + customButtonHandlers:self->_customButtonHandlers]; + } else { + self->_controller = [[RCTRedBoxController alloc] initWithCustomButtonTitles:self->_customButtonTitles + customButtonHandlers:self->_customButtonHandlers]; + } + self->_controller.actionDelegate = self; + } [self->_controller showErrorMessage:errorInfo.errorMessage withStack:errorInfo.stack isUpdate:isUpdate @@ -196,9 +205,10 @@ - (void)showErrorMessage:(NSString *)message - (void)loadExtraDataViewController { dispatch_async(dispatch_get_main_queue(), ^{ + UIViewController *controller = static_cast(self->_controller); // Make sure the CMD+E shortcut doesn't call this twice - if (self->_extraDataViewController != nil && ![self->_controller presentedViewController]) { - [self->_controller presentViewController:self->_extraDataViewController animated:YES completion:nil]; + if (self->_extraDataViewController != nil && ([controller presentedViewController] == nullptr)) { + [controller presentViewController:self->_extraDataViewController animated:YES completion:nil]; } }); } @@ -220,7 +230,7 @@ - (void)invalidate [self dismiss]; } -- (void)redBoxController:(__unused RCTRedBoxController *)redBoxController +- (void)redBoxController:(__unused UIViewController *)redBoxController openStackFrameInEditor:(RCTJSStackFrame *)stackFrame { NSURL *const bundleURL = _overrideBundleURL ?: _bundleManager.bundleURL; @@ -247,7 +257,7 @@ - (void)reload [self reloadFromRedBoxController:nil]; } -- (void)reloadFromRedBoxController:(__unused RCTRedBoxController *)redBoxController +- (void)reloadFromRedBoxController:(__unused UIViewController *)redBoxController { if (_overrideReloadAction) { _overrideReloadAction(); diff --git a/packages/react-native/React/CoreModules/RCTRedBox2Controller+Internal.h b/packages/react-native/React/CoreModules/RCTRedBox2Controller+Internal.h new file mode 100644 index 000000000000..0922cd95d13c --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTRedBox2Controller+Internal.h @@ -0,0 +1,31 @@ +/* + * 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. + */ + +#import + +#import "RCTRedBox+Internal.h" + +#if RCT_DEV_MENU + +using RCTRedBox2ButtonPressHandler = void (^)(void); + +@interface RCTRedBox2Controller : UIViewController + +@property (nonatomic, weak) id actionDelegate; + +- (instancetype)initWithCustomButtonTitles:(NSArray *)customButtonTitles + customButtonHandlers:(NSArray *)customButtonHandlers; + +- (void)showErrorMessage:(NSString *)message + withStack:(NSArray *)stack + isUpdate:(BOOL)isUpdate + errorCookie:(int)errorCookie; + +- (void)dismiss; +@end + +#endif diff --git a/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm b/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm new file mode 100644 index 000000000000..8913d98d7530 --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm @@ -0,0 +1,498 @@ +/* + * 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. + */ + +#import "RCTRedBox2Controller+Internal.h" + +#import +#import +#import +#import + +#if RCT_DEV_MENU + +#pragma mark - RCTRedBox2Controller + +// Color Palette (matching LogBoxStyle.js) +static UIColor *RCTRedBox2BackgroundColor() +{ + return [UIColor colorWithRed:51.0 / 255 green:51.0 / 255 blue:51.0 / 255 alpha:1.0]; +} + +static UIColor *RCTRedBox2ErrorColor() +{ + return [UIColor colorWithRed:243.0 / 255 green:83.0 / 255 blue:105.0 / 255 alpha:1.0]; +} + +static UIColor *RCTRedBox2TextColor(CGFloat opacity) +{ + return [UIColor colorWithWhite:1.0 alpha:opacity]; +} + +@implementation RCTRedBox2Controller { + UITableView *_stackTraceTableView; + UILabel *_headerTitleLabel; + UILabel *_errorCategoryLabel; + NSString *_lastErrorMessage; + NSArray *_lastStackTrace; + NSArray *_customButtonTitles; + NSArray *_customButtonHandlers; + int _lastErrorCookie; +} + +- (instancetype)initWithCustomButtonTitles:(NSArray *)customButtonTitles + customButtonHandlers:(NSArray *)customButtonHandlers +{ + self = [super init]; + if (self != nullptr) { + _lastErrorCookie = -1; + _customButtonTitles = customButtonTitles; + _customButtonHandlers = customButtonHandlers; + self.modalPresentationStyle = UIModalPresentationFullScreen; + } + return self; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + self.view.backgroundColor = RCTRedBox2BackgroundColor(); + + // Header bar (adds itself to self.view) + UIView *headerBar = [self createHeaderBar]; + + // Footer button bar + UIView *footerBar = [self createFooterBar]; + + // Stack trace table + _stackTraceTableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain]; + _stackTraceTableView.translatesAutoresizingMaskIntoConstraints = NO; + _stackTraceTableView.delegate = self; + _stackTraceTableView.dataSource = self; + _stackTraceTableView.backgroundColor = [UIColor clearColor]; + _stackTraceTableView.separatorStyle = UITableViewCellSeparatorStyleNone; + _stackTraceTableView.indicatorStyle = UIScrollViewIndicatorStyleWhite; + _stackTraceTableView.bounces = NO; + [self.view addSubview:_stackTraceTableView]; + + [NSLayoutConstraint activateConstraints:@[ + [_stackTraceTableView.topAnchor constraintEqualToAnchor:headerBar.bottomAnchor], + [_stackTraceTableView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [_stackTraceTableView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [_stackTraceTableView.bottomAnchor constraintEqualToAnchor:footerBar.topAnchor], + ]]; +} + +#pragma mark - Header Bar + +- (UIView *)createHeaderBar +{ + UIView *headerContainer = [[UIView alloc] init]; + headerContainer.translatesAutoresizingMaskIntoConstraints = NO; + headerContainer.backgroundColor = RCTRedBox2ErrorColor(); + + _headerTitleLabel = [[UILabel alloc] init]; + _headerTitleLabel.translatesAutoresizingMaskIntoConstraints = NO; + _headerTitleLabel.textColor = [UIColor whiteColor]; + _headerTitleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold]; + _headerTitleLabel.textAlignment = NSTextAlignmentCenter; + [headerContainer addSubview:_headerTitleLabel]; + + [self.view addSubview:headerContainer]; + + [NSLayoutConstraint activateConstraints:@[ + [headerContainer.topAnchor constraintEqualToAnchor:self.view.topAnchor], + [headerContainer.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [headerContainer.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + + [_headerTitleLabel.leadingAnchor constraintEqualToAnchor:headerContainer.leadingAnchor constant:12], + [_headerTitleLabel.trailingAnchor constraintEqualToAnchor:headerContainer.trailingAnchor constant:-12], + [_headerTitleLabel.bottomAnchor constraintEqualToAnchor:headerContainer.bottomAnchor constant:-12], + [_headerTitleLabel.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor constant:12], + ]]; + + return headerContainer; +} + +#pragma mark - Footer Bar + +- (UIView *)createFooterBar +{ + const CGFloat buttonHeight = 48; + + NSString *reloadText = @"Reload"; + NSString *dismissText = @"Dismiss"; + NSString *copyText = @"Copy"; + + UIButton *dismissButton = [self footerButton:dismissText + accessibilityIdentifier:@"redbox-dismiss" + selector:@selector(dismiss)]; + UIButton *reloadButton = [self footerButton:reloadText + accessibilityIdentifier:@"redbox-reload" + selector:@selector(reload)]; + UIButton *copyButton = [self footerButton:copyText + accessibilityIdentifier:@"redbox-copy" + selector:@selector(copyStack)]; + + UIStackView *buttonStackView = [[UIStackView alloc] init]; + buttonStackView.translatesAutoresizingMaskIntoConstraints = NO; + buttonStackView.axis = UILayoutConstraintAxisHorizontal; + buttonStackView.distribution = UIStackViewDistributionFillEqually; + buttonStackView.alignment = UIStackViewAlignmentTop; + buttonStackView.backgroundColor = RCTRedBox2BackgroundColor(); + + [buttonStackView addArrangedSubview:dismissButton]; + [buttonStackView addArrangedSubview:reloadButton]; + [buttonStackView addArrangedSubview:copyButton]; + + for (NSUInteger i = 0; i < [_customButtonTitles count]; i++) { + UIButton *button = [self footerButton:_customButtonTitles[i] + accessibilityIdentifier:@"" + handler:_customButtonHandlers[i]]; + [buttonStackView addArrangedSubview:button]; + } + + // Shadow layer above footer + buttonStackView.layer.shadowColor = [UIColor blackColor].CGColor; + buttonStackView.layer.shadowOffset = CGSizeMake(0, -2); + buttonStackView.layer.shadowRadius = 2; + buttonStackView.layer.shadowOpacity = 0.5; + + [self.view addSubview:buttonStackView]; + + CGFloat bottomInset = [self bottomSafeViewHeight]; + + [NSLayoutConstraint activateConstraints:@[ + [buttonStackView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [buttonStackView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [buttonStackView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + [buttonStackView.heightAnchor constraintEqualToConstant:buttonHeight + bottomInset], + ]]; + + for (UIButton *btn in buttonStackView.arrangedSubviews) { + [btn.heightAnchor constraintEqualToConstant:buttonHeight].active = YES; + } + + return buttonStackView; +} + +- (UIButton *)styledButton:(NSString *)title accessibilityIdentifier:(NSString *)accessibilityIdentifier +{ + UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom]; + button.accessibilityIdentifier = accessibilityIdentifier; + button.titleLabel.font = [UIFont systemFontOfSize:14]; + button.titleLabel.textAlignment = NSTextAlignmentCenter; + button.backgroundColor = RCTRedBox2BackgroundColor(); + [button setTitle:title forState:UIControlStateNormal]; + [button setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; + [button setTitleColor:RCTRedBox2TextColor(0.5) forState:UIControlStateHighlighted]; + return button; +} + +- (UIButton *)footerButton:(NSString *)title + accessibilityIdentifier:(NSString *)accessibilityIdentifier + selector:(SEL)selector +{ + UIButton *button = [self styledButton:title accessibilityIdentifier:accessibilityIdentifier]; + [button addTarget:self action:selector forControlEvents:UIControlEventTouchUpInside]; + return button; +} + +- (UIButton *)footerButton:(NSString *)title + accessibilityIdentifier:(NSString *)accessibilityIdentifier + handler:(RCTRedBox2ButtonPressHandler)handler +{ + UIButton *button = [self styledButton:title accessibilityIdentifier:accessibilityIdentifier]; + [button addAction:[UIAction actionWithHandler:^(__unused UIAction *action) { + handler(); + }] + forControlEvents:UIControlEventTouchUpInside]; + return button; +} + +- (CGFloat)bottomSafeViewHeight +{ +#if TARGET_OS_MACCATALYST + return 0; +#else + return RCTKeyWindow().safeAreaInsets.bottom; +#endif +} + +#pragma mark - Error Display + +- (NSString *)stripAnsi:(NSString *)text +{ + NSError *error = nil; + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"\\x1b\\[[0-9;]*m" + options:NSRegularExpressionCaseInsensitive + error:&error]; + return [regex stringByReplacingMatchesInString:text options:0 range:NSMakeRange(0, [text length]) withTemplate:@""]; +} + +- (void)showErrorMessage:(NSString *)message + withStack:(NSArray *)stack + isUpdate:(BOOL)isUpdate + errorCookie:(int)errorCookie +{ + // Remove ANSI color codes from the message + NSString *messageWithoutAnsi = [self stripAnsi:message]; + + BOOL isRootViewControllerPresented = self.presentingViewController != nil; + // Show if this is a new message, or if we're updating the previous message + BOOL isNew = !isRootViewControllerPresented && !isUpdate; + BOOL isUpdateForSameMessage = !isNew && + (isRootViewControllerPresented && isUpdate && + ((errorCookie == -1 && [_lastErrorMessage isEqualToString:messageWithoutAnsi]) || + (errorCookie == _lastErrorCookie))); + if (isNew || isUpdateForSameMessage) { + _lastStackTrace = stack; + // message is displayed using UILabel, which is unable to render text of + // unlimited length, so we truncate it + _lastErrorMessage = [messageWithoutAnsi substringToIndex:MIN((NSUInteger)10000, messageWithoutAnsi.length)]; + _lastErrorCookie = errorCookie; + + [_stackTraceTableView reloadData]; + + if (!isRootViewControllerPresented) { + [RCTKeyWindow().rootViewController presentViewController:self animated:NO completion:nil]; + } + } +} + +- (void)dismiss +{ + [self dismissViewControllerAnimated:NO completion:nil]; +} + +- (void)reload +{ + if (_actionDelegate != nil) { + [_actionDelegate reloadFromRedBoxController:self]; + } else { + // In bridgeless mode `RCTRedBox` gets deallocated, we need to notify listeners anyway. + RCTTriggerReloadCommandListeners(@"Redbox"); + [self dismiss]; + } +} + +- (void)copyStack +{ + NSMutableString *fullStackTrace; + + if (_lastErrorMessage != nil) { + fullStackTrace = [_lastErrorMessage mutableCopy]; + [fullStackTrace appendString:@"\n\n"]; + } else { + fullStackTrace = [NSMutableString string]; + } + + for (RCTJSStackFrame *stackFrame in _lastStackTrace) { + [fullStackTrace appendString:[NSString stringWithFormat:@"%@\n", stackFrame.methodName]]; + if (stackFrame.file != nullptr) { + [fullStackTrace appendFormat:@" %@\n", [self formatFrameSource:stackFrame]]; + } + } +#if !TARGET_OS_TV + UIPasteboard *pb = [UIPasteboard generalPasteboard]; + [pb setString:fullStackTrace]; +#endif +} + +- (NSString *)formatFrameSource:(RCTJSStackFrame *)stackFrame +{ + NSString *fileName = RCTNilIfNull(stackFrame.file) ? [stackFrame.file lastPathComponent] : @""; + NSString *lineInfo = [NSString stringWithFormat:@"%@:%lld", fileName, (long long)stackFrame.lineNumber]; + + if (stackFrame.column != 0) { + lineInfo = [lineInfo stringByAppendingFormat:@":%lld", (long long)stackFrame.column]; + } + return lineInfo; +} + +#pragma mark - TableView DataSource & Delegate + +- (NSInteger)numberOfSectionsInTableView:(__unused UITableView *)tableView +{ + return _lastStackTrace.count > 0 ? 2 : 1; +} + +- (NSInteger)tableView:(__unused UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + return section == 0 ? 1 : static_cast(_lastStackTrace.count); +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath.section == 0) { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"msg-cell"]; + return [self reuseCell:cell forErrorMessage:_lastErrorMessage]; + } + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell"]; + NSUInteger index = indexPath.row; + RCTJSStackFrame *stackFrame = _lastStackTrace[index]; + return [self reuseCell:cell forStackFrame:stackFrame]; +} + +- (UITableViewCell *)reuseCell:(UITableViewCell *)cell forErrorMessage:(NSString *)message +{ + if (cell == nullptr) { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"msg-cell"]; + cell.backgroundColor = RCTRedBox2BackgroundColor(); + cell.selectionStyle = UITableViewCellSelectionStyleNone; + + // Error category label (e.g. "Syntax Error", "Uncaught Error") + _errorCategoryLabel = [[UILabel alloc] init]; + _errorCategoryLabel.translatesAutoresizingMaskIntoConstraints = NO; + _errorCategoryLabel.textColor = RCTRedBox2ErrorColor(); + _errorCategoryLabel.font = [UIFont systemFontOfSize:21 weight:UIFontWeightBold]; + _errorCategoryLabel.numberOfLines = 1; + [cell.contentView addSubview:_errorCategoryLabel]; + + // Error message label + UILabel *messageLabel = [[UILabel alloc] init]; + messageLabel.translatesAutoresizingMaskIntoConstraints = NO; + messageLabel.accessibilityIdentifier = @"redbox-error"; + messageLabel.textColor = [UIColor whiteColor]; + messageLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium]; + messageLabel.lineBreakMode = NSLineBreakByWordWrapping; + messageLabel.numberOfLines = 0; + messageLabel.tag = 100; + [cell.contentView addSubview:messageLabel]; + + [NSLayoutConstraint activateConstraints:@[ + [_errorCategoryLabel.topAnchor constraintEqualToAnchor:cell.contentView.topAnchor constant:15], + [_errorCategoryLabel.leadingAnchor constraintEqualToAnchor:cell.contentView.leadingAnchor constant:12], + [_errorCategoryLabel.trailingAnchor constraintEqualToAnchor:cell.contentView.trailingAnchor constant:-12], + + [messageLabel.topAnchor constraintEqualToAnchor:_errorCategoryLabel.bottomAnchor constant:10], + [messageLabel.leadingAnchor constraintEqualToAnchor:cell.contentView.leadingAnchor constant:12], + [messageLabel.trailingAnchor constraintEqualToAnchor:cell.contentView.trailingAnchor constant:-12], + [messageLabel.bottomAnchor constraintEqualToAnchor:cell.contentView.bottomAnchor constant:-15], + ]]; + } + + _errorCategoryLabel.text = @"Error"; + UILabel *messageLabel = [cell.contentView viewWithTag:100]; + messageLabel.text = message; + + return cell; +} + +- (UITableViewCell *)reuseCell:(UITableViewCell *)cell forStackFrame:(RCTJSStackFrame *)stackFrame +{ + if (cell == nullptr) { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"cell"]; + cell.textLabel.font = [UIFont fontWithName:@"Menlo-Regular" size:14]; + cell.textLabel.lineBreakMode = NSLineBreakByCharWrapping; + cell.textLabel.numberOfLines = 2; + cell.detailTextLabel.font = [UIFont systemFontOfSize:12 weight:UIFontWeightLight]; + cell.detailTextLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; + cell.backgroundColor = [UIColor clearColor]; + cell.selectedBackgroundView = [UIView new]; + cell.selectedBackgroundView.backgroundColor = RCTRedBox2BackgroundColor(); + cell.selectedBackgroundView.layer.cornerRadius = 5; + } + + cell.textLabel.text = stackFrame.methodName ?: @"(unnamed method)"; + if (stackFrame.file != nullptr) { + cell.detailTextLabel.text = [self formatFrameSource:stackFrame]; + } else { + cell.detailTextLabel.text = @""; + } + + if (stackFrame.collapse) { + cell.textLabel.textColor = RCTRedBox2TextColor(0.4); + cell.detailTextLabel.textColor = RCTRedBox2TextColor(0.3); + } else { + cell.textLabel.textColor = [UIColor whiteColor]; + cell.detailTextLabel.textColor = RCTRedBox2TextColor(0.8); + } + + return cell; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath.section == 0) { + return UITableViewAutomaticDimension; + } else { + return 50; + } +} + +- (CGFloat)tableView:(__unused UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath.section == 0) { + return 100; + } + return 50; +} + +- (UIView *)tableView:(__unused UITableView *)tableView viewForHeaderInSection:(NSInteger)section +{ + if (section == 1) { + UIView *headerView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 0, 38)]; + headerView.backgroundColor = [UIColor clearColor]; + + UILabel *label = [[UILabel alloc] init]; + label.translatesAutoresizingMaskIntoConstraints = NO; + label.text = @"Call Stack"; + label.textColor = [UIColor whiteColor]; + label.font = [UIFont systemFontOfSize:18 weight:UIFontWeightSemibold]; + [headerView addSubview:label]; + + [NSLayoutConstraint activateConstraints:@[ + [label.leadingAnchor constraintEqualToAnchor:headerView.leadingAnchor constant:12], + [label.trailingAnchor constraintEqualToAnchor:headerView.trailingAnchor constant:-12], + [label.bottomAnchor constraintEqualToAnchor:headerView.bottomAnchor constant:-10], + ]]; + + return headerView; + } + return nil; +} + +- (CGFloat)tableView:(__unused UITableView *)tableView heightForHeaderInSection:(NSInteger)section +{ + return section == 1 ? 38 : 0; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath.section == 1) { + NSUInteger row = indexPath.row; + RCTJSStackFrame *stackFrame = _lastStackTrace[row]; + [_actionDelegate redBoxController:self openStackFrameInEditor:stackFrame]; + } + [tableView deselectRowAtIndexPath:indexPath animated:YES]; +} + +#pragma mark - Key Commands + +- (NSArray *)keyCommands +{ + return @[ + // Dismiss red box + [UIKeyCommand keyCommandWithInput:UIKeyInputEscape modifierFlags:0 action:@selector(dismiss)], + // Reload + [UIKeyCommand keyCommandWithInput:@"r" modifierFlags:UIKeyModifierCommand action:@selector(reload)], + // Copy = Cmd-Option C since Cmd-C in the simulator copies the pasteboard from + // the simulator to the desktop pasteboard. + [UIKeyCommand keyCommandWithInput:@"c" + modifierFlags:UIKeyModifierCommand | UIKeyModifierAlternate + action:@selector(copyStack)], + ]; +} + +- (BOOL)canBecomeFirstResponder +{ + return YES; +} + +@end + +#endif diff --git a/packages/react-native/React/CoreModules/RCTRedBoxController+Internal.h b/packages/react-native/React/CoreModules/RCTRedBoxController+Internal.h index c8e333c49dea..191f82f461f8 100644 --- a/packages/react-native/React/CoreModules/RCTRedBoxController+Internal.h +++ b/packages/react-native/React/CoreModules/RCTRedBoxController+Internal.h @@ -7,22 +7,12 @@ #import +#import "RCTRedBox+Internal.h" #import "RCTRedBox.h" #if RCT_DEV_MENU -@class RCTJSStackFrame; -@class RCTRedBoxController; - -@protocol RCTRedBoxControllerActionDelegate - -- (void)redBoxController:(RCTRedBoxController *)redBoxController openStackFrameInEditor:(RCTJSStackFrame *)stackFrame; -- (void)reloadFromRedBoxController:(RCTRedBoxController *)redBoxController; -- (void)loadExtraDataViewController; - -@end - -@interface RCTRedBoxController : UIViewController +@interface RCTRedBoxController : UIViewController @property (nonatomic, weak) id actionDelegate; diff --git a/packages/react-native/React/CoreModules/React-CoreModules.podspec b/packages/react-native/React/CoreModules/React-CoreModules.podspec index e9e095590e6e..855114667c17 100644 --- a/packages/react-native/React/CoreModules/React-CoreModules.podspec +++ b/packages/react-native/React/CoreModules/React-CoreModules.podspec @@ -51,6 +51,7 @@ Pod::Spec.new do |s| s.dependency "React-Core/CoreModulesHeaders", version s.dependency "React-RCTImage", version s.dependency "React-jsi", version + s.dependency "React-featureflags" s.dependency 'React-RCTBlob' add_dependency(s, "React-debug") add_dependency(s, "React-runtimeexecutor", :additional_framework_paths => ["platform/ios"]) From 003bf55a228f4c9b0f009b9c4b6594d5dcccdeb5 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 22 Apr 2026 07:56:36 -0700 Subject: [PATCH 04/11] Add shared C++ error parser and ANSI renderer (#56553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/56553 Add a platform-independent C++ library (`ReactCommon/react/debug/redbox/`) for error message parsing and ANSI escape sequence rendering, shared by both iOS and Android. `RedBoxErrorParser` — C++ port of `parseLogBoxException`. Classifies Metro errors, Babel transform errors, bundle loading errors, and code frame errors into a structured `ParsedError`. `AnsiParser` — converts ANSI SGR sequences into styled spans with foreground/background colors using the Afterglow theme. Uses `facebook::react::unstable_redbox` namespace to exclude from C++ API snapshots. Changelog: [Internal] Reviewed By: robhogan Differential Revision: D101357709 fbshipit-source-id: d2ecf9d12897e00f9590e1bec57ecf5d5895fcd5 --- packages/react-native/Package.swift | 1 + .../ReactCommon/react/debug/CMakeLists.txt | 3 +- .../react/debug/React-debug.podspec | 1 + .../react/debug/redbox/AnsiParser.cpp | 139 ++++++++++++++ .../react/debug/redbox/AnsiParser.h | 35 ++++ .../react/debug/redbox/RedBoxErrorParser.cpp | 171 ++++++++++++++++++ .../react/debug/redbox/RedBoxErrorParser.h | 39 ++++ .../debug/redbox/tests/AnsiParserTest.cpp | 97 ++++++++++ .../redbox/tests/RedBoxErrorParserTest.cpp | 105 +++++++++++ 9 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 packages/react-native/ReactCommon/react/debug/redbox/AnsiParser.cpp create mode 100644 packages/react-native/ReactCommon/react/debug/redbox/AnsiParser.h create mode 100644 packages/react-native/ReactCommon/react/debug/redbox/RedBoxErrorParser.cpp create mode 100644 packages/react-native/ReactCommon/react/debug/redbox/RedBoxErrorParser.h create mode 100644 packages/react-native/ReactCommon/react/debug/redbox/tests/AnsiParserTest.cpp create mode 100644 packages/react-native/ReactCommon/react/debug/redbox/tests/RedBoxErrorParserTest.cpp diff --git a/packages/react-native/Package.swift b/packages/react-native/Package.swift index aa2245923e5d..46c454e2e59e 100644 --- a/packages/react-native/Package.swift +++ b/packages/react-native/Package.swift @@ -94,6 +94,7 @@ let reactRendererConsistency = RNTarget( let reactDebug = RNTarget( name: .reactDebug, path: "ReactCommon/react/debug", + excludedPaths: ["tests", "redbox/tests"], dependencies: [.reactNativeDependencies] ) /// React-jsi.podspec diff --git a/packages/react-native/ReactCommon/react/debug/CMakeLists.txt b/packages/react-native/ReactCommon/react/debug/CMakeLists.txt index 448f163308c6..5cbe24ff6029 100644 --- a/packages/react-native/ReactCommon/react/debug/CMakeLists.txt +++ b/packages/react-native/ReactCommon/react/debug/CMakeLists.txt @@ -9,7 +9,8 @@ set(CMAKE_VERBOSE_MAKEFILE on) include(${REACT_COMMON_DIR}/cmake-utils/react-native-flags.cmake) file(GLOB react_debug_SRC CONFIGURE_DEPENDS *.cpp) -add_library(react_debug OBJECT ${react_debug_SRC}) +file(GLOB react_debug_redbox_SRC CONFIGURE_DEPENDS redbox/*.cpp) +add_library(react_debug OBJECT ${react_debug_SRC} ${react_debug_redbox_SRC}) target_include_directories(react_debug PUBLIC ${REACT_COMMON_DIR}) diff --git a/packages/react-native/ReactCommon/react/debug/React-debug.podspec b/packages/react-native/ReactCommon/react/debug/React-debug.podspec index 7b61bc7d0be4..764747d1aa6e 100644 --- a/packages/react-native/ReactCommon/react/debug/React-debug.podspec +++ b/packages/react-native/ReactCommon/react/debug/React-debug.podspec @@ -26,6 +26,7 @@ Pod::Spec.new do |s| s.platforms = min_supported_versions s.source = source s.source_files = podspec_sources("**/*.{cpp,h}", "**/*.h") + s.exclude_files = "**/tests/**/*.{cpp,h}" s.header_dir = "react/debug" s.pod_target_xcconfig = { "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard(), "DEFINES_MODULE" => "YES" } diff --git a/packages/react-native/ReactCommon/react/debug/redbox/AnsiParser.cpp b/packages/react-native/ReactCommon/react/debug/redbox/AnsiParser.cpp new file mode 100644 index 000000000000..0ab088a8e053 --- /dev/null +++ b/packages/react-native/ReactCommon/react/debug/redbox/AnsiParser.cpp @@ -0,0 +1,139 @@ +/* + * 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 "AnsiParser.h" + +#include + +#include // NOLINT(facebook-hte-BadInclude-regex) + +// @lint-ignore-every CLANGTIDY facebook-hte-StdRegexIsAwful +namespace facebook::react::unstable_redbox { + +namespace { + +// Afterglow theme colors (matching AnsiHighlight.js) +std::optional ansiColor(int code) { + switch (code) { + case 30: + return AnsiColor{.r = 27, .g = 27, .b = 27}; // black + case 31: + return AnsiColor{.r = 187, .g = 86, .b = 83}; // red + case 32: + return AnsiColor{.r = 144, .g = 157, .b = 98}; // green + case 33: + return AnsiColor{.r = 234, .g = 193, .b = 121}; // yellow + case 34: + return AnsiColor{.r = 125, .g = 169, .b = 199}; // blue + case 35: + return AnsiColor{.r = 176, .g = 101, .b = 151}; // magenta + case 36: + return AnsiColor{.r = 140, .g = 220, .b = 216}; // cyan + case 37: + return std::nullopt; // white = default + case 90: + return AnsiColor{.r = 98, .g = 98, .b = 98}; // bright black + case 91: + return AnsiColor{.r = 187, .g = 86, .b = 83}; // bright red + case 92: + return AnsiColor{.r = 144, .g = 157, .b = 98}; // bright green + case 93: + return AnsiColor{.r = 234, .g = 193, .b = 121}; // bright yellow + case 94: + return AnsiColor{.r = 125, .g = 169, .b = 199}; // bright blue + case 95: + return AnsiColor{.r = 176, .g = 101, .b = 151}; // bright magenta + case 96: + return AnsiColor{.r = 140, .g = 220, .b = 216}; // bright cyan + case 97: + return AnsiColor{.r = 247, .g = 247, .b = 247}; // bright white + default: + return std::nullopt; + } +} + +const std::regex& ansiRegex() { + static const std::regex re(R"(\x1b\[([0-9;]*)m)"); + return re; +} + +int parseSgrCode(const std::string& params, size_t& pos) { + size_t next = params.find(';', pos); + if (next == std::string::npos) { + next = params.size(); + } + int code = 0; + for (size_t i = pos; i < next; ++i) { + code = code * 10 + (params[i] - '0'); + } + pos = next + 1; + return code; +} + +} // namespace + +std::vector parseAnsi(const std::string& text) { + std::vector spans; + std::optional currentFg; + std::optional currentBg; + auto it = std::sregex_iterator(text.begin(), text.end(), ansiRegex()); + auto end = std::sregex_iterator(); + size_t lastEnd = 0; + + for (; it != end; ++it) { + const auto& match = *it; + auto matchStart = static_cast(match.position()); + + if (matchStart > lastEnd) { + spans.push_back( + AnsiSpan{ + .text = text.substr(lastEnd, matchStart - lastEnd), + .foregroundColor = currentFg, + .backgroundColor = currentBg}); + } + lastEnd = matchStart + match.length(); + + std::string params = match[1].str(); + // ESC[m (no params) is equivalent to ESC[0m (reset all attributes) + if (params.empty()) { + currentFg = std::nullopt; + currentBg = std::nullopt; + } + size_t pos = 0; + while (pos < params.size()) { + int code = parseSgrCode(params, pos); + if (code == 0) { + currentFg = std::nullopt; + currentBg = std::nullopt; + } else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { + currentFg = ansiColor(code); + } else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { + currentBg = ansiColor(code - 10); + } else if (code == 39) { + currentFg = std::nullopt; + } else if (code == 49) { + currentBg = std::nullopt; + } + } + } + + if (lastEnd < text.size()) { + spans.push_back( + AnsiSpan{ + .text = text.substr(lastEnd), + .foregroundColor = currentFg, + .backgroundColor = currentBg}); + } + + return spans; +} + +std::string stripAnsi(const std::string& text) { + return std::regex_replace(text, ansiRegex(), ""); +} + +} // namespace facebook::react::unstable_redbox diff --git a/packages/react-native/ReactCommon/react/debug/redbox/AnsiParser.h b/packages/react-native/ReactCommon/react/debug/redbox/AnsiParser.h new file mode 100644 index 000000000000..89f2aa8eda26 --- /dev/null +++ b/packages/react-native/ReactCommon/react/debug/redbox/AnsiParser.h @@ -0,0 +1,35 @@ +/* + * 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::unstable_redbox { + +struct AnsiColor { + uint8_t r, g, b; +}; + +struct AnsiSpan { + std::string text; + std::optional foregroundColor; + std::optional backgroundColor; +}; + +/** + * Parse ANSI escape sequences in text and produce a list of styled spans. + * Uses the Afterglow color theme (matching LogBox's AnsiHighlight.js). + */ +std::vector parseAnsi(const std::string &text); + +/** Strip all ANSI escape sequences from text. */ +std::string stripAnsi(const std::string &text); + +} // namespace facebook::react::unstable_redbox diff --git a/packages/react-native/ReactCommon/react/debug/redbox/RedBoxErrorParser.cpp b/packages/react-native/ReactCommon/react/debug/redbox/RedBoxErrorParser.cpp new file mode 100644 index 000000000000..e71df7330fbd --- /dev/null +++ b/packages/react-native/ReactCommon/react/debug/redbox/RedBoxErrorParser.cpp @@ -0,0 +1,171 @@ +/* + * 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 "RedBoxErrorParser.h" + +#include // NOLINT(facebook-hte-BadInclude-regex) +#include + +// @lint-ignore-every CLANGTIDY facebook-hte-StdRegexIsAwful +namespace facebook::react::unstable_redbox { + +namespace { + +const std::regex& metroErrorRegex() { + static const std::regex re( + R"(^(?:InternalError Metro has encountered an error:) (.*): (.*) \((\d+):(\d+)\)\n\n([\s\S]+))"); + return re; +} + +const std::regex& babelTransformErrorRegex() { + static const std::regex re( + R"(^(?:TransformError )?(?:SyntaxError: |ReferenceError: )(.*): (.*) \((\d+):(\d+)\)\n\n([\s\S]+))"); + return re; +} + +const std::regex& bundleLoadErrorRegex() { + static const std::regex re(R"(^(\w+) in (\S+): (.+) \((\d+):(\d+)\))"); + return re; +} + +const std::regex& babelCodeFrameErrorRegex() { + static const std::regex re( + R"(^(?:TransformError )?(?:.*):? (?:.*?)(\/.*): ([\s\S]+?)\n([ >]{2}[\d\s]+ \|[\s\S]+|\x1b[\s\S]+))"); + return re; +} + +bool startsWithTransformError(const std::string& msg) { + return msg.rfind("TransformError ", 0) == 0; +} + +const std::unordered_set& knownBundleLoadErrorTypes() { + static const std::unordered_set types{ + "SyntaxError", "ReferenceError", "TypeError", "UnableToResolveError"}; + return types; +} + +} // namespace + +ParsedError parseErrorMessage( + const std::string& message, + const std::string& name, + const std::string& componentStack, + bool isFatal) { + std::smatch match; + + if (message.empty()) { + return ParsedError{ + .title = isFatal ? "Uncaught Error" : "Error", + .message = "", + .codeFrame = std::nullopt, + .isCompileError = false, + }; + } + + // 1. Metro internal error + if (std::regex_search(message, match, metroErrorRegex())) { + return ParsedError{ + .title = match[1].str().empty() ? "Metro Error" : match[1].str(), + .message = match[2].str(), + .codeFrame = + CodeFrame{ + .content = match[5].str(), + .fileName = "", + .row = std::stoi(match[3].str()), + .column = std::stoi(match[4].str()), + }, + .isCompileError = true, + }; + } + + // 2. Babel transform error + if (std::regex_search(message, match, babelTransformErrorRegex())) { + return ParsedError{ + .title = "Syntax Error", + .message = match[2].str(), + .codeFrame = + CodeFrame{ + .content = match[5].str(), + .fileName = match[1].str(), + .row = std::stoi(match[3].str()), + .column = std::stoi(match[4].str()), + }, + .isCompileError = true, + }; + } + + // 3. Bundle loading error: "ErrorType in /path: message (line:col)" + if (std::regex_search(message, match, bundleLoadErrorRegex())) { + const auto& errorType = match[1].str(); + if (knownBundleLoadErrorTypes().count(errorType) > 0) { + std::string title = errorType == "UnableToResolveError" + ? "Module Not Found" + : "Syntax Error"; + std::optional codeFrameContent; + auto newlinePos = message.find('\n'); + if (newlinePos != std::string::npos) { + codeFrameContent = message.substr(newlinePos + 1); + } + return ParsedError{ + .title = title, + .message = match[3].str(), + .codeFrame = + CodeFrame{ + .content = codeFrameContent.value_or(""), + .fileName = match[2].str(), + .row = std::stoi(match[4].str()), + .column = std::stoi(match[5].str()), + }, + .isCompileError = true, + }; + } + } + + // 4. Babel code frame error + if (std::regex_search(message, match, babelCodeFrameErrorRegex())) { + return ParsedError{ + .title = "Syntax Error", + .message = match[2].str(), + .codeFrame = + CodeFrame{ + .content = match[3].str(), + .fileName = match[1].str(), + }, + .isCompileError = true, + }; + } + + // 5. Generic transform error (no code frame) + if (startsWithTransformError(message)) { + return ParsedError{ + .title = "Syntax Error", + .message = message, + .codeFrame = std::nullopt, + .isCompileError = true, + }; + } + + // 6. Determine title from context (matching LogBoxInspectorHeader title map) + std::string title; + if (!name.empty()) { + title = name; + } else if (!componentStack.empty()) { + title = "Render Error"; + } else if (isFatal) { + title = "Uncaught Error"; + } else { + title = "Error"; + } + return ParsedError{ + .title = title, + .message = message, + .codeFrame = std::nullopt, + .isCompileError = false, + }; +} + +} // namespace facebook::react::unstable_redbox diff --git a/packages/react-native/ReactCommon/react/debug/redbox/RedBoxErrorParser.h b/packages/react-native/ReactCommon/react/debug/redbox/RedBoxErrorParser.h new file mode 100644 index 000000000000..185869a2565d --- /dev/null +++ b/packages/react-native/ReactCommon/react/debug/redbox/RedBoxErrorParser.h @@ -0,0 +1,39 @@ +/* + * 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 + +namespace facebook::react::unstable_redbox { + +struct CodeFrame { + std::string content; + std::string fileName; + int row = 0; + int column = 0; +}; + +struct ParsedError { + std::string title; + std::string message; + std::optional codeFrame; + bool isCompileError = false; +}; + +/** + * Parse a raw error message into structured components. + * C++ port of parseLogBoxException from parseLogBoxLog.js. + */ +ParsedError parseErrorMessage( + const std::string &message, + const std::string &name = "", + const std::string &componentStack = "", + bool isFatal = true); + +} // namespace facebook::react::unstable_redbox diff --git a/packages/react-native/ReactCommon/react/debug/redbox/tests/AnsiParserTest.cpp b/packages/react-native/ReactCommon/react/debug/redbox/tests/AnsiParserTest.cpp new file mode 100644 index 000000000000..8bc78b99cc80 --- /dev/null +++ b/packages/react-native/ReactCommon/react/debug/redbox/tests/AnsiParserTest.cpp @@ -0,0 +1,97 @@ +/* + * 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 + +using namespace facebook::react::unstable_redbox; + +TEST(AnsiParserTest, ParsesPlainText) { + auto spans = parseAnsi("hello world"); + ASSERT_EQ(spans.size(), 1); + EXPECT_EQ(spans[0].text, "hello world"); + EXPECT_FALSE(spans[0].foregroundColor.has_value()); + EXPECT_FALSE(spans[0].backgroundColor.has_value()); +} + +TEST(AnsiParserTest, ParsesRedForeground) { + auto spans = parseAnsi("\x1b[31mred text\x1b[0m normal"); + ASSERT_EQ(spans.size(), 2); + EXPECT_EQ(spans[0].text, "red text"); + ASSERT_TRUE(spans[0].foregroundColor.has_value()); + EXPECT_EQ(spans[0].foregroundColor->r, 187); + EXPECT_EQ(spans[0].foregroundColor->g, 86); + EXPECT_EQ(spans[0].foregroundColor->b, 83); + + EXPECT_EQ(spans[1].text, " normal"); + EXPECT_FALSE(spans[1].foregroundColor.has_value()); +} + +TEST(AnsiParserTest, ParsesMultipleColors) { + auto spans = parseAnsi("\x1b[32mgreen\x1b[34mblue\x1b[0m"); + ASSERT_EQ(spans.size(), 2); + EXPECT_EQ(spans[0].text, "green"); + EXPECT_EQ(spans[0].foregroundColor->r, 144); + EXPECT_EQ(spans[1].text, "blue"); + EXPECT_EQ(spans[1].foregroundColor->r, 125); +} + +TEST(AnsiParserTest, ParsesBrightColors) { + auto spans = parseAnsi("\x1b[93myellow\x1b[0m"); + ASSERT_EQ(spans.size(), 1); + EXPECT_EQ(spans[0].text, "yellow"); + ASSERT_TRUE(spans[0].foregroundColor.has_value()); + EXPECT_EQ(spans[0].foregroundColor->r, 234); +} + +TEST(AnsiParserTest, ParsesBackgroundColor) { + auto spans = parseAnsi("\x1b[41mred bg\x1b[0m"); + ASSERT_EQ(spans.size(), 1); + EXPECT_EQ(spans[0].text, "red bg"); + EXPECT_FALSE(spans[0].foregroundColor.has_value()); + ASSERT_TRUE(spans[0].backgroundColor.has_value()); + EXPECT_EQ(spans[0].backgroundColor->r, 187); + EXPECT_EQ(spans[0].backgroundColor->g, 86); + EXPECT_EQ(spans[0].backgroundColor->b, 83); +} + +TEST(AnsiParserTest, ResetClearsBackground) { + auto spans = parseAnsi("\x1b[31;42mcolored\x1b[49mfg only\x1b[0m"); + ASSERT_EQ(spans.size(), 2); + ASSERT_TRUE(spans[0].foregroundColor.has_value()); + ASSERT_TRUE(spans[0].backgroundColor.has_value()); + ASSERT_TRUE(spans[1].foregroundColor.has_value()); + EXPECT_FALSE(spans[1].backgroundColor.has_value()); +} + +TEST(AnsiParserTest, ResetShorthandClearsColors) { + auto spans = parseAnsi("\x1b[31mred\x1b[mplain"); + ASSERT_EQ(spans.size(), 2); + EXPECT_EQ(spans[0].text, "red"); + ASSERT_TRUE(spans[0].foregroundColor.has_value()); + EXPECT_EQ(spans[1].text, "plain"); + EXPECT_FALSE(spans[1].foregroundColor.has_value()); + EXPECT_FALSE(spans[1].backgroundColor.has_value()); +} + +TEST(AnsiParserTest, StripsAnsi) { + auto result = stripAnsi("\x1b[31mred\x1b[0m and \x1b[32mgreen\x1b[0m"); + EXPECT_EQ(result, "red and green"); +} + +TEST(AnsiParserTest, HandlesEmptyString) { + auto spans = parseAnsi(""); + EXPECT_TRUE(spans.empty()); +} + +TEST(AnsiParserTest, HandlesSemicolonSeparatedCodes) { + auto spans = parseAnsi("\x1b[1;31mtext\x1b[0m"); + ASSERT_EQ(spans.size(), 1); + EXPECT_EQ(spans[0].text, "text"); + ASSERT_TRUE(spans[0].foregroundColor.has_value()); + EXPECT_EQ(spans[0].foregroundColor->r, 187); +} diff --git a/packages/react-native/ReactCommon/react/debug/redbox/tests/RedBoxErrorParserTest.cpp b/packages/react-native/ReactCommon/react/debug/redbox/tests/RedBoxErrorParserTest.cpp new file mode 100644 index 000000000000..7ac1c327895f --- /dev/null +++ b/packages/react-native/ReactCommon/react/debug/redbox/tests/RedBoxErrorParserTest.cpp @@ -0,0 +1,105 @@ +/* + * 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 + +using namespace facebook::react::unstable_redbox; + +TEST(RedBoxErrorParserTest, ParsesBabelTransformError) { + auto result = parseErrorMessage( + "SyntaxError: /path/to/file.js: Unexpected token (10:5)\n\n" + "> 10 | const x = {\n" + " | ^"); + + EXPECT_EQ(result.title, "Syntax Error"); + EXPECT_EQ(result.message, "Unexpected token"); + ASSERT_TRUE(result.codeFrame.has_value()); + EXPECT_EQ(result.codeFrame->fileName, "/path/to/file.js"); + EXPECT_EQ(result.codeFrame->row, 10); + EXPECT_EQ(result.codeFrame->column, 5); + EXPECT_TRUE(result.isCompileError); +} + +TEST(RedBoxErrorParserTest, ParsesMetroError) { + auto result = parseErrorMessage( + "InternalError Metro has encountered an error: " + "BundleError: Unable to resolve module (1:0)\n\n" + "code frame here"); + + EXPECT_EQ(result.title, "BundleError"); + EXPECT_EQ(result.message, "Unable to resolve module"); + ASSERT_TRUE(result.codeFrame.has_value()); + EXPECT_EQ(result.codeFrame->row, 1); + EXPECT_TRUE(result.isCompileError); +} + +TEST(RedBoxErrorParserTest, ParsesBundleLoadError_SyntaxError) { + auto result = parseErrorMessage( + "SyntaxError in /app/index.js: Unexpected token (3:10)\ncode frame"); + + EXPECT_EQ(result.title, "Syntax Error"); + EXPECT_EQ(result.message, "Unexpected token"); + ASSERT_TRUE(result.codeFrame.has_value()); + EXPECT_EQ(result.codeFrame->fileName, "/app/index.js"); + EXPECT_EQ(result.codeFrame->row, 3); + EXPECT_EQ(result.codeFrame->column, 10); + EXPECT_TRUE(result.isCompileError); +} + +TEST(RedBoxErrorParserTest, ParsesBundleLoadError_UnableToResolve) { + auto result = parseErrorMessage( + "UnableToResolveError in /app/index.js: Cannot find module (1:0)"); + + EXPECT_EQ(result.title, "Module Not Found"); + EXPECT_TRUE(result.isCompileError); +} + +TEST(RedBoxErrorParserTest, ParsesGenericTransformError) { + auto result = parseErrorMessage("TransformError some error message"); + + EXPECT_EQ(result.title, "Syntax Error"); + EXPECT_EQ(result.message, "TransformError some error message"); + EXPECT_FALSE(result.codeFrame.has_value()); + EXPECT_TRUE(result.isCompileError); +} + +TEST(RedBoxErrorParserTest, DefaultsToUncaughtError) { + auto result = parseErrorMessage("TypeError: undefined is not a function"); + + EXPECT_EQ(result.title, "Uncaught Error"); + EXPECT_EQ(result.message, "TypeError: undefined is not a function"); + EXPECT_FALSE(result.codeFrame.has_value()); + EXPECT_FALSE(result.isCompileError); +} + +TEST(RedBoxErrorParserTest, UsesNameForTitle) { + auto result = parseErrorMessage("something broke", "CustomError"); + + EXPECT_EQ(result.title, "CustomError"); +} + +TEST(RedBoxErrorParserTest, UsesRenderErrorForComponentStack) { + auto result = + parseErrorMessage("something broke", "", "in MyComponent\nin App"); + + EXPECT_EQ(result.title, "Render Error"); +} + +TEST(RedBoxErrorParserTest, NonFatalDefaultsToError) { + auto result = parseErrorMessage("warning message", "", "", false); + + EXPECT_EQ(result.title, "Error"); +} + +TEST(RedBoxErrorParserTest, HandlesEmptyMessage) { + auto result = parseErrorMessage(""); + + EXPECT_EQ(result.title, "Uncaught Error"); + EXPECT_EQ(result.message, ""); + EXPECT_FALSE(result.codeFrame.has_value()); +} From c413c95944f87c7f76794da096489dcc9ffa8d66 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 22 Apr 2026 07:56:36 -0700 Subject: [PATCH 05/11] Show syntax-highlighted code frames (#56551) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/56551 TSIA Changelog: [Internal] Reviewed By: cipolleschi Differential Revision: D98113191 fbshipit-source-id: 00b640de69a5a3e48dae624dd24c25a1da3c7548 --- .../RCTRedBox2AnsiParser+Internal.h | 22 ++ .../React/CoreModules/RCTRedBox2AnsiParser.mm | 55 ++++ .../React/CoreModules/RCTRedBox2Controller.mm | 237 +++++++++++++++--- .../RCTRedBox2ErrorParser+Internal.h | 42 ++++ .../CoreModules/RCTRedBox2ErrorParser.mm | 54 ++++ 5 files changed, 378 insertions(+), 32 deletions(-) create mode 100644 packages/react-native/React/CoreModules/RCTRedBox2AnsiParser+Internal.h create mode 100644 packages/react-native/React/CoreModules/RCTRedBox2AnsiParser.mm create mode 100644 packages/react-native/React/CoreModules/RCTRedBox2ErrorParser+Internal.h create mode 100644 packages/react-native/React/CoreModules/RCTRedBox2ErrorParser.mm diff --git a/packages/react-native/React/CoreModules/RCTRedBox2AnsiParser+Internal.h b/packages/react-native/React/CoreModules/RCTRedBox2AnsiParser+Internal.h new file mode 100644 index 000000000000..aacf61a160f5 --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTRedBox2AnsiParser+Internal.h @@ -0,0 +1,22 @@ +/* + * 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. + */ + +#import + +/** + * Parses ANSI escape sequences in text and produces an NSAttributedString + * with the corresponding foreground/background colors applied. + * + * Uses the Afterglow color theme (matching LogBox's AnsiHighlight.js). + */ +@interface RCTRedBox2AnsiParser : NSObject + ++ (NSAttributedString *)attributedStringFromAnsiText:(NSString *)text + baseFont:(UIFont *)font + baseColor:(UIColor *)color; + +@end diff --git a/packages/react-native/React/CoreModules/RCTRedBox2AnsiParser.mm b/packages/react-native/React/CoreModules/RCTRedBox2AnsiParser.mm new file mode 100644 index 000000000000..50893d3c46ff --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTRedBox2AnsiParser.mm @@ -0,0 +1,55 @@ +/* + * 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. + */ + +#import "RCTRedBox2AnsiParser+Internal.h" + +#import +#import + +#if RCT_DEV_MENU + +using facebook::react::unstable_redbox::AnsiColor; +using facebook::react::unstable_redbox::parseAnsi; + +static UIColor *RCTUIColorFromAnsiColor(const AnsiColor &c) +{ + return [UIColor colorWithRed:c.r / 255.0 green:c.g / 255.0 blue:c.b / 255.0 alpha:1.0]; +} + +@implementation RCTRedBox2AnsiParser + ++ (NSAttributedString *)attributedStringFromAnsiText:(NSString *)text baseFont:(UIFont *)font baseColor:(UIColor *)color +{ + if (text == nil) { + return [[NSAttributedString alloc] init]; + } + + auto spans = parseAnsi(text.UTF8String); + NSMutableAttributedString *result =[NSMutableAttributedString new]; + NSDictionary *baseAttributes = @{NSFontAttributeName : font, NSForegroundColorAttributeName : color}; + + for (const auto &span : spans) { + NSString *str = [NSString stringWithUTF8String:span.text.c_str()]; + if (str == nil) { + continue; + } + NSMutableDictionary *attrs = [baseAttributes mutableCopy]; + if (span.foregroundColor.has_value()) { + attrs[NSForegroundColorAttributeName] = RCTUIColorFromAnsiColor(*span.foregroundColor); + } + if (span.backgroundColor.has_value()) { + attrs[NSBackgroundColorAttributeName] = RCTUIColorFromAnsiColor(*span.backgroundColor); + } + [result appendAttributedString:[[NSAttributedString alloc] initWithString:str attributes:attrs]]; + } + + return result; +} + +@end + +#endif diff --git a/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm b/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm index 8913d98d7530..0c2c5cce2bb1 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm +++ b/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm @@ -12,6 +12,14 @@ #import #import +#include + +#import "RCTRedBox2AnsiParser+Internal.h" +#import "RCTRedBox2ErrorParser+Internal.h" + +// @lint-ignore-every CLANGTIDY clang-diagnostic-switch-default +// NOTE: clang-diagnostic-switch-default conflicts with clang-diagnostic-switch-enum + #if RCT_DEV_MENU #pragma mark - RCTRedBox2Controller @@ -32,6 +40,13 @@ return [UIColor colorWithWhite:1.0 alpha:opacity]; } +enum class Section : uint8_t { Message, CodeFrame, CallStack, kMaxValue }; +static constexpr size_t kSectionCount = static_cast(Section::kMaxValue); + +struct SectionState { + bool visible = false; +}; + @implementation RCTRedBox2Controller { UITableView *_stackTraceTableView; UILabel *_headerTitleLabel; @@ -41,6 +56,8 @@ @implementation RCTRedBox2Controller { NSArray *_customButtonTitles; NSArray *_customButtonHandlers; int _lastErrorCookie; + RCTRedBox2ErrorData *_errorData; + std::array _sectionStates; } - (instancetype)initWithCustomButtonTitles:(NSArray *)customButtonTitles @@ -255,6 +272,10 @@ - (void)showErrorMessage:(NSString *)message _lastErrorMessage = [messageWithoutAnsi substringToIndex:MIN((NSUInteger)10000, messageWithoutAnsi.length)]; _lastErrorCookie = errorCookie; + // Parse the message to extract structure (title, code frame, etc.) + _errorData = [RCTRedBox2ErrorParser parseErrorMessage:message name:nil componentStack:nil isFatal:YES]; + [self updateSectionVisibility]; + [_stackTraceTableView reloadData]; if (!isRootViewControllerPresented) { @@ -313,23 +334,77 @@ - (NSString *)formatFrameSource:(RCTJSStackFrame *)stackFrame return lineInfo; } +#pragma mark - Section Helpers + +- (void)updateSectionVisibility +{ + _sectionStates = {}; + _sectionStates[static_cast(Section::Message)].visible = true; + _sectionStates[static_cast(Section::CodeFrame)].visible = _errorData.codeFrame.length > 0; + _sectionStates[static_cast(Section::CallStack)].visible = + _lastStackTrace.count > 0 && _errorData.codeFrame.length == 0; +} + +- (NSInteger)visibleSectionCount +{ + NSInteger count = 0; + for (size_t i = 0; i < kSectionCount; i++) { + if (_sectionStates[i].visible) { + count++; + } + } + return count; +} + +- (Section)sectionForIndex:(NSInteger)index +{ + NSInteger visible = 0; + for (size_t i = 0; i < kSectionCount; i++) { + if (_sectionStates[i].visible) { + if (visible == index) { + return static_cast
(i); + } + visible++; + } + } + RCTAssert(NO, @"Invalid section index %ld", (long)index); + return Section::kMaxValue; +} + +- (NSString *)displayMessage +{ + return _errorData.message.length > 0 ? [self stripAnsi:_errorData.message] : _lastErrorMessage; +} + #pragma mark - TableView DataSource & Delegate - (NSInteger)numberOfSectionsInTableView:(__unused UITableView *)tableView { - return _lastStackTrace.count > 0 ? 2 : 1; + return [self visibleSectionCount]; } - (NSInteger)tableView:(__unused UITableView *)tableView numberOfRowsInSection:(NSInteger)section { - return section == 0 ? 1 : static_cast(_lastStackTrace.count); + if ([self sectionForIndex:section] == Section::CallStack) { + return static_cast(_lastStackTrace.count); + } + return 1; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { - if (indexPath.section == 0) { - UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"msg-cell"]; - return [self reuseCell:cell forErrorMessage:_lastErrorMessage]; + switch ([self sectionForIndex:indexPath.section]) { + case Section::Message: { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"msg-cell"]; + return [self reuseCell:cell forErrorMessage:[self displayMessage]]; + } + case Section::CodeFrame: { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"code-cell"]; + return [self reuseCell:cell forCodeFrame:_errorData]; + } + case Section::CallStack: + case Section::kMaxValue: + break; } UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell"]; NSUInteger index = indexPath.row; @@ -375,7 +450,7 @@ - (UITableViewCell *)reuseCell:(UITableViewCell *)cell forErrorMessage:(NSString ]]; } - _errorCategoryLabel.text = @"Error"; + _errorCategoryLabel.text = _errorData.title; UILabel *messageLabel = [cell.contentView viewWithTag:100]; messageLabel.text = message; @@ -415,55 +490,153 @@ - (UITableViewCell *)reuseCell:(UITableViewCell *)cell forStackFrame:(RCTJSStack return cell; } -- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +- (UITableViewCell *)reuseCell:(UITableViewCell *)cell forCodeFrame:(RCTRedBox2ErrorData *)errorData +{ + if (cell == nullptr) { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"code-cell"]; + cell.backgroundColor = [UIColor clearColor]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + } + + // Remove old subviews + for (UIView *subview in cell.contentView.subviews) { + [subview removeFromSuperview]; + } + + // Code frame container with rounded corners + UIView *container = [[UIView alloc] init]; + container.translatesAutoresizingMaskIntoConstraints = NO; + container.backgroundColor = RCTRedBox2BackgroundColor(); + container.layer.cornerRadius = 3; + container.clipsToBounds = YES; + [cell.contentView addSubview:container]; + + // Render code frame with ANSI syntax highlighting + UIFont *codeFont = [UIFont fontWithName:@"Menlo-Regular" size:12]; + NSAttributedString *highlighted = [RCTRedBox2AnsiParser attributedStringFromAnsiText:errorData.codeFrame + baseFont:codeFont + baseColor:[UIColor whiteColor]]; + + UILabel *codeLabel = [[UILabel alloc] init]; + codeLabel.translatesAutoresizingMaskIntoConstraints = NO; + codeLabel.attributedText = highlighted; + codeLabel.numberOfLines = 0; + codeLabel.lineBreakMode = NSLineBreakByClipping; + + UIScrollView *codeScrollView = [[UIScrollView alloc] init]; + codeScrollView.translatesAutoresizingMaskIntoConstraints = NO; + codeScrollView.showsHorizontalScrollIndicator = YES; + codeScrollView.showsVerticalScrollIndicator = NO; + codeScrollView.bounces = NO; + [codeScrollView addSubview:codeLabel]; + [container addSubview:codeScrollView]; + + // File name label below the code frame + UILabel *fileLabel = [[UILabel alloc] init]; + fileLabel.translatesAutoresizingMaskIntoConstraints = NO; + NSString *fileName = errorData.codeFrameFileName.lastPathComponent ? errorData.codeFrameFileName.lastPathComponent + : errorData.codeFrameFileName; + if (errorData.codeFrameRow > 0) { + fileLabel.text = [NSString + stringWithFormat:@"%@ (%ld:%ld)", fileName, (long)errorData.codeFrameRow, (long)errorData.codeFrameColumn + 1]; + } else if (fileName.length > 0) { + fileLabel.text = fileName; + } + fileLabel.textColor = RCTRedBox2TextColor(0.5); + fileLabel.font = [UIFont fontWithName:@"Menlo-Regular" size:12]; + fileLabel.textAlignment = NSTextAlignmentCenter; + [cell.contentView addSubview:fileLabel]; + + [NSLayoutConstraint activateConstraints:@[ + [container.topAnchor constraintEqualToAnchor:cell.contentView.topAnchor constant:5], + [container.leadingAnchor constraintEqualToAnchor:cell.contentView.leadingAnchor constant:10], + [container.trailingAnchor constraintEqualToAnchor:cell.contentView.trailingAnchor constant:-10], + + [codeScrollView.topAnchor constraintEqualToAnchor:container.topAnchor constant:10], + [codeScrollView.leadingAnchor constraintEqualToAnchor:container.leadingAnchor constant:10], + [codeScrollView.trailingAnchor constraintEqualToAnchor:container.trailingAnchor constant:-10], + [codeScrollView.bottomAnchor constraintEqualToAnchor:container.bottomAnchor constant:-10], + + [codeLabel.topAnchor constraintEqualToAnchor:codeScrollView.topAnchor], + [codeLabel.leadingAnchor constraintEqualToAnchor:codeScrollView.leadingAnchor], + [codeLabel.trailingAnchor constraintEqualToAnchor:codeScrollView.trailingAnchor], + [codeLabel.bottomAnchor constraintEqualToAnchor:codeScrollView.bottomAnchor], + [codeLabel.heightAnchor constraintEqualToAnchor:codeScrollView.heightAnchor], + + [fileLabel.topAnchor constraintEqualToAnchor:container.bottomAnchor constant:10], + [fileLabel.leadingAnchor constraintEqualToAnchor:cell.contentView.leadingAnchor constant:10], + [fileLabel.trailingAnchor constraintEqualToAnchor:cell.contentView.trailingAnchor constant:-10], + [fileLabel.bottomAnchor constraintEqualToAnchor:cell.contentView.bottomAnchor constant:-10], + ]]; + + return cell; +} + +- (CGFloat)tableView:(__unused UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { - if (indexPath.section == 0) { + auto section = [self sectionForIndex:indexPath.section]; + if (section == Section::Message || section == Section::CodeFrame) { return UITableViewAutomaticDimension; - } else { - return 50; } + return 50; } - (CGFloat)tableView:(__unused UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath { - if (indexPath.section == 0) { - return 100; + switch ([self sectionForIndex:indexPath.section]) { + case Section::Message: + return 100; + case Section::CodeFrame: + return 200; + case Section::CallStack: + case Section::kMaxValue: + return 50; } - return 50; } -- (UIView *)tableView:(__unused UITableView *)tableView viewForHeaderInSection:(NSInteger)section +- (UIView *)sectionHeaderViewWithTitle:(NSString *)title { - if (section == 1) { - UIView *headerView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 0, 38)]; - headerView.backgroundColor = [UIColor clearColor]; + UIView *headerView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 0, 38)]; + headerView.backgroundColor = [UIColor clearColor]; - UILabel *label = [[UILabel alloc] init]; - label.translatesAutoresizingMaskIntoConstraints = NO; - label.text = @"Call Stack"; - label.textColor = [UIColor whiteColor]; - label.font = [UIFont systemFontOfSize:18 weight:UIFontWeightSemibold]; - [headerView addSubview:label]; + UILabel *label = [[UILabel alloc] init]; + label.translatesAutoresizingMaskIntoConstraints = NO; + label.text = title; + label.textColor = [UIColor whiteColor]; + label.font = [UIFont systemFontOfSize:18 weight:UIFontWeightSemibold]; + [headerView addSubview:label]; - [NSLayoutConstraint activateConstraints:@[ - [label.leadingAnchor constraintEqualToAnchor:headerView.leadingAnchor constant:12], - [label.trailingAnchor constraintEqualToAnchor:headerView.trailingAnchor constant:-12], - [label.bottomAnchor constraintEqualToAnchor:headerView.bottomAnchor constant:-10], - ]]; + [NSLayoutConstraint activateConstraints:@[ + [label.leadingAnchor constraintEqualToAnchor:headerView.leadingAnchor constant:12], + [label.trailingAnchor constraintEqualToAnchor:headerView.trailingAnchor constant:-12], + [label.bottomAnchor constraintEqualToAnchor:headerView.bottomAnchor constant:-10], + ]]; + + return headerView; +} - return headerView; +- (UIView *)tableView:(__unused UITableView *)tableView viewForHeaderInSection:(NSInteger)section +{ + switch ([self sectionForIndex:section]) { + case Section::CodeFrame: + return [self sectionHeaderViewWithTitle:@"Source"]; + case Section::CallStack: + return [self sectionHeaderViewWithTitle:@"Call Stack"]; + case Section::Message: + case Section::kMaxValue: + return nil; } - return nil; } - (CGFloat)tableView:(__unused UITableView *)tableView heightForHeaderInSection:(NSInteger)section { - return section == 1 ? 38 : 0; + auto s = [self sectionForIndex:section]; + return (s == Section::CodeFrame || s == Section::CallStack) ? 38 : 0; } - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { - if (indexPath.section == 1) { + if ([self sectionForIndex:indexPath.section] == Section::CallStack) { NSUInteger row = indexPath.row; RCTJSStackFrame *stackFrame = _lastStackTrace[row]; [_actionDelegate redBoxController:self openStackFrameInEditor:stackFrame]; diff --git a/packages/react-native/React/CoreModules/RCTRedBox2ErrorParser+Internal.h b/packages/react-native/React/CoreModules/RCTRedBox2ErrorParser+Internal.h new file mode 100644 index 000000000000..5cfb846e6ba6 --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTRedBox2ErrorParser+Internal.h @@ -0,0 +1,42 @@ +/* + * 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. + */ + +#import + +/** + * Structured error data extracted from a raw error message. + * Mirrors LogBoxLog.js / parseLogBoxLog.js data model. + */ +@interface RCTRedBox2ErrorData : NSObject + +/// Display title, e.g. "Syntax Error", "Render Error", "Uncaught Error" +@property (nonatomic, copy) NSString *title; +/// The error message body (code frame stripped out) +@property (nonatomic, copy) NSString *message; +/// Raw code frame text with ANSI escape codes preserved (nil if not a syntax/transform error) +@property (nonatomic, copy, nullable) NSString *codeFrame; +/// Source file path for the code frame +@property (nonatomic, copy, nullable) NSString *codeFrameFileName; +/// Line number in the source file +@property (nonatomic, assign) NSInteger codeFrameRow; +/// Column number in the source file +@property (nonatomic, assign) NSInteger codeFrameColumn; + +@end + +/** + * Parses raw error messages into structured RCTRedBox2ErrorData. + * ObjC port of parseLogBoxLog.js / parseLogBoxException. + */ +@interface RCTRedBox2ErrorParser : NSObject + ++ (RCTRedBox2ErrorData *)parseErrorMessage:(NSString *)message + name:(nullable NSString *)name + componentStack:(nullable NSString *)componentStack + isFatal:(BOOL)isFatal; + +@end diff --git a/packages/react-native/React/CoreModules/RCTRedBox2ErrorParser.mm b/packages/react-native/React/CoreModules/RCTRedBox2ErrorParser.mm new file mode 100644 index 000000000000..7ab0dfdb814e --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTRedBox2ErrorParser.mm @@ -0,0 +1,54 @@ +/* + * 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. + */ + +#import "RCTRedBox2ErrorParser+Internal.h" + +#import +#import + +#if RCT_DEV_MENU + +using facebook::react::unstable_redbox::ParsedError; +using facebook::react::unstable_redbox::parseErrorMessage; + +static RCTRedBox2ErrorData *RCTRedBox2ErrorDataFromParsedError(const ParsedError &parsed) +{ + RCTRedBox2ErrorData *data = [[RCTRedBox2ErrorData alloc] init]; + data.title = [NSString stringWithUTF8String:parsed.title.c_str()]; + data.message = [NSString stringWithUTF8String:parsed.message.c_str()]; + if (parsed.codeFrame.has_value()) { + const auto &cf = *parsed.codeFrame; + data.codeFrame = [NSString stringWithUTF8String:cf.content.c_str()]; + data.codeFrameFileName = [NSString stringWithUTF8String:cf.fileName.c_str()]; + data.codeFrameRow = cf.row; + data.codeFrameColumn = cf.column; + } + + return data; +} + +@implementation RCTRedBox2ErrorData +@end + +@implementation RCTRedBox2ErrorParser + ++ (RCTRedBox2ErrorData *)parseErrorMessage:(NSString *)message + name:(nullable NSString *)name + componentStack:(nullable NSString *)componentStack + isFatal:(BOOL)isFatal +{ + auto parsed = parseErrorMessage( + (message != nullptr) ? std::string(message.UTF8String) : std::string(), + (name != nullptr) ? std::string(name.UTF8String) : std::string(), + (componentStack != nullptr) ? std::string(componentStack.UTF8String) : std::string(), + isFatal); + return RCTRedBox2ErrorDataFromParsedError(parsed); +} + +@end + +#endif From f0c37115c614f8f7abf1add76e288c5cd220606c Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 22 Apr 2026 07:56:36 -0700 Subject: [PATCH 06/11] Mark all errors as retryable (#56552) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/56552 Adds `isRetryable` and `isCompileError` to C++ `ParsedError` and its iOS wrapper, for use in RedBox 2.0. **Currently all errors are assumed to be retryable under RedBox 2.0.** Changelog: [Internal] Reviewed By: robhogan Differential Revision: D101357708 fbshipit-source-id: 6a83b5944e5451a7818c66a321bf8d45a1951970 --- .../React/CoreModules/RCTRedBox2ErrorParser+Internal.h | 4 ++++ .../react-native/React/CoreModules/RCTRedBox2ErrorParser.mm | 3 +++ .../ReactCommon/react/debug/redbox/RedBoxErrorParser.h | 1 + .../react/debug/redbox/tests/RedBoxErrorParserTest.cpp | 2 ++ 4 files changed, 10 insertions(+) diff --git a/packages/react-native/React/CoreModules/RCTRedBox2ErrorParser+Internal.h b/packages/react-native/React/CoreModules/RCTRedBox2ErrorParser+Internal.h index 5cfb846e6ba6..0a1fc88b5dfe 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox2ErrorParser+Internal.h +++ b/packages/react-native/React/CoreModules/RCTRedBox2ErrorParser+Internal.h @@ -25,6 +25,10 @@ @property (nonatomic, assign) NSInteger codeFrameRow; /// Column number in the source file @property (nonatomic, assign) NSInteger codeFrameColumn; +/// Whether this is a compile-time error (syntax, transform, resolution) vs a runtime error +@property (nonatomic, assign) BOOL isCompileError; +/// Whether auto-retry is appropriate (compile errors, connectivity failures, etc.) +@property (nonatomic, assign) BOOL isRetryable; @end diff --git a/packages/react-native/React/CoreModules/RCTRedBox2ErrorParser.mm b/packages/react-native/React/CoreModules/RCTRedBox2ErrorParser.mm index 7ab0dfdb814e..38cfb6679d63 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox2ErrorParser.mm +++ b/packages/react-native/React/CoreModules/RCTRedBox2ErrorParser.mm @@ -20,6 +20,9 @@ RCTRedBox2ErrorData *data = [[RCTRedBox2ErrorData alloc] init]; data.title = [NSString stringWithUTF8String:parsed.title.c_str()]; data.message = [NSString stringWithUTF8String:parsed.message.c_str()]; + data.isCompileError = parsed.isCompileError; + data.isRetryable = parsed.isRetryable; + if (parsed.codeFrame.has_value()) { const auto &cf = *parsed.codeFrame; data.codeFrame = [NSString stringWithUTF8String:cf.content.c_str()]; diff --git a/packages/react-native/ReactCommon/react/debug/redbox/RedBoxErrorParser.h b/packages/react-native/ReactCommon/react/debug/redbox/RedBoxErrorParser.h index 185869a2565d..b8aaf6d85b3b 100644 --- a/packages/react-native/ReactCommon/react/debug/redbox/RedBoxErrorParser.h +++ b/packages/react-native/ReactCommon/react/debug/redbox/RedBoxErrorParser.h @@ -24,6 +24,7 @@ struct ParsedError { std::string message; std::optional codeFrame; bool isCompileError = false; + bool isRetryable = true; }; /** diff --git a/packages/react-native/ReactCommon/react/debug/redbox/tests/RedBoxErrorParserTest.cpp b/packages/react-native/ReactCommon/react/debug/redbox/tests/RedBoxErrorParserTest.cpp index 7ac1c327895f..912b4b7c1191 100644 --- a/packages/react-native/ReactCommon/react/debug/redbox/tests/RedBoxErrorParserTest.cpp +++ b/packages/react-native/ReactCommon/react/debug/redbox/tests/RedBoxErrorParserTest.cpp @@ -23,6 +23,7 @@ TEST(RedBoxErrorParserTest, ParsesBabelTransformError) { EXPECT_EQ(result.codeFrame->row, 10); EXPECT_EQ(result.codeFrame->column, 5); EXPECT_TRUE(result.isCompileError); + EXPECT_TRUE(result.isRetryable); } TEST(RedBoxErrorParserTest, ParsesMetroError) { @@ -75,6 +76,7 @@ TEST(RedBoxErrorParserTest, DefaultsToUncaughtError) { EXPECT_EQ(result.message, "TypeError: undefined is not a function"); EXPECT_FALSE(result.codeFrame.has_value()); EXPECT_FALSE(result.isCompileError); + EXPECT_TRUE(result.isRetryable); } TEST(RedBoxErrorParserTest, UsesNameForTitle) { From f102644500ee76ce37a010585b9cf0f9cf197295 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 22 Apr 2026 07:56:36 -0700 Subject: [PATCH 07/11] Auto-reload on retryable errors (#56549) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/56549 Adds an automatic reload countdown to the RedBox 2.0 overlay for errors marked as retryable by the error parser. (Currently all of them, see D101357708.) Changelog: [Internal] Reviewed By: cipolleschi Differential Revision: D98107027 fbshipit-source-id: 9f39491e4ecf5086a76b7f6446c34231a5d4eb0d --- .../React/CoreModules/RCTRedBox2Controller.mm | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm b/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm index 0c2c5cce2bb1..006d6ffc4225 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm +++ b/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm @@ -47,6 +47,8 @@ bool visible = false; }; +static const NSTimeInterval kAutoRetryInterval = 20.0; + @implementation RCTRedBox2Controller { UITableView *_stackTraceTableView; UILabel *_headerTitleLabel; @@ -58,6 +60,10 @@ @implementation RCTRedBox2Controller { int _lastErrorCookie; RCTRedBox2ErrorData *_errorData; std::array _sectionStates; + NSTimer *_autoRetryTimer; + NSInteger _autoRetryCountdown; + UIButton *_reloadButton; + NSString *_reloadBaseText; } - (instancetype)initWithCustomButtonTitles:(NSArray *)customButtonTitles @@ -147,9 +153,8 @@ - (UIView *)createFooterBar UIButton *dismissButton = [self footerButton:dismissText accessibilityIdentifier:@"redbox-dismiss" selector:@selector(dismiss)]; - UIButton *reloadButton = [self footerButton:reloadText - accessibilityIdentifier:@"redbox-reload" - selector:@selector(reload)]; + _reloadBaseText = reloadText; + _reloadButton = [self footerButton:reloadText accessibilityIdentifier:@"redbox-reload" selector:@selector(reload)]; UIButton *copyButton = [self footerButton:copyText accessibilityIdentifier:@"redbox-copy" selector:@selector(copyStack)]; @@ -162,7 +167,7 @@ - (UIView *)createFooterBar buttonStackView.backgroundColor = RCTRedBox2BackgroundColor(); [buttonStackView addArrangedSubview:dismissButton]; - [buttonStackView addArrangedSubview:reloadButton]; + [buttonStackView addArrangedSubview:_reloadButton]; [buttonStackView addArrangedSubview:copyButton]; for (NSUInteger i = 0; i < [_customButtonTitles count]; i++) { @@ -281,16 +286,27 @@ - (void)showErrorMessage:(NSString *)message if (!isRootViewControllerPresented) { [RCTKeyWindow().rootViewController presentViewController:self animated:NO completion:nil]; } + + // Update all UI from _errorData (view is now guaranteed to be loaded) + _headerTitleLabel.text = _errorData.isCompileError ? @"Failed to compile" : @"Error"; + [_stackTraceTableView reloadData]; + [_stackTraceTableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0] + atScrollPosition:UITableViewScrollPositionTop + animated:NO]; + + [self startAutoRetryIfApplicable]; } } - (void)dismiss { + [self stopAutoRetry]; [self dismissViewControllerAnimated:NO completion:nil]; } - (void)reload { + [self stopAutoRetry]; if (_actionDelegate != nil) { [_actionDelegate reloadFromRedBoxController:self]; } else { @@ -300,6 +316,49 @@ - (void)reload } } +#pragma mark - Auto-Retry + +- (void)startAutoRetryIfApplicable +{ + [self stopAutoRetry]; + if (!_errorData.isRetryable) { + return; + } + _autoRetryCountdown = (NSInteger)kAutoRetryInterval; + [self updateReloadButtonTitle]; + _autoRetryTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 + target:self + selector:@selector(autoRetryTick) + userInfo:nil + repeats:YES]; +} + +- (void)stopAutoRetry +{ + [_autoRetryTimer invalidate]; + _autoRetryTimer = nil; + if (_reloadButton) { + [_reloadButton setTitle:_reloadBaseText forState:UIControlStateNormal]; + } +} + +- (void)autoRetryTick +{ + _autoRetryCountdown--; + if (_autoRetryCountdown <= 0) { + [self stopAutoRetry]; + [self reload]; + } else { + [self updateReloadButtonTitle]; + } +} + +- (void)updateReloadButtonTitle +{ + NSString *title = [NSString stringWithFormat:@"%@ (%lds)", _reloadBaseText, (long)_autoRetryCountdown]; + [_reloadButton setTitle:title forState:UIControlStateNormal]; +} + - (void)copyStack { NSMutableString *fullStackTrace; From 23b1a16448a7164447911eb636f17dc252dbfb2f Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 22 Apr 2026 07:56:36 -0700 Subject: [PATCH 08/11] Attempt to clear RedBox by automatically reloading on Metro file change (#56554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/56554 While RedBox is displayed, open a native WebSocket to Metro's `/hot` endpoint. On file change, automatically reload — bridging the gap for bundle loading errors where the JS HMR client is unavailable. Changelog: [Internal] Reviewed By: robhogan Differential Revision: D98350597 fbshipit-source-id: 9d11d99226483d937be45d75be008b0f9f25572b --- .../React/CoreModules/RCTRedBox+Internal.h | 6 + .../React/CoreModules/RCTRedBox.mm | 9 ++ .../RCTRedBox2Controller+Internal.h | 5 +- .../React/CoreModules/RCTRedBox2Controller.mm | 26 ++++ .../CoreModules/RCTRedBoxHMRClient+Internal.h | 26 ++++ .../React/CoreModules/RCTRedBoxHMRClient.mm | 125 ++++++++++++++++++ 6 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 packages/react-native/React/CoreModules/RCTRedBoxHMRClient+Internal.h create mode 100644 packages/react-native/React/CoreModules/RCTRedBoxHMRClient.mm diff --git a/packages/react-native/React/CoreModules/RCTRedBox+Internal.h b/packages/react-native/React/CoreModules/RCTRedBox+Internal.h index 4e68766b39ee..d59e8a946f30 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox+Internal.h +++ b/packages/react-native/React/CoreModules/RCTRedBox+Internal.h @@ -33,4 +33,10 @@ @end +@protocol RCTRedBox2Controlling + +@property (nonatomic, strong, nullable) NSURL *bundleURL; + +@end + #endif diff --git a/packages/react-native/React/CoreModules/RCTRedBox.mm b/packages/react-native/React/CoreModules/RCTRedBox.mm index f0630767607b..91a9878ddb1a 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox.mm +++ b/packages/react-native/React/CoreModules/RCTRedBox.mm @@ -165,6 +165,14 @@ - (void)updateErrorMessage:(NSString *)message [self showErrorMessage:message withParsedStack:stack isUpdate:YES errorCookie:errorCookie]; } +- (id)_redBox2Controller +{ + if ([_controller conformsToProtocol:@protocol(RCTRedBox2Controlling)]) { + return (id)_controller; + } + return nil; +} + - (void)showErrorMessage:(NSString *)message withParsedStack:(NSArray *)stack isUpdate:(BOOL)isUpdate @@ -195,6 +203,7 @@ - (void)showErrorMessage:(NSString *)message } self->_controller.actionDelegate = self; } + [self _redBox2Controller].bundleURL = self->_overrideBundleURL ?: self->_bundleManager.bundleURL; [self->_controller showErrorMessage:errorInfo.errorMessage withStack:errorInfo.stack isUpdate:isUpdate diff --git a/packages/react-native/React/CoreModules/RCTRedBox2Controller+Internal.h b/packages/react-native/React/CoreModules/RCTRedBox2Controller+Internal.h index 0922cd95d13c..ec6d6964a2ae 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox2Controller+Internal.h +++ b/packages/react-native/React/CoreModules/RCTRedBox2Controller+Internal.h @@ -13,7 +13,7 @@ using RCTRedBox2ButtonPressHandler = void (^)(void); -@interface RCTRedBox2Controller : UIViewController +@interface RCTRedBox2Controller : UIViewController @property (nonatomic, weak) id actionDelegate; @@ -25,6 +25,9 @@ using RCTRedBox2ButtonPressHandler = void (^)(void); isUpdate:(BOOL)isUpdate errorCookie:(int)errorCookie; +/// The bundle URL used by the app, for the native HMR connection. +@property (nonatomic, strong, nullable) NSURL *bundleURL; + - (void)dismiss; @end diff --git a/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm b/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm index 006d6ffc4225..ae94a029a64b 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm +++ b/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm @@ -16,6 +16,7 @@ #import "RCTRedBox2AnsiParser+Internal.h" #import "RCTRedBox2ErrorParser+Internal.h" +#import "RCTRedBoxHMRClient+Internal.h" // @lint-ignore-every CLANGTIDY clang-diagnostic-switch-default // NOTE: clang-diagnostic-switch-default conflicts with clang-diagnostic-switch-enum @@ -64,6 +65,7 @@ @implementation RCTRedBox2Controller { NSInteger _autoRetryCountdown; UIButton *_reloadButton; NSString *_reloadBaseText; + RCTRedBoxHMRClient *_hmrClient; } - (instancetype)initWithCustomButtonTitles:(NSArray *)customButtonTitles @@ -295,6 +297,7 @@ - (void)showErrorMessage:(NSString *)message animated:NO]; [self startAutoRetryIfApplicable]; + [self _startHMRClient]; } } @@ -306,6 +309,7 @@ - (void)dismiss - (void)reload { + [self _stopHMRClient]; [self stopAutoRetry]; if (_actionDelegate != nil) { [_actionDelegate reloadFromRedBoxController:self]; @@ -316,6 +320,28 @@ - (void)reload } } +#pragma mark - Native HMR Connection + +- (void)_startHMRClient +{ + [self _stopHMRClient]; + if (!_bundleURL) { + return; + } + __weak __typeof(self) weakSelf = self; + _hmrClient = [[RCTRedBoxHMRClient alloc] initWithBundleURL:_bundleURL + onFileChange:^{ + [weakSelf reload]; + }]; + [_hmrClient start]; +} + +- (void)_stopHMRClient +{ + [_hmrClient stop]; + _hmrClient = nil; +} + #pragma mark - Auto-Retry - (void)startAutoRetryIfApplicable diff --git a/packages/react-native/React/CoreModules/RCTRedBoxHMRClient+Internal.h b/packages/react-native/React/CoreModules/RCTRedBoxHMRClient+Internal.h new file mode 100644 index 000000000000..f877aae348d6 --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTRedBoxHMRClient+Internal.h @@ -0,0 +1,26 @@ +/* + * 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. + */ + +#import + +#import + +#if RCT_DEV_MENU + +/** + * Minimal native HMR client that connects to Metro's /hot WebSocket endpoint + * while RedBox 2.0 is displayed. When Metro detects a file change + * (update-start), this client triggers a reload so the user's fix is picked up + * automatically — even when the JS runtime has no active HMR connection. + */ +@interface RCTRedBoxHMRClient : NSObject +- (instancetype)initWithBundleURL:(NSURL *)bundleURL onFileChange:(void (^)(void))onFileChange; +- (void)start; +- (void)stop; +@end + +#endif diff --git a/packages/react-native/React/CoreModules/RCTRedBoxHMRClient.mm b/packages/react-native/React/CoreModules/RCTRedBoxHMRClient.mm new file mode 100644 index 000000000000..8186ea0a6b51 --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTRedBoxHMRClient.mm @@ -0,0 +1,125 @@ +/* + * 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. + */ + +#import "RCTRedBoxHMRClient+Internal.h" + +#if RCT_DEV_MENU + +@implementation RCTRedBoxHMRClient { + NSURL *_bundleURL; + NSURLSessionWebSocketTask *_webSocketTask; + NSURLSession *_session; + void (^_onFileChange)(void); + BOOL _stopped; +} + +- (instancetype)initWithBundleURL:(NSURL *)bundleURL onFileChange:(void (^)(void))onFileChange +{ + if (self = [super init]) { + _bundleURL = bundleURL; + _onFileChange = [onFileChange copy]; + } + return self; +} + +- (void)start +{ + if (![_bundleURL.scheme hasPrefix:@"http"]) { + return; + } + + NSURLComponents *components = [[NSURLComponents alloc] initWithURL:_bundleURL resolvingAgainstBaseURL:NO]; + components.scheme = [_bundleURL.scheme isEqualToString:@"https"] ? @"wss" : @"ws"; + components.path = @"/hot"; + components.query = nil; + components.fragment = nil; + NSURL *wsURL = components.URL; + if (!wsURL) { + return; + } + + _session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] + delegate:self + delegateQueue:nil]; + _webSocketTask = [_session webSocketTaskWithURL:wsURL]; + [_webSocketTask resume]; +} + +- (void)stop +{ + _stopped = YES; + _onFileChange = nil; + [_webSocketTask cancel]; + _webSocketTask = nil; + [_session invalidateAndCancel]; + _session = nil; +} + +- (void)URLSession:(__unused NSURLSession *)session + webSocketTask:(__unused NSURLSessionWebSocketTask *)webSocketTask + didOpenWithProtocol:(__unused NSString *)protocol +{ + NSDictionary *registration = @{ + @"type" : @"register-entrypoints", + @"entryPoints" : @[ _bundleURL.absoluteString ], + }; + NSData *json = [NSJSONSerialization dataWithJSONObject:registration options:0 error:nil]; + NSURLSessionWebSocketMessage *msg = [[NSURLSessionWebSocketMessage alloc] + initWithString:[[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding]]; + [_webSocketTask sendMessage:msg + completionHandler:^(__unused NSError *error){ + }]; + [self _listenForNextMessage]; +} + +- (void)_listenForNextMessage +{ + if (_stopped) { + return; + } + __weak __typeof(self) weakSelf = self; + [_webSocketTask receiveMessageWithCompletionHandler:^(NSURLSessionWebSocketMessage *message, NSError *error) { + if (error || !message) { + return; + } + [weakSelf _handleMessage:message]; + [weakSelf _listenForNextMessage]; + }]; +} + +- (void)_handleMessage:(NSURLSessionWebSocketMessage *)message +{ + if (message.type != NSURLSessionWebSocketMessageTypeString || _stopped) { + return; + } + NSData *data = [message.string dataUsingEncoding:NSUTF8StringEncoding]; + NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if ([json[@"type"] isEqualToString:@"update-start"]) { + // Ignore the initial update that fires when the client first registers. + // Only react to subsequent file changes. + NSDictionary *body = json[@"body"]; + if ([body isKindOfClass:[NSDictionary class]] && [body[@"isInitialUpdate"] boolValue]) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (self->_onFileChange) { + self->_onFileChange(); + } + }); + } +} + +- (void)URLSession:(__unused NSURLSession *)session + webSocketTask:(__unused NSURLSessionWebSocketTask *)task + didCloseWithCode:(__unused NSURLSessionWebSocketCloseCode)closeCode + reason:(__unused NSData *)reason +{ +} + +@end + +#endif From 0b9b5d305f8331071ffef3cfd560c57a377aaf5d Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 22 Apr 2026 07:56:36 -0700 Subject: [PATCH 09/11] Fix truncated filenames in call stack frames (#56565) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/56565 Stack frame file paths in RedBox 2.0 were displaying as truncated jsc-safe-url fragments (e.g. `dev=true` instead of `index.bundle`). This happened because `lastPathComponent` treats the `//&` path-encoded query as additional path segments. Here, we port the `jsc-safe-url` npm package to shared C++ (matching the JS implementation line-for-line, including the RFC 3986 appendix B regex) and use it to normalize stack frame URLs before extracting filenames. Query strings are also stripped after normalization. Changelog: [Internal] Reviewed By: robhogan Differential Revision: D101796395 fbshipit-source-id: 38d5a8ccb111e78f847be4a551e359dd075e6a0f --- .../CoreModules/RCTJscSafeUrl+Internal.h | 23 +++ .../React/CoreModules/RCTJscSafeUrl.mm | 38 ++++ .../React/CoreModules/RCTRedBox2Controller.mm | 12 +- .../react/debug/redbox/JscSafeUrl.cpp | 179 ++++++++++++++++++ .../react/debug/redbox/JscSafeUrl.h | 27 +++ .../debug/redbox/tests/JscSafeUrlTest.cpp | 173 +++++++++++++++++ 6 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 packages/react-native/React/CoreModules/RCTJscSafeUrl+Internal.h create mode 100644 packages/react-native/React/CoreModules/RCTJscSafeUrl.mm create mode 100644 packages/react-native/ReactCommon/react/debug/redbox/JscSafeUrl.cpp create mode 100644 packages/react-native/ReactCommon/react/debug/redbox/JscSafeUrl.h create mode 100644 packages/react-native/ReactCommon/react/debug/redbox/tests/JscSafeUrlTest.cpp diff --git a/packages/react-native/React/CoreModules/RCTJscSafeUrl+Internal.h b/packages/react-native/React/CoreModules/RCTJscSafeUrl+Internal.h new file mode 100644 index 000000000000..8db5825b1b17 --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTJscSafeUrl+Internal.h @@ -0,0 +1,23 @@ +/* + * 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. + */ + +#import + +/** + * Converts between standard URLs and JSC-safe URLs. + * + * JSC (JavaScriptCore) strips query strings from source URLs in stack traces + * as of iOS 16.4. Metro works around this by encoding the query string into + * the URL path. These methods convert between the two formats. + */ +@interface RCTJscSafeUrl : NSObject + ++ (nonnull NSString *)normalUrlFromJscSafeUrl:(nonnull NSString *)url; ++ (nonnull NSString *)jscSafeUrlFromNormalUrl:(nonnull NSString *)url; ++ (BOOL)isJscSafeUrl:(nonnull NSString *)url; + +@end diff --git a/packages/react-native/React/CoreModules/RCTJscSafeUrl.mm b/packages/react-native/React/CoreModules/RCTJscSafeUrl.mm new file mode 100644 index 000000000000..7ce7a8f92c18 --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTJscSafeUrl.mm @@ -0,0 +1,38 @@ +/* + * 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. + */ + +#import "RCTJscSafeUrl+Internal.h" + +#import +#import + +#if RCT_DEV_MENU + +using facebook::react::unstable_redbox::isJscSafeUrl; +using facebook::react::unstable_redbox::toJscSafeUrl; +using facebook::react::unstable_redbox::toNormalUrl; + +@implementation RCTJscSafeUrl + ++ (NSString *)normalUrlFromJscSafeUrl:(NSString *)url +{ + return [NSString stringWithUTF8String:toNormalUrl(url.UTF8String).c_str()]; +} + ++ (NSString *)jscSafeUrlFromNormalUrl:(NSString *)url +{ + return [NSString stringWithUTF8String:toJscSafeUrl(url.UTF8String).c_str()]; +} + ++ (BOOL)isJscSafeUrl:(NSString *)url +{ + return isJscSafeUrl(url.UTF8String); +} + +@end + +#endif diff --git a/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm b/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm index ae94a029a64b..e3a5a3f1ebee 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm +++ b/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm @@ -14,6 +14,7 @@ #include +#import "RCTJscSafeUrl+Internal.h" #import "RCTRedBox2AnsiParser+Internal.h" #import "RCTRedBox2ErrorParser+Internal.h" #import "RCTRedBoxHMRClient+Internal.h" @@ -410,7 +411,13 @@ - (void)copyStack - (NSString *)formatFrameSource:(RCTJSStackFrame *)stackFrame { - NSString *fileName = RCTNilIfNull(stackFrame.file) ? [stackFrame.file lastPathComponent] : @""; + NSString *file = [RCTJscSafeUrl normalUrlFromJscSafeUrl:stackFrame.file]; + // Strip query string (e.g. ?platform=ios&dev=true) before extracting the filename. + NSRange queryRange = [file rangeOfString:@"?"]; + if (queryRange.location != NSNotFound) { + file = [file substringToIndex:queryRange.location]; + } + NSString *fileName = RCTNilIfNull(file) ? [file lastPathComponent] : @""; NSString *lineInfo = [NSString stringWithFormat:@"%@:%lld", fileName, (long long)stackFrame.lineNumber]; if (stackFrame.column != 0) { @@ -619,8 +626,7 @@ - (UITableViewCell *)reuseCell:(UITableViewCell *)cell forCodeFrame:(RCTRedBox2E // File name label below the code frame UILabel *fileLabel = [[UILabel alloc] init]; fileLabel.translatesAutoresizingMaskIntoConstraints = NO; - NSString *fileName = errorData.codeFrameFileName.lastPathComponent ? errorData.codeFrameFileName.lastPathComponent - : errorData.codeFrameFileName; + NSString *fileName = errorData.codeFrameFileName.lastPathComponent ?: errorData.codeFrameFileName; if (errorData.codeFrameRow > 0) { fileLabel.text = [NSString stringWithFormat:@"%@ (%ld:%ld)", fileName, (long)errorData.codeFrameRow, (long)errorData.codeFrameColumn + 1]; diff --git a/packages/react-native/ReactCommon/react/debug/redbox/JscSafeUrl.cpp b/packages/react-native/ReactCommon/react/debug/redbox/JscSafeUrl.cpp new file mode 100644 index 000000000000..c24f9ae8d94f --- /dev/null +++ b/packages/react-native/ReactCommon/react/debug/redbox/JscSafeUrl.cpp @@ -0,0 +1,179 @@ +/* + * 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 "JscSafeUrl.h" + +#include +#include // NOLINT(facebook-hte-BadInclude-regex) +#include +#include + +// @lint-ignore-every CLANGTIDY facebook-hte-StdRegexIsAwful + +namespace facebook::react::unstable_redbox { + +namespace { + +// We use regex-based URL parsing as defined in RFC 3986 because it's easier to +// determine whether the input is a complete URI, a path-absolute or a +// path-rootless (as defined in the spec), and be as faithful to the input as +// possible. This will match any string, and does not imply validity. +// +// https://www.rfc-editor.org/rfc/rfc3986#appendix-B +const std::regex& uriRegex() { + static const std::regex re( + R"(^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?)"); + return re; +} + +struct ParsedUri { + std::string_view schemeAndAuthority; + std::string_view path; + bool hasQueryPart = false; + std::string_view queryWithoutQuestionMark; + std::string_view fragmentWithHash; +}; + +ParsedUri rfc3986Parse(std::string_view url) { + std::cmatch match; + if (!std::regex_match( + url.data(), url.data() + url.size(), match, uriRegex())) { + throw std::runtime_error("Unexpected error - failed to regex-match URL"); + } + + // match[1] = scheme with colon (e.g. "http:") + // match[3] = authority with slashes (e.g. "//host") + // match[5] = path + // match[6] = query with question mark (e.g. "?key=val") + // match[7] = query without question mark + // match[8] = fragment with hash (e.g. "#frag") + + auto viewOf = [&](int group) -> std::string_view { + if (!match[group].matched) { + return {}; + } + return {match[group].first, static_cast(match[group].length())}; + }; + + // schemeAndAuthority = (match[1] || "") + (match[3] || "") + // These are contiguous when both present, but may be individually absent. + std::string_view schemeAndAuthority; + if (match[1].matched && match[3].matched) { + assert(match[1].second == match[3].first); + schemeAndAuthority = { + match[1].first, static_cast(match[3].second - match[1].first)}; + } else if (match[1].matched) { + schemeAndAuthority = viewOf(1); + } else if (match[3].matched) { + schemeAndAuthority = viewOf(3); + } + + return ParsedUri{ + .schemeAndAuthority = schemeAndAuthority, + .path = viewOf(5), + .hasQueryPart = match[6].matched, + .queryWithoutQuestionMark = viewOf(7), + .fragmentWithHash = viewOf(8), + }; +} + +} // namespace + +bool isJscSafeUrl(std::string_view url) { + return !rfc3986Parse(url).hasQueryPart; +} + +std::string toNormalUrl(std::string url) { + auto parsed = rfc3986Parse(url); + auto markerPos = parsed.path.find("//&"); + if (markerPos == std::string_view::npos) { + return url; + } + + // path before //&, then ?, then path after //& + std::string_view pathBefore = parsed.path.substr(0, markerPos); + std::string_view pathAfter = parsed.path.substr(markerPos + 3); + + // We don't expect JSC urls to also have query strings, but interpret + // liberally and append them. + bool hasExistingQuery = !parsed.queryWithoutQuestionMark.empty(); + + // Likewise, JSC URLs will usually have their fragments stripped, but + // preserve if we find one. + size_t totalSize = parsed.schemeAndAuthority.size() + pathBefore.size() + + 1 /* ? */ + pathAfter.size() + + (hasExistingQuery ? 1 + parsed.queryWithoutQuestionMark.size() : 0) + + parsed.fragmentWithHash.size(); + + std::string result; + result.reserve(totalSize); + result += parsed.schemeAndAuthority; + result += pathBefore; + result += '?'; + result += pathAfter; + if (hasExistingQuery) { + result += '&'; + result += parsed.queryWithoutQuestionMark; + } + result += parsed.fragmentWithHash; + assert(result.size() == totalSize); + return result; +} + +std::string toJscSafeUrl(std::string url) { + if (!rfc3986Parse(url).hasQueryPart) { + return url; + } + url = toNormalUrl(std::move(url)); + auto parsed = rfc3986Parse(url); + if (!parsed.queryWithoutQuestionMark.empty() && + (parsed.path.empty() || parsed.path == "/")) { + throw std::invalid_argument( + "The given URL \"" + url + + "\" has an empty path and cannot be converted to a JSC-safe format."); + } + + // Query strings may contain '?' (e.g. in key or value names) - these + // must be percent-encoded to form a valid path, and not be stripped. + // Count them first so we can preallocate exactly. + bool hasQuery = !parsed.queryWithoutQuestionMark.empty(); + size_t questionMarks = 0; + if (hasQuery) { + for (char c : parsed.queryWithoutQuestionMark) { + if (c == '?') { + questionMarks++; + } + } + } + + // Each '?' becomes "%3F" (+2 bytes), plus "//&" delimiter (+3 bytes) + size_t totalSize = parsed.schemeAndAuthority.size() + parsed.path.size() + + (hasQuery ? 3 + parsed.queryWithoutQuestionMark.size() + questionMarks * 2 + : 0) + + // We expect JSC to strip this - we don't handle fragments for now. + parsed.fragmentWithHash.size(); + + std::string result; + result.reserve(totalSize); + result += parsed.schemeAndAuthority; + result += parsed.path; + if (hasQuery) { + result += "//&"; + for (char c : parsed.queryWithoutQuestionMark) { + if (c == '?') { + result += "%3F"; + } else { + result += c; + } + } + } + result += parsed.fragmentWithHash; + assert(result.size() == totalSize); + return result; +} + +} // namespace facebook::react::unstable_redbox diff --git a/packages/react-native/ReactCommon/react/debug/redbox/JscSafeUrl.h b/packages/react-native/ReactCommon/react/debug/redbox/JscSafeUrl.h new file mode 100644 index 000000000000..39417d1b06b2 --- /dev/null +++ b/packages/react-native/ReactCommon/react/debug/redbox/JscSafeUrl.h @@ -0,0 +1,27 @@ +/* + * 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 + +namespace facebook::react::unstable_redbox { + +/** + * These functions are for handling of query-string free URLs, necessitated + * by query string stripping of URLs in JavaScriptCore stack traces + * introduced in iOS 16.4. This is a direct port of https://www.npmjs.com/package/jsc-safe-url. + * + * See https://github.com/facebook/react-native/issues/36794 for context. + */ + +bool isJscSafeUrl(std::string_view url); +std::string toNormalUrl(std::string url); +std::string toJscSafeUrl(std::string url); + +} // namespace facebook::react::unstable_redbox diff --git a/packages/react-native/ReactCommon/react/debug/redbox/tests/JscSafeUrlTest.cpp b/packages/react-native/ReactCommon/react/debug/redbox/tests/JscSafeUrlTest.cpp new file mode 100644 index 000000000000..e7253b9c698c --- /dev/null +++ b/packages/react-native/ReactCommon/react/debug/redbox/tests/JscSafeUrlTest.cpp @@ -0,0 +1,173 @@ +/* + * 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 + +using namespace facebook::react::unstable_redbox; + +// --- toNormalUrl --- + +// Rewrites urls treating //& in paths as ? +TEST(JscSafeUrlTest, ToNormalUrl_RewritesMarkerInAbsolutePath) { + EXPECT_EQ( + toNormalUrl("/path1/path2//&foo=bar?bar=baz#frag?"), + "/path1/path2?foo=bar&bar=baz#frag?"); + // Idempotent + EXPECT_EQ( + toNormalUrl("/path1/path2?foo=bar&bar=baz#frag?"), + "/path1/path2?foo=bar&bar=baz#frag?"); +} + +TEST(JscSafeUrlTest, ToNormalUrl_RewritesMarkerInRelativePathWithEncoding) { + EXPECT_EQ( + toNormalUrl("relative/path/with%3B%26/encoded//&foo=bar?bar=baz#frag?"), + "relative/path/with%3B%26/encoded?foo=bar&bar=baz#frag?"); + EXPECT_EQ( + toNormalUrl("relative/path/with%3B%26/encoded?foo=bar&bar=baz#frag?"), + "relative/path/with%3B%26/encoded?foo=bar&bar=baz#frag?"); +} + +TEST(JscSafeUrlTest, ToNormalUrl_RewritesMarkerInFullUrl) { + EXPECT_EQ( + toNormalUrl( + "https://user:password@mydomain.com:8080/path1/path2//&foo=bar?bar=baz#frag?"), + "https://user:password@mydomain.com:8080/path1/path2?foo=bar&bar=baz#frag?"); + EXPECT_EQ( + toNormalUrl( + "https://user:password@mydomain.com:8080/path1/path2?foo=bar&bar=baz#frag?"), + "https://user:password@mydomain.com:8080/path1/path2?foo=bar&bar=baz#frag?"); +} + +TEST(JscSafeUrlTest, ToNormalUrl_RewritesMarkerWithoutFragment) { + EXPECT_EQ( + toNormalUrl("http://127.0.0.1/path1/path2//&foo=bar&bar=baz"), + "http://127.0.0.1/path1/path2?foo=bar&bar=baz"); + EXPECT_EQ( + toNormalUrl("http://127.0.0.1/path1/path2?foo=bar&bar=baz"), + "http://127.0.0.1/path1/path2?foo=bar&bar=baz"); +} + +// Returns other strings exactly as given +TEST(JscSafeUrlTest, ToNormalUrl_PassthroughForQueryWithMarkerAfter) { + auto url = "http://user:password/@mydomain.com/foo?bar=zoo?baz=quux//&"; + EXPECT_EQ(toNormalUrl(url), url); +} + +TEST(JscSafeUrlTest, ToNormalUrl_PassthroughForSimpleQuery) { + auto url = "/foo?bar=zoo?baz=quux"; + EXPECT_EQ(toNormalUrl(url), url); +} + +TEST(JscSafeUrlTest, ToNormalUrl_PassthroughForOpaqueUri) { + auto url = "proto:arbitrary_bad_url"; + EXPECT_EQ(toNormalUrl(url), url); +} + +TEST(JscSafeUrlTest, ToNormalUrl_PassthroughForStar) { + EXPECT_EQ(toNormalUrl("*"), "*"); +} + +TEST(JscSafeUrlTest, ToNormalUrl_PassthroughForRelativePath) { + auto url = "relative/path"; + EXPECT_EQ(toNormalUrl(url), url); +} + +// --- toJscSafeUrl --- + +// Replaces the first ? with a JSC-friendly delimiter, url-encodes subsequent +// ?, and is idempotent +TEST(JscSafeUrlTest, ToJscSafeUrl_FullUrlWithEncodedQuestionMark) { + auto input = + "https://user:password@mydomain.com:8080/path1/path2?foo=bar&bar=question?#frag?"; + auto output = + "https://user:password@mydomain.com:8080/path1/path2//&foo=bar&bar=question%3F#frag?"; + EXPECT_EQ(toJscSafeUrl(input), output); + EXPECT_EQ(toJscSafeUrl(output), output); +} + +TEST(JscSafeUrlTest, ToJscSafeUrl_SimpleUrl) { + auto input = "http://127.0.0.1/path1/path2?foo=bar"; + auto output = "http://127.0.0.1/path1/path2//&foo=bar"; + EXPECT_EQ(toJscSafeUrl(input), output); + EXPECT_EQ(toJscSafeUrl(output), output); +} + +TEST(JscSafeUrlTest, ToJscSafeUrl_PassthroughForStar) { + EXPECT_EQ(toJscSafeUrl("*"), "*"); + EXPECT_EQ(toJscSafeUrl("*"), "*"); +} + +TEST(JscSafeUrlTest, ToJscSafeUrl_PassthroughForAbsolutePath) { + EXPECT_EQ(toJscSafeUrl("/absolute/path"), "/absolute/path"); + EXPECT_EQ(toJscSafeUrl("/absolute/path"), "/absolute/path"); +} + +TEST(JscSafeUrlTest, ToJscSafeUrl_PassthroughForRelativePath) { + EXPECT_EQ(toJscSafeUrl("relative/path"), "relative/path"); + EXPECT_EQ(toJscSafeUrl("relative/path"), "relative/path"); +} + +TEST(JscSafeUrlTest, ToJscSafeUrl_EmptyQueryDropped) { + EXPECT_EQ(toJscSafeUrl("/?"), "/"); + EXPECT_EQ(toJscSafeUrl("/"), "/"); +} + +TEST(JscSafeUrlTest, ToJscSafeUrl_PassthroughForUrlWithoutQuery) { + auto url = "http://127.0.0.1/path1/path"; + EXPECT_EQ(toJscSafeUrl(url), url); + EXPECT_EQ(toJscSafeUrl(url), url); +} + +TEST(JscSafeUrlTest, ToJscSafeUrl_AbsolutePathWithEncodedQuestionMark) { + auto input = "/path1/path2?foo=bar&bar=question?#frag?"; + auto output = "/path1/path2//&foo=bar&bar=question%3F#frag?"; + EXPECT_EQ(toJscSafeUrl(input), output); + EXPECT_EQ(toJscSafeUrl(output), output); +} + +TEST(JscSafeUrlTest, ToJscSafeUrl_RelativePathWithEncodedQuestionMark) { + auto input = "relative/path?foo=bar&bar=question?#frag?"; + auto output = "relative/path//&foo=bar&bar=question%3F#frag?"; + EXPECT_EQ(toJscSafeUrl(input), output); + EXPECT_EQ(toJscSafeUrl(output), output); +} + +TEST(JscSafeUrlTest, ToJscSafeUrl_AlreadySafeWithExtraQuery) { + auto input = "/path1/path2//&foo=bar&bar=question%3F?extra=query#frag?"; + auto output = "/path1/path2//&foo=bar&bar=question%3F&extra=query#frag?"; + EXPECT_EQ(toJscSafeUrl(input), output); + EXPECT_EQ(toJscSafeUrl(output), output); +} + +// Throws on a URL with an empty path and a query string +TEST(JscSafeUrlTest, ToJscSafeUrl_ThrowsForEmptyPathWithQuery) { + EXPECT_THROW(toJscSafeUrl("http://127.0.0.1?foo=bar"), std::invalid_argument); + EXPECT_THROW(toJscSafeUrl("http://127.0.0.1?q#hash"), std::invalid_argument); + EXPECT_THROW(toJscSafeUrl("?foo=bar"), std::invalid_argument); + EXPECT_THROW(toJscSafeUrl("?foo=/bar#hash"), std::invalid_argument); + EXPECT_THROW(toJscSafeUrl("/?bar=baz/"), std::invalid_argument); +} + +// --- isJscSafeUrl --- + +TEST(JscSafeUrlTest, IsJscSafeUrl_TrueForSafeUrls) { + EXPECT_TRUE(isJscSafeUrl("http://example.com//&foo=bar//#frag=//")); + EXPECT_TRUE(isJscSafeUrl("http://example.com/with/path//&foo=bar//#frag=//")); + EXPECT_TRUE(isJscSafeUrl("//&foo=bar//#frag=//")); + EXPECT_TRUE(isJscSafeUrl("relative/path///&foo=bar//&#frag=//&")); + EXPECT_TRUE(isJscSafeUrl("/absolute/path//&foo=bar//&#frag=//&")); +} + +TEST(JscSafeUrlTest, IsJscSafeUrl_FalseForNormalUrls) { + EXPECT_FALSE(isJscSafeUrl("http://example.com?foo=bar//&#frag=//")); + EXPECT_FALSE( + isJscSafeUrl("http://example.com/with/path/?foo=bar//&#frag=//")); + EXPECT_FALSE(isJscSafeUrl("?foo=bar//&#frag=//&")); + EXPECT_FALSE(isJscSafeUrl("relative/path/?foo=bar//#frag=//")); + EXPECT_FALSE(isJscSafeUrl("/absolute/path/?foo=bar//&#frag=//")); +} From 4fdeb48e9eaa3af221b9e41553ff0b76a2d7d8db Mon Sep 17 00:00:00 2001 From: Terry Kwon Date: Wed, 22 Apr 2026 09:09:31 -0700 Subject: [PATCH 10/11] Guard tvOS-unavailable APIs in RCTRedBox2Controller (#56568) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/56568 `separatorStyle` and `UITableViewCellSeparatorStyleNone` are unavailable on tvOS, causing the airwave-tvos-appletvos build to fail. Wrap them with `#if !TARGET_OS_TV`, matching the existing pattern in `RCTRedBoxController.mm`. Changelog: [Internal] Reviewed By: motiz88 Differential Revision: D102001404 fbshipit-source-id: 383e1cc64070000c258b71a60aa4b4ef6453206e --- packages/react-native/React/CoreModules/RCTRedBox2Controller.mm | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm b/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm index e3a5a3f1ebee..1040e797562a 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm +++ b/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm @@ -99,7 +99,9 @@ - (void)viewDidLoad _stackTraceTableView.delegate = self; _stackTraceTableView.dataSource = self; _stackTraceTableView.backgroundColor = [UIColor clearColor]; +#if !TARGET_OS_TV _stackTraceTableView.separatorStyle = UITableViewCellSeparatorStyleNone; +#endif _stackTraceTableView.indicatorStyle = UIScrollViewIndicatorStyleWhite; _stackTraceTableView.bounces = NO; [self.view addSubview:_stackTraceTableView]; From 3abcc3cc49f02fe430432fb4de3275d0fb4bfc37 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 22 Apr 2026 15:16:43 -0700 Subject: [PATCH 11/11] Fix C++ syntax in Objective-C header breaking React module build (#56569) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/56569 `RCTRedBox2Controller+Internal.h` used a C++ `using` type alias, which fails when compiled as plain Objective-C. This header is currently included in the `React-Core` umbrella header (via the `CoreModulesHeaders` subspec), so it gets compiled in a pure-ObjC context and breaks the RNTester iOS CI build. Here, we replace the `using` with an equivalent `typedef`. NOTE: The underlying issue is that this `+Internal.h` header should not be in the umbrella header at all. I will follow up to make the RedBox 2.0 internal headers private at the CocoaPods level. Changelog: [Internal] ___ overriding_review_checks_triggers_an_audit_and_retroactive_review Oncall Short Name: react_native landed-with-radar-review Reviewed By: sbuggay Differential Revision: D102005814 fbshipit-source-id: d0a5727ee47ea5148a371074bc74e42e503c20f8 --- .../React/CoreModules/RCTRedBox2Controller+Internal.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native/React/CoreModules/RCTRedBox2Controller+Internal.h b/packages/react-native/React/CoreModules/RCTRedBox2Controller+Internal.h index ec6d6964a2ae..7e51918890d5 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox2Controller+Internal.h +++ b/packages/react-native/React/CoreModules/RCTRedBox2Controller+Internal.h @@ -11,7 +11,7 @@ #if RCT_DEV_MENU -using RCTRedBox2ButtonPressHandler = void (^)(void); +typedef void (^RCTRedBox2ButtonPressHandler)(void); @interface RCTRedBox2Controller : UIViewController