Summary
On iOS with the new architecture, RNCPicker reloads the whole UIPickerView on every prop update, and Fabric sends a prop update on every wheel tick of a controlled Picker. The wheel is therefore reloaded while the user's finger is still on it. A new touch that arrives between the reload and the next layout pass runs -[UIPickerView hitTest:withEvent:], which asks the column's table view for its visible cells and messages a cell the reload already released. That is the objc_msgSend crash reported in #627 (and most likely #519).
This is the root cause of #627. The fix is three early-return guards in RNCPicker.mm; a patch is at the bottom.
Environment
@react-native-picker/picker 2.11.4 (latest)
- react-native 0.86.3, new architecture enabled
- iOS 18 / iOS 26, production build, Crashlytics report
- A plain controlled
<Picker selectedValue={v} onValueChange={setV} itemStyle={{fontSize, color}}> with ~25 Picker.Items inside a bottom-sheet dialog
Stack (trimmed)
Crashed: com.apple.main-thread
0 libobjc.A.dylib objc_msgSend + 32
1 UIKitCore -[UITableView _updateVisibleCellsForRanges:createIfNecessary:] + 696
2 UIKitCore -[UITableView _updateVisibleCellsNow:] + 1100
3 UIKitCore -[UITableView _visibleCellsUsingPresentationValues:] + 352
4 UIKitCore -[UIPickerColumnView _allVisibleCells] + 52
5 UIKitCore -[UIPickerView hitTest:withEvent:] + 248
6 App -[RCTViewComponentView betterHitTest:withEvent:] (RCTViewComponentView.mm:763)
7 App -[RCTViewComponentView hitTest:withEvent:] (RCTViewComponentView.mm:776)
...
61 UIKitCore __startNewUITouch + 544
Root cause
RNCPickerComponentView.mm updateProps: re-applies every prop on every update (items, selectedIndex, color, numberOfLines, font). Three of the setters in RNCPicker.mm do not check whether anything changed:
- (void)setItems:(NSArray<NSDictionary *> *)items
{
_items = [items copy]; // new array every update, never reloads
[self setNeedsLayout];
}
- (void)setNumberOfLines:(NSInteger)numberOfLines
{
_numberOfLines = numberOfLines;
[self reloadAllComponents]; // reloads even when unchanged (it is 1 on every update)
[self setNeedsLayout];
}
- (void) setFont:(UIFont *)font
{
_font = font;
[self reloadAllComponents]; // reloads on every call, and updateProps always passes a freshly built UIFont
[self setNeedsLayout];
}
With a controlled Picker, each tick fires onChange, the JS side re-renders with a new selectedIndex, Fabric calls updateProps, and the wheel gets reloadAllComponents twice per tick (line count, then font). setSelectedIndex then queues an animated selectRow on top. Reloading a UIPickerView under an active touch is what UIKit does not tolerate.
I measured this with an NSLog on reloadAllComponents and one at the top of updateProps, driving the wheel in the iOS simulator:
| Build |
Per selection change |
Under a 25 s burst of ticks and taps |
| 2.11.4 as published |
1 updateProps, 2 reloads |
2048 reloads |
| With the patch below |
1 updateProps, 0 reloads |
0 reloads |
Opening the picker still reloads twice (rows, then the real font), and a real font change (Dynamic Type) still reloads exactly once, so nothing legitimate is lost.
Fix
Only reload when the value actually changed. setItems now reloads when the rows differ, which is also more correct than before (label changes only used to show up because the font reload happened to run after it).
--- a/ios/RNCPicker.mm
+++ b/ios/RNCPicker.mm
@@ -41,7 +41,11 @@
- (void)setItems:(NSArray<NSDictionary *> *)items
{
+ if ([_items isEqualToArray:items]) {
+ return;
+ }
_items = [items copy];
+ [self reloadAllComponents];
[self setNeedsLayout];
}
@@ -58,6 +62,9 @@
- (void)setNumberOfLines:(NSInteger)numberOfLines
{
+ if (_numberOfLines == numberOfLines) {
+ return;
+ }
_numberOfLines = numberOfLines;
[self reloadAllComponents];
[self setNeedsLayout];
@@ -65,6 +72,9 @@
- (void) setFont:(UIFont *)font
{
+ if (_font == font || [_font isEqual:font]) {
+ return;
+ }
_font = font;
[self reloadAllComponents];
[self setNeedsLayout];
Notes on the comparisons, checked on the simulator runtime: two UIFont objects built separately with the same descriptor and size are isEqual:; isEqualToArray: compares the item dictionaries by content (NSNumber, NSString, UIColor all compare by value); and a nil receiver returns NO, so the first assignment of each property still goes through.
The updateProps order (items, selectedIndex, color, numberOfLines, font) is unchanged.
What I could not do
I could not make the unpatched build crash on the simulator (x86_64 under Rosetta); the race is timing dependent and only reproduced on real devices via Crashlytics. The patch removes the reload rather than narrowing the window, so it addresses the mechanism regardless of timing.
Happy to turn this into a PR if you would like one.
Summary
On iOS with the new architecture,
RNCPickerreloads the wholeUIPickerViewon every prop update, and Fabric sends a prop update on every wheel tick of a controlledPicker. The wheel is therefore reloaded while the user's finger is still on it. A new touch that arrives between the reload and the next layout pass runs-[UIPickerView hitTest:withEvent:], which asks the column's table view for its visible cells and messages a cell the reload already released. That is theobjc_msgSendcrash reported in #627 (and most likely #519).This is the root cause of #627. The fix is three early-return guards in
RNCPicker.mm; a patch is at the bottom.Environment
@react-native-picker/picker2.11.4 (latest)<Picker selectedValue={v} onValueChange={setV} itemStyle={{fontSize, color}}>with ~25Picker.Items inside a bottom-sheet dialogStack (trimmed)
Root cause
RNCPickerComponentView.mmupdateProps:re-applies every prop on every update (items, selectedIndex, color, numberOfLines, font). Three of the setters inRNCPicker.mmdo not check whether anything changed:With a controlled
Picker, each tick firesonChange, the JS side re-renders with a newselectedIndex, Fabric callsupdateProps, and the wheel getsreloadAllComponentstwice per tick (line count, then font).setSelectedIndexthen queues an animatedselectRowon top. Reloading aUIPickerViewunder an active touch is what UIKit does not tolerate.I measured this with an
NSLogonreloadAllComponentsand one at the top ofupdateProps, driving the wheel in the iOS simulator:updateProps, 2 reloadsupdateProps, 0 reloadsOpening the picker still reloads twice (rows, then the real font), and a real font change (Dynamic Type) still reloads exactly once, so nothing legitimate is lost.
Fix
Only reload when the value actually changed.
setItemsnow reloads when the rows differ, which is also more correct than before (label changes only used to show up because the font reload happened to run after it).Notes on the comparisons, checked on the simulator runtime: two
UIFontobjects built separately with the same descriptor and size areisEqual:;isEqualToArray:compares the item dictionaries by content (NSNumber,NSString,UIColorall compare by value); and a nil receiver returnsNO, so the first assignment of each property still goes through.The
updatePropsorder (items, selectedIndex, color, numberOfLines, font) is unchanged.What I could not do
I could not make the unpatched build crash on the simulator (x86_64 under Rosetta); the race is timing dependent and only reproduced on real devices via Crashlytics. The patch removes the reload rather than narrowing the window, so it addresses the mechanism regardless of timing.
Happy to turn this into a PR if you would like one.