Skip to content

Nested NavigableCircuitContent loses retained state when parent record leaves composition #2639

@jszmltr

Description

@jszmltr

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:

  1. 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 ✓
  2. The rememberRetained holder for RetainedStateHolderImpl gets onForgotten() → release()
  3. retainedStateEntry.unregister() removes the provider from the parent's valueProviders map and returns true
  4. Since hasRemoved == true and the value is a RetainedStateRegistry, forgetUnclaimedValues() is called
  5. 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 via saveAll()/saveValue(), so hasRemoved = false and cleanup is skipped
  • Option B: saveAll() deep-copies the saved values so in-place mutation via forgetUnclaimedValues() doesn't affect the saved copy
  • Option C: In release(), check if the parent registry still holds this value before calling forgetUnclaimedValues()

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions