From 1cce7aea29b0f9c795b4d6896df22c25cbc295c9 Mon Sep 17 00:00:00 2001 From: Jin Chen Date: Tue, 8 Sep 2026 15:17:03 -0400 Subject: [PATCH] Fix index-out-of-range crash when searching the country picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit country(for:) subscripted filteredCountries and countries directly. isFiltering is derived from the search bar, so it flips on the keystroke, while filteredCountries is only replaced when the throttled search work item lands 0.25s later. A table view can therefore ask for a row index it read before the list changed, and the subscript traps. Reproduced on device in a release build: Fatal error: Index out of range CountryCodePickerViewController.country(for:) CountryCodePickerViewController.tableView(_:cellForRowAt:) -[_UIFilteredDataSource tableView:cellForRowAtIndexPath:] -[UITableView _createPreparedCellForGlobalRow:withIndexPath:willDisplay:] country(for:) now returns an optional and both call sites guard. cellForRowAt returns the dequeued cell unconfigured — the table view asks again after the pending reload — and didSelectRowAt returns without notifying the delegate, so a tap on a row that no longer exists cannot deliver the wrong country. didSelectRowAt also now deselects before the guard, so the row does not stay highlighted when the lookup misses. country(for:) is internal and has no callers outside this file, so the signature change is not source-breaking for clients. --- .../CountryCodePickerViewController.swift | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/Sources/PhoneNumberKitUI/CountryCodePickerViewController.swift b/Sources/PhoneNumberKitUI/CountryCodePickerViewController.swift index 50f25cd..81fd6ee 100644 --- a/Sources/PhoneNumberKitUI/CountryCodePickerViewController.swift +++ b/Sources/PhoneNumberKitUI/CountryCodePickerViewController.swift @@ -218,8 +218,22 @@ public class CountryCodePickerViewController: UITableViewController { // MARK: - Table view data source - func country(for indexPath: IndexPath) -> Country { - isFiltering ? filteredCountries[indexPath.row] : countries[indexPath.section][indexPath.row] + /// The country at `indexPath`, or `nil` if that row no longer exists. + /// + /// `isFiltering` is derived from the search bar, so it becomes true on the + /// keystroke, while `filteredCountries` is only replaced when the throttled + /// search work item lands 0.25s later. A table view can therefore ask for a row + /// index it read before the list changed, and subscripting it directly traps. + func country(for indexPath: IndexPath) -> Country? { + if isFiltering { + guard filteredCountries.indices.contains(indexPath.row) else { return nil } + return filteredCountries[indexPath.row] + } + guard countries.indices.contains(indexPath.section), + countries[indexPath.section].indices.contains(indexPath.row) else { + return nil + } + return countries[indexPath.section][indexPath.row] } override public func numberOfSections(in tableView: UITableView) -> Int { @@ -238,7 +252,11 @@ public class CountryCodePickerViewController: UITableViewController { cell = tableView.dequeueReusableCell(withIdentifier: CountryCodePickerTableViewCell.reuseIdentifier, for: indexPath) as! CountryCodePickerTableViewCell } - let country = self.country(for: indexPath) + guard let country = self.country(for: indexPath) else { + // The row went away between the count and this call; the table view will + // ask again after the pending reload. + return cell + } cell.configure(with: country) cell.options = options.cellOptions return cell @@ -278,8 +296,8 @@ public class CountryCodePickerViewController: UITableViewController { } override public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let country = self.country(for: indexPath) tableView.deselectRow(at: indexPath, animated: true) + guard let country = self.country(for: indexPath) else { return } delegate?.countryCodePickerViewControllerDidPickCountry(country) }