Skip to content

Fix InboxListView NPE when switching tabs#39

Merged
mikemilla merged 2 commits into
mainfrom
fix/inbox-tab-switch-npe
May 26, 2026
Merged

Fix InboxListView NPE when switching tabs#39
mikemilla merged 2 commits into
mainfrom
fix/inbox-tab-switch-npe

Conversation

@mikemilla

@mikemilla mikemilla commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

openVisibleMessages was launched on Dispatchers.IO and called LinearLayoutManager.findFirstCompletelyVisibleItemPosition() / findLastCompletelyVisibleItemPosition() from a worker thread. Those APIs walk RecyclerView children and read View.getLayoutParams(), which is not thread-safe and races with the layout pass. Switching tabs while the list was still settling reliably reproduced a fatal NullPointerException inside ViewBoundsCheck.findOneViewWithinBoundFlags:

FATAL EXCEPTION: DefaultDispatcher-worker-6
java.lang.NullPointerException: Attempt to invoke virtual method
'android.view.ViewGroup$LayoutParams android.view.View.getLayoutParams()'
on a null object reference
  at androidx.recyclerview.widget.RecyclerView$LayoutManager$2.getChildStart(RecyclerView.java:8467)
  at androidx.recyclerview.widget.ViewBoundsCheck.findOneViewWithinBoundFlags(ViewBoundsCheck.java:219)
  at androidx.recyclerview.widget.LinearLayoutManager.findOneVisibleChild(LinearLayoutManager.java:2065)
  at androidx.recyclerview.widget.LinearLayoutManager.findLastCompletelyVisibleItemPosition(LinearLayoutManager.java:2038)
  at com.courier.android.ui.inbox.InboxListView$openVisibleMessages$1.invokeSuspend(InboxListView.kt:399)

Fix

  • Switch the outer coroutine to Dispatchers.Main so LayoutManager and adapter reads happen on the UI thread.
  • Read the visible index range, then subList(...).filter { !it.isOpened } on the main thread. Iterable.filter returns a fresh list, so it's safe to hand off to IO even if the adapter mutates afterwards.
  • Defensively bound the visible range with coerceAtLeast(0) / coerceAtMost(messages.size) against stale indexes.
  • Move only the network work (message.markAsOpened()) to Dispatchers.IO via withContext.

Performance

The fix is essentially free on the main thread:

  • findFirst/LastCompletelyVisibleItemPosition() walk visible children only (~10–30 views), microseconds.
  • subList(...).filter { !it.isOpened } iterates only the visible slice.
  • Early return when nothing new is visible — no IO hop in the steady state.

A .toList() snapshot was tried in the first commit but dropped in the follow-up: it added O(totalMessages) main-thread work on every scroll event for no benefit, since adapter mutations are also confined to the main thread now.

Pre-existing follow-up (not in this PR)

recyclerView.setOnScrollChangeListener fires on every pixel of scroll, so a flick can launch dozens of openVisibleMessages coroutines per second. They mostly no-op via the early-exit, but the launches themselves aren't free. Worth throttling/debouncing or migrating to RecyclerView.OnScrollListener with onScrollStateChanged in a separate change.

Test plan

  • Open the example app inbox, switch tabs rapidly while messages are loading — no longer crashes.
  • Scroll the list and confirm visible messages are still marked as opened.
  • Confirm setInbox, addPage, addMessage, removeMessage, and the scroll listener still call into openVisibleMessages cleanly (no thread-confinement asserts).
  • Profile a fast scroll — main thread stays well under the 16ms frame budget.

mikemilla and others added 2 commits May 26, 2026 14:48
openVisibleMessages was launched on Dispatchers.IO and called
LinearLayoutManager.findFirst/LastCompletelyVisibleItemPosition()
from a worker thread. Those APIs walk RecyclerView children and
read View.getLayoutParams(), which is not thread-safe and races
with the layout pass. Switching tabs while the list was settling
reliably crashed with a NullPointerException inside
ViewBoundsCheck.findOneViewWithinBoundFlags.

Resolve the visible index range and snapshot the message list on
the main thread, then move only the markAsOpened() network calls
to Dispatchers.IO via withContext. Bound the snapshot subList
defensively against stale indexes.

Co-authored-by: Cursor <cursoragent@cursor.com>
The .toList() snapshot was added defensively, but adapter mutations
(setInbox / addPage / addMessage / removeMessage) all run on the main
thread, and openVisibleMessages now does too. The defensive copy was
adding O(totalMessages) main-thread work on every scroll event for
no benefit.

Iterable.filter already returns a fresh List, so the IO block can
still iterate messagesToOpen safely if the underlying adapter list
mutates after we hand it off.

Co-authored-by: Cursor <cursoragent@cursor.com>
@mikemilla mikemilla merged commit 4dfbff7 into main May 26, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant