Skip to content

Crash: scroll to oldest message on an empty table raises NSInternalInconsistencyException #291

Description

@fayharinn

Description

performScrollTo crashes with NSInternalInconsistencyException when a scroll to the oldest message is requested while the table has no sections.

Sources/ExyteChat/Views/UIList.swift (current main, unchanged since at least 3.1.7):

case .oldestMessage:
    let lastSection = max(tableView.numberOfSections - 1, 0)
    let lastRow = max(tableView.numberOfRows(inSection: lastSection) - 1, 0)

    guard lastRow >= 0 else { return }
    …

With zero sections, max(0 - 1, 0) evaluates to 0, so the next line asks UIKit for the row count of a section that does not exist:

NSInternalInconsistencyException: Requested the number of rows for section (0) which is out of bounds.
-[UITableViewRowData numberOfRowsInSection:]

The guard lastRow >= 0 cannot help: max(_, 0) already forced the value non-negative, so the guard is always true, and it runs after the offending call regardless.

Why this is reachable in normal use

ChatView.swift arms this scroll from a status bar tap:

.onStatusBarTap {
    self.pendingScrollTo = ScrollToParams(.oldestMessage)
}

View+StatusBar.swift implements that by swizzling UIStatusBarManager.handleTapAction: and posting a global NotificationCenter notification. The subscription is created in onAppear and released in onDisappear.

onDisappear does not fire for a view that stays mounted while off screen, which is the normal case for a ChatView inside a TabView tab or lower in a NavigationStack. The observer therefore survives navigation, and a status bar tap performed on a completely different screen still arms the scroll on that chat. If its table is empty at that moment (empty conversation, or messages not loaded yet), the app crashes.

The scroll runs inside UIView.animate wrapped in withCheckedContinuation (UIList.swift:128), so the exception surfaces from inside the animation block:

-[UITableViewRowData numberOfRowsInSection:]
…
+[UIView(UIViewAnimationWithBlocks) animateWithDuration:animations:completion:]
closure in withCheckedContinuation<T>
withCheckedContinuation<T>

Steps to reproduce

  1. Present a ChatView for a conversation with no messages, or one whose messages load asynchronously.
  2. Navigate away while keeping the view mounted (switch tab, or push another screen onto the stack).
  3. Tap the status bar.

Observed on iOS 27.0, iPhone 13 Pro, ExyteChat 3.1.7. The same code path is present on main and in 3.2.7.

Suggested fix

Guard on the section count before touching the table, and drop both clamps so the row guard becomes meaningful for an empty section:

case .oldestMessage:
    guard tableView.numberOfSections > 0 else { return }

    let lastSection = tableView.numberOfSections - 1
    let lastRow = tableView.numberOfRows(inSection: lastSection) - 1

    guard lastRow >= 0 else { return }

Happy to open a PR with this.

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