diff --git a/packages/react-native/Package.swift b/packages/react-native/Package.swift index 4271313cf8aa..a2389936c02d 100644 --- a/packages/react-native/Package.swift +++ b/packages/react-native/Package.swift @@ -93,6 +93,7 @@ let reactRendererConsistency = RNTarget( let reactDebug = RNTarget( name: .reactDebug, path: "ReactCommon/react/debug", + excludedPaths: ["tests", "redbox/tests"], dependencies: [.reactNativeDependencies] ) /// React-jsi.podspec @@ -392,7 +393,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/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+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..0c2c5cce2bb1 --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTRedBox2Controller.mm @@ -0,0 +1,671 @@ +/* + * 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 + +#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 + +// 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]; +} + +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; + UILabel *_errorCategoryLabel; + NSString *_lastErrorMessage; + NSArray *_lastStackTrace; + NSArray *_customButtonTitles; + NSArray *_customButtonHandlers; + int _lastErrorCookie; + RCTRedBox2ErrorData *_errorData; + std::array _sectionStates; +} + +- (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; + + // 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) { + [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 - 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 [self visibleSectionCount]; +} + +- (NSInteger)tableView:(__unused UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + if ([self sectionForIndex:section] == Section::CallStack) { + return static_cast(_lastStackTrace.count); + } + return 1; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + 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; + 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 = _errorData.title; + 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; +} + +- (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 +{ + auto section = [self sectionForIndex:indexPath.section]; + if (section == Section::Message || section == Section::CodeFrame) { + return UITableViewAutomaticDimension; + } + return 50; +} + +- (CGFloat)tableView:(__unused UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + switch ([self sectionForIndex:indexPath.section]) { + case Section::Message: + return 100; + case Section::CodeFrame: + return 200; + case Section::CallStack: + case Section::kMaxValue: + return 50; + } +} + +- (UIView *)sectionHeaderViewWithTitle:(NSString *)title +{ + UIView *headerView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 0, 38)]; + headerView.backgroundColor = [UIColor clearColor]; + + 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], + ]]; + + 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; + } +} + +- (CGFloat)tableView:(__unused UITableView *)tableView heightForHeaderInSection:(NSInteger)section +{ + auto s = [self sectionForIndex:section]; + return (s == Section::CodeFrame || s == Section::CallStack) ? 38 : 0; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if ([self sectionForIndex:indexPath.section] == Section::CallStack) { + 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/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 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"]) 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()); +}