BLAN-30244 — The "Save bundle: save state provider is already registered" crash in pager tests: the page-instance identity trap
While adding a UI regression test for the pager-in-NavPageDisplay save-state bug (distro
dashboard ReleasesPageViewModel — stale filter restored across page instances, fixed in
3c48c7748fb "Different instance of SaveStateHelper for each Page instance"), we hit a crash:
TaggedException: [CRITICAL] Save bundle: save state provider is already registered
Thrown by SaveStateHelperImpl.init (debugThrow — fatal in debug/test builds), nondeterministically
on the pager's initial settle and on tab switches.
- Same-class pages in the
PageCollection— NOT the cause (distinct classes reproduced it). - Sizing (
fillMaxSizevs fixed360.dp) — NOT the cause. - NavPageDisplay nesting vs a directly-rendered pager — NOT the cause (both reproduced).
- Delayed navigation (settle the window first) — NOT the cause.
rememberSaveable { randomUuid() }pageId — NOT a fix: theLazySaveableStateHoldersaves the slot's saveable state before the deferred composition disposal, so the re-created slot restores the same id → same keySuffix → same collision.- A compose/pager slot-management bug ("wave-2" re-composition dropping
rememberstate) — WRONG conclusion. Production (UserLibrary) and the debug reproducer never showed it, which contradicted a general compose issue.
PageCollection.getPageOrNull(index)=getOrNull(index)?.invoke()— it invokes the factory lambda on every resolution.- The pager's per-page state is keyed by the page instance:
PageContent→rememberComposeLifecycleOwner(key = page)+rememberPageViewModel(page)→remember(page). - Test-scenario lambdas like
{ DummyTabPage() }return a new instance every resolution → on the next measure (initial settle / tab switch) theremember(page)key changes → the per-page state is dropped → a new ViewModel + a new per-pageSaveStateHelperis created while the old composition's provider is still registered (its disposal is deferred) → the new helper registers the same position-derived keySuffix (rememberPageId()) → collision.
A/B control — same class (TestPage), same environment, SaveStateHelperImpl untouched:
| Tabs | Result |
|---|---|
{ releasesPage }, { releasePageTwo } (stable field-held instances) |
✅ pass, no re-creation |
{ releasesPage }, { TestPage() } (factory lambda) |
💥 crash — log shows a new instance on the second wave (pageHash changed) |
Production holds stable page instances (DI-injected):
DashboardScreenViewModel:{ releasesPage },{ earningsPage },{ analyticsPage.value },{ aiArtworkGalleryPage }- Debug reproducer
DebugSaveStatePagerPage:{ itemPageAlpha },{ itemPageBeta }, ...
With stable instances the remember(page) key never changes → no re-creation → no collision.
- Pager test scenarios must use stable page instances — hold them in fields
(
private val releasesPage = TestPage()+{ releasesPage }), never factory lambdas that create a new instance per resolution ({ DummyTabPage() }). - A/B control experiments beat theory — one variable at a time (instance stability) proved the cause; everything else (classes, sizing, nav host, pageId mechanism) was noise.
- Verify the APK actually contains your changes before trusting a run — several "nondeterministic" failures were stale-APK artifacts: a compile error in one module silently left old classes in the installed APK. Check the dex for a sentinel string before debugging.
- The
debugThrowcollision guard inSaveStateHelperImplis legitimate — a same-key overlap genuinely means two helpers are alive at once; fix the composition (stable instances), not the helper.
SaveStateHelperImpl.kt/SaveStateHelperTest.kt— fully reverted, no changes needed.rememberPageId()— reverted to the original composite-hash implementation.- The within-pager test passes with stable page instances and no
SaveStateHelperImplchanges.