From fe71e06879c468ef143a720580a6ce306d2c6ac5 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 12:42:03 -0700 Subject: [PATCH 01/18] feat(macos): add RCTUITableView compatibility primitive to RCTUIKit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ReactApple/Libraries/RCTUIKit/RCTUIKit.h | 1 + .../Libraries/RCTUIKit/RCTUITableView.h | 112 +++++ .../Libraries/RCTUIKit/RCTUITableView.m | 431 ++++++++++++++++++ 3 files changed, 544 insertions(+) create mode 100644 packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.h create mode 100644 packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIKit.h b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIKit.h index eccebc648400..e1a2f0ae0228 100644 --- a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIKit.h +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIKit.h @@ -16,6 +16,7 @@ #import "RCTUIView.h" #import "RCTUIScrollView.h" #import "RCTUISlider.h" +#import "RCTUITableView.h" #import "RCTUILabel.h" #import "RCTUISwitch.h" #import "RCTUIActivityIndicatorView.h" diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.h b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.h new file mode 100644 index 000000000000..d451aca2d542 --- /dev/null +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.h @@ -0,0 +1,112 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// [macOS] + +#pragma once + +#include + +#import "RCTUILabel.h" +#import "RCTUIView.h" + +#if !TARGET_OS_OSX + +#import + +@compatibility_alias RCTUITableView UITableView; +@compatibility_alias RCTUITableViewCell UITableViewCell; +#define RCTUITableViewDataSource UITableViewDataSource +#define RCTUITableViewDelegate UITableViewDelegate +#define RCTUITableViewCellStyleDefault UITableViewCellStyleDefault +#define RCTUITableViewCellStyleSubtitle UITableViewCellStyleSubtitle +#define RCTUITableViewScrollPositionTop UITableViewScrollPositionTop +#define RCTUITableViewAutomaticDimension UITableViewAutomaticDimension + +#else // TARGET_OS_OSX [ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class RCTUITableView; + +typedef NS_ENUM(NSInteger, RCTUITableViewCellStyle) { + RCTUITableViewCellStyleDefault, + RCTUITableViewCellStyleSubtitle, +}; + +typedef NS_ENUM(NSInteger, RCTUITableViewScrollPosition) { + RCTUITableViewScrollPositionTop, +}; + +extern const CGFloat RCTUITableViewAutomaticDimension; + +@interface NSIndexPath (RCTUITableView) + +@property (nonatomic, readonly) NSInteger row; ++ (instancetype)indexPathForRow:(NSInteger)row inSection:(NSInteger)section; + +@end + +@interface RCTUITableViewCell : NSTableCellView + +- (instancetype)initWithStyle:(RCTUITableViewCellStyle)style + reuseIdentifier:(nullable NSString *)reuseIdentifier NS_DESIGNATED_INITIALIZER; +- (nullable instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; + +@property (nonatomic, readonly, nullable, copy) NSString *reuseIdentifier; +@property (nonatomic, readonly, strong) RCTPlatformView *contentView; +@property (nonatomic, readonly, strong) RCTUILabel *textLabel; +@property (nonatomic, readonly, nullable, strong) RCTUILabel *detailTextLabel; +@property (nonatomic, nullable, strong) RCTUIColor *backgroundColor; + +- (void)prepareForReuse; + +@end + +@protocol RCTUITableViewDataSource + +@required +- (NSInteger)tableView:(RCTUITableView *)tableView numberOfRowsInSection:(NSInteger)section; +- (RCTUITableViewCell *)tableView:(RCTUITableView *)tableView + cellForRowAtIndexPath:(NSIndexPath *)indexPath; + +@optional +- (NSInteger)numberOfSectionsInTableView:(RCTUITableView *)tableView; + +@end + +@protocol RCTUITableViewDelegate + +@optional +- (CGFloat)tableView:(RCTUITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath; +- (nullable RCTPlatformView *)tableView:(RCTUITableView *)tableView viewForHeaderInSection:(NSInteger)section; +- (CGFloat)tableView:(RCTUITableView *)tableView heightForHeaderInSection:(NSInteger)section; +- (void)tableView:(RCTUITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath; + +@end + +@interface RCTUITableView : NSScrollView + +@property (nonatomic, weak, nullable) id dataSource; +@property (nonatomic, weak, nullable) id delegate; +@property (nonatomic, nullable, copy) RCTUIColor *separatorColor; +@property (nonatomic, readonly) CGRect visibleRect; + +- (void)reloadData; +- (nullable __kindof RCTUITableViewCell *)dequeueReusableCellWithIdentifier:(NSString *)identifier; +- (void)scrollToRowAtIndexPath:(NSIndexPath *)indexPath + atScrollPosition:(RCTUITableViewScrollPosition)position + animated:(BOOL)animated; +- (void)deselectRowAtIndexPath:(NSIndexPath *)indexPath animated:(BOOL)animated; + +@end + +NS_ASSUME_NONNULL_END + +#endif // ] TARGET_OS_OSX diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m new file mode 100644 index 000000000000..ec2e5741e8c6 --- /dev/null +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m @@ -0,0 +1,431 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// [macOS] + +#import "RCTUITableView.h" + +#if TARGET_OS_OSX + +const CGFloat RCTUITableViewAutomaticDimension = -1.0; + +typedef NS_ENUM(NSInteger, RCTUITableViewSlotKind) { + RCTUITableViewSlotKindHeader, + RCTUITableViewSlotKindRow, +}; + +@interface RCTUITableViewSlot : NSObject + +@property (nonatomic, readonly) RCTUITableViewSlotKind kind; +@property (nonatomic, readonly) NSInteger section; +@property (nonatomic, readonly) NSInteger row; +@property (nonatomic, readonly) CGFloat headerHeight; + +- (instancetype)initWithKind:(RCTUITableViewSlotKind)kind + section:(NSInteger)section + row:(NSInteger)row + headerHeight:(CGFloat)headerHeight; + +@end + +@implementation RCTUITableViewSlot + +- (instancetype)initWithKind:(RCTUITableViewSlotKind)kind + section:(NSInteger)section + row:(NSInteger)row + headerHeight:(CGFloat)headerHeight +{ + if (self = [super init]) { + _kind = kind; + _section = section; + _row = row; + _headerHeight = headerHeight; + } + + return self; +} + +@end + +@implementation NSIndexPath (RCTUITableView) + +- (NSInteger)row +{ + return self.item; +} + ++ (instancetype)indexPathForRow:(NSInteger)row inSection:(NSInteger)section +{ + return [self indexPathForItem:row inSection:section]; +} + +@end + +@interface RCTUITableViewCell () + +@property (nonatomic, readwrite, nullable, copy) NSString *reuseIdentifier; +@property (nonatomic, readwrite, strong) RCTPlatformView *contentView; +@property (nonatomic, readwrite, strong) RCTUILabel *textLabel; +@property (nonatomic, readwrite, nullable, strong) RCTUILabel *detailTextLabel; + +@end + +@implementation RCTUITableViewCell { + RCTUITableViewCellStyle _style; +} + +- (instancetype)initWithFrame:(NSRect)frameRect +{ + self = [self initWithStyle:RCTUITableViewCellStyleDefault reuseIdentifier:nil]; + self.frame = frameRect; + return self; +} + +- (instancetype)initWithStyle:(RCTUITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier +{ + if (self = [super initWithFrame:NSZeroRect]) { + [self initializeWithStyle:style reuseIdentifier:reuseIdentifier]; + } + + return self; +} + +- (void)initializeWithStyle:(RCTUITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier +{ + _style = style; + _reuseIdentifier = [reuseIdentifier copy]; + self.identifier = reuseIdentifier; + + _contentView = [[RCTPlatformView alloc] initWithFrame:self.bounds]; + _contentView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + [self addSubview:_contentView]; + + _textLabel = [[RCTUILabel alloc] initWithFrame:NSZeroRect]; + _textLabel.translatesAutoresizingMaskIntoConstraints = NO; + [_contentView addSubview:_textLabel]; + self.textField = _textLabel; + + if (style == RCTUITableViewCellStyleSubtitle) { + _detailTextLabel = [[RCTUILabel alloc] initWithFrame:NSZeroRect]; + _detailTextLabel.translatesAutoresizingMaskIntoConstraints = NO; + [_contentView addSubview:_detailTextLabel]; + + [NSLayoutConstraint activateConstraints:@[ + [_textLabel.leadingAnchor constraintEqualToAnchor:_contentView.leadingAnchor constant:5], + [_textLabel.topAnchor constraintEqualToAnchor:_contentView.topAnchor], + [_textLabel.trailingAnchor constraintEqualToAnchor:_contentView.trailingAnchor constant:-5], + [_detailTextLabel.leadingAnchor constraintEqualToAnchor:_contentView.leadingAnchor constant:5], + [_detailTextLabel.topAnchor constraintEqualToAnchor:_textLabel.bottomAnchor], + [_detailTextLabel.trailingAnchor constraintEqualToAnchor:_contentView.trailingAnchor constant:-5], + [_detailTextLabel.bottomAnchor constraintEqualToAnchor:_contentView.bottomAnchor], + ]]; + } else { + [NSLayoutConstraint activateConstraints:@[ + [_textLabel.leadingAnchor constraintEqualToAnchor:_contentView.leadingAnchor constant:5], + [_textLabel.topAnchor constraintEqualToAnchor:_contentView.topAnchor constant:5], + [_textLabel.trailingAnchor constraintEqualToAnchor:_contentView.trailingAnchor constant:-5], + [_textLabel.bottomAnchor constraintEqualToAnchor:_contentView.bottomAnchor constant:-5], + ]]; + } +} + +- (void)setBackgroundColor:(RCTUIColor *)backgroundColor +{ + _backgroundColor = [backgroundColor copy]; + self.contentView.wantsLayer = YES; + self.contentView.layer.backgroundColor = backgroundColor.CGColor; +} + +- (void)setAccessibilityIdentifier:(NSString *)accessibilityIdentifier +{ + [super setAccessibilityIdentifier:accessibilityIdentifier]; + self.textLabel.accessibilityIdentifier = accessibilityIdentifier; +} + +- (void)setAccessibilityLabel:(NSString *)accessibilityLabel +{ + [super setAccessibilityLabel:accessibilityLabel]; + self.textLabel.accessibilityLabel = accessibilityLabel; +} + +- (void)prepareForReuse +{ + [super prepareForReuse]; + self.textLabel.text = @""; + self.textLabel.maximumNumberOfLines = _style == RCTUITableViewCellStyleDefault ? 1 : 2; + self.detailTextLabel.text = @""; + self.detailTextLabel.maximumNumberOfLines = 1; +} + +@end + +@interface RCTUITableView () +@end + +@implementation RCTUITableView { + NSTableView *_tableView; + NSArray *_slots; + NSMutableDictionary *_headerViews; + NSMutableDictionary *_pendingViews; + NSMutableDictionary *_automaticHeights; + NSMutableIndexSet *_automaticRows; + CGFloat _lastContentWidth; +} + +- (instancetype)initWithFrame:(NSRect)frameRect +{ + if (self = [super initWithFrame:frameRect]) { + self.drawsBackground = NO; + + _slots = @[]; + _headerViews = [NSMutableDictionary new]; + _pendingViews = [NSMutableDictionary new]; + _automaticHeights = [NSMutableDictionary new]; + _automaticRows = [NSMutableIndexSet new]; + + _tableView = [[NSTableView alloc] initWithFrame:self.contentView.bounds]; + _tableView.autoresizingMask = NSViewWidthSizable; + _tableView.dataSource = self; + _tableView.delegate = self; + _tableView.headerView = nil; + _tableView.allowsColumnReordering = NO; + _tableView.allowsColumnResizing = NO; + _tableView.allowsTypeSelect = NO; + _tableView.columnAutoresizingStyle = NSTableViewFirstColumnOnlyAutoresizingStyle; + _tableView.backgroundColor = NSColor.clearColor; + _tableView.style = NSTableViewStyleInset; + _tableView.accessibilityRole = NSAccessibilityTableRole; + + NSTableColumn *column = [[NSTableColumn alloc] initWithIdentifier:@"RCTUITableViewColumn"]; + [_tableView addTableColumn:column]; + + self.documentView = _tableView; + _lastContentWidth = self.contentSize.width; + self.separatorColor = nil; + } + + return self; +} + +- (void)setAccessibilityIdentifier:(NSString *)accessibilityIdentifier +{ + [super setAccessibilityIdentifier:accessibilityIdentifier]; + _tableView.accessibilityIdentifier = accessibilityIdentifier; +} + +- (void)setSeparatorColor:(RCTUIColor *)separatorColor +{ + _separatorColor = [separatorColor copy]; + if (separatorColor == nil) { + _tableView.gridStyleMask = NSTableViewGridNone; + } else { + _tableView.gridColor = separatorColor; + _tableView.gridStyleMask = NSTableViewSolidHorizontalGridLineMask; + } +} + +- (CGRect)visibleRect +{ + return _tableView.visibleRect; +} + +- (void)setFrameSize:(NSSize)newSize +{ + [super setFrameSize:newSize]; + + CGFloat contentWidth = self.contentSize.width; + if (_lastContentWidth != contentWidth) { + _lastContentWidth = contentWidth; + [_automaticHeights removeAllObjects]; + if (_automaticRows.count > 0) { + [_tableView noteHeightOfRowsWithIndexesChanged:_automaticRows]; + } + } +} + +- (void)reloadData +{ + [_headerViews removeAllObjects]; + [_pendingViews removeAllObjects]; + [_automaticHeights removeAllObjects]; + [_automaticRows removeAllIndexes]; + + NSInteger sectionCount = 1; + if ([self.dataSource respondsToSelector:@selector(numberOfSectionsInTableView:)]) { + sectionCount = [self.dataSource numberOfSectionsInTableView:self]; + } + sectionCount = MAX(sectionCount, 0); + + NSMutableArray *slots = [NSMutableArray new]; + for (NSInteger section = 0; section < sectionCount; section++) { + if ([self.delegate respondsToSelector:@selector(tableView:heightForHeaderInSection:)]) { + CGFloat headerHeight = [self.delegate tableView:self heightForHeaderInSection:section]; + if (headerHeight > 0) { + [slots addObject:[[RCTUITableViewSlot alloc] initWithKind:RCTUITableViewSlotKindHeader + section:section + row:NSNotFound + headerHeight:headerHeight]]; + } + } + + NSInteger rowCount = MAX([self.dataSource tableView:self numberOfRowsInSection:section], 0); + for (NSInteger row = 0; row < rowCount; row++) { + [slots addObject:[[RCTUITableViewSlot alloc] initWithKind:RCTUITableViewSlotKindRow + section:section + row:row + headerHeight:0]]; + } + } + + _slots = [slots copy]; + [_tableView reloadData]; +} + +- (__kindof RCTUITableViewCell *)dequeueReusableCellWithIdentifier:(NSString *)identifier +{ + RCTUITableViewCell *cell = [_tableView makeViewWithIdentifier:identifier owner:self]; + [cell prepareForReuse]; + return cell; +} + +- (void)scrollToRowAtIndexPath:(NSIndexPath *)indexPath + atScrollPosition:(RCTUITableViewScrollPosition)position + animated:(BOOL)animated +{ + NSInteger backingRow = [self backingRowForIndexPath:indexPath]; + if (backingRow == NSNotFound || position != RCTUITableViewScrollPositionTop) { + return; + } + + NSRect rowRect = [_tableView rectOfRow:backingRow]; + NSClipView *clipView = self.contentView; + NSRect proposedBounds = clipView.bounds; + proposedBounds.origin.y = NSMinY(rowRect); + NSRect constrainedBounds = [clipView constrainBoundsRect:proposedBounds]; + [clipView scrollToPoint:constrainedBounds.origin]; + [self reflectScrolledClipView:clipView]; +} + +- (void)deselectRowAtIndexPath:(NSIndexPath *)indexPath animated:(BOOL)animated +{ + NSInteger backingRow = [self backingRowForIndexPath:indexPath]; + if (backingRow != NSNotFound) { + [_tableView deselectRow:backingRow]; + } +} + +- (NSInteger)backingRowForIndexPath:(NSIndexPath *)indexPath +{ + for (NSInteger backingRow = 0; backingRow < (NSInteger)_slots.count; backingRow++) { + RCTUITableViewSlot *slot = _slots[backingRow]; + if (slot.kind == RCTUITableViewSlotKindRow && slot.section == indexPath.section && slot.row == indexPath.row) { + return backingRow; + } + } + return NSNotFound; +} + +- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView +{ + return _slots.count; +} + +- (NSView *)tableView:(NSTableView *)tableView + viewForTableColumn:(NSTableColumn *)tableColumn + row:(NSInteger)row +{ + NSNumber *rowKey = @(row); + NSView *pendingView = _pendingViews[rowKey]; + if (pendingView != nil) { + [_pendingViews removeObjectForKey:rowKey]; + return pendingView; + } + + return [self viewForBackingRow:row]; +} + +- (NSView *)viewForBackingRow:(NSInteger)backingRow +{ + RCTUITableViewSlot *slot = _slots[backingRow]; + if (slot.kind == RCTUITableViewSlotKindRow) { + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:slot.row inSection:slot.section]; + return [self.dataSource tableView:self cellForRowAtIndexPath:indexPath]; + } + + NSNumber *sectionKey = @(slot.section); + RCTPlatformView *headerView = _headerViews[sectionKey]; + if (headerView == nil) { + if ([self.delegate respondsToSelector:@selector(tableView:viewForHeaderInSection:)]) { + headerView = [self.delegate tableView:self viewForHeaderInSection:slot.section]; + } + if (headerView == nil) { + headerView = [RCTPlatformView new]; + } + _headerViews[sectionKey] = headerView; + } + return headerView; +} + +- (CGFloat)tableView:(NSTableView *)tableView heightOfRow:(NSInteger)row +{ + RCTUITableViewSlot *slot = _slots[row]; + if (slot.kind == RCTUITableViewSlotKindHeader) { + return slot.headerHeight; + } + + CGFloat height = tableView.rowHeight; + if ([self.delegate respondsToSelector:@selector(tableView:heightForRowAtIndexPath:)]) { + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:slot.row inSection:slot.section]; + height = [self.delegate tableView:self heightForRowAtIndexPath:indexPath]; + } + if (height != RCTUITableViewAutomaticDimension) { + return height; + } + + [_automaticRows addIndex:row]; + NSNumber *rowKey = @(row); + NSNumber *cachedHeight = _automaticHeights[rowKey]; + if (cachedHeight != nil) { + return cachedHeight.doubleValue; + } + + RCTUITableViewCell *cell = (RCTUITableViewCell *)[tableView viewAtColumn:0 row:row makeIfNecessary:NO]; + if (cell == nil) { + cell = (RCTUITableViewCell *)_pendingViews[rowKey]; + } + if (cell == nil) { + cell = (RCTUITableViewCell *)[self viewForBackingRow:row]; + _pendingViews[rowKey] = cell; + } + + NSSize previousSize = cell.frame.size; + cell.frame = NSMakeRect(0, 0, self.contentSize.width, previousSize.height); + [cell layoutSubtreeIfNeeded]; + CGFloat fittingHeight = cell.fittingSize.height; + cell.frame = NSMakeRect(cell.frame.origin.x, cell.frame.origin.y, previousSize.width, previousSize.height); + + _automaticHeights[rowKey] = @(fittingHeight); + return fittingHeight; +} + +- (BOOL)tableView:(NSTableView *)tableView shouldSelectRow:(NSInteger)row +{ + RCTUITableViewSlot *slot = _slots[row]; + if (slot.kind == RCTUITableViewSlotKindHeader) { + return NO; + } + + if ([self.delegate respondsToSelector:@selector(tableView:didSelectRowAtIndexPath:)]) { + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:slot.row inSection:slot.section]; + [self.delegate tableView:self didSelectRowAtIndexPath:indexPath]; + } + return NO; +} + +@end + +#endif From 553f1a27e698fd30f9c9c90d5f1bbe6a4bd8a64c Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 12:42:28 -0700 Subject: [PATCH 02/18] fix(macos): implement RCTUILabel compatibility properties Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Libraries/RCTUIKit/RCTUILabel.m | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUILabel.m b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUILabel.m index 919eb5641868..76d873d22349 100644 --- a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUILabel.m +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUILabel.m @@ -31,6 +31,31 @@ - (void)setText:(NSString *)text [self setStringValue:text]; } +- (NSString *)text +{ + return self.stringValue; +} + +- (void)setNumberOfLines:(NSInteger)numberOfLines +{ + self.maximumNumberOfLines = numberOfLines; +} + +- (NSInteger)numberOfLines +{ + return self.maximumNumberOfLines; +} + +- (void)setTextAlignment:(NSTextAlignment)textAlignment +{ + self.alignment = textAlignment; +} + +- (NSTextAlignment)textAlignment +{ + return self.alignment; +} + @end #endif From 7e3c010d42009816977ab6dd889280a565463ee9 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 12:44:43 -0700 Subject: [PATCH 03/18] feat(macos): add narrow RCTUIButton action bridge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Libraries/RCTUIKit/RCTUIButton.h | 73 +++++++ .../Libraries/RCTUIKit/RCTUIButton.m | 197 ++++++++++++++++++ .../ReactApple/Libraries/RCTUIKit/RCTUIKit.h | 1 + 3 files changed, 271 insertions(+) create mode 100644 packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIButton.h create mode 100644 packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIButton.m diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIButton.h b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIButton.h new file mode 100644 index 000000000000..4f97eacf1078 --- /dev/null +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIButton.h @@ -0,0 +1,73 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// [macOS] + +#pragma once + +#include + +#import "RCTUIKitCompat.h" + +#if !TARGET_OS_OSX +#import +#else +#import +#endif + +NS_ASSUME_NONNULL_BEGIN + +#if !TARGET_OS_OSX + +@compatibility_alias RCTUIButton UIButton; +#define RCTUIControlStateNormal UIControlStateNormal +#define RCTUIControlStateHighlighted UIControlStateHighlighted +typedef UIControlState RCTUIControlState; + +#else // TARGET_OS_OSX [ + +typedef NS_OPTIONS(NSUInteger, RCTUIControlState) { + RCTUIControlStateNormal = 0, + RCTUIControlStateHighlighted = 1 << 0, +}; + +@interface RCTUIButtonTitleProxy : NSObject + +@property (nonatomic, strong, nullable) UIFont *font; +@property (nonatomic, assign) NSLineBreakMode lineBreakMode; +@property (nonatomic, assign) NSTextAlignment textAlignment; + +@end + +@interface RCTUIButton : NSButton + +@property (nonatomic, readonly) RCTUIButtonTitleProxy *titleLabel; +@property (nonatomic, copy, nullable) RCTUIColor *backgroundColor; + +- (void)setTitle:(nullable NSString *)title forState:(RCTUIControlState)state; +- (void)setTitleColor:(nullable RCTUIColor *)color forState:(RCTUIControlState)state; + +@end + +#endif // ] TARGET_OS_OSX + +typedef void (^RCTUIActionHandler)(void); + +@interface RCTUIAction : NSObject + ++ (instancetype)actionWithHandler:(RCTUIActionHandler)handler; +@property (nonatomic, readonly, copy) RCTUIActionHandler handler; + +@end + +@interface RCTUIButton (RCTUIAction) + +- (void)rct_setPrimaryAction:(RCTUIAction *)action; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIButton.m b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIButton.m new file mode 100644 index 000000000000..d01c39f7fb46 --- /dev/null +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIButton.m @@ -0,0 +1,197 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// [macOS] + +#import "RCTUIButton.h" + +#import + +@interface RCTUIAction () + +- (instancetype)initWithHandler:(RCTUIActionHandler)handler; +- (void)invoke; + +@end + +@implementation RCTUIAction + ++ (instancetype)actionWithHandler:(RCTUIActionHandler)handler +{ + return [[self alloc] initWithHandler:handler]; +} + +- (instancetype)initWithHandler:(RCTUIActionHandler)handler +{ + if (self = [super init]) { + _handler = [handler copy]; + } + return self; +} + +- (void)invoke +{ + self.handler(); +} + +@end + +#if TARGET_OS_OSX + +@class RCTUIButton; + +@interface RCTUIButtonTitleProxy () + +@property (nonatomic, weak) RCTUIButton *button; + +- (instancetype)initWithButton:(RCTUIButton *)button; + +@end + +@interface RCTUIButton () + +- (void)updateAttributedTitles; + +@end + +@implementation RCTUIButtonTitleProxy + +- (instancetype)initWithButton:(RCTUIButton *)button +{ + if (self = [super init]) { + _button = button; + _font = button.font; + _lineBreakMode = NSLineBreakByClipping; + _textAlignment = NSTextAlignmentNatural; + } + return self; +} + +- (void)setFont:(UIFont *)font +{ + _font = font; + [self.button updateAttributedTitles]; +} + +- (void)setLineBreakMode:(NSLineBreakMode)lineBreakMode +{ + _lineBreakMode = lineBreakMode; + [self.button updateAttributedTitles]; +} + +- (void)setTextAlignment:(NSTextAlignment)textAlignment +{ + _textAlignment = textAlignment; + [self.button updateAttributedTitles]; +} + +@end + +@implementation RCTUIButton { + NSString *_normalTitle; + NSString *_highlightedTitle; + RCTUIColor *_normalTitleColor; + RCTUIColor *_highlightedTitleColor; +} + +- (instancetype)initWithFrame:(NSRect)frameRect +{ + if (self = [super initWithFrame:frameRect]) { + _titleLabel = [[RCTUIButtonTitleProxy alloc] initWithButton:self]; + } + return self; +} + +- (instancetype)initWithCoder:(NSCoder *)coder +{ + if (self = [super initWithCoder:coder]) { + _titleLabel = [[RCTUIButtonTitleProxy alloc] initWithButton:self]; + } + return self; +} + +- (void)setTitle:(NSString *)title forState:(RCTUIControlState)state +{ + if (state == RCTUIControlStateHighlighted) { + _highlightedTitle = [title copy]; + } else { + _normalTitle = [title copy]; + } + [self updateAttributedTitles]; +} + +- (void)setTitleColor:(RCTUIColor *)color forState:(RCTUIControlState)state +{ + if (state == RCTUIControlStateHighlighted) { + _highlightedTitleColor = [color copy]; + } else { + _normalTitleColor = [color copy]; + } + [self updateAttributedTitles]; +} + +- (void)setBackgroundColor:(RCTUIColor *)backgroundColor +{ + _backgroundColor = [backgroundColor copy]; + self.wantsLayer = YES; + self.layer.backgroundColor = backgroundColor.CGColor; + self.bordered = NO; +} + +- (void)updateAttributedTitles +{ + self.attributedTitle = [self attributedTitleForTitle:_normalTitle ?: @"" + color:_normalTitleColor]; + self.attributedAlternateTitle = [self attributedTitleForTitle:_highlightedTitle ?: _normalTitle ?: @"" + color:_highlightedTitleColor ?: _normalTitleColor]; +} + +- (NSAttributedString *)attributedTitleForTitle:(NSString *)title color:(RCTUIColor *)color +{ + NSMutableDictionary *attributes = [NSMutableDictionary new]; + if (color != nil) { + attributes[NSForegroundColorAttributeName] = color; + } + if (self.titleLabel.font != nil) { + attributes[NSFontAttributeName] = self.titleLabel.font; + } + + NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; + paragraphStyle.lineBreakMode = self.titleLabel.lineBreakMode; + paragraphStyle.alignment = self.titleLabel.textAlignment; + attributes[NSParagraphStyleAttributeName] = paragraphStyle; + + return [[NSAttributedString alloc] initWithString:title attributes:attributes]; +} + +@end + +#endif + +@implementation RCTUIButton (RCTUIAction) + +- (void)rct_setPrimaryAction:(RCTUIAction *)action +{ +#if !TARGET_OS_OSX + RCTUIAction *previousAction = objc_getAssociatedObject(self, @selector(rct_setPrimaryAction:)); + if (previousAction != nil) { + [self removeTarget:previousAction action:@selector(invoke) forControlEvents:UIControlEventTouchUpInside]; + } +#endif + + objc_setAssociatedObject( + self, @selector(rct_setPrimaryAction:), action, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + +#if !TARGET_OS_OSX + [self addTarget:action action:@selector(invoke) forControlEvents:UIControlEventTouchUpInside]; +#else + self.target = action; + self.action = @selector(invoke); +#endif +} + +@end diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIKit.h b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIKit.h index e1a2f0ae0228..7d51cf2dfe76 100644 --- a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIKit.h +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIKit.h @@ -17,6 +17,7 @@ #import "RCTUIScrollView.h" #import "RCTUISlider.h" #import "RCTUITableView.h" +#import "RCTUIButton.h" #import "RCTUILabel.h" #import "RCTUISwitch.h" #import "RCTUIActivityIndicatorView.h" From f7f6fa45f8e9d1385c936bd17fa142fd75e95202 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 12:51:19 -0700 Subject: [PATCH 04/18] test(macos): add offscreen RCTUITableView compatibility tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../RNTesterPods.xcodeproj/project.pbxproj | 6 + .../RNTesterUnitTests/RCTUITableViewTests.m | 650 ++++++++++++++++++ 2 files changed, 656 insertions(+) create mode 100644 packages/rn-tester/RNTesterUnitTests/RCTUITableViewTests.m diff --git a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj index 90e6c295ce0e..a442c35151ae 100644 --- a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj +++ b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj @@ -23,6 +23,8 @@ 832F45BB2A8A6E1F0097B4E6 /* SwiftTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 832F45BA2A8A6E1F0097B4E6 /* SwiftTest.swift */; }; 918A215FD4EF5A828705D765 /* libPods-RNTester-macOSIntegrationTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 474EC5CD967949D41527EECF /* libPods-RNTester-macOSIntegrationTests.a */; }; A975CA6C2C05EADF0043F72A /* RCTNetworkTaskTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A975CA6B2C05EADE0043F72A /* RCTNetworkTaskTests.m */; }; + B54A00012F27800000C0FFEE /* RCTUITableViewTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B54A00032F27800000C0FFEE /* RCTUITableViewTests.m */; }; + B54A00022F27800000C0FFEE /* RCTUITableViewTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B54A00032F27800000C0FFEE /* RCTUITableViewTests.m */; }; AC30658829B14F38007A839A /* RCTComponentPropsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7DB20CC22B2BAA5005AC45F /* RCTComponentPropsTests.m */; }; AC73FCE829B1316D0003586F /* RNTesterIntegrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7C1241922BEC44B00DA25C0 /* RNTesterIntegrationTests.m */; }; AC73FCE929B131700003586F /* RCTLoggingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7DB215E22B2F3EC005AC45F /* RCTLoggingTests.m */; }; @@ -227,6 +229,7 @@ E7DB20CF22B2BAA5005AC45F /* RCTMultipartStreamReaderTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTMultipartStreamReaderTests.m; sourceTree = ""; }; E7DB20D022B2BAA5005AC45F /* RCTURLUtilsTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTURLUtilsTests.m; sourceTree = ""; }; E7DB20E022B2BAA5005AC45F /* RCTViewTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTViewTests.m; sourceTree = ""; }; + B54A00032F27800000C0FFEE /* RCTUITableViewTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTUITableViewTests.m; sourceTree = ""; }; E7DB20F022B2BD53005AC45F /* libDoubleConversion.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = libDoubleConversion.a; sourceTree = BUILT_PRODUCTS_DIR; }; E7DB20F222B2BD53005AC45F /* libFolly.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = libFolly.a; sourceTree = BUILT_PRODUCTS_DIR; }; E7DB20F422B2BD53005AC45F /* libglog.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = libglog.a; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -531,6 +534,7 @@ E7DB20C122B2BAA4005AC45F /* RCTUnicodeDecodeTests.m */, E7DB20D022B2BAA5005AC45F /* RCTURLUtilsTests.m */, E7DB20E022B2BAA5005AC45F /* RCTViewTests.m */, + B54A00032F27800000C0FFEE /* RCTUITableViewTests.m */, E7DB20B322B2BAA4005AC45F /* RNTesterUnitTestsBundle.js */, ); path = RNTesterUnitTests; @@ -1301,6 +1305,7 @@ AC73FD0929B131E10003586F /* RCTURLUtilsTests.m in Sources */, AC73FD0029B131C30003586F /* RCTModuleInitNotificationRaceTests.m in Sources */, AC73FD0A29B131E50003586F /* RCTViewTests.m in Sources */, + B54A00012F27800000C0FFEE /* RCTUITableViewTests.m in Sources */, AC73FCFE29B131BE0003586F /* RCTJSONTests.m in Sources */, AC73FCF629B1319F0003586F /* RCTDevMenuTests.m in Sources */, AC73FD0329B131CD0003586F /* RCTMultipartStreamReaderTests.m in Sources */, @@ -1349,6 +1354,7 @@ E7DB20E222B2BAA6005AC45F /* RCTGzipTests.m in Sources */, E7DB20ED22B2BAA6005AC45F /* RCTURLUtilsTests.m in Sources */, E7DB20EE22B2BAA6005AC45F /* RCTViewTests.m in Sources */, + B54A00022F27800000C0FFEE /* RCTUITableViewTests.m in Sources */, E7DB20D322B2BAA6005AC45F /* RCTBlobManagerTests.m in Sources */, E7DB20DC22B2BAA6005AC45F /* RCTUIManagerTests.m in Sources */, E7DB20E322B2BAA6005AC45F /* RCTAllocationTests.m in Sources */, diff --git a/packages/rn-tester/RNTesterUnitTests/RCTUITableViewTests.m b/packages/rn-tester/RNTesterUnitTests/RCTUITableViewTests.m new file mode 100644 index 000000000000..9a19a2401be8 --- /dev/null +++ b/packages/rn-tester/RNTesterUnitTests/RCTUITableViewTests.m @@ -0,0 +1,650 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * 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 TARGET_OS_OSX // [macOS + +#import + +typedef RCTUITableViewCell * (^RCTUITableViewTestCellProvider)( + RCTUITableView *tableView, + NSIndexPath *indexPath); +typedef CGFloat (^RCTUITableViewTestHeightProvider)(NSIndexPath *indexPath); +typedef RCTPlatformView * (^RCTUITableViewTestHeaderProvider)(NSInteger section); + +static void RCTUIPumpRunLoop(NSTimeInterval duration) +{ + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:duration]; + while (deadline.timeIntervalSinceNow > 0) { + NSDate *slice = [NSDate dateWithTimeIntervalSinceNow:0.002]; + NSEvent *event = [NSApp nextEventMatchingMask:NSEventMaskAny + untilDate:slice + inMode:NSDefaultRunLoopMode + dequeue:YES]; + if (event != nil) { + [NSApp sendEvent:event]; + } + [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:slice]; + } +} + +static NSTableView *RCTUIBackingTable(RCTUITableView *tableView) +{ + XCTAssertTrue([tableView.documentView isKindOfClass:NSTableView.class]); + return (NSTableView *)tableView.documentView; +} + +@interface RCTUITableViewTestDataSource : NSObject + +@property (nonatomic, copy) NSArray *rowCounts; +@property (nonatomic, copy) NSArray *headerHeights; +@property (nonatomic, copy) RCTUITableViewTestCellProvider cellProvider; +@property (nonatomic, copy) RCTUITableViewTestHeightProvider heightProvider; +@property (nonatomic, copy) RCTUITableViewTestHeaderProvider headerProvider; +@property (nonatomic, strong) NSMutableDictionary *headerViewCallCounts; +@property (nonatomic, strong) NSIndexPath *selectedIndexPath; + +@end + +@implementation RCTUITableViewTestDataSource + +- (instancetype)init +{ + if (self = [super init]) { + _rowCounts = @[]; + _headerHeights = @[]; + _headerViewCallCounts = [NSMutableDictionary new]; + } + return self; +} + +- (NSInteger)numberOfSectionsInTableView:(RCTUITableView *)tableView +{ + return self.rowCounts.count; +} + +- (NSInteger)tableView:(RCTUITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + return self.rowCounts[section].integerValue; +} + +- (RCTUITableViewCell *)tableView:(RCTUITableView *)tableView + cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (self.cellProvider != nil) { + return self.cellProvider(tableView, indexPath); + } + + RCTUITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell"]; + if (cell == nil) { + cell = [[RCTUITableViewCell alloc] initWithStyle:RCTUITableViewCellStyleDefault reuseIdentifier:@"cell"]; + } + cell.textLabel.text = [NSString stringWithFormat:@"%ld:%ld", (long)indexPath.section, (long)indexPath.row]; + return cell; +} + +- (CGFloat)tableView:(RCTUITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + return self.heightProvider != nil ? self.heightProvider(indexPath) : 24; +} + +- (CGFloat)tableView:(RCTUITableView *)tableView heightForHeaderInSection:(NSInteger)section +{ + return section < (NSInteger)self.headerHeights.count ? self.headerHeights[section].doubleValue : 0; +} + +- (RCTPlatformView *)tableView:(RCTUITableView *)tableView viewForHeaderInSection:(NSInteger)section +{ + NSNumber *key = @(section); + self.headerViewCallCounts[key] = @(self.headerViewCallCounts[key].integerValue + 1); + return self.headerProvider != nil ? self.headerProvider(section) : [RCTPlatformView new]; +} + +- (void)tableView:(RCTUITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + self.selectedIndexPath = indexPath; +} + +@end + +@interface RCTUITableViewSingleSectionDataSource : NSObject + +@property (nonatomic, assign) NSInteger rowCount; + +@end + +@implementation RCTUITableViewSingleSectionDataSource + +- (NSInteger)tableView:(RCTUITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + XCTAssertEqual(section, 0); + return self.rowCount; +} + +- (RCTUITableViewCell *)tableView:(RCTUITableView *)tableView + cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + RCTUITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"single-section"]; + return cell ?: [[RCTUITableViewCell alloc] initWithStyle:RCTUITableViewCellStyleDefault + reuseIdentifier:@"single-section"]; +} + +@end + +static NSInteger RCTUITrackingCellAllocationCount; + +@interface RCTUITrackingTableCell : RCTUITableViewCell + +@property (nonatomic, assign) NSInteger prepareForReuseCount; +@property (nonatomic, assign) NSInteger vendCount; + +@end + +@implementation RCTUITrackingTableCell + +- (instancetype)initWithStyle:(RCTUITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier +{ + if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) { + RCTUITrackingCellAllocationCount++; + } + return self; +} + +- (void)prepareForReuse +{ + [super prepareForReuse]; + self.prepareForReuseCount++; +} + +@end + +static CGFloat RCTUIExpectedTextHeight(NSString *text, NSFont *font, CGFloat width) +{ + NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; + paragraphStyle.lineBreakMode = NSLineBreakByWordWrapping; + NSRect bounds = [text boundingRectWithSize:NSMakeSize(width, CGFLOAT_MAX) + options:NSStringDrawingUsesLineFragmentOrigin + attributes:@{ + NSFontAttributeName : font, + NSParagraphStyleAttributeName : paragraphStyle, + }]; + return ceil(NSHeight(bounds)) + 10; +} + +@interface RCTUISizingTableCell : RCTUITableViewCell + +@property (nonatomic, copy) NSString *sizingText; +@property (nonatomic, strong) NSFont *sizingFont; +@property (nonatomic, assign) CGFloat lastFittingWidth; + +@end + +@implementation RCTUISizingTableCell + +- (NSSize)fittingSize +{ + self.lastFittingWidth = NSWidth(self.frame); + return NSMakeSize( + self.lastFittingWidth, RCTUIExpectedTextHeight(self.sizingText, self.sizingFont, self.lastFittingWidth)); +} + +@end + +@interface RCTUITableViewTests : XCTestCase +@end + +@implementation RCTUITableViewTests { + NSWindow *_window; + RCTUITableView *_tableView; + id _dataSource; + id _delegate; +} + +- (void)setUp +{ + [super setUp]; + [NSApplication sharedApplication]; + NSApp.activationPolicy = NSApplicationActivationPolicyAccessory; + + _window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 600, 400) + styleMask:NSWindowStyleMaskTitled + backing:NSBackingStoreBuffered + defer:NO]; + [_window setFrameOrigin:NSMakePoint(-4000, -4000)]; + + _tableView = [[RCTUITableView alloc] initWithFrame:NSZeroRect]; + _tableView.translatesAutoresizingMaskIntoConstraints = NO; + [_window.contentView addSubview:_tableView]; + [NSLayoutConstraint activateConstraints:@[ + [_tableView.leadingAnchor constraintEqualToAnchor:_window.contentView.leadingAnchor], + [_tableView.topAnchor constraintEqualToAnchor:_window.contentView.topAnchor], + [_tableView.trailingAnchor constraintEqualToAnchor:_window.contentView.trailingAnchor], + [_tableView.bottomAnchor constraintEqualToAnchor:_window.contentView.bottomAnchor], + ]]; + + [_window orderFront:nil]; +} + +- (void)tearDown +{ + [_window orderOut:nil]; + [_window close]; + _delegate = nil; + _dataSource = nil; + _tableView = nil; + _window = nil; + [super tearDown]; +} + +- (void)installDataSource:(id)dataSource + delegate:(id)delegate +{ + _dataSource = dataSource; + _delegate = delegate; + _tableView.dataSource = dataSource; + _tableView.delegate = delegate; +} + +- (void)setContentWidth:(CGFloat)width +{ + [_window setContentSize:NSMakeSize(width, 400)]; + [self forceLayoutAndDisplay]; +} + +- (void)forceLayoutAndDisplay +{ + [_window.contentView layoutSubtreeIfNeeded]; + [_tableView layoutSubtreeIfNeeded]; + [RCTUIBackingTable(_tableView) layoutSubtreeIfNeeded]; + [_window displayIfNeeded]; + RCTUIPumpRunLoop(0.05); +} + +- (void)reloadAndDisplay +{ + [_tableView reloadData]; + [self forceLayoutAndDisplay]; +} + +- (void)testSectionsDefaultsAndEmptySections +{ + RCTUITableViewTestDataSource *dataSource = [RCTUITableViewTestDataSource new]; + dataSource.rowCounts = @[@1, @0, @4]; + [self installDataSource:dataSource delegate:dataSource]; + [self reloadAndDisplay]; + + XCTAssertEqual(RCTUIBackingTable(_tableView).numberOfRows, 5); + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:3 inSection:2]; + XCTAssertEqual(indexPath.section, 2); + XCTAssertEqual(indexPath.row, 3); + + RCTUITableViewSingleSectionDataSource *singleSection = [RCTUITableViewSingleSectionDataSource new]; + singleSection.rowCount = 2; + [self installDataSource:singleSection delegate:nil]; + [self reloadAndDisplay]; + XCTAssertEqual(RCTUIBackingTable(_tableView).numberOfRows, 2); + + singleSection.rowCount = 0; + [self reloadAndDisplay]; + XCTAssertEqual(RCTUIBackingTable(_tableView).numberOfRows, 0); +} + +- (void)testRowsTrackSuccessiveReloads +{ + RCTUITableViewTestDataSource *dataSource = [RCTUITableViewTestDataSource new]; + [self installDataSource:dataSource delegate:dataSource]; + + for (NSNumber *rowCount in @[@2, @9, @1, @0]) { + dataSource.rowCounts = @[rowCount]; + [self reloadAndDisplay]; + XCTAssertEqual(RCTUIBackingTable(_tableView).numberOfRows, rowCount.integerValue); + XCTAssertTrue(isfinite(NSMinY(_tableView.visibleRect))); + XCTAssertGreaterThanOrEqual(NSWidth(_tableView.visibleRect), 0); + XCTAssertGreaterThanOrEqual(NSHeight(_tableView.visibleRect), 0); + } +} + +- (void)testRealReuseIsNilFirstBoundedAndPreparedExactlyOnce +{ + RCTUITrackingCellAllocationCount = 0; + RCTUITableViewTestDataSource *dataSource = [RCTUITableViewTestDataSource new]; + dataSource.rowCounts = @[@200]; + + __block BOOL observedFirstDequeue = NO; + __block BOOL firstDequeueWasNil = NO; + __block BOOL prepareCountMismatch = NO; + __block BOOL doubleAttachmentDetected = NO; + NSMutableArray *allocatedCells = [NSMutableArray new]; + dataSource.cellProvider = ^RCTUITableViewCell *(RCTUITableView *tableView, NSIndexPath *indexPath) { + RCTUITrackingTableCell *cell = [tableView dequeueReusableCellWithIdentifier:@"tracking"]; + if (!observedFirstDequeue) { + observedFirstDequeue = YES; + firstDequeueWasNil = cell == nil; + } + + if (cell == nil) { + cell = [[RCTUITrackingTableCell alloc] initWithStyle:RCTUITableViewCellStyleDefault + reuseIdentifier:@"tracking"]; + [allocatedCells addObject:cell]; + } else { + doubleAttachmentDetected |= cell.superview != nil; + prepareCountMismatch |= cell.prepareForReuseCount != cell.vendCount; + } + + cell.vendCount++; + cell.textLabel.text = [NSString stringWithFormat:@"Row %ld", (long)indexPath.row]; + return cell; + }; + + [self installDataSource:dataSource delegate:dataSource]; + [self reloadAndDisplay]; + + NSTableView *backingTable = RCTUIBackingTable(_tableView); + NSUInteger maximumVisibleRows = [backingTable rowsInRect:backingTable.visibleRect].length; + XCTAssertGreaterThan(maximumVisibleRows, 0u); + + [_tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:199 inSection:0] + atScrollPosition:RCTUITableViewScrollPositionTop + animated:NO]; + [self forceLayoutAndDisplay]; + maximumVisibleRows = MAX(maximumVisibleRows, [backingTable rowsInRect:backingTable.visibleRect].length); + + [_tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0] + atScrollPosition:RCTUITableViewScrollPositionTop + animated:NO]; + [self forceLayoutAndDisplay]; + maximumVisibleRows = MAX(maximumVisibleRows, [backingTable rowsInRect:backingTable.visibleRect].length); + + XCTAssertTrue(observedFirstDequeue); + XCTAssertTrue(firstDequeueWasNil); + XCTAssertFalse(prepareCountMismatch); + XCTAssertFalse(doubleAttachmentDetected); + XCTAssertLessThanOrEqual(RCTUITrackingCellAllocationCount, (NSInteger)(3 * maximumVisibleRows)); + for (RCTUITrackingTableCell *cell in allocatedCells) { + XCTAssertEqual(cell.prepareForReuseCount, MAX(cell.vendCount - 1, 0)); + } +} + +- (void)testSelectionDeselectAndHeaderNonselection +{ + RCTUITableViewTestDataSource *dataSource = [RCTUITableViewTestDataSource new]; + dataSource.rowCounts = @[@1, @2]; + dataSource.headerHeights = @[@24, @0]; + [self installDataSource:dataSource delegate:dataSource]; + [self reloadAndDisplay]; + + NSTableView *backingTable = RCTUIBackingTable(_tableView); + XCTAssertFalse([backingTable.delegate tableView:backingTable shouldSelectRow:0]); + XCTAssertNil(dataSource.selectedIndexPath); + + XCTAssertFalse([backingTable.delegate tableView:backingTable shouldSelectRow:3]); + XCTAssertEqual(dataSource.selectedIndexPath.section, 1); + XCTAssertEqual(dataSource.selectedIndexPath.row, 1); + XCTAssertEqual(backingTable.selectedRow, -1); + + XCTAssertNoThrow([_tableView deselectRowAtIndexPath:[NSIndexPath indexPathForRow:1 inSection:1] animated:YES]); + XCTAssertEqual(backingTable.selectedRow, -1); +} + +- (void)testMixedAutomaticAndFixedHeightsReflowWithContentWidth +{ + NSString *text = + @"This deliberately long automatic row wraps repeatedly so the test can distinguish a wide content width from " + "a narrow content width while preserving fixed rows. The compatibility layer must measure against the clip " + "view's content width every time the window changes size, without allowing the two fixed rows to drift. This " + "additional sentence keeps the wide layout above fifty points and makes the narrow reflow unambiguous."; + NSFont *font = [NSFont systemFontOfSize:14]; + RCTUITableViewTestDataSource *dataSource = [RCTUITableViewTestDataSource new]; + dataSource.rowCounts = @[@1, @2]; + + __block RCTUISizingTableCell *sizingCell; + dataSource.cellProvider = ^RCTUITableViewCell *(RCTUITableView *tableView, NSIndexPath *indexPath) { + if (indexPath.section == 0) { + sizingCell = [tableView dequeueReusableCellWithIdentifier:@"sizing"]; + if (sizingCell == nil) { + sizingCell = [[RCTUISizingTableCell alloc] initWithStyle:RCTUITableViewCellStyleDefault + reuseIdentifier:@"sizing"]; + } + sizingCell.sizingText = text; + sizingCell.sizingFont = font; + sizingCell.textLabel.text = text; + sizingCell.contentView.wantsLayer = YES; + sizingCell.contentView.layer.cornerRadius = 8; + sizingCell.contentView.layer.cornerCurve = kCACornerCurveContinuous; + return sizingCell; + } + + RCTUITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"fixed"]; + return cell ?: [[RCTUITableViewCell alloc] initWithStyle:RCTUITableViewCellStyleDefault reuseIdentifier:@"fixed"]; + }; + dataSource.heightProvider = ^CGFloat(NSIndexPath *indexPath) { + return indexPath.section == 0 ? RCTUITableViewAutomaticDimension : 50; + }; + + [self installDataSource:dataSource delegate:dataSource]; + [self setContentWidth:880]; + [self reloadAndDisplay]; + + NSTableView *backingTable = RCTUIBackingTable(_tableView); + XCTAssertEqual(backingTable.effectiveStyle, NSTableViewStyleInset); + NSRect wideCellFrame = [backingTable frameOfCellAtColumn:0 row:0]; + XCTAssertEqual(NSMinX(wideCellFrame), 16); + XCTAssertEqual(NSWidth(wideCellFrame), 848); + + CGFloat wideContentWidth = _tableView.contentSize.width; + CGFloat wideHeight = NSHeight([backingTable rectOfRow:0]); + XCTAssertEqual(sizingCell.lastFittingWidth, wideContentWidth); + XCTAssertEqualWithAccuracy(wideHeight, RCTUIExpectedTextHeight(text, font, wideContentWidth), 0.5); + XCTAssertGreaterThan(wideHeight, 50); + XCTAssertEqualWithAccuracy(NSHeight([backingTable rectOfRow:1]), 50, 0.5); + XCTAssertEqualWithAccuracy(NSHeight([backingTable rectOfRow:2]), 50, 0.5); + XCTAssertEqual(sizingCell.contentView.layer.cornerRadius, 8); + XCTAssertEqualObjects(sizingCell.contentView.layer.cornerCurve, kCACornerCurveContinuous); + + [self setContentWidth:320]; + NSRect narrowCellFrame = [backingTable frameOfCellAtColumn:0 row:0]; + XCTAssertEqual(NSMinX(narrowCellFrame), 16); + XCTAssertEqual(NSWidth(narrowCellFrame), 288); + + CGFloat narrowContentWidth = _tableView.contentSize.width; + CGFloat narrowHeight = NSHeight([backingTable rectOfRow:0]); + XCTAssertEqual(sizingCell.lastFittingWidth, narrowContentWidth); + XCTAssertEqualWithAccuracy(narrowHeight, RCTUIExpectedTextHeight(text, font, narrowContentWidth), 0.5); + XCTAssertGreaterThan(narrowHeight, wideHeight); + XCTAssertEqualWithAccuracy(NSHeight([backingTable rectOfRow:1]), 50, 0.5); + XCTAssertEqualWithAccuracy(NSHeight([backingTable rectOfRow:2]), 50, 0.5); +} + +- (void)testHeadersAreLazyStablePerReloadAndHeightZeroHasNoSlot +{ + RCTUITableViewTestDataSource *dataSource = [RCTUITableViewTestDataSource new]; + dataSource.rowCounts = @[@40, @0, @40]; + dataSource.headerHeights = @[@30, @0, @25]; + dataSource.headerProvider = ^RCTPlatformView *(NSInteger section) { + RCTPlatformView *view = [RCTPlatformView new]; + view.accessibilityIdentifier = [NSString stringWithFormat:@"header-%ld", (long)section]; + return view; + }; + [self installDataSource:dataSource delegate:dataSource]; + [self reloadAndDisplay]; + + NSTableView *backingTable = RCTUIBackingTable(_tableView); + XCTAssertEqual(backingTable.numberOfRows, 82); + XCTAssertEqualWithAccuracy(NSHeight([backingTable rectOfRow:0]), 30, 0.5); + XCTAssertEqualWithAccuracy(NSHeight([backingTable rectOfRow:41]), 25, 0.5); + XCTAssertFalse([backingTable.delegate tableView:backingTable shouldSelectRow:0]); + XCTAssertFalse([backingTable.delegate tableView:backingTable shouldSelectRow:41]); + + NSView *firstHeader = [backingTable.delegate tableView:backingTable + viewForTableColumn:backingTable.tableColumns.firstObject + row:0]; + NSView *firstHeaderAgain = [backingTable.delegate tableView:backingTable + viewForTableColumn:backingTable.tableColumns.firstObject + row:0]; + XCTAssertEqual(firstHeader, firstHeaderAgain); + NSView *thirdSectionHeader = [backingTable.delegate tableView:backingTable + viewForTableColumn:backingTable.tableColumns.firstObject + row:41]; + NSView *thirdSectionHeaderAgain = [backingTable.delegate tableView:backingTable + viewForTableColumn:backingTable.tableColumns.firstObject + row:41]; + XCTAssertEqual(thirdSectionHeader, thirdSectionHeaderAgain); + XCTAssertEqual(dataSource.headerViewCallCounts[@0].integerValue, 1); + XCTAssertEqual(dataSource.headerViewCallCounts[@1].integerValue, 0); + XCTAssertEqual(dataSource.headerViewCallCounts[@2].integerValue, 1); + + [_tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:39 inSection:2] + atScrollPosition:RCTUITableViewScrollPositionTop + animated:NO]; + [self forceLayoutAndDisplay]; + [_tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0] + atScrollPosition:RCTUITableViewScrollPositionTop + animated:NO]; + [self forceLayoutAndDisplay]; + XCTAssertEqual(dataSource.headerViewCallCounts[@0].integerValue, 1); + XCTAssertEqual(dataSource.headerViewCallCounts[@2].integerValue, 1); + + dataSource.headerViewCallCounts[@0] = @0; + dataSource.headerViewCallCounts[@2] = @0; + [self reloadAndDisplay]; + NSView *nextGenerationHeader = [backingTable.delegate tableView:backingTable + viewForTableColumn:backingTable.tableColumns.firstObject + row:0]; + XCTAssertNotEqual(firstHeader, nextGenerationHeader); + XCTAssertEqual(dataSource.headerViewCallCounts[@0].integerValue, 1); + XCTAssertEqual(dataSource.headerViewCallCounts[@1].integerValue, 0); + + dataSource.headerProvider = ^RCTPlatformView *(NSInteger section) { + return nil; + }; + [self reloadAndDisplay]; + NSView *emptyHeader = [backingTable.delegate tableView:backingTable + viewForTableColumn:backingTable.tableColumns.firstObject + row:0]; + XCTAssertNotNil(emptyHeader); + XCTAssertTrue([emptyHeader isKindOfClass:RCTPlatformView.class]); +} + +- (void)testAccessibilityContainerAndCellIdentifiersSurviveReuse +{ + RCTUITableViewTestDataSource *dataSource = [RCTUITableViewTestDataSource new]; + dataSource.rowCounts = @[@100]; + dataSource.cellProvider = ^RCTUITableViewCell *(RCTUITableView *tableView, NSIndexPath *indexPath) { + RCTUITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"accessible"]; + if (cell == nil) { + cell = [[RCTUITableViewCell alloc] initWithStyle:RCTUITableViewCellStyleDefault + reuseIdentifier:@"accessible"]; + } + cell.textLabel.accessibilityIdentifier = + indexPath.row == 0 ? @"redbox-error" : [NSString stringWithFormat:@"cell-%ld", (long)indexPath.row]; + return cell; + }; + + _tableView.accessibilityIdentifier = @"compatibility-table"; + [self installDataSource:dataSource delegate:dataSource]; + [self reloadAndDisplay]; + + NSTableView *backingTable = RCTUIBackingTable(_tableView); + XCTAssertEqualObjects(backingTable.accessibilityRole, NSAccessibilityTableRole); + XCTAssertEqualObjects(backingTable.accessibilityIdentifier, @"compatibility-table"); + + RCTUITableViewCell *firstCell = [backingTable viewAtColumn:0 row:0 makeIfNecessary:YES]; + XCTAssertEqualObjects(firstCell.textLabel.accessibilityIdentifier, @"redbox-error"); + + [_tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:99 inSection:0] + atScrollPosition:RCTUITableViewScrollPositionTop + animated:NO]; + [self forceLayoutAndDisplay]; + [_tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0] + atScrollPosition:RCTUITableViewScrollPositionTop + animated:NO]; + [self forceLayoutAndDisplay]; + + firstCell = [backingTable viewAtColumn:0 row:0 makeIfNecessary:YES]; + XCTAssertEqualObjects(firstCell.textLabel.accessibilityIdentifier, @"redbox-error"); + XCTAssertEqual( + backingTable.accessibilityChildren.count, [backingTable rowsInRect:backingTable.visibleRect].length); +} + +- (void)testScrollToTopPositionAndReturnToFirstRow +{ + RCTUITableViewTestDataSource *dataSource = [RCTUITableViewTestDataSource new]; + dataSource.rowCounts = @[@100]; + dataSource.heightProvider = ^CGFloat(NSIndexPath *indexPath) { + return 30; + }; + [self installDataSource:dataSource delegate:dataSource]; + [self reloadAndDisplay]; + + NSTableView *backingTable = RCTUIBackingTable(_tableView); + [_tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:80 inSection:0] + atScrollPosition:RCTUITableViewScrollPositionTop + animated:NO]; + [self forceLayoutAndDisplay]; + + NSRect targetRect = [backingTable rectOfRow:80]; + XCTAssertEqualWithAccuracy(NSMinY(targetRect), NSMinY(_tableView.visibleRect), 1); + XCTAssertTrue(NSContainsRect(_tableView.visibleRect, targetRect)); + + [_tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0] + atScrollPosition:RCTUITableViewScrollPositionTop + animated:NO]; + [self forceLayoutAndDisplay]; + XCTAssertEqualWithAccuracy(NSMinY(_tableView.visibleRect), 0, 0.5); +} + +- (void)testLabelButtonActionAndIdentifierForwarding +{ + RCTUILabel *label = [RCTUILabel new]; + label.text = @"forwarded"; + label.numberOfLines = 3; + label.textAlignment = NSTextAlignmentCenter; + XCTAssertEqualObjects(label.text, @"forwarded"); + XCTAssertEqualObjects(label.stringValue, @"forwarded"); + XCTAssertEqual(label.numberOfLines, 3); + XCTAssertEqual(label.maximumNumberOfLines, 3); + XCTAssertEqual(label.textAlignment, NSTextAlignmentCenter); + XCTAssertEqual(label.alignment, NSTextAlignmentCenter); + + RCTUIButton *button = [RCTUIButton new]; + NSFont *font = [NSFont systemFontOfSize:13]; + button.titleLabel.font = font; + button.titleLabel.lineBreakMode = NSLineBreakByWordWrapping; + button.titleLabel.textAlignment = NSTextAlignmentCenter; + [button setTitle:@"Reload" forState:RCTUIControlStateNormal]; + [button setTitleColor:NSColor.whiteColor forState:RCTUIControlStateNormal]; + [button setTitleColor:[NSColor colorWithWhite:1 alpha:0.5] forState:RCTUIControlStateHighlighted]; + button.accessibilityIdentifier = @"redbox-reload"; + + XCTAssertEqualObjects(button.attributedTitle.string, @"Reload"); + XCTAssertEqualObjects( + [button.attributedTitle attribute:NSFontAttributeName atIndex:0 effectiveRange:nil], font); + NSParagraphStyle *paragraphStyle = + [button.attributedTitle attribute:NSParagraphStyleAttributeName atIndex:0 effectiveRange:nil]; + XCTAssertEqual(paragraphStyle.lineBreakMode, NSLineBreakByWordWrapping); + XCTAssertEqual(paragraphStyle.alignment, NSTextAlignmentCenter); + XCTAssertEqualObjects(button.accessibilityIdentifier, @"redbox-reload"); + + __block NSInteger clickCount = 0; + __weak RCTUIAction *weakAction; + @autoreleasepool { + RCTUIAction *action = [RCTUIAction actionWithHandler:^{ + clickCount++; + }]; + weakAction = action; + [button rct_setPrimaryAction:action]; + } + XCTAssertNotNil(weakAction); + [button performClick:nil]; + XCTAssertEqual(clickCount, 1); +} + +@end + +#endif // macOS] From 786ebf4aba219d37c2cd623a4a6a78b768e8296e Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 12:55:40 -0700 Subject: [PATCH 05/18] refactor(macos): migrate RedBox table to RCTUITableView Preserve the inset rounded message card while enabling real cell reuse, resetting reused row configuration, fixing the redbox-error identifier, and mapping nil messages through sections. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../React/CoreModules/RCTRedBox.mm | 271 +++++------------- 1 file changed, 69 insertions(+), 202 deletions(-) diff --git a/packages/react-native/React/CoreModules/RCTRedBox.mm b/packages/react-native/React/CoreModules/RCTRedBox.mm index 0531ab50eb7a..25577289e75b 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox.mm +++ b/packages/react-native/React/CoreModules/RCTRedBox.mm @@ -15,6 +15,7 @@ #import #import #import +#import // [macOS] #import #import @@ -89,20 +90,13 @@ - (void)loadExtraDataViewController; @end -#if !TARGET_OS_OSX // [macOS] -@interface RCTRedBoxController : UIViewController -#else // [macOS -@interface RCTRedBoxController : NSViewController -#endif // macOS] +@interface RCTRedBoxController + : RCTPlatformViewController // [macOS] @property (nonatomic, weak) id actionDelegate; @end @implementation RCTRedBoxController { -#if !TARGET_OS_OSX // [macOS] - UITableView *_stackTraceTableView; -#else // [macOS - NSTableView *_stackTraceTableView; -#endif // macOS] + RCTUITableView *_stackTraceTableView; // [macOS] NSString *_lastErrorMessage; NSArray *_lastStackTrace; NSArray *_customButtonTitles; @@ -138,38 +132,19 @@ - (void)viewDidLoad detailsFrame.size.height -= buttonHeight + (double)[self bottomSafeViewHeight]; #if !TARGET_OS_OSX // [macOS] - _stackTraceTableView = [[UITableView alloc] initWithFrame:detailsFrame style:UITableViewStylePlain]; + _stackTraceTableView = [[RCTUITableView alloc] initWithFrame:detailsFrame style:UITableViewStylePlain]; // [macOS] _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]; #else // [macOS - NSScrollView *scrollView = [[NSScrollView alloc] initWithFrame:NSZeroRect]; - scrollView.translatesAutoresizingMaskIntoConstraints = NO; - scrollView.autoresizesSubviews = YES; - scrollView.drawsBackground = NO; - - _stackTraceTableView = [[NSTableView alloc] initWithFrame:NSZeroRect]; + _stackTraceTableView = [[RCTUITableView alloc] initWithFrame:NSZeroRect]; _stackTraceTableView.translatesAutoresizingMaskIntoConstraints = NO; +#endif // macOS] _stackTraceTableView.dataSource = self; _stackTraceTableView.delegate = self; - _stackTraceTableView.headerView = nil; - _stackTraceTableView.allowsColumnReordering = NO; - _stackTraceTableView.allowsColumnResizing = NO; - _stackTraceTableView.columnAutoresizingStyle = NSTableViewFirstColumnOnlyAutoresizingStyle; - _stackTraceTableView.backgroundColor = [NSColor clearColor]; - _stackTraceTableView.allowsTypeSelect = NO; - - NSTableColumn *tableColumn = [[NSTableColumn alloc] initWithIdentifier:@"info"]; - [_stackTraceTableView addTableColumn:tableColumn]; - - scrollView.documentView = _stackTraceTableView; - [self.view addSubview:scrollView]; -#endif // macOS] + [self.view addSubview:_stackTraceTableView]; #if TARGET_OS_SIMULATOR || TARGET_OS_MACCATALYST || TARGET_OS_OSX // [macOS] NSString *reloadText = @"Reload\n(\u2318R)"; @@ -299,10 +274,10 @@ - (void)viewDidLoad #if TARGET_OS_OSX // [macOS [NSLayoutConstraint activateConstraints:@[ - [[scrollView leadingAnchor] constraintEqualToAnchor:[[self view] leadingAnchor]], - [[scrollView topAnchor] constraintEqualToAnchor:[[self view] topAnchor]], - [[scrollView trailingAnchor] constraintEqualToAnchor:[[self view] trailingAnchor]], - [[scrollView bottomAnchor] constraintEqualToAnchor:[topBorder topAnchor]], + [[_stackTraceTableView leadingAnchor] constraintEqualToAnchor:[[self view] leadingAnchor]], + [[_stackTraceTableView topAnchor] constraintEqualToAnchor:[[self view] topAnchor]], + [[_stackTraceTableView trailingAnchor] constraintEqualToAnchor:[[self view] trailingAnchor]], + [[_stackTraceTableView bottomAnchor] constraintEqualToAnchor:[topBorder topAnchor]], ]]; #endif // macOS] } @@ -401,14 +376,13 @@ - (void)showErrorMessage:(NSString *)message [_stackTraceTableView reloadData]; if (!isRootViewControllerPresented) { -#if !TARGET_OS_OSX // [macOS] [_stackTraceTableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0] - atScrollPosition:UITableViewScrollPositionTop - animated:NO]; + atScrollPosition:RCTUITableViewScrollPositionTop + animated:NO]; // [macOS] +#if !TARGET_OS_OSX // [macOS] [RCTKeyWindow().rootViewController presentViewController:self animated:YES completion:nil]; #else // [macOS - [_stackTraceTableView scrollRowToVisible:0]; - [[RCTKeyWindow() contentViewController] presentViewControllerAsSheet:self]; + [[RCTKeyWindow() contentViewController] presentViewControllerAsSheet:self]; #endif // macOS] } } @@ -481,200 +455,116 @@ - (NSString *)formatFrameSource:(RCTJSStackFrame *)stackFrame #pragma mark - TableView -#if !TARGET_OS_OSX // [macOS] -- (NSInteger)numberOfSectionsInTableView:(__unused UITableView *)tableView +- (NSInteger)numberOfSectionsInTableView:(__unused RCTUITableView *)tableView // [macOS] { return 2; } -- (NSInteger)tableView:(__unused UITableView *)tableView numberOfRowsInSection:(NSInteger)section -{ - return section == 0 ? 1 : _lastStackTrace.count; -} -#else // [macOS -- (NSInteger)numberOfRowsInTableView:(__unused NSTableView *)tableView +- (NSInteger)tableView:(__unused RCTUITableView *)tableView numberOfRowsInSection:(NSInteger)section // [macOS] { - return (_lastErrorMessage != nil) + _lastStackTrace.count; + return section == 0 ? (_lastErrorMessage != nil) : _lastStackTrace.count; } -#endif // macOS] -#if !TARGET_OS_OSX // [macOS] -- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +- (RCTUITableViewCell *)tableView:(RCTUITableView *)tableView + cellForRowAtIndexPath:(NSIndexPath *)indexPath // [macOS] { if (indexPath.section == 0) { - UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"msg-cell"]; + RCTUITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"msg-cell"]; return [self reuseCell:cell forErrorMessage:_lastErrorMessage]; } - UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell"]; + RCTUITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell"]; NSUInteger index = indexPath.row; RCTJSStackFrame *stackFrame = _lastStackTrace[index]; return [self reuseCell:cell forStackFrame:stackFrame]; } -#else // [macOS -- (nullable NSView *)tableView:(NSTableView *)tableView viewForTableColumn:(nullable NSTableColumn *)tableColumn row:(NSInteger)row -{ - if (row == 0) { - NSTableCellView *cell = [tableView makeViewWithIdentifier:@"msg-cell" owner:nil]; - return [self reuseCell:cell forErrorMessage:_lastErrorMessage]; - } - NSTableCellView *cell = [tableView makeViewWithIdentifier:@"cell" owner:nil]; - NSUInteger index = row - 1; - RCTJSStackFrame *stackFrame = _lastStackTrace[index]; - return [self reuseCell:cell forStackFrame:stackFrame]; - -} -#endif // macOS] -#if !TARGET_OS_OSX -- (UITableViewCell *)reuseCell:(UITableViewCell *)cell forErrorMessage:(NSString *)message +- (RCTUITableViewCell *)reuseCell:(RCTUITableViewCell *)cell forErrorMessage:(NSString *)message // [macOS] { if (!cell) { - cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"msg-cell"]; - cell.textLabel.accessibilityIdentifier = @"redbox-error"; - cell.textLabel.textColor = [UIColor whiteColor]; + cell = [[RCTUITableViewCell alloc] initWithStyle:RCTUITableViewCellStyleDefault reuseIdentifier:@"msg-cell"]; + cell.textLabel.textColor = [RCTUIColor whiteColor]; // [macOS] // 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.detailTextLabel.textColor = [RCTUIColor whiteColor]; // [macOS] + cell.backgroundColor = [RCTUIColor colorWithRed:0.82 green:0.10 blue:0.15 alpha:1.0]; // [macOS] +#if !TARGET_OS_OSX // [macOS] cell.selectionStyle = UITableViewCellSelectionStyleNone; +#else // [macOS + cell.contentView.layer.cornerRadius = 8.0; + cell.contentView.layer.cornerCurve = kCACornerCurveContinuous; +#endif // macOS] } + cell.textLabel.accessibilityIdentifier = @"redbox-error"; + cell.textLabel.lineBreakMode = NSLineBreakByWordWrapping; + cell.textLabel.numberOfLines = 0; +#if !TARGET_OS_OSX // [macOS] cell.textLabel.text = message; - - return cell; -} #else // [macOS -- (NSTableCellView *)reuseCell:(NSTableCellView *)cell forErrorMessage:(NSString *)message -{ - if (!cell) { - cell = [[NSTableCellView alloc] initWithFrame:NSZeroRect]; - cell.rowSizeStyle = NSTableViewRowSizeStyleCustom; - cell.textField.accessibilityIdentifier = @"red box-error"; - - NSTextField *label = [[NSTextField alloc] initWithFrame:NSZeroRect]; - label.translatesAutoresizingMaskIntoConstraints = NO; - label.drawsBackground = NO; - label.bezeled = NO; - label.editable = NO; - - [cell addSubview:label]; - cell.textField = label; - - [NSLayoutConstraint activateConstraints:@[ - [[label leadingAnchor] constraintEqualToAnchor:[cell leadingAnchor] constant:5], - [[label topAnchor] constraintEqualToAnchor:[cell topAnchor] constant:5], - [[label trailingAnchor] constraintEqualToAnchor:[cell trailingAnchor] constant:-5], - [[label bottomAnchor] constraintEqualToAnchor:[cell bottomAnchor] constant:-5], - ]]; - - // Prefer a monofont for formatting messages that were designed - // to be displayed in a terminal. - cell.textField.font = [NSFont monospacedSystemFontOfSize:14 weight:NSFontWeightBold]; - - cell.textField.lineBreakMode = NSLineBreakByWordWrapping; - cell.textField.maximumNumberOfLines = 0; - cell.wantsLayer = true; - cell.layer.cornerRadius = 8.0; - cell.layer.cornerCurve = kCACornerCurveContinuous; - - cell.layer.backgroundColor = [NSColor colorWithRed:0.82 green:0.10 blue:0.15 alpha:1.0].CGColor; - } - NSDictionary *attributes = @{ NSForegroundColorAttributeName : [NSColor whiteColor], NSFontAttributeName : [NSFont systemFontOfSize:16], }; NSAttributedString *title = [[NSAttributedString alloc] initWithString:message attributes:attributes]; - - cell.textField.attributedStringValue = title; + cell.textLabel.attributedStringValue = title; +#endif // macOS] return cell; } -#endif // [macOS] -#if !TARGET_OS_OSX // [macOS] -- (UITableViewCell *)reuseCell:(UITableViewCell *)cell forStackFrame:(RCTJSStackFrame *)stackFrame +- (RCTUITableViewCell *)reuseCell:(RCTUITableViewCell *)cell forStackFrame:(RCTJSStackFrame *)stackFrame // [macOS] { if (!cell) { - cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"cell"]; + cell = [[RCTUITableViewCell alloc] initWithStyle:RCTUITableViewCellStyleSubtitle 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.textColor = [RCTUIColor colorWithRed:0.70 green:0.70 blue:0.70 alpha:1.0]; // [macOS] cell.detailTextLabel.font = [UIFont fontWithName:@"Menlo-Regular" size:11]; cell.detailTextLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; - cell.backgroundColor = [UIColor clearColor]; + cell.backgroundColor = [RCTUIColor clearColor]; // [macOS] +#if !TARGET_OS_OSX // [macOS] cell.selectedBackgroundView = [UIView new]; cell.selectedBackgroundView.backgroundColor = [UIColor colorWithWhite:0 alpha:0.2]; +#else // [macOS + [cell.detailTextLabel removeFromSuperview]; + [cell.textLabel.bottomAnchor constraintEqualToAnchor:cell.contentView.bottomAnchor].active = YES; +#endif // macOS] } - cell.textLabel.text = stackFrame.methodName ?: @"(unnamed method)"; - if (stackFrame.file) { - cell.detailTextLabel.text = [self formatFrameSource:stackFrame]; - } else { - cell.detailTextLabel.text = @""; - } + NSString *text = stackFrame.methodName ?: @"(unnamed method)"; + cell.textLabel.lineBreakMode = NSLineBreakByCharWrapping; + cell.textLabel.numberOfLines = 2; + +#if !TARGET_OS_OSX // [macOS] + cell.textLabel.text = text; + cell.detailTextLabel.text = stackFrame.file ? [self formatFrameSource:stackFrame] : @""; 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; -} #else // [macOS -- (NSTableCellView *)reuseCell:(NSTableCellView *)cell forStackFrame:(RCTJSStackFrame *)stackFrame -{ - if (!cell) { - cell = [[NSTableCellView alloc] initWithFrame:NSZeroRect]; - - NSTextField *label = [[NSTextField alloc] initWithFrame:NSZeroRect]; - label.translatesAutoresizingMaskIntoConstraints = NO; - label.backgroundColor = [NSColor clearColor]; - label.bezeled = NO; - label.editable = NO; - - label.maximumNumberOfLines = 2; - - [cell addSubview:label]; - cell.textField = label; - - [NSLayoutConstraint activateConstraints:@[ - [[label leadingAnchor] constraintEqualToAnchor:[cell leadingAnchor] constant:5], - [[label topAnchor] constraintEqualToAnchor:[cell topAnchor]], - [[label trailingAnchor] constraintEqualToAnchor:[cell trailingAnchor] constant:-5], - [[label bottomAnchor] constraintEqualToAnchor:[cell bottomAnchor]], - ]]; - } - - NSString *text = stackFrame.methodName ?: @"(unnamed method)"; - NSMutableParagraphStyle *textParagraphStyle = [NSMutableParagraphStyle new]; textParagraphStyle.lineBreakMode = NSLineBreakByCharWrapping; - + NSDictionary *textAttributes = @{ NSForegroundColorAttributeName : stackFrame.collapse ? [NSColor lightGrayColor] : [NSColor whiteColor], NSFontAttributeName : [NSFont fontWithName:@"Menlo-Regular" size:14], NSParagraphStyleAttributeName : textParagraphStyle, }; - + NSAttributedString *attributedText = [[NSAttributedString alloc] initWithString:text attributes:textAttributes]; - - NSMutableAttributedString *title = [attributedText mutableCopy]; // NSTableCellView doesn't contain a subtitle text field. Rather than define our own custom row view, // let's append the detail text with a new line if it is needed. + cell.textLabel.maximumNumberOfLines = stackFrame.file ? 3 : 2; if (stackFrame.file) { - cell.textField.maximumNumberOfLines = 3; - NSString *detailText = [self formatFrameSource:stackFrame]; - + NSMutableParagraphStyle *detailTextParagraphStyle = [NSMutableParagraphStyle new]; detailTextParagraphStyle.lineBreakMode = NSLineBreakByTruncatingMiddle; - + NSDictionary *detailTextAttributes = @{ NSForegroundColorAttributeName : stackFrame.collapse ? [NSColor colorWithRed:0.50 green:0.50 blue:0.50 alpha:1.0] : @@ -683,37 +573,26 @@ - (NSTableCellView *)reuseCell:(NSTableCellView *)cell forStackFrame:(RCTJSStack NSParagraphStyleAttributeName : detailTextParagraphStyle, }; NSAttributedString *attributedDetailText = [[NSAttributedString alloc] initWithString:detailText attributes:detailTextAttributes]; - + [title appendAttributedString:[[NSAttributedString alloc] initWithString:@"\n"]]; [title appendAttributedString:attributedDetailText]; } - - cell.textField.attributedStringValue = title; + cell.textLabel.attributedStringValue = title; +#endif // macOS] return cell; } -#endif // macOS] -#if !TARGET_OS_OSX // [macOS] -- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath -#else // [macOS -- (CGFloat)tableView:(NSTableView *)tableView heightOfRow:(NSInteger)row -#endif // macOS] +- (CGFloat)tableView:(RCTUITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath // [macOS] { -#if !TARGET_OS_OSX // [macOS] if (indexPath.section == 0) { -#else // [macOS - if (row == 0) { -#endif // macOS] NSMutableParagraphStyle *paragraphStyle = [[NSParagraphStyle defaultParagraphStyle] mutableCopy]; paragraphStyle.lineBreakMode = NSLineBreakByWordWrapping; - NSDictionary *attributes = -#if !TARGET_OS_OSX // [macOS] - @{NSFontAttributeName : [UIFont boldSystemFontOfSize:16], NSParagraphStyleAttributeName : paragraphStyle}; -#else // [macOS - @{NSFontAttributeName : [NSFont boldSystemFontOfSize:16], NSParagraphStyleAttributeName : paragraphStyle}; -#endif // macOS] + NSDictionary *attributes = @{ + NSFontAttributeName : [UIFont boldSystemFontOfSize:16], + NSParagraphStyleAttributeName : paragraphStyle + }; // [macOS] CGRect boundingRect = [_lastErrorMessage boundingRectWithSize:CGSizeMake(tableView.frame.size.width - 30, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin @@ -725,8 +604,7 @@ - (CGFloat)tableView:(NSTableView *)tableView heightOfRow:(NSInteger)row } } -#if !TARGET_OS_OSX // [macOS -- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +- (void)tableView:(RCTUITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath // [macOS] { if (indexPath.section == 1) { NSUInteger row = indexPath.row; @@ -735,17 +613,6 @@ - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath } [tableView deselectRowAtIndexPath:indexPath animated:YES]; } -#else // [macOS -- (BOOL)tableView:(__unused NSTableView *)tableView shouldSelectRow:(__unused NSInteger)row -{ - if (row != 0) { - NSUInteger index = row - 1; - RCTJSStackFrame *stackFrame = _lastStackTrace[index]; - [_actionDelegate redBoxController:self openStackFrameInEditor:stackFrame]; - } - return NO; -} -#endif // macOS] #pragma mark - Key commands From 5734e2dc3b48665171c421f0d0ca24b50f8ec8da Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 12:57:10 -0700 Subject: [PATCH 06/18] refactor(macos): migrate RedBox buttons to RCTUIButton Keep the existing AppKit attributed titles, key equivalents, borderless momentary style, layout, and selector actions while fixing accessibility identifiers and retaining block actions through RCTUIAction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../React/CoreModules/RCTRedBox.mm | 126 +++--------------- 1 file changed, 18 insertions(+), 108 deletions(-) diff --git a/packages/react-native/React/CoreModules/RCTRedBox.mm b/packages/react-native/React/CoreModules/RCTRedBox.mm index 25577289e75b..a05184e3895c 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox.mm +++ b/packages/react-native/React/CoreModules/RCTRedBox.mm @@ -18,70 +18,12 @@ #import // [macOS] #import -#import - #import "CoreModulesPlugins.h" #if RCT_DEV_MENU @class RCTRedBoxController; -#if !TARGET_OS_OSX // [macOS] -@interface UIButton (RCTRedBox) -#else // [macOS -@interface NSButton (RCTRedBox) -#endif // macOS] - -@property (nonatomic) RCTRedBoxButtonPressHandler rct_handler; - -#if !TARGET_OS_OSX // [macOS] -- (void)rct_addBlock:(RCTRedBoxButtonPressHandler)handler forControlEvents:(UIControlEvents)controlEvents; -#else // [macOS -- (void)rct_addBlock:(RCTRedBoxButtonPressHandler)handler; -#endif // macOS] - -@end - -#if !TARGET_OS_OSX // [macOS] -@implementation UIButton (RCTRedBox) -#else // [macOS -@implementation NSButton (RCTRedBox) -#endif // macOS] - -- (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(); - } -} - -#if !TARGET_OS_OSX // [macOS] -- (void)rct_addBlock:(RCTRedBoxButtonPressHandler)handler forControlEvents:(UIControlEvents)controlEvents; -#else // [macOS -- (void)rct_addBlock:(RCTRedBoxButtonPressHandler)handler -#endif // macOS] -{ - self.rct_handler = handler; -#if !TARGET_OS_OSX // [macOS] - [self addTarget:self action:@selector(rct_callBlock) forControlEvents:controlEvents]; -#else // [macOS - [self setTarget:self]; - [self setAction:@selector(rct_callBlock)]; -#endif // macOS] -} - -@end - @protocol RCTRedBoxControllerActionDelegate - (void)redBoxController:(RCTRedBoxController *)redBoxController openStackFrameInEditor:(RCTJSStackFrame *)stackFrame; @@ -158,45 +100,28 @@ - (void)viewDidLoad NSString *extraText = @"Extra Info"; #endif -#if !TARGET_OS_OSX // [macOS] - UIButton *dismissButton = [self redBoxButton:dismissText + RCTUIButton *dismissButton = [self redBoxButton:dismissText // [macOS] accessibilityIdentifier:@"redbox-dismiss" selector:@selector(dismiss) block:nil]; - UIButton *reloadButton = [self redBoxButton:reloadText + RCTUIButton *reloadButton = [self redBoxButton:reloadText // [macOS] accessibilityIdentifier:@"redbox-reload" selector:@selector(reload) block:nil]; - UIButton *copyButton = [self redBoxButton:copyText + RCTUIButton *copyButton = [self redBoxButton:copyText // [macOS] accessibilityIdentifier:@"redbox-copy" selector:@selector(copyStack) block:nil]; - UIButton *extraButton = [self redBoxButton:extraText + RCTUIButton *extraButton = [self redBoxButton:extraText // [macOS] accessibilityIdentifier:@"redbox-extra" selector:@selector(showExtraDataViewController) block:nil]; -#else // [macOS - NSButton *dismissButton = [self redBoxButton:dismissText - accessibilityIdentifier:@"redbox-dismiss" - selector:@selector(dismiss) - block:nil]; +#if TARGET_OS_OSX // [macOS [dismissButton setKeyEquivalent:@"\e"]; - NSButton *reloadButton = [self redBoxButton:reloadText - accessibilityIdentifier:@"redbox-reload" - selector:@selector(reload) - block:nil]; [reloadButton setKeyEquivalent:@"r"]; [reloadButton setKeyEquivalentModifierMask:NSEventModifierFlagCommand]; - NSButton *copyButton = [self redBoxButton:copyText - accessibilityIdentifier:@"redbox-copy" - selector:@selector(copyStack) - block:nil]; [copyButton setKeyEquivalent:@"c"]; [copyButton setKeyEquivalentModifierMask:NSEventModifierFlagOption | NSEventModifierFlagCommand]; - NSButton *extraButton = [self redBoxButton:extraText - accessibilityIdentifier:@"redbox-extra" - selector:@selector(showExtraDataViewController) - block:nil]; #endif // macOS] [NSLayoutConstraint activateConstraints:@[ @@ -239,17 +164,10 @@ - (void)viewDidLoad for (NSUInteger i = 0; i < [_customButtonTitles count]; i++) { -#if !TARGET_OS_OSX // [macOS] - UIButton *button = [self redBoxButton:_customButtonTitles[i] - accessibilityIdentifier:@"" - selector:nil - block:_customButtonHandlers[i]]; -#else // [macOS - NSButton *button = [self redBoxButton:_customButtonTitles[i] + RCTUIButton *button = [self redBoxButton:_customButtonTitles[i] // [macOS] accessibilityIdentifier:@"" selector:nil block:_customButtonHandlers[i]]; -#endif // macOS] [button.heightAnchor constraintEqualToConstant:buttonHeight].active = YES; [buttonStackView addArrangedSubview:button]; } @@ -282,16 +200,15 @@ - (void)viewDidLoad #endif // macOS] } -#if !TARGET_OS_OSX // [macOS] -- (UIButton *)redBoxButton:(NSString *)title +- (RCTUIButton *)redBoxButton:(NSString *)title // [macOS] accessibilityIdentifier:(NSString *)accessibilityIdentifier selector:(SEL)selector block:(RCTRedBoxButtonPressHandler)block { - UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom]; +#if !TARGET_OS_OSX // [macOS] + RCTUIButton *button = [RCTUIButton buttonWithType:UIButtonTypeCustom]; // [macOS] button.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleRightMargin; - button.accessibilityIdentifier = accessibilityIdentifier; button.titleLabel.font = [UIFont systemFontOfSize:13]; button.titleLabel.lineBreakMode = NSLineBreakByWordWrapping; button.titleLabel.textAlignment = NSTextAlignmentCenter; @@ -299,37 +216,30 @@ - (UIButton *)redBoxButton:(NSString *)title [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; -} #else // [macOS -- (NSButton *)redBoxButton:(NSString *)title - accessibilityIdentifier:(NSString *)accessibilityIdentifier - selector:(SEL)selector - block:(RCTRedBoxButtonPressHandler)block -{ - NSButton *button = [[NSButton alloc] initWithFrame:NSZeroRect]; + RCTUIButton *button = [[RCTUIButton alloc] initWithFrame:NSZeroRect]; button.translatesAutoresizingMaskIntoConstraints = NO; - button.accessibilityIdentifier = @"accessibilityIdentifier"; button.bordered = NO; NSAttributedString *attributedTitle = [[NSAttributedString alloc] initWithString:title attributes:@{NSForegroundColorAttributeName : [ NSColor whiteColor] }]; button.attributedTitle = attributedTitle; [button setButtonType:NSButtonTypeMomentaryPushIn]; +#endif // macOS] + button.accessibilityIdentifier = accessibilityIdentifier; + if (selector) { +#if !TARGET_OS_OSX // [macOS] + [button addTarget:self action:selector forControlEvents:UIControlEventTouchUpInside]; +#else // [macOS button.target = self; button.action = selector; +#endif // macOS] } else if (block) { - [button rct_addBlock:block]; + [button rct_setPrimaryAction:[RCTUIAction actionWithHandler:block]]; // [macOS] } return button; } -#endif // macOS] - (NSInteger)bottomSafeViewHeight { From bbc3c8abd573a27e981f51a126f86874b16714f3 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 13:18:27 -0700 Subject: [PATCH 07/18] fix(macos): correct RCTUITableView lifecycle and sizing Use AppKit-owned reuse preparation and automatic row heights, remove per-row measurement retention, avoid visibleRect coordinate overriding, and guard deselection against selection-query re-entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Libraries/RCTUIKit/RCTUITableView.h | 1 - .../Libraries/RCTUIKit/RCTUITableView.m | 59 ++++--------------- 2 files changed, 13 insertions(+), 47 deletions(-) diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.h b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.h index d451aca2d542..1abaf797f52b 100644 --- a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.h +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.h @@ -96,7 +96,6 @@ extern const CGFloat RCTUITableViewAutomaticDimension; @property (nonatomic, weak, nullable) id dataSource; @property (nonatomic, weak, nullable) id delegate; @property (nonatomic, nullable, copy) RCTUIColor *separatorColor; -@property (nonatomic, readonly) CGRect visibleRect; - (void)reloadData; - (nullable __kindof RCTUITableViewCell *)dequeueReusableCellWithIdentifier:(NSString *)identifier; diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m index ec2e5741e8c6..2948c30db97d 100644 --- a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m @@ -161,6 +161,15 @@ - (void)prepareForReuse self.detailTextLabel.maximumNumberOfLines = 1; } +- (void)layout +{ + [super layout]; + + CGFloat preferredWidth = MAX(NSWidth(self.contentView.bounds) - 10, 0); + self.textLabel.preferredMaxLayoutWidth = preferredWidth; + self.detailTextLabel.preferredMaxLayoutWidth = preferredWidth; +} + @end @interface RCTUITableView () @@ -170,8 +179,6 @@ @implementation RCTUITableView { NSTableView *_tableView; NSArray *_slots; NSMutableDictionary *_headerViews; - NSMutableDictionary *_pendingViews; - NSMutableDictionary *_automaticHeights; NSMutableIndexSet *_automaticRows; CGFloat _lastContentWidth; } @@ -183,8 +190,6 @@ - (instancetype)initWithFrame:(NSRect)frameRect _slots = @[]; _headerViews = [NSMutableDictionary new]; - _pendingViews = [NSMutableDictionary new]; - _automaticHeights = [NSMutableDictionary new]; _automaticRows = [NSMutableIndexSet new]; _tableView = [[NSTableView alloc] initWithFrame:self.contentView.bounds]; @@ -198,6 +203,7 @@ - (instancetype)initWithFrame:(NSRect)frameRect _tableView.columnAutoresizingStyle = NSTableViewFirstColumnOnlyAutoresizingStyle; _tableView.backgroundColor = NSColor.clearColor; _tableView.style = NSTableViewStyleInset; + _tableView.usesAutomaticRowHeights = YES; _tableView.accessibilityRole = NSAccessibilityTableRole; NSTableColumn *column = [[NSTableColumn alloc] initWithIdentifier:@"RCTUITableViewColumn"]; @@ -228,11 +234,6 @@ - (void)setSeparatorColor:(RCTUIColor *)separatorColor } } -- (CGRect)visibleRect -{ - return _tableView.visibleRect; -} - - (void)setFrameSize:(NSSize)newSize { [super setFrameSize:newSize]; @@ -240,7 +241,6 @@ - (void)setFrameSize:(NSSize)newSize CGFloat contentWidth = self.contentSize.width; if (_lastContentWidth != contentWidth) { _lastContentWidth = contentWidth; - [_automaticHeights removeAllObjects]; if (_automaticRows.count > 0) { [_tableView noteHeightOfRowsWithIndexesChanged:_automaticRows]; } @@ -250,8 +250,6 @@ - (void)setFrameSize:(NSSize)newSize - (void)reloadData { [_headerViews removeAllObjects]; - [_pendingViews removeAllObjects]; - [_automaticHeights removeAllObjects]; [_automaticRows removeAllIndexes]; NSInteger sectionCount = 1; @@ -287,9 +285,7 @@ - (void)reloadData - (__kindof RCTUITableViewCell *)dequeueReusableCellWithIdentifier:(NSString *)identifier { - RCTUITableViewCell *cell = [_tableView makeViewWithIdentifier:identifier owner:self]; - [cell prepareForReuse]; - return cell; + return [_tableView makeViewWithIdentifier:identifier owner:self]; } - (void)scrollToRowAtIndexPath:(NSIndexPath *)indexPath @@ -313,7 +309,7 @@ - (void)scrollToRowAtIndexPath:(NSIndexPath *)indexPath - (void)deselectRowAtIndexPath:(NSIndexPath *)indexPath animated:(BOOL)animated { NSInteger backingRow = [self backingRowForIndexPath:indexPath]; - if (backingRow != NSNotFound) { + if (backingRow != NSNotFound && [_tableView.selectedRowIndexes containsIndex:backingRow]) { [_tableView deselectRow:backingRow]; } } @@ -338,13 +334,6 @@ - (NSView *)tableView:(NSTableView *)tableView viewForTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row { - NSNumber *rowKey = @(row); - NSView *pendingView = _pendingViews[rowKey]; - if (pendingView != nil) { - [_pendingViews removeObjectForKey:rowKey]; - return pendingView; - } - return [self viewForBackingRow:row]; } @@ -387,29 +376,7 @@ - (CGFloat)tableView:(NSTableView *)tableView heightOfRow:(NSInteger)row } [_automaticRows addIndex:row]; - NSNumber *rowKey = @(row); - NSNumber *cachedHeight = _automaticHeights[rowKey]; - if (cachedHeight != nil) { - return cachedHeight.doubleValue; - } - - RCTUITableViewCell *cell = (RCTUITableViewCell *)[tableView viewAtColumn:0 row:row makeIfNecessary:NO]; - if (cell == nil) { - cell = (RCTUITableViewCell *)_pendingViews[rowKey]; - } - if (cell == nil) { - cell = (RCTUITableViewCell *)[self viewForBackingRow:row]; - _pendingViews[rowKey] = cell; - } - - NSSize previousSize = cell.frame.size; - cell.frame = NSMakeRect(0, 0, self.contentSize.width, previousSize.height); - [cell layoutSubtreeIfNeeded]; - CGFloat fittingHeight = cell.fittingSize.height; - cell.frame = NSMakeRect(cell.frame.origin.x, cell.frame.origin.y, previousSize.width, previousSize.height); - - _automaticHeights[rowKey] = @(fittingHeight); - return fittingHeight; + return tableView.rowHeight; } - (BOOL)tableView:(NSTableView *)tableView shouldSelectRow:(NSInteger)row From d9d3cc59bd251f58f51f2fdff433edca4b4170f0 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 13:19:56 -0700 Subject: [PATCH 08/18] fix(macos): constrain RCTUITableViewCell content Pin the compatibility content view to the AppKit cell so native automatic row heights can derive wrapped label intrinsic height at the actual cell width. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ReactApple/Libraries/RCTUIKit/RCTUITableView.m | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m index 2948c30db97d..193f08ed55aa 100644 --- a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m @@ -100,9 +100,15 @@ - (void)initializeWithStyle:(RCTUITableViewCellStyle)style reuseIdentifier:(NSSt _reuseIdentifier = [reuseIdentifier copy]; self.identifier = reuseIdentifier; - _contentView = [[RCTPlatformView alloc] initWithFrame:self.bounds]; - _contentView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + _contentView = [[RCTPlatformView alloc] initWithFrame:NSZeroRect]; + _contentView.translatesAutoresizingMaskIntoConstraints = NO; [self addSubview:_contentView]; + [NSLayoutConstraint activateConstraints:@[ + [_contentView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor], + [_contentView.topAnchor constraintEqualToAnchor:self.topAnchor], + [_contentView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor], + [_contentView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor], + ]]; _textLabel = [[RCTUILabel alloc] initWithFrame:NSZeroRect]; _textLabel.translatesAutoresizingMaskIntoConstraints = NO; From 6f7039b5a7fe4260ff3a7444f9010faecaaeb70f Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 13:21:06 -0700 Subject: [PATCH 09/18] fix(macos): preserve fixed RCTUITableView row heights Apply private cell height constraints for explicit delegate heights while leaving automatic rows to AppKit intrinsic sizing and intercell spacing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Libraries/RCTUIKit/RCTUITableView.m | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m index 193f08ed55aa..e56a697f2d01 100644 --- a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m @@ -72,9 +72,12 @@ @interface RCTUITableViewCell () @property (nonatomic, readwrite, strong) RCTUILabel *textLabel; @property (nonatomic, readwrite, nullable, strong) RCTUILabel *detailTextLabel; +- (void)setFixedHeight:(nullable NSNumber *)fixedHeight; + @end @implementation RCTUITableViewCell { + NSLayoutConstraint *_fixedHeightConstraint; RCTUITableViewCellStyle _style; } @@ -176,6 +179,22 @@ - (void)layout self.detailTextLabel.preferredMaxLayoutWidth = preferredWidth; } +- (void)setFixedHeight:(NSNumber *)fixedHeight +{ + if (fixedHeight == nil) { + _fixedHeightConstraint.active = NO; + _fixedHeightConstraint = nil; + return; + } + + if (_fixedHeightConstraint == nil) { + _fixedHeightConstraint = [self.heightAnchor constraintEqualToConstant:fixedHeight.doubleValue]; + _fixedHeightConstraint.active = YES; + } else { + _fixedHeightConstraint.constant = fixedHeight.doubleValue; + } +} + @end @interface RCTUITableView () @@ -348,7 +367,10 @@ - (NSView *)viewForBackingRow:(NSInteger)backingRow RCTUITableViewSlot *slot = _slots[backingRow]; if (slot.kind == RCTUITableViewSlotKindRow) { NSIndexPath *indexPath = [NSIndexPath indexPathForRow:slot.row inSection:slot.section]; - return [self.dataSource tableView:self cellForRowAtIndexPath:indexPath]; + RCTUITableViewCell *cell = [self.dataSource tableView:self cellForRowAtIndexPath:indexPath]; + CGFloat height = [self requestedHeightForSlot:slot]; + [cell setFixedHeight:height == RCTUITableViewAutomaticDimension ? nil : @(height)]; + return cell; } NSNumber *sectionKey = @(slot.section); @@ -372,11 +394,7 @@ - (CGFloat)tableView:(NSTableView *)tableView heightOfRow:(NSInteger)row return slot.headerHeight; } - CGFloat height = tableView.rowHeight; - if ([self.delegate respondsToSelector:@selector(tableView:heightForRowAtIndexPath:)]) { - NSIndexPath *indexPath = [NSIndexPath indexPathForRow:slot.row inSection:slot.section]; - height = [self.delegate tableView:self heightForRowAtIndexPath:indexPath]; - } + CGFloat height = [self requestedHeightForSlot:slot]; if (height != RCTUITableViewAutomaticDimension) { return height; } @@ -385,6 +403,16 @@ - (CGFloat)tableView:(NSTableView *)tableView heightOfRow:(NSInteger)row return tableView.rowHeight; } +- (CGFloat)requestedHeightForSlot:(RCTUITableViewSlot *)slot +{ + if (![self.delegate respondsToSelector:@selector(tableView:heightForRowAtIndexPath:)]) { + return _tableView.rowHeight; + } + + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:slot.row inSection:slot.section]; + return [self.delegate tableView:self heightForRowAtIndexPath:indexPath]; +} + - (BOOL)tableView:(NSTableView *)tableView shouldSelectRow:(NSInteger)row { RCTUITableViewSlot *slot = _slots[row]; From b82f4de8900cb39ef29deca59c2b65d2116df97d Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 13:23:37 -0700 Subject: [PATCH 10/18] test(macos): strengthen RCTUITableView behavior coverage Exercise AppKit-owned reuse preparation, real wrapped-label automatic sizing, spacing-aware fixed heights, SDK-relative inset geometry, accessibility-tree containment, and selection-query-safe deselection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../RNTesterUnitTests/RCTUITableViewTests.m | 228 +++++++++++------- 1 file changed, 147 insertions(+), 81 deletions(-) diff --git a/packages/rn-tester/RNTesterUnitTests/RCTUITableViewTests.m b/packages/rn-tester/RNTesterUnitTests/RCTUITableViewTests.m index 9a19a2401be8..6b844f9785d2 100644 --- a/packages/rn-tester/RNTesterUnitTests/RCTUITableViewTests.m +++ b/packages/rn-tester/RNTesterUnitTests/RCTUITableViewTests.m @@ -36,8 +36,30 @@ static void RCTUIPumpRunLoop(NSTimeInterval duration) static NSTableView *RCTUIBackingTable(RCTUITableView *tableView) { - XCTAssertTrue([tableView.documentView isKindOfClass:NSTableView.class]); - return (NSTableView *)tableView.documentView; + NSTableView *backingTable = [tableView valueForKey:@"tableView"]; + XCTAssertTrue([backingTable isKindOfClass:NSTableView.class]); + return backingTable; +} + +static BOOL RCTUIAccessibilityTreeContainsIdentifier(id element, NSString *identifier, NSUInteger depth) +{ + if (depth == 0 || element == nil) { + return NO; + } + if ([element respondsToSelector:@selector(accessibilityIdentifier)] && + [[element accessibilityIdentifier] isEqualToString:identifier]) { + return YES; + } + if (![element respondsToSelector:@selector(accessibilityChildren)]) { + return NO; + } + + for (id child in [element accessibilityChildren]) { + if (RCTUIAccessibilityTreeContainsIdentifier(child, identifier, depth - 1)) { + return YES; + } + } + return NO; } @interface RCTUITableViewTestDataSource : NSObject @@ -49,6 +71,8 @@ @interface RCTUITableViewTestDataSource : NSObject *headerViewCallCounts; @property (nonatomic, strong) NSIndexPath *selectedIndexPath; +@property (nonatomic, assign) NSInteger selectionCallCount; +@property (nonatomic, assign) BOOL deselectDuringSelection; @end @@ -108,7 +132,11 @@ - (RCTPlatformView *)tableView:(RCTUITableView *)tableView viewForHeaderInSectio - (void)tableView:(RCTUITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + self.selectionCallCount++; self.selectedIndexPath = indexPath; + if (self.deselectDuringSelection) { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + } } @end @@ -137,6 +165,43 @@ - (RCTUITableViewCell *)tableView:(RCTUITableView *)tableView @end +@interface RCTUIInsetControlDataSource : NSObject +@end + +@implementation RCTUIInsetControlDataSource + +- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView +{ + return 1; +} + +- (NSView *)tableView:(NSTableView *)tableView + viewForTableColumn:(NSTableColumn *)tableColumn + row:(NSInteger)row +{ + return [NSTableCellView new]; +} + +@end + +static NSTableView *RCTUICreateInsetControlTable(CGFloat width, RCTUIInsetControlDataSource *dataSource) +{ + NSTableView *tableView = [[NSTableView alloc] initWithFrame:NSMakeRect(0, 0, width, 400)]; + tableView.dataSource = dataSource; + tableView.delegate = dataSource; + tableView.headerView = nil; + tableView.allowsColumnReordering = NO; + tableView.allowsColumnResizing = NO; + tableView.allowsTypeSelect = NO; + tableView.columnAutoresizingStyle = NSTableViewFirstColumnOnlyAutoresizingStyle; + tableView.backgroundColor = NSColor.clearColor; + tableView.style = NSTableViewStyleInset; + [tableView addTableColumn:[[NSTableColumn alloc] initWithIdentifier:@"ControlColumn"]]; + [tableView reloadData]; + [tableView layoutSubtreeIfNeeded]; + return tableView; +} + static NSInteger RCTUITrackingCellAllocationCount; @interface RCTUITrackingTableCell : RCTUITableViewCell @@ -164,38 +229,6 @@ - (void)prepareForReuse @end -static CGFloat RCTUIExpectedTextHeight(NSString *text, NSFont *font, CGFloat width) -{ - NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; - paragraphStyle.lineBreakMode = NSLineBreakByWordWrapping; - NSRect bounds = [text boundingRectWithSize:NSMakeSize(width, CGFLOAT_MAX) - options:NSStringDrawingUsesLineFragmentOrigin - attributes:@{ - NSFontAttributeName : font, - NSParagraphStyleAttributeName : paragraphStyle, - }]; - return ceil(NSHeight(bounds)) + 10; -} - -@interface RCTUISizingTableCell : RCTUITableViewCell - -@property (nonatomic, copy) NSString *sizingText; -@property (nonatomic, strong) NSFont *sizingFont; -@property (nonatomic, assign) CGFloat lastFittingWidth; - -@end - -@implementation RCTUISizingTableCell - -- (NSSize)fittingSize -{ - self.lastFittingWidth = NSWidth(self.frame); - return NSMakeSize( - self.lastFittingWidth, RCTUIExpectedTextHeight(self.sizingText, self.sizingFont, self.lastFittingWidth)); -} - -@end - @interface RCTUITableViewTests : XCTestCase @end @@ -303,10 +336,11 @@ - (void)testRowsTrackSuccessiveReloads for (NSNumber *rowCount in @[@2, @9, @1, @0]) { dataSource.rowCounts = @[rowCount]; [self reloadAndDisplay]; - XCTAssertEqual(RCTUIBackingTable(_tableView).numberOfRows, rowCount.integerValue); - XCTAssertTrue(isfinite(NSMinY(_tableView.visibleRect))); - XCTAssertGreaterThanOrEqual(NSWidth(_tableView.visibleRect), 0); - XCTAssertGreaterThanOrEqual(NSHeight(_tableView.visibleRect), 0); + NSTableView *backingTable = RCTUIBackingTable(_tableView); + XCTAssertEqual(backingTable.numberOfRows, rowCount.integerValue); + XCTAssertTrue(isfinite(NSMinY(backingTable.visibleRect))); + XCTAssertGreaterThanOrEqual(NSWidth(backingTable.visibleRect), 0); + XCTAssertGreaterThanOrEqual(NSHeight(backingTable.visibleRect), 0); } } @@ -376,16 +410,19 @@ - (void)testSelectionDeselectAndHeaderNonselection RCTUITableViewTestDataSource *dataSource = [RCTUITableViewTestDataSource new]; dataSource.rowCounts = @[@1, @2]; dataSource.headerHeights = @[@24, @0]; + dataSource.deselectDuringSelection = YES; [self installDataSource:dataSource delegate:dataSource]; [self reloadAndDisplay]; NSTableView *backingTable = RCTUIBackingTable(_tableView); XCTAssertFalse([backingTable.delegate tableView:backingTable shouldSelectRow:0]); XCTAssertNil(dataSource.selectedIndexPath); + XCTAssertEqual(dataSource.selectionCallCount, 0); XCTAssertFalse([backingTable.delegate tableView:backingTable shouldSelectRow:3]); XCTAssertEqual(dataSource.selectedIndexPath.section, 1); XCTAssertEqual(dataSource.selectedIndexPath.row, 1); + XCTAssertEqual(dataSource.selectionCallCount, 1); XCTAssertEqual(backingTable.selectedRow, -1); XCTAssertNoThrow([_tableView deselectRowAtIndexPath:[NSIndexPath indexPathForRow:1 inSection:1] animated:YES]); @@ -399,29 +436,33 @@ - (void)testMixedAutomaticAndFixedHeightsReflowWithContentWidth "a narrow content width while preserving fixed rows. The compatibility layer must measure against the clip " "view's content width every time the window changes size, without allowing the two fixed rows to drift. This " "additional sentence keeps the wide layout above fifty points and makes the narrow reflow unambiguous."; - NSFont *font = [NSFont systemFontOfSize:14]; RCTUITableViewTestDataSource *dataSource = [RCTUITableViewTestDataSource new]; dataSource.rowCounts = @[@1, @2]; - __block RCTUISizingTableCell *sizingCell; + __block RCTUITableViewCell *automaticCell; dataSource.cellProvider = ^RCTUITableViewCell *(RCTUITableView *tableView, NSIndexPath *indexPath) { if (indexPath.section == 0) { - sizingCell = [tableView dequeueReusableCellWithIdentifier:@"sizing"]; - if (sizingCell == nil) { - sizingCell = [[RCTUISizingTableCell alloc] initWithStyle:RCTUITableViewCellStyleDefault - reuseIdentifier:@"sizing"]; + automaticCell = [tableView dequeueReusableCellWithIdentifier:@"automatic"]; + if (automaticCell == nil) { + automaticCell = [[RCTUITableViewCell alloc] initWithStyle:RCTUITableViewCellStyleDefault + reuseIdentifier:@"automatic"]; } - sizingCell.sizingText = text; - sizingCell.sizingFont = font; - sizingCell.textLabel.text = text; - sizingCell.contentView.wantsLayer = YES; - sizingCell.contentView.layer.cornerRadius = 8; - sizingCell.contentView.layer.cornerCurve = kCACornerCurveContinuous; - return sizingCell; + automaticCell.textLabel.font = [NSFont systemFontOfSize:14]; + automaticCell.textLabel.lineBreakMode = NSLineBreakByWordWrapping; + automaticCell.textLabel.numberOfLines = 0; + automaticCell.textLabel.text = text; + automaticCell.contentView.wantsLayer = YES; + automaticCell.contentView.layer.cornerRadius = 8; + automaticCell.contentView.layer.cornerCurve = kCACornerCurveContinuous; + return automaticCell; } RCTUITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"fixed"]; - return cell ?: [[RCTUITableViewCell alloc] initWithStyle:RCTUITableViewCellStyleDefault reuseIdentifier:@"fixed"]; + if (cell == nil) { + cell = [[RCTUITableViewCell alloc] initWithStyle:RCTUITableViewCellStyleDefault reuseIdentifier:@"fixed"]; + } + cell.textLabel.text = @"Fixed"; + return cell; }; dataSource.heightProvider = ^CGFloat(NSIndexPath *indexPath) { return indexPath.section == 0 ? RCTUITableViewAutomaticDimension : 50; @@ -432,33 +473,53 @@ - (void)testMixedAutomaticAndFixedHeightsReflowWithContentWidth [self reloadAndDisplay]; NSTableView *backingTable = RCTUIBackingTable(_tableView); + XCTAssertTrue(backingTable.usesAutomaticRowHeights); XCTAssertEqual(backingTable.effectiveStyle, NSTableViewStyleInset); + + RCTUIInsetControlDataSource *controlDataSource = [RCTUIInsetControlDataSource new]; + NSTableView *wideControlTable = + RCTUICreateInsetControlTable(NSWidth(backingTable.bounds), controlDataSource); NSRect wideCellFrame = [backingTable frameOfCellAtColumn:0 row:0]; - XCTAssertEqual(NSMinX(wideCellFrame), 16); - XCTAssertEqual(NSWidth(wideCellFrame), 848); - - CGFloat wideContentWidth = _tableView.contentSize.width; - CGFloat wideHeight = NSHeight([backingTable rectOfRow:0]); - XCTAssertEqual(sizingCell.lastFittingWidth, wideContentWidth); - XCTAssertEqualWithAccuracy(wideHeight, RCTUIExpectedTextHeight(text, font, wideContentWidth), 0.5); - XCTAssertGreaterThan(wideHeight, 50); - XCTAssertEqualWithAccuracy(NSHeight([backingTable rectOfRow:1]), 50, 0.5); - XCTAssertEqualWithAccuracy(NSHeight([backingTable rectOfRow:2]), 50, 0.5); - XCTAssertEqual(sizingCell.contentView.layer.cornerRadius, 8); - XCTAssertEqualObjects(sizingCell.contentView.layer.cornerCurve, kCACornerCurveContinuous); + NSRect wideControlFrame = [wideControlTable frameOfCellAtColumn:0 row:0]; + XCTAssertEqual(wideCellFrame.origin.x, wideControlFrame.origin.x); + XCTAssertEqual(wideCellFrame.size.width, wideControlFrame.size.width); + CGFloat wideLeftInset = NSMinX(wideCellFrame); + CGFloat wideRightInset = NSWidth(backingTable.bounds) - NSMaxX(wideCellFrame); + XCTAssertEqualWithAccuracy(wideLeftInset, wideRightInset, 0.5); + + CGFloat rowSpacing = backingTable.intercellSpacing.height; + CGFloat wideAutomaticHeight = NSHeight([backingTable rectOfRow:0]) - rowSpacing; + CGFloat wideFixedHeight = NSHeight([backingTable rectOfRow:1]); + XCTAssertEqual([backingTable.delegate tableView:backingTable heightOfRow:1], 50); + XCTAssertEqual([backingTable.delegate tableView:backingTable heightOfRow:2], 50); + XCTAssertEqualWithAccuracy(wideFixedHeight, 50 + rowSpacing, 0.5); + XCTAssertEqualWithAccuracy(NSHeight([backingTable rectOfRow:2]), wideFixedHeight, 0.5); + XCTAssertGreaterThan(wideAutomaticHeight, 50); + XCTAssertGreaterThan(automaticCell.textLabel.preferredMaxLayoutWidth, 0); + CGFloat widePreferredWidth = automaticCell.textLabel.preferredMaxLayoutWidth; + XCTAssertEqual(automaticCell.contentView.layer.cornerRadius, 8); + XCTAssertEqualObjects(automaticCell.contentView.layer.cornerCurve, kCACornerCurveContinuous); [self setContentWidth:320]; + NSTableView *narrowControlTable = + RCTUICreateInsetControlTable(NSWidth(backingTable.bounds), controlDataSource); NSRect narrowCellFrame = [backingTable frameOfCellAtColumn:0 row:0]; - XCTAssertEqual(NSMinX(narrowCellFrame), 16); - XCTAssertEqual(NSWidth(narrowCellFrame), 288); - - CGFloat narrowContentWidth = _tableView.contentSize.width; - CGFloat narrowHeight = NSHeight([backingTable rectOfRow:0]); - XCTAssertEqual(sizingCell.lastFittingWidth, narrowContentWidth); - XCTAssertEqualWithAccuracy(narrowHeight, RCTUIExpectedTextHeight(text, font, narrowContentWidth), 0.5); - XCTAssertGreaterThan(narrowHeight, wideHeight); - XCTAssertEqualWithAccuracy(NSHeight([backingTable rectOfRow:1]), 50, 0.5); - XCTAssertEqualWithAccuracy(NSHeight([backingTable rectOfRow:2]), 50, 0.5); + NSRect narrowControlFrame = [narrowControlTable frameOfCellAtColumn:0 row:0]; + XCTAssertEqual(narrowCellFrame.origin.x, narrowControlFrame.origin.x); + XCTAssertEqual(narrowCellFrame.size.width, narrowControlFrame.size.width); + CGFloat narrowLeftInset = NSMinX(narrowCellFrame); + CGFloat narrowRightInset = NSWidth(backingTable.bounds) - NSMaxX(narrowCellFrame); + XCTAssertEqualWithAccuracy(narrowLeftInset, narrowRightInset, 0.5); + XCTAssertEqualWithAccuracy(narrowLeftInset, wideLeftInset, 0.5); + + CGFloat narrowAutomaticHeight = NSHeight([backingTable rectOfRow:0]) - rowSpacing; + CGFloat narrowFixedHeight = NSHeight([backingTable rectOfRow:1]); + XCTAssertGreaterThan(narrowAutomaticHeight, wideAutomaticHeight); + XCTAssertLessThan(automaticCell.textLabel.preferredMaxLayoutWidth, widePreferredWidth); + XCTAssertEqualWithAccuracy(narrowFixedHeight, wideFixedHeight, 0.5); + XCTAssertEqualWithAccuracy(NSHeight([backingTable rectOfRow:2]), wideFixedHeight, 0.5); + XCTAssertEqual([backingTable.delegate tableView:backingTable heightOfRow:1], 50); + XCTAssertEqual([backingTable.delegate tableView:backingTable heightOfRow:2], 50); } - (void)testHeadersAreLazyStablePerReloadAndHeightZeroHasNoSlot @@ -476,8 +537,12 @@ - (void)testHeadersAreLazyStablePerReloadAndHeightZeroHasNoSlot NSTableView *backingTable = RCTUIBackingTable(_tableView); XCTAssertEqual(backingTable.numberOfRows, 82); - XCTAssertEqualWithAccuracy(NSHeight([backingTable rectOfRow:0]), 30, 0.5); - XCTAssertEqualWithAccuracy(NSHeight([backingTable rectOfRow:41]), 25, 0.5); + XCTAssertEqual([backingTable.delegate tableView:backingTable heightOfRow:0], 30); + XCTAssertEqual([backingTable.delegate tableView:backingTable heightOfRow:41], 25); + XCTAssertEqualWithAccuracy( + NSHeight([backingTable rectOfRow:0]), 30 + backingTable.intercellSpacing.height, 0.5); + XCTAssertEqualWithAccuracy( + NSHeight([backingTable rectOfRow:41]), 25 + backingTable.intercellSpacing.height, 0.5); XCTAssertFalse([backingTable.delegate tableView:backingTable shouldSelectRow:0]); XCTAssertFalse([backingTable.delegate tableView:backingTable shouldSelectRow:41]); @@ -556,11 +621,13 @@ - (void)testAccessibilityContainerAndCellIdentifiersSurviveReuse RCTUITableViewCell *firstCell = [backingTable viewAtColumn:0 row:0 makeIfNecessary:YES]; XCTAssertEqualObjects(firstCell.textLabel.accessibilityIdentifier, @"redbox-error"); + XCTAssertTrue(RCTUIAccessibilityTreeContainsIdentifier(backingTable, @"redbox-error", 6)); [_tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:99 inSection:0] atScrollPosition:RCTUITableViewScrollPositionTop animated:NO]; [self forceLayoutAndDisplay]; + XCTAssertTrue(RCTUIAccessibilityTreeContainsIdentifier(backingTable, @"cell-99", 6)); [_tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0] atScrollPosition:RCTUITableViewScrollPositionTop animated:NO]; @@ -568,8 +635,7 @@ - (void)testAccessibilityContainerAndCellIdentifiersSurviveReuse firstCell = [backingTable viewAtColumn:0 row:0 makeIfNecessary:YES]; XCTAssertEqualObjects(firstCell.textLabel.accessibilityIdentifier, @"redbox-error"); - XCTAssertEqual( - backingTable.accessibilityChildren.count, [backingTable rowsInRect:backingTable.visibleRect].length); + XCTAssertTrue(RCTUIAccessibilityTreeContainsIdentifier(backingTable, @"redbox-error", 6)); } - (void)testScrollToTopPositionAndReturnToFirstRow @@ -589,14 +655,14 @@ - (void)testScrollToTopPositionAndReturnToFirstRow [self forceLayoutAndDisplay]; NSRect targetRect = [backingTable rectOfRow:80]; - XCTAssertEqualWithAccuracy(NSMinY(targetRect), NSMinY(_tableView.visibleRect), 1); - XCTAssertTrue(NSContainsRect(_tableView.visibleRect, targetRect)); + XCTAssertEqualWithAccuracy(NSMinY(targetRect), NSMinY(backingTable.visibleRect), 1); + XCTAssertTrue(NSContainsRect(backingTable.visibleRect, targetRect)); [_tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0] atScrollPosition:RCTUITableViewScrollPositionTop animated:NO]; [self forceLayoutAndDisplay]; - XCTAssertEqualWithAccuracy(NSMinY(_tableView.visibleRect), 0, 0.5); + XCTAssertEqualWithAccuracy(NSMinY(backingTable.visibleRect), 0, 0.5); } - (void)testLabelButtonActionAndIdentifierForwarding From 9d8dd2a6072ffe9a5bd323ea84efe38d2d5e1037 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 13:24:44 -0700 Subject: [PATCH 11/18] fix(macos): preserve RedBox edge behavior Skip invalid nil-message scrolling, restore selectable AppKit message and stack labels, and measure message rows against the scroll view content width while retaining iOS sizing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../react-native/React/CoreModules/RCTRedBox.mm | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/react-native/React/CoreModules/RCTRedBox.mm b/packages/react-native/React/CoreModules/RCTRedBox.mm index a05184e3895c..c8333558adae 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox.mm +++ b/packages/react-native/React/CoreModules/RCTRedBox.mm @@ -286,9 +286,11 @@ - (void)showErrorMessage:(NSString *)message [_stackTraceTableView reloadData]; if (!isRootViewControllerPresented) { - [_stackTraceTableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0] - atScrollPosition:RCTUITableViewScrollPositionTop - animated:NO]; // [macOS] + if (_lastErrorMessage != nil) { // [macOS + [_stackTraceTableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0] + atScrollPosition:RCTUITableViewScrollPositionTop + animated:NO]; + } // macOS] #if !TARGET_OS_OSX // [macOS] [RCTKeyWindow().rootViewController presentViewController:self animated:YES completion:nil]; #else // [macOS @@ -405,6 +407,7 @@ - (RCTUITableViewCell *)reuseCell:(RCTUITableViewCell *)cell forErrorMessage:(NS #else // [macOS cell.contentView.layer.cornerRadius = 8.0; cell.contentView.layer.cornerCurve = kCACornerCurveContinuous; + cell.textLabel.selectable = YES; #endif // macOS] } @@ -440,6 +443,7 @@ - (RCTUITableViewCell *)reuseCell:(RCTUITableViewCell *)cell forStackFrame:(RCTJ #else // [macOS [cell.detailTextLabel removeFromSuperview]; [cell.textLabel.bottomAnchor constraintEqualToAnchor:cell.contentView.bottomAnchor].active = YES; + cell.textLabel.selectable = YES; #endif // macOS] } @@ -503,8 +507,12 @@ - (CGFloat)tableView:(RCTUITableView *)tableView heightForRowAtIndexPath:(NSInde NSFontAttributeName : [UIFont boldSystemFontOfSize:16], NSParagraphStyleAttributeName : paragraphStyle }; // [macOS] + CGFloat tableWidth = tableView.frame.size.width; +#if TARGET_OS_OSX // [macOS + tableWidth = tableView.contentSize.width; +#endif // macOS] CGRect boundingRect = - [_lastErrorMessage boundingRectWithSize:CGSizeMake(tableView.frame.size.width - 30, CGFLOAT_MAX) + [_lastErrorMessage boundingRectWithSize:CGSizeMake(tableWidth - 30, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin attributes:attributes context:nil]; From d2b18ce148f0cf4bab2bd05a7fff755e553f4d45 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 13:25:29 -0700 Subject: [PATCH 12/18] fix(macos): update table label wrapping on resize Refresh preferred wrapping widths when AppKit assigns cell frames so automatic height fitting sees the current content width before layout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ReactApple/Libraries/RCTUIKit/RCTUITableView.m | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m index e56a697f2d01..279027908fc0 100644 --- a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m @@ -170,11 +170,11 @@ - (void)prepareForReuse self.detailTextLabel.maximumNumberOfLines = 1; } -- (void)layout +- (void)setFrameSize:(NSSize)newSize { - [super layout]; + [super setFrameSize:newSize]; - CGFloat preferredWidth = MAX(NSWidth(self.contentView.bounds) - 10, 0); + CGFloat preferredWidth = MAX(newSize.width - 10, 0); self.textLabel.preferredMaxLayoutWidth = preferredWidth; self.detailTextLabel.preferredMaxLayoutWidth = preferredWidth; } From a3f1e512c69ae63218be05b0a897b6f7ec3cb89d Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 13:26:10 -0700 Subject: [PATCH 13/18] fix(macos): preserve RCTUITableView header heights Constrain each lazily cached header to its declared slot height so AppKit automatic row sizing keeps the explicit header contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m index 279027908fc0..23a1c6b481e5 100644 --- a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m @@ -382,6 +382,7 @@ - (NSView *)viewForBackingRow:(NSInteger)backingRow if (headerView == nil) { headerView = [RCTPlatformView new]; } + [headerView.heightAnchor constraintEqualToConstant:slot.headerHeight].active = YES; _headerViews[sectionKey] = headerView; } return headerView; From f47aa235200567670303b8044107a4ef07fd7469 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 14:45:47 -0700 Subject: [PATCH 14/18] fix(macos): preserve custom RCTUITableViewCell layout Use NSTableViewRowSizeStyleCustom so NSTableCellView does not override the compatibility label constraints assigned through textField. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m index 23a1c6b481e5..60f7e62fd4a5 100644 --- a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m @@ -102,6 +102,7 @@ - (void)initializeWithStyle:(RCTUITableViewCellStyle)style reuseIdentifier:(NSSt _style = style; _reuseIdentifier = [reuseIdentifier copy]; self.identifier = reuseIdentifier; + self.rowSizeStyle = NSTableViewRowSizeStyleCustom; _contentView = [[RCTPlatformView alloc] initWithFrame:NSZeroRect]; _contentView.translatesAutoresizingMaskIntoConstraints = NO; From f20f454c5f58760fdedbe09728d76bd42dd26019 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 14:46:50 -0700 Subject: [PATCH 15/18] fix(macos): restore RedBox message card rounding Put the macOS message fill and continuous corner radius back on NSTableCellView.layer, matching the baseline layer ownership and CURRENT card rendering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/react-native/React/CoreModules/RCTRedBox.mm | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/react-native/React/CoreModules/RCTRedBox.mm b/packages/react-native/React/CoreModules/RCTRedBox.mm index c8333558adae..29611a3d6472 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox.mm +++ b/packages/react-native/React/CoreModules/RCTRedBox.mm @@ -401,12 +401,14 @@ - (RCTUITableViewCell *)reuseCell:(RCTUITableViewCell *)cell forErrorMessage:(NS cell.textLabel.font = [UIFont monospacedSystemFontOfSize:14 weight:UIFontWeightBold]; cell.detailTextLabel.textColor = [RCTUIColor whiteColor]; // [macOS] - cell.backgroundColor = [RCTUIColor colorWithRed:0.82 green:0.10 blue:0.15 alpha:1.0]; // [macOS] #if !TARGET_OS_OSX // [macOS] + cell.backgroundColor = [UIColor colorWithRed:0.82 green:0.10 blue:0.15 alpha:1.0]; cell.selectionStyle = UITableViewCellSelectionStyleNone; #else // [macOS - cell.contentView.layer.cornerRadius = 8.0; - cell.contentView.layer.cornerCurve = kCACornerCurveContinuous; + cell.wantsLayer = YES; + cell.layer.backgroundColor = [NSColor colorWithRed:0.82 green:0.10 blue:0.15 alpha:1.0].CGColor; + cell.layer.cornerRadius = 8.0; + cell.layer.cornerCurve = kCACornerCurveContinuous; cell.textLabel.selectable = YES; #endif // macOS] } From ddaf23958b5435d9a710be6e952414bf647ced43 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 14:47:57 -0700 Subject: [PATCH 16/18] fix(macos): reuse RCTUITableView header constraints Associate one primitive-owned height constraint with each header view, deactivate it between reload generations, and update/reactivate it when a delegate reuses the view. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Libraries/RCTUIKit/RCTUITableView.m | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m index 60f7e62fd4a5..7b04e329114b 100644 --- a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m @@ -11,7 +11,11 @@ #if TARGET_OS_OSX +#import + const CGFloat RCTUITableViewAutomaticDimension = -1.0; +static NSString *const RCTUITableViewHeaderHeightConstraintIdentifier = @"RCTUITableViewHeaderHeight"; +static char RCTUITableViewHeaderHeightConstraintKey; typedef NS_ENUM(NSInteger, RCTUITableViewSlotKind) { RCTUITableViewSlotKindHeader, @@ -275,6 +279,11 @@ - (void)setFrameSize:(NSSize)newSize - (void)reloadData { + for (RCTPlatformView *headerView in _headerViews.allValues) { + NSLayoutConstraint *heightConstraint = + objc_getAssociatedObject(headerView, &RCTUITableViewHeaderHeightConstraintKey); + heightConstraint.active = NO; + } [_headerViews removeAllObjects]; [_automaticRows removeAllIndexes]; @@ -383,7 +392,21 @@ - (NSView *)viewForBackingRow:(NSInteger)backingRow if (headerView == nil) { headerView = [RCTPlatformView new]; } - [headerView.heightAnchor constraintEqualToConstant:slot.headerHeight].active = YES; + headerView.translatesAutoresizingMaskIntoConstraints = NO; + NSLayoutConstraint *heightConstraint = + objc_getAssociatedObject(headerView, &RCTUITableViewHeaderHeightConstraintKey); + if (heightConstraint == nil) { + heightConstraint = [headerView.heightAnchor constraintEqualToConstant:slot.headerHeight]; + heightConstraint.identifier = RCTUITableViewHeaderHeightConstraintIdentifier; + objc_setAssociatedObject( + headerView, + &RCTUITableViewHeaderHeightConstraintKey, + heightConstraint, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } else { + heightConstraint.constant = slot.headerHeight; + } + heightConstraint.active = YES; _headerViews[sectionKey] = headerView; } return headerView; From 16689a363439fa4db08b387d08a030fa9fb6a890 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 14:50:22 -0700 Subject: [PATCH 17/18] fix(macos): preserve table cell content insets Keep 5pt vertical containment for subtitle cells and the RedBox stack carve-out so AppKit cannot clip glyph tops at row boundaries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/react-native/React/CoreModules/RCTRedBox.mm | 2 +- .../ReactApple/Libraries/RCTUIKit/RCTUITableView.m | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-native/React/CoreModules/RCTRedBox.mm b/packages/react-native/React/CoreModules/RCTRedBox.mm index 29611a3d6472..28b945778da6 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox.mm +++ b/packages/react-native/React/CoreModules/RCTRedBox.mm @@ -444,7 +444,7 @@ - (RCTUITableViewCell *)reuseCell:(RCTUITableViewCell *)cell forStackFrame:(RCTJ cell.selectedBackgroundView.backgroundColor = [UIColor colorWithWhite:0 alpha:0.2]; #else // [macOS [cell.detailTextLabel removeFromSuperview]; - [cell.textLabel.bottomAnchor constraintEqualToAnchor:cell.contentView.bottomAnchor].active = YES; + [cell.textLabel.bottomAnchor constraintEqualToAnchor:cell.contentView.bottomAnchor constant:-5].active = YES; cell.textLabel.selectable = YES; #endif // macOS] } diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m index 7b04e329114b..808668e26979 100644 --- a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m @@ -130,12 +130,12 @@ - (void)initializeWithStyle:(RCTUITableViewCellStyle)style reuseIdentifier:(NSSt [NSLayoutConstraint activateConstraints:@[ [_textLabel.leadingAnchor constraintEqualToAnchor:_contentView.leadingAnchor constant:5], - [_textLabel.topAnchor constraintEqualToAnchor:_contentView.topAnchor], + [_textLabel.topAnchor constraintEqualToAnchor:_contentView.topAnchor constant:5], [_textLabel.trailingAnchor constraintEqualToAnchor:_contentView.trailingAnchor constant:-5], [_detailTextLabel.leadingAnchor constraintEqualToAnchor:_contentView.leadingAnchor constant:5], [_detailTextLabel.topAnchor constraintEqualToAnchor:_textLabel.bottomAnchor], [_detailTextLabel.trailingAnchor constraintEqualToAnchor:_contentView.trailingAnchor constant:-5], - [_detailTextLabel.bottomAnchor constraintEqualToAnchor:_contentView.bottomAnchor], + [_detailTextLabel.bottomAnchor constraintEqualToAnchor:_contentView.bottomAnchor constant:-5], ]]; } else { [NSLayoutConstraint activateConstraints:@[ From 19d39e57e0218be98683a14257b9f81d74e39c52 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 28 Jul 2026 14:50:58 -0700 Subject: [PATCH 18/18] test(macos): cover table cell visual layout regressions Assert real post-reload label containment for default and subtitle cells, RedBox-like cell-layer rounding, and one reusable primitive-owned header height constraint across changed heights. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../RNTesterUnitTests/RCTUITableViewTests.m | 95 ++++++++++++++++++- 1 file changed, 90 insertions(+), 5 deletions(-) diff --git a/packages/rn-tester/RNTesterUnitTests/RCTUITableViewTests.m b/packages/rn-tester/RNTesterUnitTests/RCTUITableViewTests.m index 6b844f9785d2..8e21d378050a 100644 --- a/packages/rn-tester/RNTesterUnitTests/RCTUITableViewTests.m +++ b/packages/rn-tester/RNTesterUnitTests/RCTUITableViewTests.m @@ -451,9 +451,9 @@ - (void)testMixedAutomaticAndFixedHeightsReflowWithContentWidth automaticCell.textLabel.lineBreakMode = NSLineBreakByWordWrapping; automaticCell.textLabel.numberOfLines = 0; automaticCell.textLabel.text = text; - automaticCell.contentView.wantsLayer = YES; - automaticCell.contentView.layer.cornerRadius = 8; - automaticCell.contentView.layer.cornerCurve = kCACornerCurveContinuous; + automaticCell.wantsLayer = YES; + automaticCell.layer.cornerRadius = 8; + automaticCell.layer.cornerCurve = kCACornerCurveContinuous; return automaticCell; } @@ -497,8 +497,8 @@ - (void)testMixedAutomaticAndFixedHeightsReflowWithContentWidth XCTAssertGreaterThan(wideAutomaticHeight, 50); XCTAssertGreaterThan(automaticCell.textLabel.preferredMaxLayoutWidth, 0); CGFloat widePreferredWidth = automaticCell.textLabel.preferredMaxLayoutWidth; - XCTAssertEqual(automaticCell.contentView.layer.cornerRadius, 8); - XCTAssertEqualObjects(automaticCell.contentView.layer.cornerCurve, kCACornerCurveContinuous); + XCTAssertEqual(automaticCell.layer.cornerRadius, 8); + XCTAssertEqualObjects(automaticCell.layer.cornerCurve, kCACornerCurveContinuous); [self setContentWidth:320]; NSTableView *narrowControlTable = @@ -522,6 +522,57 @@ - (void)testMixedAutomaticAndFixedHeightsReflowWithContentWidth XCTAssertEqual([backingTable.delegate tableView:backingTable heightOfRow:2], 50); } +- (void)testCellLayoutInsetsAndMessageCardLayer +{ + RCTUITableViewTestDataSource *dataSource = [RCTUITableViewTestDataSource new]; + dataSource.rowCounts = @[@2]; + dataSource.heightProvider = ^CGFloat(NSIndexPath *indexPath) { + return 80; + }; + dataSource.cellProvider = ^RCTUITableViewCell *(RCTUITableView *tableView, NSIndexPath *indexPath) { + RCTUITableViewCellStyle style = + indexPath.row == 0 ? RCTUITableViewCellStyleDefault : RCTUITableViewCellStyleSubtitle; + NSString *identifier = indexPath.row == 0 ? @"message-layout" : @"stack-layout"; + RCTUITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier]; + if (cell == nil) { + cell = [[RCTUITableViewCell alloc] initWithStyle:style reuseIdentifier:identifier]; + } + cell.textLabel.text = indexPath.row == 0 ? @"Message" : @"Stack frame"; + cell.detailTextLabel.text = @"RCTUITableViewParity.js:42:7"; + if (indexPath.row == 0) { + cell.wantsLayer = YES; + cell.layer.backgroundColor = [NSColor colorWithRed:0.82 green:0.10 blue:0.15 alpha:1].CGColor; + cell.layer.cornerRadius = 8; + cell.layer.cornerCurve = kCACornerCurveContinuous; + } + return cell; + }; + + [self installDataSource:dataSource delegate:dataSource]; + [self reloadAndDisplay]; + + NSTableView *backingTable = RCTUIBackingTable(_tableView); + for (NSInteger row = 0; row < 2; row++) { + RCTUITableViewCell *cell = [backingTable viewAtColumn:0 row:row makeIfNecessary:YES]; + [cell layoutSubtreeIfNeeded]; + + XCTAssertEqual(cell.rowSizeStyle, NSTableViewRowSizeStyleCustom); + XCTAssertGreaterThanOrEqual(NSMinX(cell.textLabel.frame), 5); + XCTAssertGreaterThanOrEqual(NSMinY(cell.textLabel.frame), 5); + XCTAssertLessThanOrEqual(NSMaxX(cell.textLabel.frame), NSMaxX(cell.contentView.bounds) - 5); + XCTAssertLessThanOrEqual(NSMaxY(cell.textLabel.frame), NSMaxY(cell.contentView.bounds) - 5); + XCTAssertTrue(NSContainsRect(cell.contentView.bounds, cell.textLabel.frame)); + XCTAssertTrue(NSContainsRect(cell.textLabel.bounds, cell.textLabel.visibleRect)); + XCTAssertTrue( + NSContainsRect(cell.textLabel.bounds, [cell.textLabel.cell drawingRectForBounds:cell.textLabel.bounds])); + } + + RCTUITableViewCell *messageCell = [backingTable viewAtColumn:0 row:0 makeIfNecessary:YES]; + XCTAssertEqual(messageCell.layer.cornerRadius, 8); + XCTAssertEqualObjects(messageCell.layer.cornerCurve, kCACornerCurveContinuous); + XCTAssertNotNil((__bridge id)messageCell.layer.backgroundColor); +} + - (void)testHeadersAreLazyStablePerReloadAndHeightZeroHasNoSlot { RCTUITableViewTestDataSource *dataSource = [RCTUITableViewTestDataSource new]; @@ -596,6 +647,40 @@ - (void)testHeadersAreLazyStablePerReloadAndHeightZeroHasNoSlot XCTAssertTrue([emptyHeader isKindOfClass:RCTPlatformView.class]); } +- (void)testReusedHeaderViewMaintainsOnePrimitiveHeightConstraint +{ + RCTUITableViewTestDataSource *dataSource = [RCTUITableViewTestDataSource new]; + dataSource.rowCounts = @[@1]; + dataSource.headerHeights = @[@30]; + RCTPlatformView *sharedHeader = [RCTPlatformView new]; + dataSource.headerProvider = ^RCTPlatformView *(NSInteger section) { + return sharedHeader; + }; + [self installDataSource:dataSource delegate:dataSource]; + [self reloadAndDisplay]; + + NSTableView *backingTable = RCTUIBackingTable(_tableView); + XCTAssertEqual([backingTable viewAtColumn:0 row:0 makeIfNecessary:YES], sharedHeader); + NSPredicate *primitiveConstraint = + [NSPredicate predicateWithFormat:@"identifier == %@", @"RCTUITableViewHeaderHeight"]; + NSArray *heightConstraints = + [sharedHeader.constraints filteredArrayUsingPredicate:primitiveConstraint]; + XCTAssertFalse(sharedHeader.translatesAutoresizingMaskIntoConstraints); + XCTAssertEqual(heightConstraints.count, 1); + XCTAssertTrue(heightConstraints.firstObject.active); + XCTAssertEqual(heightConstraints.firstObject.constant, 30); + NSLayoutConstraint *originalHeightConstraint = heightConstraints.firstObject; + + dataSource.headerHeights = @[@44]; + [self reloadAndDisplay]; + XCTAssertEqual([backingTable viewAtColumn:0 row:0 makeIfNecessary:YES], sharedHeader); + heightConstraints = [sharedHeader.constraints filteredArrayUsingPredicate:primitiveConstraint]; + XCTAssertEqual(heightConstraints.count, 1); + XCTAssertEqual(heightConstraints.firstObject, originalHeightConstraint); + XCTAssertTrue(heightConstraints.firstObject.active); + XCTAssertEqual(heightConstraints.firstObject.constant, 44); +} + - (void)testAccessibilityContainerAndCellIdentifiersSurviveReuse { RCTUITableViewTestDataSource *dataSource = [RCTUITableViewTestDataSource new];