A minimal, self-contained Android app to isolate a Jetpack Paging 3 scroll-position bug:
during pagination, a LazyColumn backed by a Room PagingSource + RemoteMediator jumps backward
mid-scroll — the presented itemCount transiently collapses to ~initialLoadSize and the viewport is
clamped.
This project starts from the simplest possible setup and adds one data-source characteristic at a time, scrolling on a real device after each change, to find the exact trigger.
- Paging 3.3.6, Room 2.8.4, AGP 8.9.3, Kotlin 2.1.20, Compose BOM 2026.03.01, Gradle 8.11.1
PagingConfig:pageSize=50, initialLoadSize=100, prefetchDistance=12, enablePlaceholders=true- Data: PokeAPI (
/api/v2/pokemon?limit=50&offset=X) — offset pagination, no auth, ~1300 items - Log tag:
ReproTrace— per framevisibleIndex,itemCount,maxSeen, and a<<< COLLAPSEmarker whenitemCountdrops below the max already loaded.
| # | Setup | Result | Doc |
|---|---|---|---|
| 01 | Baseline — flat Room table, single-transaction RemoteMediator, SELECT * ORDER BY position |
No jump | 01-baseline-mediator-pagingsource.md |
| 02 | + generation membership (two-table JOIN + MAX(generation) + gen-bump-on-refresh + prune) |
No jump | 02-generation-membership.md |
| 03 | Instrumented deep-dive (timing, invalidation count, LoadStates) — same config as 02 | No jump — placeholders keep itemCount = before+got+after = full COUNT |
03-instrumented-why-placeholders-stay-stable.md |
| 04 | REFRESH over cached (higher-generation) data + REFRESH/CLEAR-DB controls | REPRODUCED — gen-bump refresh → MAX(generation) = partial page → COUNT 150→50 → collapse |
04-REPRODUCED-refresh-generation-swap.md |
| 05 | Reactive pager (flatMapLatest) + auto mid-scroll input re-emit |
REPRODUCED on plain scroll, no manual refresh — rebuild → new mediator launchInitialRefresh → gen bump → COUNT 200→50 → viewport jumped 145→37 |
05-REPRODUCED-reactive-rebuild-plain-scroll.md |
Trigger (test 05) + mechanism (test 04) = the whole bug. A reactive-pager rebuild (a combine
input re-emitting mid-scroll) creates a new RemoteMediator whose launchInitialRefresh bumps the
generation over cached data → MAX(generation) collapses COUNT → viewport clamp → jump. Not Paging 3.
Fix: (1) stop mid-scroll pager rebuilds (distinct combine inputs), (2) atomic-generation swap / gate
launchInitialRefresh when cache present.
The generation swap is not atomic: MAX(generation) advances on the first new-generation write
(page 0), so a REFRESH that bumps the generation while cached (higher-count) data is shown collapses
COUNT(*) to the new partial page → the Paging presenter clamps the viewport → jump. Requires
(1) cached data already shown + (2) a gen-bumping REFRESH. Fix: make the swap atomic (active-generation
pointer that advances only on completion) and/or stop mid-scroll pager rebuilds/refreshes.
JAVA_HOME=<jdk17> ./gradlew :app:installDebug
adb shell am start -n com.example.pagingrepro/.MainActivity
adb logcat -c && adb logcat -v time ReproTrace:D '*:S'Then deep-scroll 0 → ~800 and read the trace.