Skip to content

[iOS] RNCPicker reloads the wheel on every prop update; UIPickerView hitTest crash under touch (root cause of #627) #676

Description

@christian-apollo

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions