From 019c8ccc358847da220ffcc600f382f708bcea2f Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Fri, 8 Nov 2024 11:44:41 +0900 Subject: [PATCH 01/26] Added navigableCircuitContentRetainTest --- .../NavigableCircuitConditionalRetainTest.kt | 351 ++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitConditionalRetainTest.kt diff --git a/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitConditionalRetainTest.kt b/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitConditionalRetainTest.kt new file mode 100644 index 000000000..f777a3fc9 --- /dev/null +++ b/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitConditionalRetainTest.kt @@ -0,0 +1,351 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.foundation + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.slack.circuit.backstack.rememberSaveableBackStack +import com.slack.circuit.retained.rememberRetained +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.screen.Screen +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +private const val TAG_SHOW_CHILD_BUTTON = "TAG_SHOW_CHILD_BUTTON" +private const val TAG_HIDE_CHILD_BUTTON = "TAG_HIDE_CHILD_BUTTON" +private const val TAG_INC_BUTTON = "TAG_INC_BUTTON" +private const val TAG_GOTO_BUTTON = "TAG_GOTO_BUTTON" +private const val TAG_POP_BUTTON = "TAG_POP_BUTTON" +private const val TAG_CONDITIONAL_RETAINED = "TAG_CONDITIONAL_RETAINED" +private const val TAG_UI_RETAINED = "TAG_UI_RETAINED" +private const val TAG_PRESENTER_RETAINED = "TAG_PRESENTER_RETAINED" +private const val TAG_STATE = "TAG_STATE" + +@RunWith(ComposeUiTestRunner::class) +class NavigableCircuitConditionalRetainTest { + + @get:Rule val composeTestRule = createComposeRule() + + private val dataSource = DataSource() + + private val circuit = + Circuit.Builder() + .addPresenter { _, _, _ -> ScreenAPresenter() } + .addUi { _, modifier -> ScreenAUi(modifier) } + .addPresenter { _, _, _ -> ScreenBPresenter(dataSource) } + .addUi { state, modifier -> ScreenBUi(state, modifier) } + .addPresenter { _, navigator, _ -> ScreenCPresenter(navigator) } + .addUi { state, modifier -> ScreenCUi(state, modifier) } + .addPresenter { _, navigator, _ -> ScreenDPresenter(navigator) } + .addUi { state, modifier -> ScreenDUi(state, modifier) } + .build() + + @Test + fun nestedCircuitContentWithPresentWithLifecycle() { + nestedCircuitContent(presentWithLifecycle = true) + } + + @Test + fun nestedCircuitContentWithoutPresentWithLifecycle() { + nestedCircuitContent(presentWithLifecycle = false) + } + + @Test + fun conditionalRetainedWithPresentWithLifecycle() { + conditionalRetained(presentWithLifecycle = true) + } + + @Test + fun conditionalRetainedWithoutPresentWithLifecycle() { + conditionalRetained(presentWithLifecycle = false) + } + + /** Nested circuit content should not be retained if it is removed */ + private fun nestedCircuitContent(presentWithLifecycle: Boolean) { + composeTestRule.run { + val modifiedCircuit = circuit.newBuilder().presentWithLifecycle(presentWithLifecycle).build() + setUpTestContent(modifiedCircuit, ScreenA) + + onNodeWithTag(TAG_STATE).assertDoesNotExist() + onNodeWithTag(TAG_PRESENTER_RETAINED).assertDoesNotExist() + onNodeWithTag(TAG_UI_RETAINED).assertDoesNotExist() + + // Show child + onNodeWithTag(TAG_SHOW_CHILD_BUTTON).performClick() + + dataSource.value = 1 + + onNodeWithTag(TAG_STATE).assertTextEquals("1") + onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("1") + onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("1") + + // Hide child + onNodeWithTag(TAG_HIDE_CHILD_BUTTON).performClick() + + onNodeWithTag(TAG_STATE).assertDoesNotExist() + onNodeWithTag(TAG_PRESENTER_RETAINED).assertDoesNotExist() + onNodeWithTag(TAG_UI_RETAINED).assertDoesNotExist() + + dataSource.value = 2 + + // Show child + onNodeWithTag(TAG_SHOW_CHILD_BUTTON).performClick() + + // Retained reset + onNodeWithTag(TAG_STATE).assertTextEquals("2") + onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("2") + onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("2") + } + } + + /** + * Conditional rememberRetained should not be retained if it is removed no matter current + * RetainedStateRegistry is saved or not. + */ + private fun conditionalRetained(presentWithLifecycle: Boolean) { + composeTestRule.run { + val modifiedCircuit = circuit.newBuilder().presentWithLifecycle(presentWithLifecycle).build() + setUpTestContent(modifiedCircuit, ScreenC) + + onNodeWithTag(TAG_STATE).assertDoesNotExist() + onNodeWithTag(TAG_PRESENTER_RETAINED).assertDoesNotExist() + onNodeWithTag(TAG_UI_RETAINED).assertDoesNotExist() + + // Show child + onNodeWithTag(TAG_SHOW_CHILD_BUTTON).performClick() + + onNodeWithTag(TAG_CONDITIONAL_RETAINED).assertTextEquals("0") + onNodeWithTag(TAG_INC_BUTTON).performClick() + onNodeWithTag(TAG_CONDITIONAL_RETAINED).assertTextEquals("1") + + // Hide child + onNodeWithTag(TAG_HIDE_CHILD_BUTTON).performClick() + + // Navigate other screen and pop for saving ScreenC's state + onNodeWithTag(TAG_GOTO_BUTTON).performClick() + onNodeWithTag(TAG_POP_BUTTON).performClick() + + // Show child + onNodeWithTag(TAG_SHOW_CHILD_BUTTON).performClick() + + // Child's retained state should not be retained + onNodeWithTag(TAG_CONDITIONAL_RETAINED).assertTextEquals("0") + } + } + + /** + * Conditional rememberRetained should be retained if it is added and current + * RetainedStateRegistry is saved + */ + private fun conditionalRetained2(presentWithLifecycle: Boolean) { + composeTestRule.run { + val modifiedCircuit = circuit.newBuilder().presentWithLifecycle(presentWithLifecycle).build() + setUpTestContent(modifiedCircuit, ScreenC) + + onNodeWithTag(TAG_STATE).assertDoesNotExist() + onNodeWithTag(TAG_PRESENTER_RETAINED).assertDoesNotExist() + onNodeWithTag(TAG_UI_RETAINED).assertDoesNotExist() + + // Show child + onNodeWithTag(TAG_SHOW_CHILD_BUTTON).performClick() + + onNodeWithTag(TAG_CONDITIONAL_RETAINED).assertTextEquals("0") + onNodeWithTag(TAG_INC_BUTTON).performClick() + onNodeWithTag(TAG_CONDITIONAL_RETAINED).assertTextEquals("1") + + // Navigate other screen and pop for saving ScreenC's state + onNodeWithTag(TAG_GOTO_BUTTON).performClick() + onNodeWithTag(TAG_POP_BUTTON).performClick() + + // Child's retained state should be retained + onNodeWithTag(TAG_CONDITIONAL_RETAINED).assertTextEquals("1") + + // Hide child + onNodeWithTag(TAG_HIDE_CHILD_BUTTON).performClick() + // Show child + onNodeWithTag(TAG_SHOW_CHILD_BUTTON).performClick() + + // Child's retained state should not be retained + onNodeWithTag(TAG_CONDITIONAL_RETAINED).assertTextEquals("0") + } + } + + private fun ComposeContentTestRule.setUpTestContent(circuit: Circuit, screen: Screen): Navigator { + lateinit var navigator: Navigator + setContent { + CircuitCompositionLocals(circuit) { + val backStack = rememberSaveableBackStack(screen) + navigator = rememberCircuitNavigator(backStack = backStack, onRootPop = {}) + NavigableCircuitContent(navigator = navigator, backStack = backStack) + } + } + return navigator + } + + private data object ScreenA : Screen { + data object State : CircuitUiState + } + + private class ScreenAPresenter : Presenter { + @Composable + override fun present(): ScreenA.State { + return ScreenA.State + } + } + + @Composable + private fun ScreenAUi(modifier: Modifier = Modifier) { + Column(modifier) { + val isChildVisible = remember { mutableStateOf(false) } + Button( + modifier = Modifier.testTag(TAG_SHOW_CHILD_BUTTON), + onClick = { isChildVisible.value = true }, + ) { + Text("show") + } + Button( + modifier = Modifier.testTag(TAG_HIDE_CHILD_BUTTON), + onClick = { isChildVisible.value = false }, + ) { + Text("hide") + } + if (isChildVisible.value) { + CircuitContent(screen = ScreenB) + } + } + } + + private data object ScreenB : Screen { + + data class State(val count: Int, val retainedCount: Int) : CircuitUiState + } + + private class ScreenBPresenter(private val source: DataSource) : Presenter { + + @Composable + override fun present(): ScreenB.State { + val count = source.fetch() + val retained = rememberRetained { count } + return ScreenB.State(count, retained) + } + } + + @Composable + private fun ScreenBUi(state: ScreenB.State, modifier: Modifier = Modifier) { + Column(modifier) { + val retained = rememberRetained { state.count } + Text(text = retained.toString(), modifier = Modifier.testTag(TAG_UI_RETAINED)) + Text(text = state.count.toString(), modifier = Modifier.testTag(TAG_STATE)) + Text( + text = state.retainedCount.toString(), + modifier = Modifier.testTag(TAG_PRESENTER_RETAINED), + ) + } + } + + private data object ScreenC : Screen { + + data class State(val eventSink: (Event) -> Unit) : CircuitUiState + + sealed interface Event : CircuitUiEvent { + data class GoTo(val screen: Screen) : Event + } + } + + private class ScreenCPresenter(private val navigator: Navigator) : Presenter { + @Composable + override fun present(): ScreenC.State { + return ScreenC.State { event -> + when (event) { + is ScreenC.Event.GoTo -> navigator.goTo(event.screen) + } + } + } + } + + @Composable + private fun ScreenCUi(state: ScreenC.State, modifier: Modifier = Modifier) { + Column(modifier) { + Button( + modifier = Modifier.testTag(TAG_GOTO_BUTTON), + onClick = { state.eventSink(ScreenC.Event.GoTo(ScreenD)) }, + ) { + Text("goto") + } + val isVisible = rememberRetained { mutableStateOf(false) } + Button( + modifier = Modifier.testTag(TAG_SHOW_CHILD_BUTTON), + onClick = { isVisible.value = true }, + ) { + Text("show") + } + Button( + modifier = Modifier.testTag(TAG_HIDE_CHILD_BUTTON), + onClick = { isVisible.value = false }, + ) { + Text("hide") + } + if (isVisible.value) { + val count = rememberRetained { mutableStateOf(0) } + Button(modifier = Modifier.testTag(TAG_INC_BUTTON), onClick = { count.value += 1 }) { + Text("inc") + } + Text(modifier = Modifier.testTag(TAG_CONDITIONAL_RETAINED), text = count.value.toString()) + } + } + } + + private data object ScreenD : Screen { + + data class State(val eventSink: (Event) -> Unit) : CircuitUiState + + sealed interface Event : CircuitUiEvent { + data object Pop : Event + } + } + + private class ScreenDPresenter(private val navigator: Navigator) : Presenter { + + @Composable + override fun present(): ScreenD.State { + return ScreenD.State { event -> + when (event) { + is ScreenD.Event.Pop -> navigator.pop() + } + } + } + } + + @Composable + private fun ScreenDUi(state: ScreenD.State, modifier: Modifier = Modifier) { + Column(modifier) { + Button( + onClick = { state.eventSink(ScreenD.Event.Pop) }, + modifier = Modifier.testTag(TAG_POP_BUTTON), + ) { + Text(text = "pop") + } + } + } + + private class DataSource { + var value: Int = 0 + + fun fetch(): Int = value + } +} From 5b279d45e1a7f3151a0a9cf69d214259161f1c62 Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Wed, 13 Nov 2024 11:11:59 +0900 Subject: [PATCH 02/26] Added condition retain test --- .../circuit/retained/android/RetainedTest.kt | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedTest.kt b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedTest.kt index ea90be9fe..aa8002c7c 100644 --- a/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedTest.kt +++ b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedTest.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.RememberObserver import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -23,6 +24,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -53,6 +55,7 @@ private const val TAG_RETAINED_2 = "retained2" private const val TAG_RETAINED_3 = "retained3" private const val TAG_BUTTON_SHOW = "btn_show" private const val TAG_BUTTON_HIDE = "btn_hide" +private const val TAG_BUTTON_INC = "btn_inc" class RetainedTest { private val composeTestRule = createAndroidComposeRule() @@ -394,6 +397,54 @@ class RetainedTest { assertThat(subject.onForgottenCalled).isEqualTo(1) } + @Test + fun conditionalRetainBeforeSave() { + val registry = RetainedStateRegistry() + val content = @Composable { ConditionalRetainContent(registry) } + setActivityContent(content) + + composeTestRule.onNodeWithTag(TAG_RETAINED_1).assertDoesNotExist() + + composeTestRule.onNodeWithTag(TAG_BUTTON_SHOW).performClick() + composeTestRule.onNodeWithTag(TAG_RETAINED_1).assertIsDisplayed() + composeTestRule.onNodeWithTag(TAG_RETAINED_1).assertTextEquals("0") + + composeTestRule.onNodeWithTag(TAG_BUTTON_INC).performClick() + composeTestRule.onNodeWithTag(TAG_RETAINED_1).assertTextEquals("1") + + composeTestRule.onNodeWithTag(TAG_BUTTON_HIDE).performClick() + composeTestRule.onNodeWithTag(TAG_RETAINED_1).assertDoesNotExist() + + composeTestRule.onNodeWithTag(TAG_BUTTON_SHOW).performClick() + composeTestRule.onNodeWithTag(TAG_RETAINED_1).assertIsDisplayed() + composeTestRule.onNodeWithTag(TAG_RETAINED_1).assertTextEquals("0") + } + + @Test + fun conditionalRetainAfterSave() { + val registry = RetainedStateRegistry() + val content = @Composable { ConditionalRetainContent(registry) } + setActivityContent(content) + + composeTestRule.onNodeWithTag(TAG_RETAINED_1).assertDoesNotExist() + + composeTestRule.onNodeWithTag(TAG_BUTTON_SHOW).performClick() + composeTestRule.onNodeWithTag(TAG_RETAINED_1).assertIsDisplayed() + composeTestRule.onNodeWithTag(TAG_RETAINED_1).assertTextEquals("0") + + composeTestRule.onNodeWithTag(TAG_BUTTON_INC).performClick() + composeTestRule.onNodeWithTag(TAG_RETAINED_1).assertTextEquals("1") + + composeTestRule.onNodeWithTag(TAG_BUTTON_HIDE).performClick() + composeTestRule.onNodeWithTag(TAG_RETAINED_1).assertDoesNotExist() + + registry.saveAll() + + composeTestRule.onNodeWithTag(TAG_BUTTON_SHOW).performClick() + composeTestRule.onNodeWithTag(TAG_RETAINED_1).assertIsDisplayed() + composeTestRule.onNodeWithTag(TAG_RETAINED_1).assertTextEquals("0") + } + private fun nestedRegistriesWithPopAndPush(useKeys: Boolean) { val content = @Composable { NestedRetainWithPushAndPop(useKeys = useKeys) } setActivityContent(content) @@ -729,3 +780,25 @@ private fun InputsContent(input: String) { ) } } + +@Composable +private fun ConditionalRetainContent(registry: RetainedStateRegistry) { + CompositionLocalProvider(LocalRetainedStateRegistry provides registry) { + var showContent by remember { mutableStateOf(false) } + Column { + Button(modifier = Modifier.testTag(TAG_BUTTON_HIDE), onClick = { showContent = false }) { + Text(text = "Hide content") + } + Button(modifier = Modifier.testTag(TAG_BUTTON_SHOW), onClick = { showContent = true }) { + Text(text = "Show content") + } + if (showContent) { + var count by rememberRetained { mutableIntStateOf(0) } + Button(modifier = Modifier.testTag(TAG_BUTTON_INC), onClick = { count += 1 }) { + Text(text = "Increment") + } + Text(modifier = Modifier.testTag(TAG_RETAINED_1), text = count.toString()) + } + } + } +} From 4b0cc82601c33eeba5d55ebca21188f6b9afa242 Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Tue, 12 Nov 2024 11:59:16 +0900 Subject: [PATCH 03/26] Changed rememberRetained to be cleared when node removed --- .../foundation/NavigableCircuitContent.kt | 13 ++++- .../slack/circuit/foundation/PausableState.kt | 4 +- .../circuit/retained/AndroidContinuity.kt | 2 +- .../circuit/retained/RememberRetained.kt | 47 +++++++------------ .../circuit/retained/RetainedStateRegistry.kt | 12 ++++- 5 files changed, 45 insertions(+), 33 deletions(-) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index e81f05d7e..7b8427109 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -19,6 +19,7 @@ import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf @@ -174,13 +175,15 @@ private fun buildCircuitContentProviders( val lifecycle = remember { MutableRecordLifecycle() }.apply { isActive = lastBackStack.topRecord == record } + val parentRetainedStateRegistry = LocalRetainedStateRegistry.current CompositionLocalProvider(LocalCanRetainChecker provides recordInBackStackRetainChecker) { // Now provide a new registry to the content for it to store any retained state in, // along with a retain checker which is always true (as upstream registries will // maintain the lifetime), and the other provided values + val registryKey = record.registryKey val recordRetainedStateRegistry = - rememberRetained(key = record.registryKey) { RetainedStateRegistry() } + rememberRetained(key = registryKey) { RetainedStateRegistry() } CompositionLocalProvider( LocalRetainedStateRegistry provides recordRetainedStateRegistry, @@ -195,6 +198,14 @@ private fun buildCircuitContentProviders( key = record.key, ) } + + DisposableEffect(registryKey, recordRetainedStateRegistry) { + onDispose { + if (recordInBackStackRetainChecker.canRetain(recordRetainedStateRegistry)) { + parentRetainedStateRegistry.saveValue(registryKey) + } + } + } } } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/PausableState.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/PausableState.kt index 410c7ba4a..12e0013b5 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/PausableState.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/PausableState.kt @@ -5,6 +5,7 @@ package com.slack.circuit.foundation import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import com.slack.circuit.foundation.internal.withCompositionLocalProvider @@ -60,9 +61,9 @@ public fun pausableState( val state = remember(key) { MutableRef(null) } val saveableStateHolder = rememberSaveableStateHolderWithReturn() + val retainedStateRegistry = rememberRetained(key = key) { RetainedStateRegistry() } return if (isActive || state.value == null) { - val retainedStateRegistry = rememberRetained(key = key) { RetainedStateRegistry() } withCompositionLocalProvider(LocalRetainedStateRegistry provides retainedStateRegistry) { saveableStateHolder.SaveableStateProvider( key = key ?: "pausable_state", @@ -72,6 +73,7 @@ public fun pausableState( .also { // Store the last emitted state state.value = it + DisposableEffect(retainedStateRegistry) { onDispose { retainedStateRegistry.saveAll() } } } } else { // Else, we just emit the last stored state instance diff --git a/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/AndroidContinuity.kt b/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/AndroidContinuity.kt index 8e99abad0..2afe94777 100644 --- a/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/AndroidContinuity.kt +++ b/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/AndroidContinuity.kt @@ -40,7 +40,7 @@ internal class ContinuityViewModel : ViewModel(), RetainedStateRegistry { } override fun onCleared() { - delegate.retained.clear() + delegate.forgetUnclaimedValues() delegate.valueProviders.clear() } diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RememberRetained.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RememberRetained.kt index 431dbf90f..bddec8f47 100644 --- a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RememberRetained.kt +++ b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RememberRetained.kt @@ -328,33 +328,6 @@ private class RetainableSaveableHolder( return registry == null || registry.canBeSaved(value) } - fun saveIfRetainable() { - val v = value ?: return - val reg = retainedStateRegistry ?: return - - if (!canRetainChecker.canRetain(reg)) { - retainedStateEntry?.unregister() - when (v) { - // If value is a RememberObserver, we notify that it has been forgotten. - is RememberObserver -> v.onForgotten() - // Or if its a registry, we need to tell it to clear, which will forward the 'forgotten' - // call onto its values - is RetainedStateRegistry -> { - // First we saveAll, which flattens down the value providers to our retained list - v.saveAll() - // Now we drop all retained values - v.forgetUnclaimedValues() - } - } - } else if (v is RetainedStateRegistry) { - // If the value is a RetainedStateRegistry, we need to take care to retain it. - // First we tell it to saveAll, to retain it's values. Then we need to tell the host - // registry to retain the child registry. - v.saveAll() - reg.saveValue(key) - } - } - override fun onRemembered() { registerRetained() registerSaveable() @@ -367,13 +340,29 @@ private class RetainableSaveableHolder( } override fun onForgotten() { - saveIfRetainable() + val v = value + val reg = retainedStateRegistry + if (reg != null && !canRetainChecker.canRetain(reg)) { + when (v) { + is RememberObserver -> v.onForgotten() + is RetainedStateRegistry -> v.forgetUnclaimedValues() + } + } saveableStateEntry?.unregister() + retainedStateEntry?.unregister() } override fun onAbandoned() { - saveIfRetainable() + val v = value + val reg = retainedStateRegistry + if (reg != null && !canRetainChecker.canRetain(reg)) { + when (v) { + is RememberObserver -> v.onForgotten() + is RetainedStateRegistry -> v.forgetUnclaimedValues() + } + } saveableStateEntry?.unregister() + retainedStateEntry?.unregister() } fun getValueIfInputsAreEqual(inputs: Array): T? { diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt index 15d1a8569..acc0b12d3 100644 --- a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt +++ b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt @@ -121,6 +121,7 @@ internal class RetainedStateRegistryImpl(retained: MutableMap } if (values.isNotEmpty()) { + values.values.forEach { it.forEach(::save) } // Store the values in our retained map retained.putAll(values) } @@ -131,11 +132,20 @@ internal class RetainedStateRegistryImpl(retained: MutableMap override fun saveValue(key: String) { val providers = valueProviders[key] if (providers != null) { - retained[key] = providers.map { it.invoke() } + retained[key] = providers.map { it.invoke().also(::save) } valueProviders.remove(key) } } + private fun save(value: Any?) { + when (value) { + // If we get a RetainedHolder value, need to unwrap and call again + is RetainedValueHolder<*> -> save(value.value) + // Dispatch the call to nested registries + is RetainedStateRegistry -> value.saveAll() + } + } + override fun forgetUnclaimedValues() { fun clearValue(value: Any?) { when (value) { From d769169f4c1af6ec4c761376b16ca0531a9b17cb Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Tue, 12 Nov 2024 12:11:26 +0900 Subject: [PATCH 04/26] Changed AndroidContinuity to save values on stopped --- .../circuit/retained/AndroidContinuity.kt | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/AndroidContinuity.kt b/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/AndroidContinuity.kt index 2afe94777..aa2af13a4 100644 --- a/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/AndroidContinuity.kt +++ b/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/AndroidContinuity.kt @@ -5,11 +5,10 @@ package com.slack.circuit.retained import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.RememberObserver -import androidx.compose.runtime.remember import androidx.compose.runtime.withFrameNanos import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.compose.viewModel @@ -86,20 +85,10 @@ public fun continuityRetainedStateRegistry( @Suppress("ComposeViewModelInjection") val vm = viewModel(key = key, factory = factory) - remember(vm, canRetainChecker) { - object : RememberObserver { - override fun onAbandoned() = saveIfRetainable() - - override fun onForgotten() = saveIfRetainable() - - override fun onRemembered() { - // Do nothing - } - - fun saveIfRetainable() { - if (canRetainChecker.canRetain(vm)) { - vm.saveAll() - } + LifecycleStartEffect(vm) { + onStopOrDispose { + if (canRetainChecker.canRetain(vm)) { + vm.saveAll() } } } From 465f98f28e8f01193b6e678420bd1246de04faf6 Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Tue, 12 Nov 2024 12:12:56 +0900 Subject: [PATCH 05/26] Fixed recreation test scenario --- .../slack/circuitx/effects/RememberImpressionNavigatorTest.kt | 2 +- .../kotlin/com/slack/circuitx/effects/ImpressionEffectTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/circuitx/effects/src/androidUnitTest/kotlin/com/slack/circuitx/effects/RememberImpressionNavigatorTest.kt b/circuitx/effects/src/androidUnitTest/kotlin/com/slack/circuitx/effects/RememberImpressionNavigatorTest.kt index 6e9d23c8d..5aff78c73 100644 --- a/circuitx/effects/src/androidUnitTest/kotlin/com/slack/circuitx/effects/RememberImpressionNavigatorTest.kt +++ b/circuitx/effects/src/androidUnitTest/kotlin/com/slack/circuitx/effects/RememberImpressionNavigatorTest.kt @@ -177,8 +177,8 @@ class RememberImpressionNavigatorTest { } private fun ComposeContentTestRule.recreate() { - composed.value = false registry.saveAll() + composed.value = false waitForIdle() composed.value = true waitForIdle() diff --git a/circuitx/effects/src/commonTest/kotlin/com/slack/circuitx/effects/ImpressionEffectTest.kt b/circuitx/effects/src/commonTest/kotlin/com/slack/circuitx/effects/ImpressionEffectTest.kt index 59da3be2c..84fbdeab6 100644 --- a/circuitx/effects/src/commonTest/kotlin/com/slack/circuitx/effects/ImpressionEffectTest.kt +++ b/circuitx/effects/src/commonTest/kotlin/com/slack/circuitx/effects/ImpressionEffectTest.kt @@ -171,8 +171,8 @@ internal class ImpressionEffectTestSharedImpl : ImpressionEffectTestShared { /** Simulate a retained leaving and joining of the composition. */ private fun recreate() { - composed.value = false registry.saveAll() + composed.value = false composed.value = true } } From 24d8547d1edbf81b88af030011e04f1d708e7bea Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Tue, 12 Nov 2024 13:21:52 +0900 Subject: [PATCH 06/26] Replaced registry usage with RetainedStateProvider --- .../foundation/NavigableCircuitContent.kt | 35 +++++------------ .../slack/circuit/foundation/PausableState.kt | 16 ++------ .../internal/WithRetainedStateProvider.kt | 36 ++++++++++++++++++ .../circuit/retained/RetainedStateProvider.kt | 38 +++++++++++++++++++ .../circuit/retained/RetainedStateRegistry.kt | 20 +++++----- 5 files changed, 98 insertions(+), 47 deletions(-) create mode 100644 circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/WithRetainedStateProvider.kt create mode 100644 circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateProvider.kt diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index 7b8427109..cbe505b49 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -19,7 +19,6 @@ import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf @@ -40,6 +39,7 @@ import com.slack.circuit.backstack.providedValuesForBackStack import com.slack.circuit.retained.CanRetainChecker import com.slack.circuit.retained.LocalCanRetainChecker import com.slack.circuit.retained.LocalRetainedStateRegistry +import com.slack.circuit.retained.RetainedStateProvider import com.slack.circuit.retained.RetainedStateRegistry import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.InternalCircuitApi @@ -175,35 +175,20 @@ private fun buildCircuitContentProviders( val lifecycle = remember { MutableRecordLifecycle() }.apply { isActive = lastBackStack.topRecord == record } - val parentRetainedStateRegistry = LocalRetainedStateRegistry.current CompositionLocalProvider(LocalCanRetainChecker provides recordInBackStackRetainChecker) { // Now provide a new registry to the content for it to store any retained state in, // along with a retain checker which is always true (as upstream registries will // maintain the lifetime), and the other provided values - val registryKey = record.registryKey - val recordRetainedStateRegistry = - rememberRetained(key = registryKey) { RetainedStateRegistry() } - - CompositionLocalProvider( - LocalRetainedStateRegistry provides recordRetainedStateRegistry, - LocalCanRetainChecker provides CanRetainChecker.Always, - LocalRecordLifecycle provides lifecycle, - ) { - CircuitContent( - screen = record.screen, - navigator = lastNavigator, - circuit = lastCircuit, - unavailableContent = lastUnavailableRoute, - key = record.key, - ) - } - - DisposableEffect(registryKey, recordRetainedStateRegistry) { - onDispose { - if (recordInBackStackRetainChecker.canRetain(recordRetainedStateRegistry)) { - parentRetainedStateRegistry.saveValue(registryKey) - } + RetainedStateProvider(record.registryKey) { + CompositionLocalProvider(LocalRecordLifecycle provides lifecycle) { + CircuitContent( + screen = record.screen, + navigator = lastNavigator, + circuit = lastCircuit, + unavailableContent = lastUnavailableRoute, + key = record.key, + ) } } } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/PausableState.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/PausableState.kt index 12e0013b5..6d1d0e880 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/PausableState.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/PausableState.kt @@ -5,13 +5,9 @@ package com.slack.circuit.foundation import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.remember -import com.slack.circuit.foundation.internal.withCompositionLocalProvider -import com.slack.circuit.retained.LocalRetainedStateRegistry -import com.slack.circuit.retained.RetainedStateRegistry -import com.slack.circuit.retained.rememberRetained +import com.slack.circuit.foundation.internal.withRetainedStateProvider import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.presenter.Presenter @@ -61,19 +57,15 @@ public fun pausableState( val state = remember(key) { MutableRef(null) } val saveableStateHolder = rememberSaveableStateHolderWithReturn() - val retainedStateRegistry = rememberRetained(key = key) { RetainedStateRegistry() } return if (isActive || state.value == null) { - withCompositionLocalProvider(LocalRetainedStateRegistry provides retainedStateRegistry) { - saveableStateHolder.SaveableStateProvider( - key = key ?: "pausable_state", - content = produceState, - ) + val finalKey = key ?: "pausable_state" + withRetainedStateProvider(finalKey) { + saveableStateHolder.SaveableStateProvider(key = finalKey, content = produceState) } .also { // Store the last emitted state state.value = it - DisposableEffect(retainedStateRegistry) { onDispose { retainedStateRegistry.saveAll() } } } } else { // Else, we just emit the last stored state instance diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/WithRetainedStateProvider.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/WithRetainedStateProvider.kt new file mode 100644 index 000000000..3573af256 --- /dev/null +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/WithRetainedStateProvider.kt @@ -0,0 +1,36 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.foundation.internal + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import com.slack.circuit.retained.CanRetainChecker +import com.slack.circuit.retained.LocalCanRetainChecker +import com.slack.circuit.retained.LocalRetainedStateRegistry +import com.slack.circuit.retained.RetainedStateProvider +import com.slack.circuit.retained.RetainedStateRegistry +import com.slack.circuit.retained.rememberRetained + +/** Copy of [RetainedStateProvider] to return content value */ +@Composable +internal fun withRetainedStateProvider(key: String, content: @Composable () -> T): T { + val canRetainChecker = LocalCanRetainChecker.current ?: CanRetainChecker.Always + val parentRegistry = LocalRetainedStateRegistry.current + val registry = rememberRetained(key = key) { RetainedStateRegistry() } + return withCompositionLocalProvider( + LocalRetainedStateRegistry provides registry, + LocalCanRetainChecker provides CanRetainChecker.Always, + ) { + content() + } + .also { + DisposableEffect(key, registry) { + onDispose { + registry.saveAll() + if (canRetainChecker.canRetain(registry)) { + parentRegistry.saveValue(key) + } + } + } + } +} diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateProvider.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateProvider.kt new file mode 100644 index 000000000..bc3119e07 --- /dev/null +++ b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateProvider.kt @@ -0,0 +1,38 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.retained + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Provides a [RetainedStateRegistry] for the child [content] based on the specified [key]. Before + * the provided registry is disposed, it calls [RetainedStateRegistry.saveValue] on the parent + * registry to save the current value, allowing it to be restored on the next visit with the same + * key. + */ +@Composable +public fun RetainedStateProvider(key: String? = null, content: @Composable () -> T) { + @OptIn(ExperimentalUuidApi::class) + val finalKey = key ?: rememberRetained { Uuid.random().toString() } + val canRetainChecker = LocalCanRetainChecker.current ?: CanRetainChecker.Always + val parentRegistry = LocalRetainedStateRegistry.current + val registry = rememberRetained(key = finalKey) { RetainedStateRegistry() } + CompositionLocalProvider( + LocalRetainedStateRegistry provides registry, + LocalCanRetainChecker provides CanRetainChecker.Always, + ) { + content() + } + DisposableEffect(finalKey, registry) { + onDispose { + registry.saveAll() + if (canRetainChecker.canRetain(registry)) { + parentRegistry.saveValue(finalKey) + } + } + } +} diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt index acc0b12d3..d3cbf2986 100644 --- a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt +++ b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt @@ -110,6 +110,15 @@ internal class RetainedStateRegistryImpl(retained: MutableMap } override fun saveAll() { + fun save(value: Any?) { + when (value) { + // If we get a RetainedHolder value, need to unwrap and call again + is RetainedValueHolder<*> -> save(value.value) + // Dispatch the call to nested registries + is RetainedStateRegistry -> value.saveAll() + } + } + val values = valueProviders.mapValues { (_, list) -> // If we have multiple providers we should store null values as well to preserve @@ -132,20 +141,11 @@ internal class RetainedStateRegistryImpl(retained: MutableMap override fun saveValue(key: String) { val providers = valueProviders[key] if (providers != null) { - retained[key] = providers.map { it.invoke().also(::save) } + retained[key] = providers.map { it.invoke() } valueProviders.remove(key) } } - private fun save(value: Any?) { - when (value) { - // If we get a RetainedHolder value, need to unwrap and call again - is RetainedValueHolder<*> -> save(value.value) - // Dispatch the call to nested registries - is RetainedStateRegistry -> value.saveAll() - } - } - override fun forgetUnclaimedValues() { fun clearValue(value: Any?) { when (value) { From affec25ac7a80314aa459ff838f5d4582c923dc1 Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Tue, 12 Nov 2024 13:40:05 +0900 Subject: [PATCH 07/26] Fixed retainedTest to use RetainedStateProvider --- .../circuit/retained/android/RetainedTest.kt | 30 +++++-------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedTest.kt b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedTest.kt index aa8002c7c..b8b50bb19 100644 --- a/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedTest.kt +++ b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedTest.kt @@ -40,6 +40,7 @@ import com.slack.circuit.retained.Continuity import com.slack.circuit.retained.ContinuityViewModel import com.slack.circuit.retained.LocalCanRetainChecker import com.slack.circuit.retained.LocalRetainedStateRegistry +import com.slack.circuit.retained.RetainedStateProvider import com.slack.circuit.retained.RetainedStateRegistry import com.slack.circuit.retained.continuityRetainedStateRegistry import com.slack.circuit.retained.rememberRetained @@ -367,10 +368,8 @@ class RetainedTest { val content = @Composable { - val nestedRegistryLevel1 = rememberRetained { RetainedStateRegistry() } - CompositionLocalProvider(LocalRetainedStateRegistry provides nestedRegistryLevel1) { - val nestedRegistryLevel2 = rememberRetained { RetainedStateRegistry() } - CompositionLocalProvider(LocalRetainedStateRegistry provides nestedRegistryLevel2) { + RetainedStateProvider { + RetainedStateProvider { @Suppress("UNUSED_VARIABLE") val retainedSubject = rememberRetained { subject } } } @@ -654,10 +653,7 @@ private fun NestedRetainLevel1(useKeys: Boolean) { label = {}, ) - val nestedRegistry = rememberRetained { RetainedStateRegistry() } - CompositionLocalProvider(LocalRetainedStateRegistry provides nestedRegistry) { - NestedRetainLevel2(useKeys) - } + RetainedStateProvider("retained2-registry") { NestedRetainLevel2(useKeys) } } @Composable @@ -705,13 +701,7 @@ private fun NestedRetainWithPushAndPop(useKeys: Boolean) { // Keep the retained state registry around even if showNestedContent becomes false CompositionLocalProvider(LocalCanRetainChecker provides CanRetainChecker.Always) { if (showNestedContent.value) { - val nestedRegistry = rememberRetained { RetainedStateRegistry() } - CompositionLocalProvider( - LocalRetainedStateRegistry provides nestedRegistry, - LocalCanRetainChecker provides CanRetainChecker.Always, - ) { - NestedRetainLevel1(useKeys) - } + RetainedStateProvider("retained1_registry") { NestedRetainLevel1(useKeys) } } } } @@ -747,15 +737,9 @@ private fun NestedRetainWithPushAndPopAndCannotRetain(useKeys: Boolean) { } // Keep the retained state registry around even if showNestedContent becomes false - CompositionLocalProvider(LocalCanRetainChecker provides CanRetainChecker.Always) { + CompositionLocalProvider(LocalCanRetainChecker provides { false }) { if (showNestedContent.value) { - val nestedRegistry = rememberRetained { RetainedStateRegistry() } - CompositionLocalProvider( - LocalRetainedStateRegistry provides nestedRegistry, - LocalCanRetainChecker provides { false }, - ) { - NestedRetainLevel1(useKeys) - } + RetainedStateProvider("retained1_registry") { NestedRetainLevel1(useKeys) } } } } From 67d156fa08f05c19f59877cc60d264bcb4960fb4 Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Thu, 21 Nov 2024 22:01:20 +0900 Subject: [PATCH 08/26] Generate apiDumps --- circuit-retained/api/android/circuit-retained.api | 4 ++++ circuit-retained/api/circuit-retained.klib.api | 1 + circuit-retained/api/jvm/circuit-retained.api | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/circuit-retained/api/android/circuit-retained.api b/circuit-retained/api/android/circuit-retained.api index 7870b92d7..f014edf90 100644 --- a/circuit-retained/api/android/circuit-retained.api +++ b/circuit-retained/api/android/circuit-retained.api @@ -60,6 +60,10 @@ public final class com/slack/circuit/retained/RememberRetainedKt { public static final fun rememberRetainedSaveable ([Ljava/lang/Object;Landroidx/compose/runtime/saveable/Saver;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)Ljava/lang/Object; } +public final class com/slack/circuit/retained/RetainedStateProviderKt { + public static final fun RetainedStateProvider (Ljava/lang/String;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +} + public abstract interface class com/slack/circuit/retained/RetainedStateRegistry { public abstract fun consumeValue (Ljava/lang/String;)Ljava/lang/Object; public abstract fun forgetUnclaimedValues ()V diff --git a/circuit-retained/api/circuit-retained.klib.api b/circuit-retained/api/circuit-retained.klib.api index d9bb2e987..c03f21fc2 100644 --- a/circuit-retained/api/circuit-retained.klib.api +++ b/circuit-retained/api/circuit-retained.klib.api @@ -64,6 +64,7 @@ final fun <#A: kotlin/Any> com.slack.circuit.retained/rememberRetained(kotlin/Ar final fun <#A: kotlin/Any> com.slack.circuit.retained/rememberRetained(kotlin/Array..., kotlin/String?, kotlin/Function0<#A>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): #A // com.slack.circuit.retained/rememberRetained|rememberRetained(kotlin.Array...;kotlin.String?;kotlin.Function0<0:0>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§}[0] final fun <#A: kotlin/Any> com.slack.circuit.retained/rememberRetainedSaveable(kotlin/Array..., androidx.compose.runtime.saveable/Saver<#A, out kotlin/Any>?, kotlin/String?, kotlin/Function0<#A>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): #A // com.slack.circuit.retained/rememberRetainedSaveable|rememberRetainedSaveable(kotlin.Array...;androidx.compose.runtime.saveable.Saver<0:0,out|kotlin.Any>?;kotlin.String?;kotlin.Function0<0:0>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§}[0] final fun <#A: kotlin/Any?> (kotlinx.coroutines.flow/StateFlow<#A>).com.slack.circuit.retained/collectAsRetainedState(kotlin.coroutines/CoroutineContext?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): androidx.compose.runtime/State<#A> // com.slack.circuit.retained/collectAsRetainedState|collectAsRetainedState@kotlinx.coroutines.flow.StateFlow<0:0>(kotlin.coroutines.CoroutineContext?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§}[0] +final fun <#A: kotlin/Any?> com.slack.circuit.retained/RetainedStateProvider(kotlin/String?, kotlin/Function2, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.slack.circuit.retained/RetainedStateProvider|RetainedStateProvider(kotlin.String?;kotlin.Function2;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§}[0] final fun <#A: kotlin/Any?> com.slack.circuit.retained/produceRetainedState(#A, kotlin.coroutines/SuspendFunction1, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.runtime/State<#A> // com.slack.circuit.retained/produceRetainedState|produceRetainedState(0:0;kotlin.coroutines.SuspendFunction1,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int){0§}[0] final fun <#A: kotlin/Any?> com.slack.circuit.retained/produceRetainedState(#A, kotlin/Any?, kotlin.coroutines/SuspendFunction1, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.runtime/State<#A> // com.slack.circuit.retained/produceRetainedState|produceRetainedState(0:0;kotlin.Any?;kotlin.coroutines.SuspendFunction1,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int){0§}[0] final fun <#A: kotlin/Any?> com.slack.circuit.retained/produceRetainedState(#A, kotlin/Any?, kotlin/Any?, kotlin.coroutines/SuspendFunction1, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.runtime/State<#A> // com.slack.circuit.retained/produceRetainedState|produceRetainedState(0:0;kotlin.Any?;kotlin.Any?;kotlin.coroutines.SuspendFunction1,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int){0§}[0] diff --git a/circuit-retained/api/jvm/circuit-retained.api b/circuit-retained/api/jvm/circuit-retained.api index 384214896..c31af4b36 100644 --- a/circuit-retained/api/jvm/circuit-retained.api +++ b/circuit-retained/api/jvm/circuit-retained.api @@ -59,6 +59,10 @@ public final class com/slack/circuit/retained/RememberRetainedKt { public static final fun rememberRetainedSaveable ([Ljava/lang/Object;Landroidx/compose/runtime/saveable/Saver;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)Ljava/lang/Object; } +public final class com/slack/circuit/retained/RetainedStateProviderKt { + public static final fun RetainedStateProvider (Ljava/lang/String;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +} + public abstract interface class com/slack/circuit/retained/RetainedStateRegistry { public abstract fun consumeValue (Ljava/lang/String;)Ljava/lang/Object; public abstract fun forgetUnclaimedValues ()V From ff654b49b5db6035b003e8cc0b569001de683d0c Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Fri, 22 Nov 2024 21:10:41 +0900 Subject: [PATCH 09/26] Replaced RetainedStateProvider with RetainedStateHolder --- .../foundation/NavigableCircuitContent.kt | 56 +++++++------ .../slack/circuit/foundation/PausableState.kt | 7 +- .../circuit/foundation/RetainedStateHolder.kt | 77 ++++++++++++++++++ .../internal/WithRetainedStateProvider.kt | 36 --------- .../api/android/circuit-retained.api | 8 +- .../api/circuit-retained.klib.api | 6 +- circuit-retained/api/jvm/circuit-retained.api | 8 +- .../circuit/retained/android/RetainedTest.kt | 23 +++--- .../circuit/retained/RetainedStateHolder.kt | 79 +++++++++++++++++++ .../circuit/retained/RetainedStateProvider.kt | 38 --------- 10 files changed, 217 insertions(+), 121 deletions(-) create mode 100644 circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt delete mode 100644 circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/WithRetainedStateProvider.kt create mode 100644 circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt delete mode 100644 circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateProvider.kt diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index cbe505b49..ab2cc9e01 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -39,9 +39,9 @@ import com.slack.circuit.backstack.providedValuesForBackStack import com.slack.circuit.retained.CanRetainChecker import com.slack.circuit.retained.LocalCanRetainChecker import com.slack.circuit.retained.LocalRetainedStateRegistry -import com.slack.circuit.retained.RetainedStateProvider import com.slack.circuit.retained.RetainedStateRegistry import com.slack.circuit.retained.rememberRetained +import com.slack.circuit.retained.rememberRetainedStateHolder import com.slack.circuit.runtime.InternalCircuitApi import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.Screen @@ -104,21 +104,31 @@ public fun NavigableCircuitContent( */ val outerKey = "_navigable_registry_${currentCompositeKeyHash.toString(MaxSupportedRadix)}" val outerRegistry = rememberRetained(key = outerKey) { RetainedStateRegistry() } - + val lastBackStack by rememberUpdatedState(backStack) val saveableStateHolder = rememberSaveableStateHolder() CompositionLocalProvider(LocalRetainedStateRegistry provides outerRegistry) { + val retainedStateHolder = rememberRetainedStateHolder() + decoration.DecoratedContent(activeContentProviders, backStack.size, modifier) { provider -> val record = provider.record + val recordInBackStackRetainChecker = + remember(lastBackStack, record) { + CanRetainChecker { lastBackStack.containsRecord(record, includeSaved = true) } + } saveableStateHolder.SaveableStateProvider(record.key) { - // Remember the `providedValues` lookup because this composition can live longer than - // the record is present in the backstack, if the decoration is animated for example. - val values = remember(record) { providedValues[record] }?.provideValues() - val providedLocals = remember(values) { values?.toTypedArray() ?: emptyArray() } - - CompositionLocalProvider(LocalBackStack provides backStack, *providedLocals) { - provider.content(record) + CompositionLocalProvider(LocalCanRetainChecker provides recordInBackStackRetainChecker) { + retainedStateHolder.RetainedStateProvider(record.key) { + // Remember the `providedValues` lookup because this composition can live longer than + // the record is present in the backstack, if the decoration is animated for example. + val values = remember(record) { providedValues[record] }?.provideValues() + val providedLocals = remember(values) { values?.toTypedArray() ?: emptyArray() } + + CompositionLocalProvider(LocalBackStack provides backStack, *providedLocals) { + provider.content(record) + } + } } } } @@ -168,29 +178,17 @@ private fun buildCircuitContentProviders( fun createRecordContent() = movableContentOf { record -> - val recordInBackStackRetainChecker = - remember(lastBackStack, record) { - CanRetainChecker { lastBackStack.containsRecord(record, includeSaved = true) } - } - val lifecycle = remember { MutableRecordLifecycle() }.apply { isActive = lastBackStack.topRecord == record } - CompositionLocalProvider(LocalCanRetainChecker provides recordInBackStackRetainChecker) { - // Now provide a new registry to the content for it to store any retained state in, - // along with a retain checker which is always true (as upstream registries will - // maintain the lifetime), and the other provided values - RetainedStateProvider(record.registryKey) { - CompositionLocalProvider(LocalRecordLifecycle provides lifecycle) { - CircuitContent( - screen = record.screen, - navigator = lastNavigator, - circuit = lastCircuit, - unavailableContent = lastUnavailableRoute, - key = record.key, - ) - } - } + CompositionLocalProvider(LocalRecordLifecycle provides lifecycle) { + CircuitContent( + screen = record.screen, + navigator = lastNavigator, + circuit = lastCircuit, + unavailableContent = lastUnavailableRoute, + key = record.key, + ) } } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/PausableState.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/PausableState.kt index 6d1d0e880..788f9b09f 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/PausableState.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/PausableState.kt @@ -7,7 +7,6 @@ package com.slack.circuit.foundation import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember -import com.slack.circuit.foundation.internal.withRetainedStateProvider import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.presenter.Presenter @@ -57,11 +56,13 @@ public fun pausableState( val state = remember(key) { MutableRef(null) } val saveableStateHolder = rememberSaveableStateHolderWithReturn() + val retainedStateHolder = rememberRetainedStateHolderWithReturn() return if (isActive || state.value == null) { val finalKey = key ?: "pausable_state" - withRetainedStateProvider(finalKey) { - saveableStateHolder.SaveableStateProvider(key = finalKey, content = produceState) + saveableStateHolder + .SaveableStateProvider(finalKey) { + retainedStateHolder.RetainedStateProvider(key = finalKey, content = produceState) } .also { // Store the last emitted state diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt new file mode 100644 index 000000000..542a41fde --- /dev/null +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt @@ -0,0 +1,77 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.foundation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import com.slack.circuit.foundation.internal.withCompositionLocalProvider +import com.slack.circuit.retained.CanRetainChecker +import com.slack.circuit.retained.LocalCanRetainChecker +import com.slack.circuit.retained.LocalRetainedStateRegistry +import com.slack.circuit.retained.RetainedStateRegistry +import com.slack.circuit.retained.RetainedValueProvider +import com.slack.circuit.retained.rememberRetained + +/** Copy of [RetainedStateHolder] to return content value */ +internal interface RetainedStateHolder { + + @Composable fun RetainedStateProvider(key: String, content: @Composable () -> T): T +} + +/** Creates and remembers the instance of [RetainedStateHolder]. */ +@Composable +internal fun rememberRetainedStateHolderWithReturn(): RetainedStateHolder { + return rememberRetained { RetainedStateHolderImpl() } +} + +private class RetainedStateHolderImpl : RetainedStateHolder, RetainedStateRegistry { + + private val registry = RetainedStateRegistry() + + @Composable + override fun RetainedStateProvider(key: String, content: @Composable (() -> T)): T { + return withCompositionLocalProvider(LocalRetainedStateRegistry provides this) { + val canRetainChecker = LocalCanRetainChecker.current ?: CanRetainChecker.Always + val childRegistry = rememberRetained(key = key) { RetainedStateRegistry() } + withCompositionLocalProvider( + LocalRetainedStateRegistry provides childRegistry, + LocalCanRetainChecker provides CanRetainChecker.Always, + ) { + content() + } + .also { + DisposableEffect(key, childRegistry) { + onDispose { + childRegistry.saveAll() + if (canRetainChecker.canRetain(this@RetainedStateHolderImpl)) { + saveValue(key) + } + } + } + } + } + } + + override fun consumeValue(key: String): Any? { + return registry.consumeValue(key) + } + + override fun registerValue( + key: String, + valueProvider: RetainedValueProvider, + ): RetainedStateRegistry.Entry { + return registry.registerValue(key, valueProvider) + } + + override fun saveAll() { + registry.saveAll() + } + + override fun saveValue(key: String) { + registry.saveValue(key) + } + + override fun forgetUnclaimedValues() { + registry.forgetUnclaimedValues() + } +} diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/WithRetainedStateProvider.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/WithRetainedStateProvider.kt deleted file mode 100644 index 3573af256..000000000 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/WithRetainedStateProvider.kt +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (C) 2024 Slack Technologies, LLC -// SPDX-License-Identifier: Apache-2.0 -package com.slack.circuit.foundation.internal - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import com.slack.circuit.retained.CanRetainChecker -import com.slack.circuit.retained.LocalCanRetainChecker -import com.slack.circuit.retained.LocalRetainedStateRegistry -import com.slack.circuit.retained.RetainedStateProvider -import com.slack.circuit.retained.RetainedStateRegistry -import com.slack.circuit.retained.rememberRetained - -/** Copy of [RetainedStateProvider] to return content value */ -@Composable -internal fun withRetainedStateProvider(key: String, content: @Composable () -> T): T { - val canRetainChecker = LocalCanRetainChecker.current ?: CanRetainChecker.Always - val parentRegistry = LocalRetainedStateRegistry.current - val registry = rememberRetained(key = key) { RetainedStateRegistry() } - return withCompositionLocalProvider( - LocalRetainedStateRegistry provides registry, - LocalCanRetainChecker provides CanRetainChecker.Always, - ) { - content() - } - .also { - DisposableEffect(key, registry) { - onDispose { - registry.saveAll() - if (canRetainChecker.canRetain(registry)) { - parentRegistry.saveValue(key) - } - } - } - } -} diff --git a/circuit-retained/api/android/circuit-retained.api b/circuit-retained/api/android/circuit-retained.api index f014edf90..7865e9104 100644 --- a/circuit-retained/api/android/circuit-retained.api +++ b/circuit-retained/api/android/circuit-retained.api @@ -60,8 +60,12 @@ public final class com/slack/circuit/retained/RememberRetainedKt { public static final fun rememberRetainedSaveable ([Ljava/lang/Object;Landroidx/compose/runtime/saveable/Saver;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)Ljava/lang/Object; } -public final class com/slack/circuit/retained/RetainedStateProviderKt { - public static final fun RetainedStateProvider (Ljava/lang/String;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +public abstract interface class com/slack/circuit/retained/RetainedStateHolder { + public abstract fun RetainedStateProvider (Ljava/lang/String;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V +} + +public final class com/slack/circuit/retained/RetainedStateHolderKt { + public static final fun rememberRetainedStateHolder (Landroidx/compose/runtime/Composer;I)Lcom/slack/circuit/retained/RetainedStateHolder; } public abstract interface class com/slack/circuit/retained/RetainedStateRegistry { diff --git a/circuit-retained/api/circuit-retained.klib.api b/circuit-retained/api/circuit-retained.klib.api index c03f21fc2..0f327764c 100644 --- a/circuit-retained/api/circuit-retained.klib.api +++ b/circuit-retained/api/circuit-retained.klib.api @@ -25,6 +25,10 @@ abstract interface <#A: kotlin/Any?> com.slack.circuit.retained/RetainedValueHol abstract fun (): #A // com.slack.circuit.retained/RetainedValueHolder.value.|(){}[0] } +abstract interface com.slack.circuit.retained/RetainedStateHolder { // com.slack.circuit.retained/RetainedStateHolder|null[0] + abstract fun RetainedStateProvider(kotlin/String, kotlin/Function2, androidx.compose.runtime/Composer?, kotlin/Int) // com.slack.circuit.retained/RetainedStateHolder.RetainedStateProvider|RetainedStateProvider(kotlin.String;kotlin.Function2;androidx.compose.runtime.Composer?;kotlin.Int){}[0] +} + abstract interface com.slack.circuit.retained/RetainedStateRegistry { // com.slack.circuit.retained/RetainedStateRegistry|null[0] abstract fun consumeValue(kotlin/String): kotlin/Any? // com.slack.circuit.retained/RetainedStateRegistry.consumeValue|consumeValue(kotlin.String){}[0] abstract fun forgetUnclaimedValues() // com.slack.circuit.retained/RetainedStateRegistry.forgetUnclaimedValues|forgetUnclaimedValues(){}[0] @@ -64,7 +68,6 @@ final fun <#A: kotlin/Any> com.slack.circuit.retained/rememberRetained(kotlin/Ar final fun <#A: kotlin/Any> com.slack.circuit.retained/rememberRetained(kotlin/Array..., kotlin/String?, kotlin/Function0<#A>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): #A // com.slack.circuit.retained/rememberRetained|rememberRetained(kotlin.Array...;kotlin.String?;kotlin.Function0<0:0>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§}[0] final fun <#A: kotlin/Any> com.slack.circuit.retained/rememberRetainedSaveable(kotlin/Array..., androidx.compose.runtime.saveable/Saver<#A, out kotlin/Any>?, kotlin/String?, kotlin/Function0<#A>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): #A // com.slack.circuit.retained/rememberRetainedSaveable|rememberRetainedSaveable(kotlin.Array...;androidx.compose.runtime.saveable.Saver<0:0,out|kotlin.Any>?;kotlin.String?;kotlin.Function0<0:0>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§}[0] final fun <#A: kotlin/Any?> (kotlinx.coroutines.flow/StateFlow<#A>).com.slack.circuit.retained/collectAsRetainedState(kotlin.coroutines/CoroutineContext?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): androidx.compose.runtime/State<#A> // com.slack.circuit.retained/collectAsRetainedState|collectAsRetainedState@kotlinx.coroutines.flow.StateFlow<0:0>(kotlin.coroutines.CoroutineContext?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§}[0] -final fun <#A: kotlin/Any?> com.slack.circuit.retained/RetainedStateProvider(kotlin/String?, kotlin/Function2, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // com.slack.circuit.retained/RetainedStateProvider|RetainedStateProvider(kotlin.String?;kotlin.Function2;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§}[0] final fun <#A: kotlin/Any?> com.slack.circuit.retained/produceRetainedState(#A, kotlin.coroutines/SuspendFunction1, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.runtime/State<#A> // com.slack.circuit.retained/produceRetainedState|produceRetainedState(0:0;kotlin.coroutines.SuspendFunction1,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int){0§}[0] final fun <#A: kotlin/Any?> com.slack.circuit.retained/produceRetainedState(#A, kotlin/Any?, kotlin.coroutines/SuspendFunction1, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.runtime/State<#A> // com.slack.circuit.retained/produceRetainedState|produceRetainedState(0:0;kotlin.Any?;kotlin.coroutines.SuspendFunction1,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int){0§}[0] final fun <#A: kotlin/Any?> com.slack.circuit.retained/produceRetainedState(#A, kotlin/Any?, kotlin/Any?, kotlin.coroutines/SuspendFunction1, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.runtime/State<#A> // com.slack.circuit.retained/produceRetainedState|produceRetainedState(0:0;kotlin.Any?;kotlin.Any?;kotlin.coroutines.SuspendFunction1,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int){0§}[0] @@ -79,6 +82,7 @@ final fun com.slack.circuit.retained/com_slack_circuit_retained_RetainableSaveab final fun com.slack.circuit.retained/com_slack_circuit_retained_RetainedStateRegistryImpl$stableprop_getter(): kotlin/Int // com.slack.circuit.retained/com_slack_circuit_retained_RetainedStateRegistryImpl$stableprop_getter|com_slack_circuit_retained_RetainedStateRegistryImpl$stableprop_getter(){}[0] final fun com.slack.circuit.retained/continuityRetainedStateRegistry(kotlin/String?, com.slack.circuit.retained/CanRetainChecker?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): com.slack.circuit.retained/RetainedStateRegistry // com.slack.circuit.retained/continuityRetainedStateRegistry|continuityRetainedStateRegistry(kotlin.String?;com.slack.circuit.retained.CanRetainChecker?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] final fun com.slack.circuit.retained/rememberCanRetainChecker(androidx.compose.runtime/Composer?, kotlin/Int): com.slack.circuit.retained/CanRetainChecker // com.slack.circuit.retained/rememberCanRetainChecker|rememberCanRetainChecker(androidx.compose.runtime.Composer?;kotlin.Int){}[0] +final fun com.slack.circuit.retained/rememberRetainedStateHolder(androidx.compose.runtime/Composer?, kotlin/Int): com.slack.circuit.retained/RetainedStateHolder // com.slack.circuit.retained/rememberRetainedStateHolder|rememberRetainedStateHolder(androidx.compose.runtime.Composer?;kotlin.Int){}[0] // Targets: [native] abstract fun interface com.slack.circuit.retained/RetainedValueProvider : kotlin/Function0 { // com.slack.circuit.retained/RetainedValueProvider|null[0] diff --git a/circuit-retained/api/jvm/circuit-retained.api b/circuit-retained/api/jvm/circuit-retained.api index c31af4b36..ea8e8cf85 100644 --- a/circuit-retained/api/jvm/circuit-retained.api +++ b/circuit-retained/api/jvm/circuit-retained.api @@ -59,8 +59,12 @@ public final class com/slack/circuit/retained/RememberRetainedKt { public static final fun rememberRetainedSaveable ([Ljava/lang/Object;Landroidx/compose/runtime/saveable/Saver;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)Ljava/lang/Object; } -public final class com/slack/circuit/retained/RetainedStateProviderKt { - public static final fun RetainedStateProvider (Ljava/lang/String;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +public abstract interface class com/slack/circuit/retained/RetainedStateHolder { + public abstract fun RetainedStateProvider (Ljava/lang/String;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V +} + +public final class com/slack/circuit/retained/RetainedStateHolderKt { + public static final fun rememberRetainedStateHolder (Landroidx/compose/runtime/Composer;I)Lcom/slack/circuit/retained/RetainedStateHolder; } public abstract interface class com/slack/circuit/retained/RetainedStateRegistry { diff --git a/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedTest.kt b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedTest.kt index b8b50bb19..e2e5a3e60 100644 --- a/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedTest.kt +++ b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedTest.kt @@ -40,10 +40,10 @@ import com.slack.circuit.retained.Continuity import com.slack.circuit.retained.ContinuityViewModel import com.slack.circuit.retained.LocalCanRetainChecker import com.slack.circuit.retained.LocalRetainedStateRegistry -import com.slack.circuit.retained.RetainedStateProvider import com.slack.circuit.retained.RetainedStateRegistry import com.slack.circuit.retained.continuityRetainedStateRegistry import com.slack.circuit.retained.rememberRetained +import com.slack.circuit.retained.rememberRetainedStateHolder import kotlinx.coroutines.flow.MutableStateFlow import leakcanary.DetectLeaksAfterTestSuccess.Companion.detectLeaksAfterTestSuccessWrapping import org.junit.Rule @@ -368,8 +368,10 @@ class RetainedTest { val content = @Composable { - RetainedStateProvider { - RetainedStateProvider { + val holder1 = rememberRetainedStateHolder() + holder1.RetainedStateProvider("registry1") { + val holder2 = rememberRetainedStateHolder() + holder2.RetainedStateProvider("registry2") { @Suppress("UNUSED_VARIABLE") val retainedSubject = rememberRetained { subject } } } @@ -634,10 +636,8 @@ private fun NestedRetains(useKeys: Boolean) { label = {}, ) - val nestedRegistryLevel1 = rememberRetained { RetainedStateRegistry() } - CompositionLocalProvider(LocalRetainedStateRegistry provides nestedRegistryLevel1) { - NestedRetainLevel1(useKeys) - } + val nestedStateHolderLevel1 = rememberRetainedStateHolder() + nestedStateHolderLevel1.RetainedStateProvider("registryLevel1") { NestedRetainLevel1(useKeys) } } } @@ -653,7 +653,8 @@ private fun NestedRetainLevel1(useKeys: Boolean) { label = {}, ) - RetainedStateProvider("retained2-registry") { NestedRetainLevel2(useKeys) } + val nestedStateHolderLevel2 = rememberRetainedStateHolder() + nestedStateHolderLevel2.RetainedStateProvider("registryLevel2") { NestedRetainLevel2(useKeys) } } @Composable @@ -698,10 +699,11 @@ private fun NestedRetainWithPushAndPop(useKeys: Boolean) { Text(text = "Show child") } + val retainedStateHolder = rememberRetainedStateHolder() // Keep the retained state registry around even if showNestedContent becomes false CompositionLocalProvider(LocalCanRetainChecker provides CanRetainChecker.Always) { if (showNestedContent.value) { - RetainedStateProvider("retained1_registry") { NestedRetainLevel1(useKeys) } + retainedStateHolder.RetainedStateProvider("registry") { NestedRetainLevel1(useKeys) } } } } @@ -737,9 +739,10 @@ private fun NestedRetainWithPushAndPopAndCannotRetain(useKeys: Boolean) { } // Keep the retained state registry around even if showNestedContent becomes false + val holder = rememberRetainedStateHolder() CompositionLocalProvider(LocalCanRetainChecker provides { false }) { if (showNestedContent.value) { - RetainedStateProvider("retained1_registry") { NestedRetainLevel1(useKeys) } + holder.RetainedStateProvider("registry") { NestedRetainLevel1(useKeys) } } } } diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt new file mode 100644 index 000000000..3e8ac6c88 --- /dev/null +++ b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt @@ -0,0 +1,79 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.retained + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect + +/** + * A holder that provides a unique retainedStateRegistry for each subtree and retains all preserved + * values. Each [RetainedStateProvider] maintains a unique retainedStateRegistry for each key, + * allowing it to save and restore states. + */ +public interface RetainedStateHolder { + + /** + * Provides a [RetainedStateRegistry] for the child [content] based on the specified [key]. Before + * the provided registry is disposed, it calls [RetainedStateRegistry.saveValue] on the holder's + * registry to save the current value, allowing it to be restored on the next visit with the same + * key. + */ + @Composable public fun RetainedStateProvider(key: String, content: @Composable () -> Unit) +} + +/** Creates and remembers the instance of [RetainedStateHolder]. */ +@Composable +public fun rememberRetainedStateHolder(): RetainedStateHolder { + return rememberRetained { RetainedStateHolderImpl() } +} + +private class RetainedStateHolderImpl : RetainedStateHolder, RetainedStateRegistry { + + private val registry = RetainedStateRegistry() + + @Composable + override fun RetainedStateProvider(key: String, content: @Composable (() -> Unit)) { + CompositionLocalProvider(LocalRetainedStateRegistry provides this) { + val canRetainChecker = LocalCanRetainChecker.current ?: CanRetainChecker.Always + val childRegistry = rememberRetained(key = key) { RetainedStateRegistry() } + CompositionLocalProvider( + LocalRetainedStateRegistry provides childRegistry, + LocalCanRetainChecker provides CanRetainChecker.Always, + ) { + content() + } + DisposableEffect(key, childRegistry) { + onDispose { + childRegistry.saveAll() + if (canRetainChecker.canRetain(this@RetainedStateHolderImpl)) { + saveValue(key) + } + } + } + } + } + + override fun consumeValue(key: String): Any? { + return registry.consumeValue(key) + } + + override fun registerValue( + key: String, + valueProvider: RetainedValueProvider, + ): RetainedStateRegistry.Entry { + return registry.registerValue(key, valueProvider) + } + + override fun saveAll() { + registry.saveAll() + } + + override fun saveValue(key: String) { + registry.saveValue(key) + } + + override fun forgetUnclaimedValues() { + registry.forgetUnclaimedValues() + } +} diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateProvider.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateProvider.kt deleted file mode 100644 index bc3119e07..000000000 --- a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateProvider.kt +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (C) 2024 Slack Technologies, LLC -// SPDX-License-Identifier: Apache-2.0 -package com.slack.circuit.retained - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid - -/** - * Provides a [RetainedStateRegistry] for the child [content] based on the specified [key]. Before - * the provided registry is disposed, it calls [RetainedStateRegistry.saveValue] on the parent - * registry to save the current value, allowing it to be restored on the next visit with the same - * key. - */ -@Composable -public fun RetainedStateProvider(key: String? = null, content: @Composable () -> T) { - @OptIn(ExperimentalUuidApi::class) - val finalKey = key ?: rememberRetained { Uuid.random().toString() } - val canRetainChecker = LocalCanRetainChecker.current ?: CanRetainChecker.Always - val parentRegistry = LocalRetainedStateRegistry.current - val registry = rememberRetained(key = finalKey) { RetainedStateRegistry() } - CompositionLocalProvider( - LocalRetainedStateRegistry provides registry, - LocalCanRetainChecker provides CanRetainChecker.Always, - ) { - content() - } - DisposableEffect(finalKey, registry) { - onDispose { - registry.saveAll() - if (canRetainChecker.canRetain(registry)) { - parentRegistry.saveValue(finalKey) - } - } - } -} From 5d30dd71cf17140fe13836b4e8eabc884c133b37 Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Fri, 22 Nov 2024 21:15:25 +0900 Subject: [PATCH 10/26] Removed duplicated codes --- .../slack/circuit/retained/RememberRetained.kt | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RememberRetained.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RememberRetained.kt index bddec8f47..762f1f1ad 100644 --- a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RememberRetained.kt +++ b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RememberRetained.kt @@ -340,19 +340,14 @@ private class RetainableSaveableHolder( } override fun onForgotten() { - val v = value - val reg = retainedStateRegistry - if (reg != null && !canRetainChecker.canRetain(reg)) { - when (v) { - is RememberObserver -> v.onForgotten() - is RetainedStateRegistry -> v.forgetUnclaimedValues() - } - } - saveableStateEntry?.unregister() - retainedStateEntry?.unregister() + release() } override fun onAbandoned() { + release() + } + + private fun release() { val v = value val reg = retainedStateRegistry if (reg != null && !canRetainChecker.canRetain(reg)) { From a367cbb13dde51113a4a72e77713b5c92ab4ed32 Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Fri, 22 Nov 2024 21:23:31 +0900 Subject: [PATCH 11/26] Changed to use record.registryKey --- .../com/slack/circuit/foundation/NavigableCircuitContent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index ab2cc9e01..33c7748cd 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -119,7 +119,7 @@ public fun NavigableCircuitContent( saveableStateHolder.SaveableStateProvider(record.key) { CompositionLocalProvider(LocalCanRetainChecker provides recordInBackStackRetainChecker) { - retainedStateHolder.RetainedStateProvider(record.key) { + retainedStateHolder.RetainedStateProvider(record.registryKey) { // Remember the `providedValues` lookup because this composition can live longer than // the record is present in the backstack, if the decoration is animated for example. val values = remember(record) { providedValues[record] }?.provideValues() From 0e4e8e376bc1a13c43b52e3e20d17e3d999fa712 Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Fri, 22 Nov 2024 21:33:15 +0900 Subject: [PATCH 12/26] Moved RetainedStateProvider to inner of movableContentOf --- .../foundation/NavigableCircuitContent.kt | 62 ++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index 33c7748cd..8d4679aa2 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -39,6 +39,7 @@ import com.slack.circuit.backstack.providedValuesForBackStack import com.slack.circuit.retained.CanRetainChecker import com.slack.circuit.retained.LocalCanRetainChecker import com.slack.circuit.retained.LocalRetainedStateRegistry +import com.slack.circuit.retained.RetainedStateHolder import com.slack.circuit.retained.RetainedStateRegistry import com.slack.circuit.retained.rememberRetained import com.slack.circuit.retained.rememberRetainedStateHolder @@ -104,30 +105,20 @@ public fun NavigableCircuitContent( */ val outerKey = "_navigable_registry_${currentCompositeKeyHash.toString(MaxSupportedRadix)}" val outerRegistry = rememberRetained(key = outerKey) { RetainedStateRegistry() } - val lastBackStack by rememberUpdatedState(backStack) val saveableStateHolder = rememberSaveableStateHolder() CompositionLocalProvider(LocalRetainedStateRegistry provides outerRegistry) { val retainedStateHolder = rememberRetainedStateHolder() - - decoration.DecoratedContent(activeContentProviders, backStack.size, modifier) { provider -> - val record = provider.record - val recordInBackStackRetainChecker = - remember(lastBackStack, record) { - CanRetainChecker { lastBackStack.containsRecord(record, includeSaved = true) } - } - - saveableStateHolder.SaveableStateProvider(record.key) { - CompositionLocalProvider(LocalCanRetainChecker provides recordInBackStackRetainChecker) { - retainedStateHolder.RetainedStateProvider(record.registryKey) { - // Remember the `providedValues` lookup because this composition can live longer than - // the record is present in the backstack, if the decoration is animated for example. - val values = remember(record) { providedValues[record] }?.provideValues() - val providedLocals = remember(values) { values?.toTypedArray() ?: emptyArray() } - - CompositionLocalProvider(LocalBackStack provides backStack, *providedLocals) { - provider.content(record) - } + CompositionLocalProvider(LocalRetainedStateHolder provides retainedStateHolder) { + decoration.DecoratedContent(activeContentProviders, backStack.size, modifier) { provider -> + val record = provider.record + saveableStateHolder.SaveableStateProvider(record.key) { + // Remember the `providedValues` lookup because this composition can live longer than + // the record is present in the backstack, if the decoration is animated for example. + val values = remember(record) { providedValues[record] }?.provideValues() + val providedLocals = remember(values) { values?.toTypedArray() ?: emptyArray() } + CompositionLocalProvider(LocalBackStack provides backStack, *providedLocals) { + provider.content(record) } } } @@ -178,17 +169,27 @@ private fun buildCircuitContentProviders( fun createRecordContent() = movableContentOf { record -> + val recordInBackStackRetainChecker = + remember(lastBackStack, record) { + CanRetainChecker { lastBackStack.containsRecord(record, includeSaved = true) } + } + val lifecycle = remember { MutableRecordLifecycle() }.apply { isActive = lastBackStack.topRecord == record } - - CompositionLocalProvider(LocalRecordLifecycle provides lifecycle) { - CircuitContent( - screen = record.screen, - navigator = lastNavigator, - circuit = lastCircuit, - unavailableContent = lastUnavailableRoute, - key = record.key, - ) + val retainedStateHolder = LocalRetainedStateHolder.current + + CompositionLocalProvider(LocalCanRetainChecker provides recordInBackStackRetainChecker) { + retainedStateHolder.RetainedStateProvider(record.registryKey) { + CompositionLocalProvider(LocalRecordLifecycle provides lifecycle) { + CircuitContent( + screen = record.screen, + navigator = lastNavigator, + circuit = lastCircuit, + unavailableContent = lastUnavailableRoute, + key = record.key, + ) + } + } } } @@ -360,3 +361,6 @@ public object NavigatorDefaults { public val LocalBackStack: ProvidableCompositionLocal?> = compositionLocalOf { null } + +private val LocalRetainedStateHolder = + compositionLocalOf { error("No RetainedStateHolder provided") } From 9fbc2df174b5045327da759e141e351eb07a2658 Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Fri, 22 Nov 2024 21:39:22 +0900 Subject: [PATCH 13/26] Restored un-intended changes --- .../com/slack/circuit/foundation/NavigableCircuitContent.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index 8d4679aa2..c7d9113c7 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -112,11 +112,13 @@ public fun NavigableCircuitContent( CompositionLocalProvider(LocalRetainedStateHolder provides retainedStateHolder) { decoration.DecoratedContent(activeContentProviders, backStack.size, modifier) { provider -> val record = provider.record + saveableStateHolder.SaveableStateProvider(record.key) { // Remember the `providedValues` lookup because this composition can live longer than // the record is present in the backstack, if the decoration is animated for example. val values = remember(record) { providedValues[record] }?.provideValues() val providedLocals = remember(values) { values?.toTypedArray() ?: emptyArray() } + CompositionLocalProvider(LocalBackStack provides backStack, *providedLocals) { provider.content(record) } @@ -179,6 +181,9 @@ private fun buildCircuitContentProviders( val retainedStateHolder = LocalRetainedStateHolder.current CompositionLocalProvider(LocalCanRetainChecker provides recordInBackStackRetainChecker) { + // Now provide a new registry to the content for it to store any retained state in, + // along with a retain checker which is always true (as upstream registries will + // maintain the lifetime), and the other provided values retainedStateHolder.RetainedStateProvider(record.registryKey) { CompositionLocalProvider(LocalRecordLifecycle provides lifecycle) { CircuitContent( From fb672cfc365638fa5450adbf427f9b2794c46e86 Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Sat, 23 Nov 2024 14:01:07 +0900 Subject: [PATCH 14/26] Added removeState in RetainedStateHolder --- circuit-retained/api/android/circuit-retained.api | 1 + circuit-retained/api/circuit-retained.klib.api | 1 + circuit-retained/api/jvm/circuit-retained.api | 1 + .../com/slack/circuit/retained/RetainedStateHolder.kt | 7 +++++++ 4 files changed, 10 insertions(+) diff --git a/circuit-retained/api/android/circuit-retained.api b/circuit-retained/api/android/circuit-retained.api index 7865e9104..b06312fc8 100644 --- a/circuit-retained/api/android/circuit-retained.api +++ b/circuit-retained/api/android/circuit-retained.api @@ -62,6 +62,7 @@ public final class com/slack/circuit/retained/RememberRetainedKt { public abstract interface class com/slack/circuit/retained/RetainedStateHolder { public abstract fun RetainedStateProvider (Ljava/lang/String;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V + public abstract fun removeState (Ljava/lang/String;)V } public final class com/slack/circuit/retained/RetainedStateHolderKt { diff --git a/circuit-retained/api/circuit-retained.klib.api b/circuit-retained/api/circuit-retained.klib.api index 0f327764c..8655c57c2 100644 --- a/circuit-retained/api/circuit-retained.klib.api +++ b/circuit-retained/api/circuit-retained.klib.api @@ -27,6 +27,7 @@ abstract interface <#A: kotlin/Any?> com.slack.circuit.retained/RetainedValueHol abstract interface com.slack.circuit.retained/RetainedStateHolder { // com.slack.circuit.retained/RetainedStateHolder|null[0] abstract fun RetainedStateProvider(kotlin/String, kotlin/Function2, androidx.compose.runtime/Composer?, kotlin/Int) // com.slack.circuit.retained/RetainedStateHolder.RetainedStateProvider|RetainedStateProvider(kotlin.String;kotlin.Function2;androidx.compose.runtime.Composer?;kotlin.Int){}[0] + abstract fun removeState(kotlin/String) // com.slack.circuit.retained/RetainedStateHolder.removeState|removeState(kotlin.String){}[0] } abstract interface com.slack.circuit.retained/RetainedStateRegistry { // com.slack.circuit.retained/RetainedStateRegistry|null[0] diff --git a/circuit-retained/api/jvm/circuit-retained.api b/circuit-retained/api/jvm/circuit-retained.api index ea8e8cf85..428f5c8e8 100644 --- a/circuit-retained/api/jvm/circuit-retained.api +++ b/circuit-retained/api/jvm/circuit-retained.api @@ -61,6 +61,7 @@ public final class com/slack/circuit/retained/RememberRetainedKt { public abstract interface class com/slack/circuit/retained/RetainedStateHolder { public abstract fun RetainedStateProvider (Ljava/lang/String;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V + public abstract fun removeState (Ljava/lang/String;)V } public final class com/slack/circuit/retained/RetainedStateHolderKt { diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt index 3e8ac6c88..1fad4c4d4 100644 --- a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt +++ b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt @@ -20,6 +20,9 @@ public interface RetainedStateHolder { * key. */ @Composable public fun RetainedStateProvider(key: String, content: @Composable () -> Unit) + + /** Removes the retained state associated with the passed [key]. */ + public fun removeState(key: String) } /** Creates and remembers the instance of [RetainedStateHolder]. */ @@ -54,6 +57,10 @@ private class RetainedStateHolderImpl : RetainedStateHolder, RetainedStateRegist } } + override fun removeState(key: String) { + consumeValue(key) + } + override fun consumeValue(key: String): Any? { return registry.consumeValue(key) } From 16cf579170bd954ff272122c4fee25e1f43e030c Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Sun, 12 Jan 2025 01:26:47 +0900 Subject: [PATCH 15/26] Changed RetainedStateHolder to use ReusableContent --- .../circuit/retained/android/RetainedTest.kt | 1 - .../circuit/retained/RetainedStateHolder.kt | 55 +++++++++++++------ 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedTest.kt b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedTest.kt index e2e5a3e60..7007f4383 100644 --- a/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedTest.kt +++ b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedTest.kt @@ -14,7 +14,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.RememberObserver import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt index 1fad4c4d4..3ef0604ee 100644 --- a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt +++ b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt @@ -5,6 +5,8 @@ package com.slack.circuit.retained import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.ReusableContent +import androidx.compose.runtime.remember /** * A holder that provides a unique retainedStateRegistry for each subtree and retains all preserved @@ -33,24 +35,31 @@ public fun rememberRetainedStateHolder(): RetainedStateHolder { private class RetainedStateHolderImpl : RetainedStateHolder, RetainedStateRegistry { - private val registry = RetainedStateRegistry() + private val registry: RetainedStateRegistry = RetainedStateRegistry() + + private val canRetainCheckers = mutableMapOf() @Composable override fun RetainedStateProvider(key: String, content: @Composable (() -> Unit)) { - CompositionLocalProvider(LocalRetainedStateRegistry provides this) { - val canRetainChecker = LocalCanRetainChecker.current ?: CanRetainChecker.Always - val childRegistry = rememberRetained(key = key) { RetainedStateRegistry() } - CompositionLocalProvider( - LocalRetainedStateRegistry provides childRegistry, - LocalCanRetainChecker provides CanRetainChecker.Always, - ) { - content() - } - DisposableEffect(key, childRegistry) { - onDispose { - childRegistry.saveAll() - if (canRetainChecker.canRetain(this@RetainedStateHolderImpl)) { - saveValue(key) + CompositionLocalProvider(LocalRetainedStateRegistry provides registry) { + val parentCanRetainChecker = LocalCanRetainChecker.current ?: CanRetainChecker.Always + ReusableContent(key) { + val entryCanRetainChecker = + remember(parentCanRetainChecker) { EntryCanRetainChecker(parentCanRetainChecker) } + val childRegistry = rememberRetained(key = key) { RetainedStateRegistry() } + CompositionLocalProvider( + LocalRetainedStateRegistry provides childRegistry, + LocalCanRetainChecker provides CanRetainChecker.Always, + content = content, + ) + DisposableEffect(Unit) { + canRetainCheckers[key] = entryCanRetainChecker + onDispose { + childRegistry.saveAll() + if (entryCanRetainChecker.canRetain(registry)) { + registry.saveValue(key) + } + canRetainCheckers -= key } } } @@ -58,7 +67,12 @@ private class RetainedStateHolderImpl : RetainedStateHolder, RetainedStateRegist } override fun removeState(key: String) { - consumeValue(key) + val canRetainChecker = canRetainCheckers[key] + if (canRetainChecker != null) { + canRetainChecker.shouldSave = false + } else { + registry.consumeValue(key) + } } override fun consumeValue(key: String): Any? { @@ -83,4 +97,13 @@ private class RetainedStateHolderImpl : RetainedStateHolder, RetainedStateRegist override fun forgetUnclaimedValues() { registry.forgetUnclaimedValues() } + + private class EntryCanRetainChecker(private val parentChecker: CanRetainChecker) : + CanRetainChecker { + + var shouldSave = true + + override fun canRetain(registry: RetainedStateRegistry): Boolean = + parentChecker.canRetain(registry) && shouldSave + } } From ff477ad7996dc2065adf213a2d622b6f55380309 Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Sun, 12 Jan 2025 14:24:45 +0900 Subject: [PATCH 16/26] Changed RetainedStateHolderWithReturn to use key (instead of ReusableContent) --- .../circuit/foundation/RetainedStateHolder.kt | 61 ++++++++++++++----- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt index 542a41fde..704ef0d6c 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt @@ -4,6 +4,8 @@ package com.slack.circuit.foundation import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember import com.slack.circuit.foundation.internal.withCompositionLocalProvider import com.slack.circuit.retained.CanRetainChecker import com.slack.circuit.retained.LocalCanRetainChecker @@ -16,6 +18,8 @@ import com.slack.circuit.retained.rememberRetained internal interface RetainedStateHolder { @Composable fun RetainedStateProvider(key: String, content: @Composable () -> T): T + + fun removeState(key: String) } /** Creates and remembers the instance of [RetainedStateHolder]. */ @@ -28,27 +32,43 @@ private class RetainedStateHolderImpl : RetainedStateHolder, RetainedStateRegist private val registry = RetainedStateRegistry() + private val canRetainCheckers = mutableMapOf() + @Composable override fun RetainedStateProvider(key: String, content: @Composable (() -> T)): T { - return withCompositionLocalProvider(LocalRetainedStateRegistry provides this) { - val canRetainChecker = LocalCanRetainChecker.current ?: CanRetainChecker.Always - val childRegistry = rememberRetained(key = key) { RetainedStateRegistry() } - withCompositionLocalProvider( - LocalRetainedStateRegistry provides childRegistry, - LocalCanRetainChecker provides CanRetainChecker.Always, - ) { - content() - } - .also { - DisposableEffect(key, childRegistry) { - onDispose { - childRegistry.saveAll() - if (canRetainChecker.canRetain(this@RetainedStateHolderImpl)) { - saveValue(key) + return withCompositionLocalProvider(LocalRetainedStateRegistry provides registry) { + val parentCanRetainChecker = LocalCanRetainChecker.current ?: CanRetainChecker.Always + key(key) { + val entryCanRetainChecker = + remember(parentCanRetainChecker) { EntryCanRetainChecker(parentCanRetainChecker) } + val childRegistry = rememberRetained(key = key) { RetainedStateRegistry() } + withCompositionLocalProvider( + LocalRetainedStateRegistry provides childRegistry, + LocalCanRetainChecker provides CanRetainChecker.Always, + content = content, + ) + .also { + DisposableEffect(Unit) { + canRetainCheckers[key] = entryCanRetainChecker + onDispose { + childRegistry.saveAll() + if (entryCanRetainChecker.canRetain(registry)) { + registry.saveValue(key) + } + canRetainCheckers -= key } } } - } + } + } + } + + override fun removeState(key: String) { + val canRetainChecker = canRetainCheckers[key] + if (canRetainChecker != null) { + canRetainChecker.shouldSave = false + } else { + registry.consumeValue(key) } } @@ -74,4 +94,13 @@ private class RetainedStateHolderImpl : RetainedStateHolder, RetainedStateRegist override fun forgetUnclaimedValues() { registry.forgetUnclaimedValues() } + + private class EntryCanRetainChecker(private val parentChecker: CanRetainChecker) : + CanRetainChecker { + + var shouldSave = true + + override fun canRetain(registry: RetainedStateRegistry): Boolean = + parentChecker.canRetain(registry) && shouldSave + } } From 7900ffbfca51df520999b690bdf9b302105eae11 Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Sun, 12 Jan 2025 14:40:52 +0900 Subject: [PATCH 17/26] Changed test method names for more readability --- .../NavigableCircuitConditionalRetainTest.kt | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitConditionalRetainTest.kt b/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitConditionalRetainTest.kt index f777a3fc9..090c5bf89 100644 --- a/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitConditionalRetainTest.kt +++ b/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/NavigableCircuitConditionalRetainTest.kt @@ -66,13 +66,23 @@ class NavigableCircuitConditionalRetainTest { } @Test - fun conditionalRetainedWithPresentWithLifecycle() { - conditionalRetained(presentWithLifecycle = true) + fun removedConditionalRetainWithPresentWithLifecycle() { + removedConditionalRetain(presentWithLifecycle = true) } @Test - fun conditionalRetainedWithoutPresentWithLifecycle() { - conditionalRetained(presentWithLifecycle = false) + fun removedConditionalRetainWithoutPresentWithLifecycle() { + removedConditionalRetain(presentWithLifecycle = false) + } + + @Test + fun addedConditionalRetainWithPresentWithLifecycle() { + addedConditionalRetain(presentWithLifecycle = true) + } + + @Test + fun addedConditionalRetainWithoutPresentWithLifecycle() { + addedConditionalRetain(presentWithLifecycle = false) } /** Nested circuit content should not be retained if it is removed */ @@ -85,11 +95,11 @@ class NavigableCircuitConditionalRetainTest { onNodeWithTag(TAG_PRESENTER_RETAINED).assertDoesNotExist() onNodeWithTag(TAG_UI_RETAINED).assertDoesNotExist() + dataSource.value = 1 + // Show child onNodeWithTag(TAG_SHOW_CHILD_BUTTON).performClick() - dataSource.value = 1 - onNodeWithTag(TAG_STATE).assertTextEquals("1") onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("1") onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("1") @@ -117,7 +127,7 @@ class NavigableCircuitConditionalRetainTest { * Conditional rememberRetained should not be retained if it is removed no matter current * RetainedStateRegistry is saved or not. */ - private fun conditionalRetained(presentWithLifecycle: Boolean) { + private fun removedConditionalRetain(presentWithLifecycle: Boolean) { composeTestRule.run { val modifiedCircuit = circuit.newBuilder().presentWithLifecycle(presentWithLifecycle).build() setUpTestContent(modifiedCircuit, ScreenC) @@ -152,7 +162,7 @@ class NavigableCircuitConditionalRetainTest { * Conditional rememberRetained should be retained if it is added and current * RetainedStateRegistry is saved */ - private fun conditionalRetained2(presentWithLifecycle: Boolean) { + private fun addedConditionalRetain(presentWithLifecycle: Boolean) { composeTestRule.run { val modifiedCircuit = circuit.newBuilder().presentWithLifecycle(presentWithLifecycle).build() setUpTestContent(modifiedCircuit, ScreenC) From df33d0758b551851cf001840aa0300dfe28b9524 Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Sun, 12 Jan 2025 17:44:37 +0900 Subject: [PATCH 18/26] Added saveAll return value --- .../com/slack/circuit/foundation/RetainedStateHolder.kt | 4 ++-- .../kotlin/com/slack/circuit/retained/AndroidContinuity.kt | 4 ++-- .../com/slack/circuit/retained/NoOpRetainedStateRegistry.kt | 2 +- .../kotlin/com/slack/circuit/retained/RetainedStateHolder.kt | 4 ++-- .../com/slack/circuit/retained/RetainedStateRegistry.kt | 5 +++-- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt index 704ef0d6c..23dda199a 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt @@ -83,8 +83,8 @@ private class RetainedStateHolderImpl : RetainedStateHolder, RetainedStateRegist return registry.registerValue(key, valueProvider) } - override fun saveAll() { - registry.saveAll() + override fun saveAll(): Map> { + return registry.saveAll() } override fun saveValue(key: String) { diff --git a/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/AndroidContinuity.kt b/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/AndroidContinuity.kt index aa2af13a4..f354c8d9e 100644 --- a/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/AndroidContinuity.kt +++ b/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/AndroidContinuity.kt @@ -26,8 +26,8 @@ internal class ContinuityViewModel : ViewModel(), RetainedStateRegistry { return delegate.registerValue(key, valueProvider) } - override fun saveAll() { - delegate.saveAll() + override fun saveAll(): Map> { + return delegate.saveAll() } override fun saveValue(key: String) { diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/NoOpRetainedStateRegistry.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/NoOpRetainedStateRegistry.kt index 1adf45a74..f3f655bef 100644 --- a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/NoOpRetainedStateRegistry.kt +++ b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/NoOpRetainedStateRegistry.kt @@ -11,7 +11,7 @@ public object NoOpRetainedStateRegistry : RetainedStateRegistry { valueProvider: RetainedValueProvider, ): RetainedStateRegistry.Entry = NoOpEntry - override fun saveAll() {} + override fun saveAll(): Map> = emptyMap() override fun saveValue(key: String) {} diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt index 3ef0604ee..a6618a7c1 100644 --- a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt +++ b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt @@ -86,8 +86,8 @@ private class RetainedStateHolderImpl : RetainedStateHolder, RetainedStateRegist return registry.registerValue(key, valueProvider) } - override fun saveAll() { - registry.saveAll() + override fun saveAll(): Map> { + return registry.saveAll() } override fun saveValue(key: String) { diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt index d3cbf2986..802cf2240 100644 --- a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt +++ b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt @@ -41,7 +41,7 @@ public interface RetainedStateRegistry { * Executes all the registered value providers and combines these values into a map. We have a * list of values for each key as it is allowed to have multiple providers for the same key. */ - public fun saveAll() + public fun saveAll(): Map> /** Executes the value providers registered with the given [key], and saves them for retrieval. */ public fun saveValue(key: String) @@ -109,7 +109,7 @@ internal class RetainedStateRegistryImpl(retained: MutableMap } } - override fun saveAll() { + override fun saveAll(): Map> { fun save(value: Any?) { when (value) { // If we get a RetainedHolder value, need to unwrap and call again @@ -136,6 +136,7 @@ internal class RetainedStateRegistryImpl(retained: MutableMap } // Clear the value providers now that we've stored the values valueProviders.clear() + return retained } override fun saveValue(key: String) { From 82e84a18b2b2547a44d84a07eb66b39e1690822e Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Sun, 12 Jan 2025 18:41:28 +0900 Subject: [PATCH 19/26] Changed RetainedStateRegistry not to save empty registry --- .../circuit/foundation/RetainedStateHolder.kt | 4 ++-- .../circuit/retained/RetainedStateHolder.kt | 4 ++-- .../circuit/retained/RetainedStateRegistry.kt | 24 ++++++++++--------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt index 23dda199a..d9c3fee3f 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RetainedStateHolder.kt @@ -51,8 +51,8 @@ private class RetainedStateHolderImpl : RetainedStateHolder, RetainedStateRegist DisposableEffect(Unit) { canRetainCheckers[key] = entryCanRetainChecker onDispose { - childRegistry.saveAll() - if (entryCanRetainChecker.canRetain(registry)) { + val retained = childRegistry.saveAll() + if (retained.isNotEmpty() && entryCanRetainChecker.canRetain(registry)) { registry.saveValue(key) } canRetainCheckers -= key diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt index a6618a7c1..5c829a790 100644 --- a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt +++ b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt @@ -55,8 +55,8 @@ private class RetainedStateHolderImpl : RetainedStateHolder, RetainedStateRegist DisposableEffect(Unit) { canRetainCheckers[key] = entryCanRetainChecker onDispose { - childRegistry.saveAll() - if (entryCanRetainChecker.canRetain(registry)) { + val retained = childRegistry.saveAll() + if (retained.isNotEmpty() && entryCanRetainChecker.canRetain(registry)) { registry.saveValue(key) } canRetainCheckers -= key diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt index 802cf2240..25fa5e0cc 100644 --- a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt +++ b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt @@ -110,17 +110,18 @@ internal class RetainedStateRegistryImpl(retained: MutableMap } override fun saveAll(): Map> { - fun save(value: Any?) { - when (value) { + fun save(value: Any?): Boolean { + return when (value) { // If we get a RetainedHolder value, need to unwrap and call again is RetainedValueHolder<*> -> save(value.value) // Dispatch the call to nested registries - is RetainedStateRegistry -> value.saveAll() + is RetainedStateRegistry -> value.saveAll().isNotEmpty() + else -> true } } - val values = - valueProviders.mapValues { (_, list) -> + valueProviders + .mapValues { (_, list) -> // If we have multiple providers we should store null values as well to preserve // the order in which providers were registered. Say there were two providers. // the first provider returned null(nothing to save) and the second one returned @@ -128,12 +129,13 @@ internal class RetainedStateRegistryImpl(retained: MutableMap // same as to have nothing to restore) and the second one restore "1". list.map(RetainedValueProvider::invoke) } - - if (values.isNotEmpty()) { - values.values.forEach { it.forEach(::save) } - // Store the values in our retained map - retained.putAll(values) - } + .forEach { (key, value) -> + val filtered = value.filter { save(it) } + if (filtered.isNotEmpty()) { + // Store the values in our retained map + retained[key] = filtered + } + } // Clear the value providers now that we've stored the values valueProviders.clear() return retained From 1e8083ecc3e6805bbf375e0e14db19524694f868 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Fri, 2 Feb 2024 16:33:11 -0500 Subject: [PATCH 20/26] Add RetainedStateHolderTests (cherry-pick) --- build.gradle.kts | 2 + .../AndroidManifest.xml | 5 +- .../android/RetainedStateHolderTest.kt | 322 ++++++++++++++++++ .../android/RetainedStateRestorationTester.kt | 134 ++++++++ 4 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateHolderTest.kt create mode 100644 circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateRestorationTester.kt diff --git a/build.gradle.kts b/build.gradle.kts index a097d288f..d3b900307 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -106,6 +106,8 @@ allprojects { "**/Remove.kt", "**/Pets.kt", "**/SystemUiController.kt", + "**/RetainedStateHolderTest.kt", + "**/RetainedStateRestorationTester.kt", ) } } diff --git a/circuit-retained/src/androidInstrumentedTest/AndroidManifest.xml b/circuit-retained/src/androidInstrumentedTest/AndroidManifest.xml index ada8acbc5..0798a1b1f 100644 --- a/circuit-retained/src/androidInstrumentedTest/AndroidManifest.xml +++ b/circuit-retained/src/androidInstrumentedTest/AndroidManifest.xml @@ -1,6 +1,7 @@ - + + - \ No newline at end of file + diff --git a/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateHolderTest.kt b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateHolderTest.kt new file mode 100644 index 000000000..6eed37a2a --- /dev/null +++ b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateHolderTest.kt @@ -0,0 +1,322 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.slack.circuit.retained.android + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.google.common.truth.Truth.assertThat +import com.slack.circuit.retained.LocalRetainedStateRegistry +import com.slack.circuit.retained.RetainedStateHolder +import com.slack.circuit.retained.RetainedStateRegistry +import com.slack.circuit.retained.rememberRetained +import com.slack.circuit.retained.rememberRetainedStateHolder +import leakcanary.DetectLeaksAfterTestSuccess.Companion.detectLeaksAfterTestSuccessWrapping +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain + +// TODO adapt for retained more +class RetainedStateHolderTest { + + private val composeTestRule = createAndroidComposeRule() + + @get:Rule + val rule = + RuleChain.emptyRuleChain().detectLeaksAfterTestSuccessWrapping(tag = "ActivitiesDestroyed") { + around(composeTestRule) + } + + private val restorationTester = RetainedStateRestorationTester(composeTestRule) + + @Test + fun stateIsRestoredWhenGoBackToScreen1() { + var increment = 0 + var screen by mutableStateOf(Screens.Screen1) + var numberOnScreen1 = -1 + var restorableNumberOnScreen1 = -1 + restorationTester.setContent { + val holder = rememberRetainedStateHolder() + holder.RetainedStateProvider(screen.name) { + if (screen == Screens.Screen1) { + numberOnScreen1 = remember { increment++ } + restorableNumberOnScreen1 = rememberRetained { increment++ } + } else { + // screen 2 + remember { 100 } + } + } + } + + composeTestRule.runOnIdle { + assertThat(numberOnScreen1).isEqualTo(0) + assertThat(restorableNumberOnScreen1).isEqualTo(1) + screen = Screens.Screen2 + } + + // wait for the screen switch to apply + composeTestRule.runOnIdle { + numberOnScreen1 = -1 + restorableNumberOnScreen1 = -1 + // switch back to screen1 + screen = Screens.Screen1 + } + + composeTestRule.runOnIdle { + assertThat(numberOnScreen1).isEqualTo(2) + assertThat(restorableNumberOnScreen1).isEqualTo(1) + } + } + + @Test + fun simpleRestoreOnlyOneScreen() { + var increment = 0 + var number = -1 + var restorableNumber = -1 + restorationTester.setContent { + val holder = rememberRetainedStateHolder() + holder.RetainedStateProvider(Screens.Screen1.name) { + number = remember { increment++ } + restorableNumber = rememberRetained { increment++ } + } + } + + composeTestRule.runOnIdle { + assertThat(number).isEqualTo(0) + assertThat(restorableNumber).isEqualTo(1) + number = -1 + restorableNumber = -1 + } + + restorationTester.emulateRetainedInstanceStateRestore() + + composeTestRule.runOnIdle { + assertThat(number).isEqualTo(2) + assertThat(restorableNumber).isEqualTo(1) + } + } + + @Test + fun switchToScreen2AndRestore() { + var increment = 0 + var screen by mutableStateOf(Screens.Screen1) + var numberOnScreen2 = -1 + var restorableNumberOnScreen2 = -1 + restorationTester.setContent { + val holder = rememberRetainedStateHolder() + holder.RetainedStateProvider(screen.name) { + if (screen == Screens.Screen2) { + numberOnScreen2 = remember { increment++ } + restorableNumberOnScreen2 = rememberRetained { increment++ } + } + } + } + + composeTestRule.runOnIdle { screen = Screens.Screen2 } + + // wait for the screen switch to apply + composeTestRule.runOnIdle { + assertThat(numberOnScreen2).isEqualTo(0) + assertThat(restorableNumberOnScreen2).isEqualTo(1) + numberOnScreen2 = -1 + restorableNumberOnScreen2 = -1 + } + + restorationTester.emulateRetainedInstanceStateRestore() + + composeTestRule.runOnIdle { + assertThat(numberOnScreen2).isEqualTo(2) + assertThat(restorableNumberOnScreen2).isEqualTo(1) + } + } + + @Test + fun stateOfScreen1IsSavedAndRestoredWhileWeAreOnScreen2() { + var increment = 0 + var screen by mutableStateOf(Screens.Screen1) + var numberOnScreen1 = -1 + var restorableNumberOnScreen1 = -1 + restorationTester.setContent { + val holder = rememberRetainedStateHolder() + holder.RetainedStateProvider(screen.name) { + if (screen == Screens.Screen1) { + numberOnScreen1 = remember { increment++ } + restorableNumberOnScreen1 = rememberRetained { increment++ } + } else { + // screen 2 + remember { 100 } + } + } + } + + composeTestRule.runOnIdle { + assertThat(numberOnScreen1).isEqualTo(0) + assertThat(restorableNumberOnScreen1).isEqualTo(1) + screen = Screens.Screen2 + } + + // wait for the screen switch to apply + composeTestRule.runOnIdle { + numberOnScreen1 = -1 + restorableNumberOnScreen1 = -1 + } + + restorationTester.emulateRetainedInstanceStateRestore() + + // switch back to screen1 + composeTestRule.runOnIdle { screen = Screens.Screen1 } + + composeTestRule.runOnIdle { + assertThat(numberOnScreen1).isEqualTo(2) + assertThat(restorableNumberOnScreen1).isEqualTo(1) + } + } + + @Test + fun weCanSkipSavingForCurrentScreen() { + var increment = 0 + var screen by mutableStateOf(Screens.Screen1) + var restorableStateHolder: RetainedStateHolder? = null + var restorableNumberOnScreen1 = -1 + restorationTester.setContent { + val holder = rememberRetainedStateHolder() + restorableStateHolder = holder + holder.RetainedStateProvider(screen.name) { + if (screen == Screens.Screen1) { + restorableNumberOnScreen1 = rememberRetained { increment++ } + } else { + // screen 2 + remember { 100 } + } + } + } + + composeTestRule.runOnIdle { + assertThat(restorableNumberOnScreen1).isEqualTo(0) + restorableNumberOnScreen1 = -1 + restorableStateHolder!!.removeState(Screens.Screen1.name) + screen = Screens.Screen2 + } + + composeTestRule.runOnIdle { + // switch back to screen1 + screen = Screens.Screen1 + } + + composeTestRule.runOnIdle { assertThat(restorableNumberOnScreen1).isEqualTo(1) } + } + + @Test + fun weCanRemoveAlreadySavedState() { + var increment = 0 + var screen by mutableStateOf(Screens.Screen1) + var restorableStateHolder: RetainedStateHolder? = null + var restorableNumberOnScreen1 = -1 + restorationTester.setContent { + val holder = rememberRetainedStateHolder() + restorableStateHolder = holder + holder.RetainedStateProvider(screen.name) { + if (screen == Screens.Screen1) { + restorableNumberOnScreen1 = rememberRetained { increment++ } + } else { + // screen 2 + remember { 100 } + } + } + } + + composeTestRule.runOnIdle { + assertThat(restorableNumberOnScreen1).isEqualTo(0) + restorableNumberOnScreen1 = -1 + screen = Screens.Screen2 + } + + composeTestRule.runOnIdle { + // switch back to screen1 + restorableStateHolder!!.removeState(Screens.Screen1.name) + screen = Screens.Screen1 + } + + composeTestRule.runOnIdle { assertThat(restorableNumberOnScreen1).isEqualTo(1) } + } + + @Test + fun restoringStateOfThePreviousPageAfterCreatingBundle() { + var showFirstPage by mutableStateOf(true) + var firstPageState: MutableState? = null + + composeTestRule.setContent { + val holder = rememberRetainedStateHolder() + holder.RetainedStateProvider(showFirstPage.toString()) { + if (showFirstPage) { + firstPageState = rememberRetained { mutableStateOf(0) } + } + } + } + + composeTestRule.runOnIdle { + assertThat(firstPageState!!.value).isEqualTo(0) + // change the value, so we can assert this change will be restored + firstPageState!!.value = 1 + firstPageState = null + showFirstPage = false + } + + composeTestRule.runOnIdle { + composeTestRule.activity.doFakeSave() + showFirstPage = true + } + + composeTestRule.runOnIdle { assertThat(firstPageState!!.value).isEqualTo(1) } + } + + @Test + fun saveNothingWhenNoRememberRetainedIsUsedInternally() { + var showFirstPage by mutableStateOf(true) + val registry = RetainedStateRegistry(emptyMap()) + + composeTestRule.setContent { + CompositionLocalProvider(LocalRetainedStateRegistry provides registry) { + val holder = rememberRetainedStateHolder() + holder.RetainedStateProvider(showFirstPage.toString()) {} + } + } + + composeTestRule.runOnIdle { showFirstPage = false } + + composeTestRule.runOnIdle { + val savedData = registry.saveAll() + assertThat(savedData).isEqualTo(emptyMap>()) + } + } + + class Activity : ComponentActivity() { + fun doFakeSave() { + onSaveInstanceState(Bundle()) + } + } +} + +enum class Screens { + Screen1, + Screen2, +} diff --git a/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateRestorationTester.kt b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateRestorationTester.kt new file mode 100644 index 000000000..203d0b04c --- /dev/null +++ b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateRestorationTester.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.slack.circuit.retained.android + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import com.slack.circuit.retained.LocalRetainedStateRegistry +import com.slack.circuit.retained.RetainedStateRegistry +import com.slack.circuit.retained.RetainedValueProvider +import com.slack.circuit.retained.rememberRetained + +/** + * Helps to test the retained state restoration for your Composable component. + * + * Instead of calling [ComposeContentTestRule.setContent] you need to use [setContent] on this + * object, then change your state so there is some change to be restored, then execute + * [emulateRetainedInstanceStateRestore] and assert your state is restored properly. + * + * Note that this tests only the restoration of the local state of the composable you passed to + * [setContent] and useful for testing [rememberRetained] integration. It is not testing the + * integration with any other life cycles or Activity callbacks. + */ +// TODO recreate for more realism? Need to save the content function to do that, or call it after +// TODO make this available in test utils? +class RetainedStateRestorationTester(private val composeTestRule: ComposeContentTestRule) { + + private var registry: RestorationRegistry? = null + + /** + * This functions is a direct replacement for [ComposeContentTestRule.setContent] if you are going + * to use [emulateRetainedInstanceStateRestore] in the test. + * + * @see ComposeContentTestRule.setContent + */ + fun setContent(composable: @Composable () -> Unit) { + composeTestRule.setContent { + InjectRestorationRegistry { registry -> + this.registry = registry + composable() + } + } + } + + /** + * Saves all the state stored via [rememberRetained], disposes current composition, and composes + * again the content passed to [setContent]. Allows to test how your component behaves when the + * state restoration is happening. Note that the state stored via regular state() or remember() + * will be lost. + */ + fun emulateRetainedInstanceStateRestore() { + val registry = checkNotNull(registry) { "setContent should be called first!" } + composeTestRule.runOnIdle { registry.saveStateAndDisposeChildren() } + composeTestRule.runOnIdle { registry.emitChildrenWithRestoredState() } + composeTestRule.runOnIdle { + // we just wait for the children to be emitted + } + } + + @Composable + private fun InjectRestorationRegistry(content: @Composable (RestorationRegistry) -> Unit) { + val original = + requireNotNull(LocalRetainedStateRegistry.current) { + "StateRestorationTester requires composeTestRule.setContent() to provide " + + "a RetainedStateRegistry implementation via LocalRetainedStateRegistry" + } + val restorationRegistry = remember { RestorationRegistry(original) } + CompositionLocalProvider(LocalRetainedStateRegistry provides restorationRegistry) { + if (restorationRegistry.shouldEmitChildren) { + content(restorationRegistry) + } + } + } + + private class RestorationRegistry(private val original: RetainedStateRegistry) : + RetainedStateRegistry { + + var shouldEmitChildren by mutableStateOf(true) + private set + + private var currentRegistry: RetainedStateRegistry = original + private var savedMap: Map> = emptyMap() + + fun saveStateAndDisposeChildren() { + savedMap = currentRegistry.saveAll() + shouldEmitChildren = false + } + + fun emitChildrenWithRestoredState() { + currentRegistry = RetainedStateRegistry(values = savedMap) + shouldEmitChildren = true + } + + override fun consumeValue(key: String): Any? { + return currentRegistry.consumeValue(key) + } + + override fun registerValue( + key: String, + valueProvider: RetainedValueProvider, + ): RetainedStateRegistry.Entry { + return currentRegistry.registerValue(key, valueProvider) + } + + override fun saveAll(): Map> { + return currentRegistry.saveAll() + } + + override fun saveValue(key: String) { + currentRegistry.saveValue(key) + } + + override fun forgetUnclaimedValues() { + currentRegistry.forgetUnclaimedValues() + } + } +} From afebc7adab72e71e59a26553306bf78341c20231 Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Sun, 12 Jan 2025 19:18:52 +0900 Subject: [PATCH 21/26] Added default RetainedStateRegistry --- .../retained/android/RetainedStateRestorationTester.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateRestorationTester.kt b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateRestorationTester.kt index 203d0b04c..99e7ab8cf 100644 --- a/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateRestorationTester.kt +++ b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateRestorationTester.kt @@ -52,9 +52,13 @@ class RetainedStateRestorationTester(private val composeTestRule: ComposeContent */ fun setContent(composable: @Composable () -> Unit) { composeTestRule.setContent { - InjectRestorationRegistry { registry -> - this.registry = registry - composable() + CompositionLocalProvider( + LocalRetainedStateRegistry provides remember { RetainedStateRegistry() } + ) { + InjectRestorationRegistry { registry -> + this.registry = registry + composable() + } } } } From 9714ce3b4ee900179c570f6ba94dfbe4ba01c79c Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Sun, 12 Jan 2025 19:40:09 +0900 Subject: [PATCH 22/26] Added RetainedStateHolder to star sample --- .../com/slack/circuit/star/home/HomeScreen.kt | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt index b5d7e6b0b..1ba234897 100644 --- a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt +++ b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -28,9 +29,11 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.unit.IntOffset import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.foundation.CircuitContent +import com.slack.circuit.foundation.LocalCircuit import com.slack.circuit.foundation.NavEvent import com.slack.circuit.foundation.onNavEvent import com.slack.circuit.retained.rememberRetained +import com.slack.circuit.retained.rememberRetainedStateHolder import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.Navigator @@ -127,12 +130,19 @@ fun HomeContent(state: HomeScreen.State, modifier: Modifier = Modifier) = }, ) { paddingValues -> contentComposed = true - val screen = state.navItems[state.selectedIndex].screen - CircuitContent( - screen, - modifier = Modifier.padding(paddingValues), - onNavEvent = { event -> state.eventSink(ChildNav(event)) }, - ) + val saveableStateHolder = rememberSaveableStateHolder() + val retainedStateHolder = rememberRetainedStateHolder() + val currentScreen = state.navItems[state.selectedIndex].screen + saveableStateHolder.SaveableStateProvider(currentScreen) { + retainedStateHolder.RetainedStateProvider(state.selectedIndex.toString()) { + CircuitContent( + currentScreen, + modifier = Modifier.padding(paddingValues), + onNavEvent = { event -> state.eventSink(ChildNav(event)) }, + ) + } + } + contentComposed = true } Platform.ReportDrawnWhen { contentComposed } } From 0c3c6b1d8d11ccf0481447442eb4e25fb9eabc4f Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Sun, 12 Jan 2025 19:40:58 +0900 Subject: [PATCH 23/26] spotless --- .../commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt index 1ba234897..86e138558 100644 --- a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt +++ b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.unit.IntOffset import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.foundation.CircuitContent -import com.slack.circuit.foundation.LocalCircuit import com.slack.circuit.foundation.NavEvent import com.slack.circuit.foundation.onNavEvent import com.slack.circuit.retained.rememberRetained From ffcf6f6ff95de9ea9650f4ed9dc495b139851d4d Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Sun, 12 Jan 2025 19:54:31 +0900 Subject: [PATCH 24/26] apiDump --- circuit-retained/api/android/circuit-retained.api | 4 ++-- circuit-retained/api/circuit-retained.klib.api | 4 ++-- circuit-retained/api/jvm/circuit-retained.api | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/circuit-retained/api/android/circuit-retained.api b/circuit-retained/api/android/circuit-retained.api index b06312fc8..5fa6a509a 100644 --- a/circuit-retained/api/android/circuit-retained.api +++ b/circuit-retained/api/android/circuit-retained.api @@ -40,7 +40,7 @@ public final class com/slack/circuit/retained/NoOpRetainedStateRegistry : com/sl public fun consumeValue (Ljava/lang/String;)Ljava/lang/Object; public fun forgetUnclaimedValues ()V public fun registerValue (Ljava/lang/String;Lcom/slack/circuit/retained/RetainedValueProvider;)Lcom/slack/circuit/retained/RetainedStateRegistry$Entry; - public fun saveAll ()V + public fun saveAll ()Ljava/util/Map; public fun saveValue (Ljava/lang/String;)V } @@ -73,7 +73,7 @@ public abstract interface class com/slack/circuit/retained/RetainedStateRegistry public abstract fun consumeValue (Ljava/lang/String;)Ljava/lang/Object; public abstract fun forgetUnclaimedValues ()V public abstract fun registerValue (Ljava/lang/String;Lcom/slack/circuit/retained/RetainedValueProvider;)Lcom/slack/circuit/retained/RetainedStateRegistry$Entry; - public abstract fun saveAll ()V + public abstract fun saveAll ()Ljava/util/Map; public abstract fun saveValue (Ljava/lang/String;)V } diff --git a/circuit-retained/api/circuit-retained.klib.api b/circuit-retained/api/circuit-retained.klib.api index 8655c57c2..366dc4bcf 100644 --- a/circuit-retained/api/circuit-retained.klib.api +++ b/circuit-retained/api/circuit-retained.klib.api @@ -34,7 +34,7 @@ abstract interface com.slack.circuit.retained/RetainedStateRegistry { // com.sla abstract fun consumeValue(kotlin/String): kotlin/Any? // com.slack.circuit.retained/RetainedStateRegistry.consumeValue|consumeValue(kotlin.String){}[0] abstract fun forgetUnclaimedValues() // com.slack.circuit.retained/RetainedStateRegistry.forgetUnclaimedValues|forgetUnclaimedValues(){}[0] abstract fun registerValue(kotlin/String, com.slack.circuit.retained/RetainedValueProvider): com.slack.circuit.retained/RetainedStateRegistry.Entry // com.slack.circuit.retained/RetainedStateRegistry.registerValue|registerValue(kotlin.String;com.slack.circuit.retained.RetainedValueProvider){}[0] - abstract fun saveAll() // com.slack.circuit.retained/RetainedStateRegistry.saveAll|saveAll(){}[0] + abstract fun saveAll(): kotlin.collections/Map> // com.slack.circuit.retained/RetainedStateRegistry.saveAll|saveAll(){}[0] abstract fun saveValue(kotlin/String) // com.slack.circuit.retained/RetainedStateRegistry.saveValue|saveValue(kotlin.String){}[0] abstract interface Entry { // com.slack.circuit.retained/RetainedStateRegistry.Entry|null[0] @@ -51,7 +51,7 @@ final object com.slack.circuit.retained/NoOpRetainedStateRegistry : com.slack.ci final fun consumeValue(kotlin/String): kotlin/Any? // com.slack.circuit.retained/NoOpRetainedStateRegistry.consumeValue|consumeValue(kotlin.String){}[0] final fun forgetUnclaimedValues() // com.slack.circuit.retained/NoOpRetainedStateRegistry.forgetUnclaimedValues|forgetUnclaimedValues(){}[0] final fun registerValue(kotlin/String, com.slack.circuit.retained/RetainedValueProvider): com.slack.circuit.retained/RetainedStateRegistry.Entry // com.slack.circuit.retained/NoOpRetainedStateRegistry.registerValue|registerValue(kotlin.String;com.slack.circuit.retained.RetainedValueProvider){}[0] - final fun saveAll() // com.slack.circuit.retained/NoOpRetainedStateRegistry.saveAll|saveAll(){}[0] + final fun saveAll(): kotlin.collections/Map> // com.slack.circuit.retained/NoOpRetainedStateRegistry.saveAll|saveAll(){}[0] final fun saveValue(kotlin/String) // com.slack.circuit.retained/NoOpRetainedStateRegistry.saveValue|saveValue(kotlin.String){}[0] } diff --git a/circuit-retained/api/jvm/circuit-retained.api b/circuit-retained/api/jvm/circuit-retained.api index 428f5c8e8..8ac050849 100644 --- a/circuit-retained/api/jvm/circuit-retained.api +++ b/circuit-retained/api/jvm/circuit-retained.api @@ -39,7 +39,7 @@ public final class com/slack/circuit/retained/NoOpRetainedStateRegistry : com/sl public fun consumeValue (Ljava/lang/String;)Ljava/lang/Object; public fun forgetUnclaimedValues ()V public fun registerValue (Ljava/lang/String;Lcom/slack/circuit/retained/RetainedValueProvider;)Lcom/slack/circuit/retained/RetainedStateRegistry$Entry; - public fun saveAll ()V + public fun saveAll ()Ljava/util/Map; public fun saveValue (Ljava/lang/String;)V } @@ -72,7 +72,7 @@ public abstract interface class com/slack/circuit/retained/RetainedStateRegistry public abstract fun consumeValue (Ljava/lang/String;)Ljava/lang/Object; public abstract fun forgetUnclaimedValues ()V public abstract fun registerValue (Ljava/lang/String;Lcom/slack/circuit/retained/RetainedValueProvider;)Lcom/slack/circuit/retained/RetainedStateRegistry$Entry; - public abstract fun saveAll ()V + public abstract fun saveAll ()Ljava/util/Map; public abstract fun saveValue (Ljava/lang/String;)V } From 91c2fcdbe34b6c93475381666bd593ffceb47005 Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Sun, 12 Jan 2025 20:37:00 +0900 Subject: [PATCH 25/26] Fixed lint --- .../slack/circuit/retained/android/RetainedStateHolderTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateHolderTest.kt b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateHolderTest.kt index 6eed37a2a..3f5896010 100644 --- a/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateHolderTest.kt +++ b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateHolderTest.kt @@ -15,6 +15,7 @@ */ package com.slack.circuit.retained.android +import android.annotation.SuppressLint import android.os.Bundle import androidx.activity.ComponentActivity import androidx.compose.runtime.CompositionLocalProvider @@ -36,6 +37,7 @@ import org.junit.Test import org.junit.rules.RuleChain // TODO adapt for retained more +@SuppressLint("RememberReturnType") class RetainedStateHolderTest { private val composeTestRule = createAndroidComposeRule() From b726772f1c50376f589f84bde10e87ee5a46281d Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Wed, 15 Jan 2025 23:23:24 +0900 Subject: [PATCH 26/26] Moved rememberRetainedStateHolder declaration in NavigableCircuitContent --- .../foundation/NavigableCircuitContent.kt | 47 ++++++++----------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index d1a00da5d..f46a49f44 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -46,7 +46,6 @@ import com.slack.circuit.foundation.NavigatorDefaults.DefaultDecorator.DefaultAn import com.slack.circuit.retained.CanRetainChecker import com.slack.circuit.retained.LocalCanRetainChecker import com.slack.circuit.retained.LocalRetainedStateRegistry -import com.slack.circuit.retained.RetainedStateHolder import com.slack.circuit.retained.RetainedStateRegistry import com.slack.circuit.retained.rememberRetained import com.slack.circuit.retained.rememberRetainedStateHolder @@ -68,14 +67,6 @@ public fun NavigableCircuitContent( unavailableRoute: (@Composable (screen: Screen, modifier: Modifier) -> Unit) = circuit.onUnavailableContent, ) { - val activeContentProviders = - buildCircuitContentProviders( - backStack = backStack, - navigator = navigator, - circuit = circuit, - unavailableRoute = unavailableRoute, - ) - if (backStack.isEmpty) return /* @@ -115,20 +106,25 @@ public fun NavigableCircuitContent( val saveableStateHolder = rememberSaveableStateHolder() CompositionLocalProvider(LocalRetainedStateRegistry provides outerRegistry) { - val retainedStateHolder = rememberRetainedStateHolder() - CompositionLocalProvider(LocalRetainedStateHolder provides retainedStateHolder) { - decoration.DecoratedContent(activeContentProviders, backStack.size, modifier) { provider -> - val record = provider.record - - saveableStateHolder.SaveableStateProvider(record.key) { - // Remember the `providedValues` lookup because this composition can live longer than - // the record is present in the backstack, if the decoration is animated for example. - val values = remember(record) { providedValues[record] }?.provideValues() - val providedLocals = remember(values) { values?.toTypedArray() ?: emptyArray() } - - CompositionLocalProvider(LocalBackStack provides backStack, *providedLocals) { - provider.content(record) - } + val activeContentProviders = + buildCircuitContentProviders( + backStack = backStack, + navigator = navigator, + circuit = circuit, + unavailableRoute = unavailableRoute, + ) + + decoration.DecoratedContent(activeContentProviders, backStack.size, modifier) { provider -> + val record = provider.record + + saveableStateHolder.SaveableStateProvider(record.key) { + // Remember the `providedValues` lookup because this composition can live longer than + // the record is present in the backstack, if the decoration is animated for example. + val values = remember(record) { providedValues[record] }?.provideValues() + val providedLocals = remember(values) { values?.toTypedArray() ?: emptyArray() } + + CompositionLocalProvider(LocalBackStack provides backStack, *providedLocals) { + provider.content(record) } } } @@ -179,6 +175,7 @@ private fun buildCircuitContentProviders( val lastNavigator by rememberUpdatedState(navigator) val lastCircuit by rememberUpdatedState(circuit) val lastUnavailableRoute by rememberUpdatedState(unavailableRoute) + val retainedStateHolder = rememberRetainedStateHolder() fun createRecordContent() = movableContentOf { record -> @@ -189,7 +186,6 @@ private fun buildCircuitContentProviders( val lifecycle = remember { MutableRecordLifecycle() }.apply { isActive = lastBackStack.topRecord == record } - val retainedStateHolder = LocalRetainedStateHolder.current CompositionLocalProvider(LocalCanRetainChecker provides recordInBackStackRetainChecker) { // Now provide a new registry to the content for it to store any retained state in, @@ -396,6 +392,3 @@ public object NavigatorDefaults { public val LocalBackStack: ProvidableCompositionLocal?> = compositionLocalOf { null } - -private val LocalRetainedStateHolder = - compositionLocalOf { error("No RetainedStateHolder provided") }