diff --git a/backstack/api/android/backstack.api b/backstack/api/android/backstack.api index 5a3a99864..cc0e2c372 100644 --- a/backstack/api/android/backstack.api +++ b/backstack/api/android/backstack.api @@ -1,7 +1,9 @@ public abstract interface class com/slack/circuit/backstack/BackStack : java/lang/Iterable, kotlin/jvm/internal/markers/KMappedMarker { public abstract fun containsRecord (Lcom/slack/circuit/backstack/BackStack$Record;Z)Z + public abstract fun getRootRecord ()Lcom/slack/circuit/backstack/BackStack$Record; public abstract fun getSize ()I public abstract fun getTopRecord ()Lcom/slack/circuit/backstack/BackStack$Record; + public abstract fun isRecordReachable (Ljava/lang/String;IZ)Z public abstract fun pop (Lcom/slack/circuit/runtime/screen/PopResult;)Lcom/slack/circuit/backstack/BackStack$Record; public static synthetic fun pop$default (Lcom/slack/circuit/backstack/BackStack;Lcom/slack/circuit/runtime/screen/PopResult;ILjava/lang/Object;)Lcom/slack/circuit/backstack/BackStack$Record; public fun popUntil (Lkotlin/jvm/functions/Function1;)Lkotlinx/collections/immutable/ImmutableList; @@ -50,9 +52,12 @@ public final class com/slack/circuit/backstack/SaveableBackStack : com/slack/cir public fun (Lcom/slack/circuit/runtime/screen/Screen;)V public synthetic fun containsRecord (Lcom/slack/circuit/backstack/BackStack$Record;Z)Z public fun containsRecord (Lcom/slack/circuit/backstack/SaveableBackStack$Record;Z)Z + public synthetic fun getRootRecord ()Lcom/slack/circuit/backstack/BackStack$Record; + public fun getRootRecord ()Lcom/slack/circuit/backstack/SaveableBackStack$Record; public fun getSize ()I public synthetic fun getTopRecord ()Lcom/slack/circuit/backstack/BackStack$Record; public fun getTopRecord ()Lcom/slack/circuit/backstack/SaveableBackStack$Record; + public fun isRecordReachable (Ljava/lang/String;IZ)Z public fun iterator ()Ljava/util/Iterator; public synthetic fun pop (Lcom/slack/circuit/runtime/screen/PopResult;)Lcom/slack/circuit/backstack/BackStack$Record; public fun pop (Lcom/slack/circuit/runtime/screen/PopResult;)Lcom/slack/circuit/backstack/SaveableBackStack$Record; diff --git a/backstack/api/backstack.klib.api b/backstack/api/backstack.klib.api index b531a47b5..d7b3eea74 100644 --- a/backstack/api/backstack.klib.api +++ b/backstack/api/backstack.klib.api @@ -15,12 +15,15 @@ abstract fun interface com.slack.circuit.backstack/ProvidedValues { // com.slack } abstract interface <#A: com.slack.circuit.backstack/BackStack.Record> com.slack.circuit.backstack/BackStack : kotlin.collections/Iterable<#A> { // com.slack.circuit.backstack/BackStack|null[0] + abstract val rootRecord // com.slack.circuit.backstack/BackStack.rootRecord|{}rootRecord[0] + abstract fun (): #A? // com.slack.circuit.backstack/BackStack.rootRecord.|(){}[0] abstract val size // com.slack.circuit.backstack/BackStack.size|{}size[0] abstract fun (): kotlin/Int // com.slack.circuit.backstack/BackStack.size.|(){}[0] abstract val topRecord // com.slack.circuit.backstack/BackStack.topRecord|{}topRecord[0] abstract fun (): #A? // com.slack.circuit.backstack/BackStack.topRecord.|(){}[0] abstract fun containsRecord(#A, kotlin/Boolean): kotlin/Boolean // com.slack.circuit.backstack/BackStack.containsRecord|containsRecord(1:0;kotlin.Boolean){}[0] + abstract fun isRecordReachable(kotlin/String, kotlin/Int, kotlin/Boolean): kotlin/Boolean // com.slack.circuit.backstack/BackStack.isRecordReachable|isRecordReachable(kotlin.String;kotlin.Int;kotlin.Boolean){}[0] abstract fun pop(com.slack.circuit.runtime.screen/PopResult? = ...): #A? // com.slack.circuit.backstack/BackStack.pop|pop(com.slack.circuit.runtime.screen.PopResult?){}[0] abstract fun push(#A, kotlin/String? = ...): kotlin/Boolean // com.slack.circuit.backstack/BackStack.push|push(1:0;kotlin.String?){}[0] abstract fun push(com.slack.circuit.runtime.screen/Screen, kotlin/String? = ...): kotlin/Boolean // com.slack.circuit.backstack/BackStack.push|push(com.slack.circuit.runtime.screen.Screen;kotlin.String?){}[0] @@ -51,12 +54,15 @@ final class com.slack.circuit.backstack/SaveableBackStack : com.slack.circuit.ba constructor (com.slack.circuit.backstack/SaveableBackStack.Record) // com.slack.circuit.backstack/SaveableBackStack.|(com.slack.circuit.backstack.SaveableBackStack.Record){}[0] constructor (com.slack.circuit.runtime.screen/Screen) // com.slack.circuit.backstack/SaveableBackStack.|(com.slack.circuit.runtime.screen.Screen){}[0] + final val rootRecord // com.slack.circuit.backstack/SaveableBackStack.rootRecord|{}rootRecord[0] + final fun (): com.slack.circuit.backstack/SaveableBackStack.Record? // com.slack.circuit.backstack/SaveableBackStack.rootRecord.|(){}[0] final val size // com.slack.circuit.backstack/SaveableBackStack.size|{}size[0] final fun (): kotlin/Int // com.slack.circuit.backstack/SaveableBackStack.size.|(){}[0] final val topRecord // com.slack.circuit.backstack/SaveableBackStack.topRecord|{}topRecord[0] final fun (): com.slack.circuit.backstack/SaveableBackStack.Record? // com.slack.circuit.backstack/SaveableBackStack.topRecord.|(){}[0] final fun containsRecord(com.slack.circuit.backstack/SaveableBackStack.Record, kotlin/Boolean): kotlin/Boolean // com.slack.circuit.backstack/SaveableBackStack.containsRecord|containsRecord(com.slack.circuit.backstack.SaveableBackStack.Record;kotlin.Boolean){}[0] + final fun isRecordReachable(kotlin/String, kotlin/Int, kotlin/Boolean): kotlin/Boolean // com.slack.circuit.backstack/SaveableBackStack.isRecordReachable|isRecordReachable(kotlin.String;kotlin.Int;kotlin.Boolean){}[0] final fun iterator(): kotlin.collections/Iterator // com.slack.circuit.backstack/SaveableBackStack.iterator|iterator(){}[0] final fun pop(com.slack.circuit.runtime.screen/PopResult?): com.slack.circuit.backstack/SaveableBackStack.Record? // com.slack.circuit.backstack/SaveableBackStack.pop|pop(com.slack.circuit.runtime.screen.PopResult?){}[0] final fun push(com.slack.circuit.backstack/SaveableBackStack.Record, kotlin/String?): kotlin/Boolean // com.slack.circuit.backstack/SaveableBackStack.push|push(com.slack.circuit.backstack.SaveableBackStack.Record;kotlin.String?){}[0] diff --git a/backstack/api/jvm/backstack.api b/backstack/api/jvm/backstack.api index 5a3a99864..cc0e2c372 100644 --- a/backstack/api/jvm/backstack.api +++ b/backstack/api/jvm/backstack.api @@ -1,7 +1,9 @@ public abstract interface class com/slack/circuit/backstack/BackStack : java/lang/Iterable, kotlin/jvm/internal/markers/KMappedMarker { public abstract fun containsRecord (Lcom/slack/circuit/backstack/BackStack$Record;Z)Z + public abstract fun getRootRecord ()Lcom/slack/circuit/backstack/BackStack$Record; public abstract fun getSize ()I public abstract fun getTopRecord ()Lcom/slack/circuit/backstack/BackStack$Record; + public abstract fun isRecordReachable (Ljava/lang/String;IZ)Z public abstract fun pop (Lcom/slack/circuit/runtime/screen/PopResult;)Lcom/slack/circuit/backstack/BackStack$Record; public static synthetic fun pop$default (Lcom/slack/circuit/backstack/BackStack;Lcom/slack/circuit/runtime/screen/PopResult;ILjava/lang/Object;)Lcom/slack/circuit/backstack/BackStack$Record; public fun popUntil (Lkotlin/jvm/functions/Function1;)Lkotlinx/collections/immutable/ImmutableList; @@ -50,9 +52,12 @@ public final class com/slack/circuit/backstack/SaveableBackStack : com/slack/cir public fun (Lcom/slack/circuit/runtime/screen/Screen;)V public synthetic fun containsRecord (Lcom/slack/circuit/backstack/BackStack$Record;Z)Z public fun containsRecord (Lcom/slack/circuit/backstack/SaveableBackStack$Record;Z)Z + public synthetic fun getRootRecord ()Lcom/slack/circuit/backstack/BackStack$Record; + public fun getRootRecord ()Lcom/slack/circuit/backstack/SaveableBackStack$Record; public fun getSize ()I public synthetic fun getTopRecord ()Lcom/slack/circuit/backstack/BackStack$Record; public fun getTopRecord ()Lcom/slack/circuit/backstack/SaveableBackStack$Record; + public fun isRecordReachable (Ljava/lang/String;IZ)Z public fun iterator ()Ljava/util/Iterator; public synthetic fun pop (Lcom/slack/circuit/runtime/screen/PopResult;)Lcom/slack/circuit/backstack/BackStack$Record; public fun pop (Lcom/slack/circuit/runtime/screen/PopResult;)Lcom/slack/circuit/backstack/SaveableBackStack$Record; diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt index 1a28c6b08..cdcebf64b 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/BackStack.kt @@ -35,6 +35,9 @@ public interface BackStack : Iterable { /** The top-most record in the [BackStack], or `null` if the [BackStack] is empty. */ public val topRecord: R? + /** The bottom-most record in the [BackStack], or `null` if the [BackStack] is empty. */ + public val rootRecord: R? + /** * Push a new [Record] onto the back stack. The new record will become the top of the stack. * @@ -103,6 +106,20 @@ public interface BackStack : Iterable { */ public fun containsRecord(record: R, includeSaved: Boolean): Boolean + /** + * Whether a record with the given [key] is reachable within the back stack or saved state. + * Reachable means that it is either currently in the visible back stack or if we popped `depth` + * times, it would be found. + * + * @param key The record's key to look for. + * @param depth How many records to consider popping from the top of the stack before considering + * the key unreachable. A depth of zero means only check the current visible stack. A depth of 1 + * means check the current visible stack plus one record popped off the top, and so on. + * @param includeSaved Whether to also check if the record is contained by any saved back stack + * state. See [saveState]. + */ + public fun isRecordReachable(key: String, depth: Int, includeSaved: Boolean): Boolean + @Stable public interface Record { /** diff --git a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt index d8c841345..d02b0ef83 100644 --- a/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt +++ b/backstack/src/commonMain/kotlin/com/slack/circuit/backstack/SaveableBackStack.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.snapshots.Snapshot import com.slack.circuit.runtime.screen.PopResult import com.slack.circuit.runtime.screen.Screen +import kotlin.math.min import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid import kotlinx.coroutines.CompletableDeferred @@ -92,6 +93,9 @@ internal constructor( public override val topRecord: Record? get() = entryList.firstOrNull() + override val rootRecord: Record? + get() = entryList.lastOrNull() + public override fun push(screen: Screen, resultKey: String?): Boolean { return push(screen, emptyMap(), resultKey) } @@ -154,6 +158,24 @@ internal constructor( return false } + override fun isRecordReachable(key: String, depth: Int, includeSaved: Boolean): Boolean { + if (depth < 0) return false + // Check in the current entry list + for (i in 0 until min(depth, entryList.size)) { + if (entryList[i].key == key) return true + } + // If includeSaved, check saved backstack states too + if (includeSaved && stateStore.isNotEmpty()) { + val storedValues = stateStore.values + for ((i, stored) in storedValues.withIndex()) { + if (i >= depth) break + // stored can mutate, so safely get the record. + if (stored.getOrNull(i)?.key == key) return true + } + } + return false + } + public data class Record( override val screen: Screen, val args: Map = emptyMap(), diff --git a/circuit-foundation/api/android/circuit-foundation.api b/circuit-foundation/api/android/circuit-foundation.api index 33cbd5d04..a481ee3a9 100644 --- a/circuit-foundation/api/android/circuit-foundation.api +++ b/circuit-foundation/api/android/circuit-foundation.api @@ -77,12 +77,6 @@ public final class com/slack/circuit/foundation/ComposableSingletons$CircuitKt { public final fun getLambda-1$circuit_foundation_release ()Lkotlin/jvm/functions/Function4; } -public final class com/slack/circuit/foundation/ComposableSingletons$NavigableCircuitContentKt { - public static final field INSTANCE Lcom/slack/circuit/foundation/ComposableSingletons$NavigableCircuitContentKt; - public fun ()V - public final fun getLambda-1$circuit_foundation_release ()Lkotlin/jvm/functions/Function4; -} - public final class com/slack/circuit/foundation/ContentProviderState { public static final field $stable I public fun (Landroidx/compose/runtime/saveable/SaveableStateHolder;Lcom/slack/circuit/retained/RetainedStateHolder;Lcom/slack/circuit/backstack/BackStack;Lcom/slack/circuit/runtime/Navigator;Lcom/slack/circuit/foundation/Circuit;Lkotlin/jvm/functions/Function4;)V diff --git a/circuit-foundation/api/jvm/circuit-foundation.api b/circuit-foundation/api/jvm/circuit-foundation.api index 7b5454405..413f7d3ca 100644 --- a/circuit-foundation/api/jvm/circuit-foundation.api +++ b/circuit-foundation/api/jvm/circuit-foundation.api @@ -77,12 +77,6 @@ public final class com/slack/circuit/foundation/ComposableSingletons$CircuitKt { public final fun getLambda-1$circuit_foundation ()Lkotlin/jvm/functions/Function4; } -public final class com/slack/circuit/foundation/ComposableSingletons$NavigableCircuitContentKt { - public static final field INSTANCE Lcom/slack/circuit/foundation/ComposableSingletons$NavigableCircuitContentKt; - public fun ()V - public final fun getLambda-1$circuit_foundation ()Lkotlin/jvm/functions/Function4; -} - public final class com/slack/circuit/foundation/ContentProviderState { public static final field $stable I public fun (Landroidx/compose/runtime/saveable/SaveableStateHolder;Lcom/slack/circuit/retained/RetainedStateHolder;Lcom/slack/circuit/backstack/BackStack;Lcom/slack/circuit/runtime/Navigator;Lcom/slack/circuit/foundation/Circuit;Lkotlin/jvm/functions/Function4;)V diff --git a/circuit-foundation/src/androidInstrumentedTest/kotlin/com/slack/circuit/foundation/SaveableRestRootTest.kt b/circuit-foundation/src/androidInstrumentedTest/kotlin/com/slack/circuit/foundation/SaveableRestRootTest.kt index 3071e4418..2a03da18f 100644 --- a/circuit-foundation/src/androidInstrumentedTest/kotlin/com/slack/circuit/foundation/SaveableRestRootTest.kt +++ b/circuit-foundation/src/androidInstrumentedTest/kotlin/com/slack/circuit/foundation/SaveableRestRootTest.kt @@ -50,8 +50,18 @@ class SaveableRestRootTest { mainClock.autoAdvance = false navigator.resetRoot(newRoot = TestScreen.ScreenA, saveState = true, restoreState = true) mainClock.advanceTimeByFrame() - navigator.resetRoot(newRoot = TestScreen.ScreenB, saveState = true, restoreState = true) - mainClock.advanceTimeByFrame() + repeat(10) { + navigator.resetRoot(newRoot = TestScreen.ScreenB, saveState = true, restoreState = true) + mainClock.advanceTimeByFrame() + navigator.resetRoot(newRoot = TestScreen.ScreenA, saveState = true, restoreState = true) + mainClock.advanceTimeByFrame() + navigator.resetRoot(newRoot = TestScreen.ScreenC, saveState = true, restoreState = true) + mainClock.advanceTimeByFrame() + navigator.resetRoot(newRoot = TestScreen.ScreenA, saveState = true, restoreState = true) + mainClock.advanceTimeByFrame() + navigator.resetRoot(newRoot = TestScreen.ScreenB, saveState = true, restoreState = true) + mainClock.advanceTimeByFrame() + } navigator.resetRoot(newRoot = TestScreen.ScreenA, saveState = true, restoreState = true) mainClock.autoAdvance = true onNodeWithTag(TAG_LABEL).assertTextEquals(TestScreen.ScreenA.label) 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 82d21211f..11757d47f 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 @@ -32,6 +32,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.SaveableStateHolder import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue @@ -59,7 +60,9 @@ import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.Screen import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentSet @OptIn(ExperimentalCircuitApi::class) @Composable @@ -142,7 +145,6 @@ public fun NavigableCircuitContent( lastUnavailableRoute = unavailableRoute } val activeContentProviders = buildCircuitContentProviders(backStack = backStack) - navDecoration.DecoratedContent(activeContentProviders, backStack.size, modifier) { provider -> val record = provider.record @@ -188,22 +190,48 @@ private fun buildCircuitContentProviders( backStack: BackStack ): ImmutableList> { val previousContentProviders = remember { mutableMapOf>() } + val activeRecordKeys = remember { mutableSetOf() } + val recordKeys by + remember { mutableStateOf(persistentSetOf()) } + .apply { value = backStack.map { it.key }.toPersistentSet() } + val latestBackStack by rememberUpdatedState(backStack) + DisposableEffect(recordKeys) { + // Delay cleanup until the next backstack change. + // - Any record in composition is considered active + // - Any record in the backstack can be shown by a decorator + // - Any reachable record can be shown on a root reset + val contentNotInBackStack = + previousContentProviders.keys.filterNot { + it in activeRecordKeys || + it in recordKeys || + latestBackStack.isRecordReachable(key = it, depth = 1, includeSaved = true) + } + onDispose { + // Only remove the keys that are no longer in the backstack or composition. + contentNotInBackStack + .filterNot { + latestBackStack.isRecordReachable(key = it, depth = 1, includeSaved = true) || + it in activeRecordKeys + } + .forEach { previousContentProviders.remove(it) } + } + } return backStack .map { record -> // Query the previous content providers map, so that we use the same // RecordContentProvider instances across calls. previousContentProviders.getOrPut(record.key) { - RecordContentProvider(record = record, content = createRecordContent()) + RecordContentProvider( + record = record, + content = + createRecordContent( + onActive = { activeRecordKeys.add(record.key) }, + onDispose = { activeRecordKeys.remove(record.key) }, + ), + ) } } .toImmutableList() - .also { list -> - // Update the previousContentProviders map so we can reference it on the next call - previousContentProviders.clear() - for (provider in list) { - previousContentProviders[provider.record.key] = provider - } - } } @Stable @@ -238,12 +266,11 @@ public class ContentProviderState( } } -private fun createRecordContent() = +private fun createRecordContent(onActive: () -> Unit, onDispose: () -> Unit) = movableContentOf> { record, contentProviderState -> with(contentProviderState) { val lifecycle = remember { MutableRecordLifecycle() }.apply { isActive = lastBackStack.topRecord == record } - saveableStateHolder.SaveableStateProvider(record.registryKey) { // Provides a RetainedStateRegistry that is maintained independently for each record while // the record exists in the back stack. @@ -269,6 +296,11 @@ private fun createRecordContent() = } } } + // Track if the movableContent is still active to correctly reuse it and not create a new one. + DisposableEffect(Unit) { + onActive() + onDispose { onDispose() } + } } /** The maximum radix available for conversion to and from strings. */ diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/TransitionUtils.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt similarity index 72% rename from circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/TransitionUtils.kt rename to circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt index c52959930..248e889bc 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/TransitionUtils.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavTransitionHolder.kt @@ -2,19 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuitx.gesturenavigation -import androidx.compose.animation.core.Transition import androidx.compose.runtime.Immutable import com.slack.circuit.backstack.NavArgument import com.slack.circuit.foundation.animation.AnimatedNavState import com.slack.circuit.runtime.screen.Screen -internal fun Transition.isStateBeingAnimated(equals: (T) -> Boolean): Boolean { - return isRunning && (equals(currentState) || equals(targetState)) -} - -internal val Transition<*>.isPending: Boolean - get() = this.currentState != this.targetState - /** * A holder class used by the `AnimatedContent` composables. This enables us to pass through all of * the necessary information as an argument, which is optimal for `AnimatedContent`. diff --git a/samples/star/build.gradle.kts b/samples/star/build.gradle.kts index 96bddd5f1..67c1e2639 100644 --- a/samples/star/build.gradle.kts +++ b/samples/star/build.gradle.kts @@ -171,11 +171,16 @@ kotlin { val androidInstrumentedTest by getting { // Annoyingly cannot depend on commonJvmTest dependencies { + implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.ui.testing.junit) implementation(libs.androidx.compose.ui.testing.manifest) + implementation(libs.compose.ui.testing.junit) + implementation(libs.coroutines.android) implementation(libs.coroutines.test) + implementation(libs.junit) implementation(libs.leakcanary.android.instrumentation) implementation(projects.circuitTest) + implementation(projects.internalTestUtils) implementation(projects.samples.star.coilRule) } } diff --git a/samples/star/src/androidInstrumentedTest/kotlin/com/slack/circuit/star/GestureSaveableRestRootTest.kt b/samples/star/src/androidInstrumentedTest/kotlin/com/slack/circuit/star/GestureSaveableRestRootTest.kt new file mode 100644 index 000000000..93f6bd488 --- /dev/null +++ b/samples/star/src/androidInstrumentedTest/kotlin/com/slack/circuit/star/GestureSaveableRestRootTest.kt @@ -0,0 +1,110 @@ +// Copyright (C) 2025 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.star + +import android.annotation.SuppressLint +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Modifier +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 com.slack.circuit.backstack.rememberSaveableBackStack +import com.slack.circuit.foundation.CircuitCompositionLocals +import com.slack.circuit.foundation.NavigableCircuitContent +import com.slack.circuit.foundation.NavigatorDefaults +import com.slack.circuit.foundation.rememberCircuitNavigator +import com.slack.circuit.internal.test.TestContentTags +import com.slack.circuit.internal.test.TestCountPresenter +import com.slack.circuit.internal.test.TestScreen +import com.slack.circuit.internal.test.createTestCircuit +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.sharedelements.PreviewSharedElementTransitionLayout +import com.slack.circuitx.gesturenavigation.GestureNavigationDecorationFactory +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class GestureSaveableRestRootTest { + + @get:Rule val composeTestRule = createComposeRule() + + /* + What happens: + - Record A is set as args + - Record A removed from args, Record B is args + - Record A is in composition until transition completes + - Record B removed from args, Record A is args + - Record A is not in previousContentProviders + - Record A has a new content provider created + - New movable is created for the same spot, even though it can reparent the old one + - SaveableState is reused (same key) but the old movable hasn't been disposed, so savable it still registered + - SaveableState crashes + */ + @Test + fun testReset() = runTest { + lateinit var navigator: Navigator + composeTestRule.run { + setTestContent { TestContent { navigator = it } } + onNodeWithTag(TestContentTags.TAG_LABEL).assertTextEquals(TestScreen.RootAlpha.label) + mainClock.autoAdvance = false + navigator.resetRoot(newRoot = TestScreen.ScreenA, saveState = true, restoreState = true) + mainClock.advanceTimeByFrame() + repeat(10) { + navigator.resetRoot(newRoot = TestScreen.ScreenB, saveState = true, restoreState = true) + mainClock.advanceTimeByFrame() + navigator.resetRoot(newRoot = TestScreen.ScreenA, saveState = true, restoreState = true) + mainClock.advanceTimeByFrame() + navigator.resetRoot(newRoot = TestScreen.ScreenC, saveState = true, restoreState = true) + mainClock.advanceTimeByFrame() + navigator.resetRoot(newRoot = TestScreen.ScreenA, saveState = true, restoreState = true) + mainClock.advanceTimeByFrame() + navigator.resetRoot(newRoot = TestScreen.ScreenB, saveState = true, restoreState = true) + mainClock.advanceTimeByFrame() + } + navigator.resetRoot(newRoot = TestScreen.ScreenA, saveState = true, restoreState = true) + mainClock.autoAdvance = true + onNodeWithTag(TestContentTags.TAG_LABEL).assertTextEquals(TestScreen.ScreenA.label) + Snapshot.withMutableSnapshot { + navigator.goTo(TestScreen.RootBeta) + navigator.goTo(TestScreen.RootAlpha) + } + onNodeWithTag(TestContentTags.TAG_LABEL).assertTextEquals(TestScreen.RootAlpha.label) + navigator.resetRoot(newRoot = TestScreen.ScreenB, saveState = false, restoreState = false) + onNodeWithTag(TestContentTags.TAG_LABEL).assertTextEquals(TestScreen.ScreenB.label) + navigator.resetRoot(newRoot = TestScreen.ScreenA, saveState = false, restoreState = false) + onNodeWithTag(TestContentTags.TAG_LABEL).assertTextEquals(TestScreen.ScreenA.label) + } + } + + @SuppressLint("NewApi") + @OptIn(ExperimentalSharedTransitionApi::class) + private fun ComposeContentTestRule.setTestContent(content: @Composable () -> Unit) { + val circuit = + createTestCircuit(rememberType = TestCountPresenter.RememberType.Saveable) + .newBuilder() + .setAnimatedNavDecoratorFactory(NavigatorDefaults.DefaultDecoratorFactory) + .setAnimatedNavDecoratorFactory(GestureNavigationDecorationFactory {}) + .build() + setContent { + PreviewSharedElementTransitionLayout { CircuitCompositionLocals(circuit) { content() } } + } + } +} + +@Composable +private fun TestContent(liftNavigator: (Navigator) -> Unit) { + Column(Modifier.windowInsetsPadding(WindowInsets.safeDrawing)) { + val backStack = rememberSaveableBackStack(root = TestScreen.RootAlpha) + val navigator = rememberCircuitNavigator(backStack = backStack) + SideEffect { liftNavigator(navigator) } + NavigableCircuitContent(navigator, backStack, modifier = Modifier) + } +}