Breaking Changes
PagergainedkeyDebounceMsandconcurrencyparameters betweencacheSizeandreadData. Named arguments and
the trailing-lambda form are unaffected; only calls that passreadDataas the fourth positional argument need
updating.Pagernow fetches up toconcurrency(default4) chunks in parallel, so a source that must not be hit in
parallel has to opt out withconcurrency = 1.PagingMediatoris unaffected by the new default: it passes its own
concurrency(default1) down to the pager.- Dropped the
macosX64(Intel macOS) target frompaging-core; thepaging-core-macosx64artifact is no longer
published. Kotlin 2.4 deprecates the target and will remove it in a future release, as Apple winds down x86_64
support.macosArm64(Apple Silicon) is unaffected, as areiosX64,linuxX64andmingwX64.
Added
-
Pagertakes akeyDebounceMs(default300), matchingStreamingPagerConfig.keyDebounceMs. The delay was
hard-coded before, so a local or otherwise cheap source had no way to serve position changes sooner. -
Pagertakes aconcurrency(default4): the number of chunks one loading pass keeps in flight (#8). Chunks used
to be fetched strictly one after another, so filling the preload window after a jump cost one round trip per chunk -
900 ms for a ±60 item window over a 100 ms backend, of which the chunk the user is actually looking at was only the
first 100 ms. It now costsceil(chunks / concurrency)round trips (500 ms for the same jump, debounce included). -
A
compose_compiler_config.confat the repository root declaring every UI-facing type stable to the Compose compiler
(#16).paging-coreis built without the Compose compiler plugin - which is what keeps it free of a Compose runtime
dependency - and the compiler only infers stability for classes it compiles itself, so it treatedPagingDataand
everything around it as unstable: a composable taking one was never skippable and re-ran on every emission, however
little had changed. Copy the file and pointcomposeCompiler { stabilityConfigurationFiles }at it; the README
documents the setup and how to verify it with a Compose compiler report.
Changed
- Reading an already loaded position no longer notifies the pager (#16).
PagingMap.getis what tells the pager where
the viewport is, and aLazyColumnre-reads every visible row on every recomposition - each read updating a
MutableStateFlowand restarting the key debounce, on the UI thread. Both pagers now track the stretch of positions
whose access cannot change anything they would do, which is the loaded window pulled in byloadSizeon each side,
and reading inside it costs nothing but the lookup. Reads within a page of the window edge still move the window, and
arefresh(), a failed load or a hole left by a short portion puts every read back in play. Pager.flowandStreamingPager.floware now conflated (#12).PagingDatais a complete snapshot of the list, so
only the newest one is worth rendering, but thechannelFlowunderneath buffered 64 of them: a UI slower than the
source - which a live SSE stream easily is - worked through a queue of states it rendered and immediately discarded.
A collector that falls behind now receives the current state instead. Nothing is lost, since every snapshot carries
the whole window; only intermediateLoadStatetransitions can be skipped.StreamingPagerno longer re-emits its aggregatedLoadStatewhen it did not change (#12). The per-range map
underneath is touched by every streamed message, while the single state derived from it moves only when a range
actually opens or settles, so a portion arriving next to a range that was still loading used to reach the UI as two
PagingDatainstead of one. Marking a range with the state it already holds is also skipped outright now, instead of
rebuilding the map for it once per streamed message.Pagernow snaps every fetch range to aloadSizegrid anchored at 0, instead of centring the first chunk on the
accessed position (#10). Chunk starts used to depend on where the user landed, so a single jump issued overlapping
requests (490..509and480..499forkey=500,loadSize=20) and the same positions were fetched twice; the same
data was also requested under different(offset, limit)pairs across passes, which no HTTP or CDN cache can reuse
and which inflates the key space of offset-paged backends.StreamingPageralready aligned its chunks this way. The
cache window now also always covers the range the current load fetches, so alignment cannot push freshly requested
items outside it.Pagernow drives every load - key access, retry and refresh - from a single collector, so the scheduling state
(in-flight job, planned range, last read key) is only ever touched from one coroutine.Pagerprefetches the side the position is moving towards first (#8). The direction was computed and then discarded
by a distance sort spanning both sides of the key, so the window filled symmetrically (490, 510, 480, 530, ...) and
half the requests went to the side being scrolled away from before the leading side was covered. Each side is now
ordered nearest-first on its own, andDirectionis named after what it actually does.Pagerno longer holds its mutex across the fetches, only across the cache transitions they produce, so a load can
no longer be blocked by the one it supersedes.PagingMediatorConfig.concurrencynow bounds the remote fetches of a whole pager rather than those of a single
readDatacall: the chunks of one loading pass and the missing sub-ranges within each of them draw from one budget.Pager,StreamingPagerConfigandPagingMediatorConfignow reject a cache narrower than the preload window
(cacheSize >= preloadSize,cacheSize >= prefetchSizefor the mediator) with anIllegalArgumentExceptionat
construction time. Such a configuration used to be accepted silently while the pager streamed a window it could not
retain — measured at ~76% of the payload discarded on arrival, with the streams that produced it left open (#13).
Pageralso validatesloadSize > 0andpreloadSize >= 0, which it did not check at all before.StreamingPagerConfigvalidates itself in its owninitinstead of inStreamingPager, socopy()is covered too.- Bumped library versions: Kotlin to 2.4.10, kotlinx-coroutines to 1.11.0, Compose Multiplatform to 1.11.1,
androidx-activity-composeto 1.13.0,kotlinx-collections-immutableto 0.5.1, and Kermit to 2.1.0. - Bumped the BuildKonfig Gradle plugin to 0.22.0 (build-only, no effect on the published artifacts).
paging-samplesno longer builds theiosX64target — Compose Multiplatform 1.11 stopped publishing artifacts for
it. The publishedpaging-corelibrary keepsiosX64and its full target set unchanged.- Upgraded the Android Gradle Plugin to 9.3.1 (from 8.13.2) and the Gradle wrapper to 9.6.1 (from 9.2.1, the minimum
AGP 9.3.1 accepts is 9.5.0). AGP 9 dropped Kotlin Multiplatform support fromcom.android.library, so both modules
now applycom.android.kotlin.multiplatform.libraryand configure Android from inside thekotlin { }block
instead of a top-levelandroid { }one. Published coordinates are unchanged — the Android artifact is still
paging-core-android; only the internal Gradle publication name changes (androidRelease->android), which
consumers do not match on.
Documentation
- Clarified that
preloadSize/prefetchSizeandcacheSizeare radii in indices around the current position, not
item counts —Pager's KDoc previously describedcacheSizeas the "maximum number of items to keep in memory",
while the cache actually holds up to2 * cacheSizeitems. The invariant is now stated in the KDoc of all three
configurations and inREADME.md.
Fixed
- Pager:
PagingData.retry(key)now actually retries. Retries used to be routed through the same conflating
StateFlowthat already held the failed key, so the documented recovery path (retry(loadState.key), used by the
README and by the sample's error overlay) emitted nothing and the pager stayed inLoadState.Errorforever - only
retrying with a different key recovered. Retries travel on their own channel now and are never debounced (#3). - Pager:
refresh()reloads instead of just emptying the list. It used to clear the cache and rely on the UI touching
an item again, which the conflating key trigger then swallowed, leaving the list empty while reporting
LoadState.Success.refresh()now cancels and awaits the in-flight load - which held a pre-refresh cache snapshot
and could write cleared items back - clears the cache, and reloads the window around the position last accessed (#4). - Pager: the initial load is no longer debounced.
keyTriggerstarts at0, so the first load waited out the full
300 ms debounce despite having nothing to debounce - 75% of a 400 ms first paint against a 100 ms backend. Only
subsequent position changes are debounced now (#7). - StreamingPager: the pager no longer reports
LoadState.Loadingforever after the total shrinks. The out-of-bounds
key clamp and the stream close filter were both off by one, which produced an empty chunk that was marked as loading
but never opened, so the marker was never cleared (#5). - StreamingPager: a portion stream covering exactly the new boundary index is now closed when the total shrinks,
instead of being left open past the end of the list.
Tests
- Added regression coverage for the shrink path in
StreamingPagerTest, plusWindowHelpersTestpinning the invariant
that the chunk planner never returns an empty range for an out-of-bounds key. PagerTestcovers retry with the failed key, retry without waiting for the debounce, refresh reloading the window
around the last accessed position, refresh racing an in-flight load, the undebounced initial load, and
keyDebounceMsvalidation and effect. The F1/F5/F6 characterisation tests inDiagnosticsFindingsTestwere flipped
from documenting the defects to asserting the fixed behaviour.