Skip to content

fix(android): keep the pager's Compose state out of the host's saved state - #1194

Open
abulenok wants to merge 2 commits into
callstack:mainfrom
abulenok:fix/android-compose-saved-state
Open

abulenok wants to merge 2 commits into
callstack:mainfrom
abulenok:fix/android-compose-saved-state

Conversation

@abulenok

@abulenok abulenok commented Oct 20, 2026 •

Copy link
Copy Markdown

Changelog

  • A remounted pager now always starts on initialPage. Before, it could start on the page of a previous pager on the same screen, after react-native-screens restored that screen's saved view state.
  • The pager no longer saves or restores its page through Android's saved instance state. Apps relying on that may now return to initialPage after Activity or process recreation

Summary

Problem. Since 9.0, each PagerView on Android draws through a Jetpack Compose view inside the native view. Compose stores some state with rememberSaveable, including the pager's current page. That state goes into the host Fragment's saved-state store, keyed by the view's id. The pager's internal view has no id, so every pager in the app uses the same key (SaveableStateRegistry:-1).

When react-native-screens destroys the view of a screen that's covered by another, it saves that state. The next pager to mount under that key restores the old page and ignores initialPage. In our app, Home remounts its pager after the user reorder feeds, so it opened on the old index and the tab bar didn't match the feed shown.

Solution. The library already gives the Compose view its own private Lifecycle owner so that react-native-screens can't kill it. The fix makes that same object provide the saved-state store too. The store starts empty and is never written out, so nothing is saved or restored. That's fine because React already owns the pager's state (initialPage and the page events).

Test Plan

What's required for testing (prerequisites)?

What are the steps to reproduce (after prerequisites)?

Compatibility

OS Implemented
iOS ❌
Android ✅

Checklist

  • I have tested this on a device and a simulator
  • I added the documentation in README.md
  • I updated the typed files (TS and Flow)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The focused change isolates saved state with correct lifecycle initialization and no identified blocking issues.

No open issues 🎉

What changed in this PR

Isolates Android pager Compose saved state from the host Fragment, preventing remounted pagers from restoring another pager’s page instead of using initialPage.

Changes:

  • Gives each pager’s existing lifecycle owner a private saved-state registry.
  • Initializes the registry without restored state and assigns it to the Compose view.
File Description
android/​src/​main/​java/​com/​reactnativepagerview/​ComposePagerView.kt Adds and connects a private saved-state registry while preserving lifecycle behavior.

Review effort: Balanced

@ArekChr ArekChr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@abulenok Thanks for the contribution! The fix makes sense to me.

Before merging, could you add a regression test that saves the old pager position, remounts it with a different initialPage, and checks that the new value is used? Ideally, it should fail before this fix and pass after it.

Could you also add a short changelog note? Apps relying on native state restoration may now return to initialPage after Activity or process recreation.

@ArekChr ArekChr self-assigned this Oct 22, 2026
@youchi1

youchi1 commented Oct 22, 2026

Copy link
Copy Markdown

This also fixes a crash we see on Android, so here is a second reason to merge it.

Symptom: TransactionTooLargeException when the Activity saves its state, for example when the app opens a system picker (photo or document picker) after a long session.

Cause: the largest entry in the saved Bundle (about 500 KB) was the SaveableStateRegistry of the pager's ComposeView, stored in the host Fragment's saved-state registry. isSaveEnabled = false does not stop it, because Compose registers with the view tree SavedStateRegistryOwner, not through onSaveInstanceState. The AndroidView pages save their embedded view hierarchies into it as well. Our tab pages stay mounted, so that state gets bigger the longer the session lasts, until the Bundle is over the Binder limit.

We fixed it locally by wrapping PagerContent() in CompositionLocalProvider(LocalSaveableStateRegistry provides null), and the crash stopped. Your change gives the composition its own SavedStateRegistryOwner that is never saved, so it should remove the same entry. We have not run this PR yet.

@ArekChr

ArekChr commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

@abulenok Could you please address the regression test and changelog note from my review? The additional crash report makes this fix even more useful, but I’d still like to see the test before approving

abulenok and others added 2 commits October 23, 2026 13:56
…state

The pager's internal ComposeView has no view id, so Compose saved its
rememberSaveable state (the pager's current page, the page hosts' view state)
in the host Fragment's SavedStateRegistry under the shared key
"SaveableStateRegistry:-1". When react-native-screens destroys the Fragment
view of a covered screen, that state is saved, and a pager mounted afterwards
restores it: it starts on the old page and ignores initialPage.

Give the ComposeView a SavedStateRegistryOwner from the same private owner that
already provides its Lifecycle. Its registry starts empty and is never saved,
so nothing is persisted or restored. React owns the pager state, so nothing is
lost. A unique view id would also avoid the collision, but every remount would
leave an orphaned entry in the Fragment's saved state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abulenok
abulenok force-pushed the fix/android-compose-saved-state branch from 0257c61 to 2480976 Compare October 23, 2026 11:59
@abulenok

Copy link
Copy Markdown
Author

@ArekChr I added the regression test and a changlelog note in the description. Let me know, if there is a better place for the note.

The test fails on main and works correctly with the change from this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants