-
Notifications
You must be signed in to change notification settings - Fork 113
Nested NavigableCircuitContent loses retained state when parent record leaves composition #2639
Description
Problem
When using nested NavigableCircuitContent (an outer one managing app-level navigation and an inner one managing tab-based navigation within a screen), the inner NavigableCircuitContent per-record retained state is wiped when the outer record leaves composition — even though the outer RetainedStateRegistry chain correctly saves and restores.
Steps to reproduce:
Outer NavigableCircuitContent
└─ ScreenA (contains inner NavigableCircuitContent)
└─ Tab1 (uses rememberRetained)
└─ Tab2
-
On Tab1, state is created via rememberRetained
-
Go to Tab2 and back, everything works state is retained ✅
-
Go to ScreenB onto the outer backstack (ScreenA leaves composition)
-
Pop ScreenB (ScreenA re-enters composition)
-
rememberRetained values inside nested/inner circuit Tab1 are lost (restored=false)
-
Tab switching within the inner NavigableCircuitContent (without leaving outer composition) preserves retained state correctly. ✅
-
Navigating to Screen B from Tab2 correctly comes back to Tab2 ✅
-
It's just that retained within the inner screen / presenter itself is not retained.
Session with Claude
This is the information after I spent good amount of time on pairing on this with Claude so I can't guarantee this is correct, but it seemed reasonable to me.
The issue is in RetainableSaveableHolder.release() in RememberRetained.kt:
private fun release() {
saveableStateEntry?.unregister()
val hasRemoved = retainedStateEntry?.unregister() ?: true
if (hasRemoved) {
when (val v = value) {
is RememberObserver -> v.onForgotten()
is RetainedStateRegistry -> v.forgetUnclaimedValues()
}
}
}
When ScreenA leaves composition, the following sequence occurs:
- The outer NavigableCircuitContent's outerRegistry.saveAll() is called, which saves the inner RetainedStateHolderImpl (and its internal registry containing per-record state) into the retained map ✓
- The rememberRetained holder for RetainedStateHolderImpl gets onForgotten() → release()
- retainedStateEntry.unregister() removes the provider from the parent's valueProviders map and returns true
- Since hasRemoved == true and the value is a RetainedStateRegistry, forgetUnclaimedValues() is called
- forgetUnclaimedValues() clears the retained map of the RetainedStateHolderImpl's internal registry
Because Java objects are passed by reference, the value saved in step 1 and the object cleared in step 5 are the same instance. The parent's retained map now holds a reference to an emptied registry.
Solution
The inner NavigableCircuitContent's per-record retained state should survive when the outer record leaves and re-enters composition, since the outer RetainedStateRegistry correctly retains the intermediate registry objects.
Suggested Fix:
release() should not call forgetUnclaimedValues() when the value has already been saved by the parent. Possible approaches:
- Option A:
unregister()returns false when the value has already been moved to the retained map viasaveAll()/saveValue(), sohasRemoved = falseand cleanup is skipped - Option B:
saveAll()deep-copies the saved values so in-place mutation viaforgetUnclaimedValues()doesn't affect the saved copy - Option C: In
release(), check if the parent registry still holds this value before callingforgetUnclaimedValues()