From 019c8ccc358847da220ffcc600f382f708bcea2f Mon Sep 17 00:00:00 2001 From: vulpeszerda Date: Fri, 8 Nov 2024 11:44:41 +0900 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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) }