Skip to content

Instantly share code, notes, and snippets.

@esafirm
Created August 15, 2026 06:12
Show Gist options
  • Select an option

  • Save esafirm/f41803c94feae59fad6f6d9b0a51d364 to your computer and use it in GitHub Desktop.

Select an option

Save esafirm/f41803c94feae59fad6f6d9b0a51d364 to your computer and use it in GitHub Desktop.
BLAN-30244 lesson: SaveStateHelper 'already registered' crash in pager tests — the page-instance identity trap

BLAN-30244 — The "Save bundle: save state provider is already registered" crash in pager tests: the page-instance identity trap

Background

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.

The investigation (what we ruled out first)

  • Same-class pages in the PageCollection — NOT the cause (distinct classes reproduced it).
  • Sizing (fillMaxSize vs fixed 360.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: the LazySaveableStateHolder saves 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 remember state) — WRONG conclusion. Production (UserLibrary) and the debug reproducer never showed it, which contradicted a general compose issue.

The root cause (proven by an A/B control experiment)

  • 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) the remember(page) key changes → the per-page state is dropped → a new ViewModel + a new per-page SaveStateHelper is 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)

Why production is safe

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.

Lessons

  1. 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() }).
  2. 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.
  3. 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.
  4. The debugThrow collision guard in SaveStateHelperImpl is legitimate — a same-key overlap genuinely means two helpers are alive at once; fix the composition (stable instances), not the helper.

Current state

  • 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 SaveStateHelperImpl changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment