From a504a8f1e7de0ec45b1f5da2c4f2b6c53903a8cf Mon Sep 17 00:00:00 2001 From: Arkadii Ivanov Date: Sun, 30 Jun 2024 21:34:32 +0100 Subject: [PATCH] Allow duplicate configurations as an experimental feature --- decompose/api/android/decompose.api | 24 ++- decompose/api/decompose.klib.api | 22 ++- decompose/api/jvm/decompose.api | 24 ++- .../kotlin/com/arkivanov/decompose/Child.kt | 34 +++- .../decompose/DecomposeExperimentFlags.kt | 7 + .../com/arkivanov/decompose/GettingList.kt | 3 + .../kotlin/com/arkivanov/decompose/Utils.kt | 11 ++ .../router/children/ChildrenFactory.kt | 3 +- .../router/children/ChildrenNavigator.kt | 151 ++++++++++++++++-- .../decompose/router/stack/ChildStack.kt | 18 ++- .../router/stack/StackNavigatorExt.kt | 7 +- .../router/children/ChildrenBasicTest.kt | 98 ++++++++++++ .../router/children/ChildrenLifecycleTest.kt | 45 ++++++ .../children/ChildrenRetainedInstanceTest.kt | 93 ++++++++--- .../router/children/ChildrenSavedStateTest.kt | 39 +++++ .../router/children/ChildrenTestBase.kt | 10 ++ .../router/stack/ChildStackIntegrationTest.kt | 63 ++++++-- .../DefaultWebHistoryControllerTest.kt | 47 ++++++ .../android/stack/StackRouterView.kt | 12 +- .../extensions/compose/pages/Pages.kt | 6 +- .../extensions/compose/stack/Children.kt | 13 +- .../stack/animation/AbstractStackAnimation.kt | 22 +-- .../stack/animation/MovableStackAnimation.kt | 4 +- .../stack/animation/SimpleStackAnimation.kt | 4 +- .../predictiveback/PredictiveBackAnimation.kt | 10 +- .../extensions/compose/stack/ChildrenTest.kt | 131 ++++++++++++--- 26 files changed, 773 insertions(+), 128 deletions(-) create mode 100644 decompose/src/commonMain/kotlin/com/arkivanov/decompose/DecomposeExperimentFlags.kt diff --git a/decompose/api/android/decompose.api b/decompose/api/android/decompose.api index eab4d2735..0120a5109 100644 --- a/decompose/api/android/decompose.api +++ b/decompose/api/android/decompose.api @@ -5,30 +5,39 @@ public abstract interface class com/arkivanov/decompose/Cancellation { public abstract class com/arkivanov/decompose/Child { public abstract fun getConfiguration ()Ljava/lang/Object; public abstract fun getInstance ()Ljava/lang/Object; + public abstract fun getKey ()Ljava/lang/Object; } public final class com/arkivanov/decompose/Child$Created : com/arkivanov/decompose/Child { public fun (Ljava/lang/Object;Ljava/lang/Object;)V + public fun (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)V public final fun component1 ()Ljava/lang/Object; public final fun component2 ()Ljava/lang/Object; - public final fun copy (Ljava/lang/Object;Ljava/lang/Object;)Lcom/arkivanov/decompose/Child$Created; - public static synthetic fun copy$default (Lcom/arkivanov/decompose/Child$Created;Ljava/lang/Object;Ljava/lang/Object;ILjava/lang/Object;)Lcom/arkivanov/decompose/Child$Created; + public final fun component3 ()Ljava/lang/Object; + public final synthetic fun copy (Ljava/lang/Object;Ljava/lang/Object;)Lcom/arkivanov/decompose/Child; + public final fun copy (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Lcom/arkivanov/decompose/Child$Created; + public static synthetic fun copy$default (Lcom/arkivanov/decompose/Child$Created;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;ILjava/lang/Object;)Lcom/arkivanov/decompose/Child$Created; public fun equals (Ljava/lang/Object;)Z public fun getConfiguration ()Ljava/lang/Object; public fun getInstance ()Ljava/lang/Object; + public fun getKey ()Ljava/lang/Object; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/arkivanov/decompose/Child$Destroyed : com/arkivanov/decompose/Child { public fun (Ljava/lang/Object;)V + public fun (Ljava/lang/Object;Ljava/lang/Object;)V public final fun component1 ()Ljava/lang/Object; - public final fun copy (Ljava/lang/Object;)Lcom/arkivanov/decompose/Child$Destroyed; - public static synthetic fun copy$default (Lcom/arkivanov/decompose/Child$Destroyed;Ljava/lang/Object;ILjava/lang/Object;)Lcom/arkivanov/decompose/Child$Destroyed; + public final fun component2 ()Ljava/lang/Object; + public final synthetic fun copy (Ljava/lang/Object;)Lcom/arkivanov/decompose/Child; + public final fun copy (Ljava/lang/Object;Ljava/lang/Object;)Lcom/arkivanov/decompose/Child$Destroyed; + public static synthetic fun copy$default (Lcom/arkivanov/decompose/Child$Destroyed;Ljava/lang/Object;Ljava/lang/Object;ILjava/lang/Object;)Lcom/arkivanov/decompose/Child$Destroyed; public fun equals (Ljava/lang/Object;)Z public fun getConfiguration ()Ljava/lang/Object; public synthetic fun getInstance ()Ljava/lang/Object; public fun getInstance ()Ljava/lang/Void; + public fun getKey ()Ljava/lang/Object; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -49,6 +58,12 @@ public abstract interface class com/arkivanov/decompose/ComponentContextFactoryO public abstract fun getComponentContextFactory ()Lcom/arkivanov/decompose/ComponentContextFactory; } +public final class com/arkivanov/decompose/DecomposeExperimentFlags { + public static final field INSTANCE Lcom/arkivanov/decompose/DecomposeExperimentFlags; + public final fun getDuplicateConfigurationsEnabled ()Z + public final fun setDuplicateConfigurationsEnabled (Z)V +} + public final class com/arkivanov/decompose/DeeplinkUtilsKt { public static final fun handleDeepLink (Landroid/app/Activity;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; } @@ -270,6 +285,7 @@ public final class com/arkivanov/decompose/router/stack/ChildStack { public fun (Lcom/arkivanov/decompose/Child$Created;Ljava/util/List;)V public synthetic fun (Lcom/arkivanov/decompose/Child$Created;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Ljava/lang/Object;Ljava/lang/Object;)V + public fun (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)V public final fun component1 ()Lcom/arkivanov/decompose/Child$Created; public final fun component2 ()Ljava/util/List; public final fun copy (Lcom/arkivanov/decompose/Child$Created;Ljava/util/List;)Lcom/arkivanov/decompose/router/stack/ChildStack; diff --git a/decompose/api/decompose.klib.api b/decompose/api/decompose.klib.api index 73a2af0d8..d0ff66aa7 100644 --- a/decompose/api/decompose.klib.api +++ b/decompose/api/decompose.klib.api @@ -126,6 +126,7 @@ final class <#A: out kotlin/Any, #B: out kotlin/Any> com.arkivanov.decompose.rou } final class <#A: out kotlin/Any, #B: out kotlin/Any> com.arkivanov.decompose.router.stack/ChildStack { // com.arkivanov.decompose.router.stack/ChildStack|null[0] constructor (#A, #B) // com.arkivanov.decompose.router.stack/ChildStack.|(1:0;1:1){}[0] + constructor (#A, #B, kotlin/Any) // com.arkivanov.decompose.router.stack/ChildStack.|(1:0;1:1;kotlin.Any){}[0] constructor (com.arkivanov.decompose/Child.Created<#A, #B>, kotlin.collections/List> = ...) // com.arkivanov.decompose.router.stack/ChildStack.|(com.arkivanov.decompose.Child.Created<1:0,1:1>;kotlin.collections.List>){}[0] final fun component1(): com.arkivanov.decompose/Child.Created<#A, #B> // com.arkivanov.decompose.router.stack/ChildStack.component1|component1(){}[0] final fun component2(): kotlin.collections/List> // com.arkivanov.decompose.router.stack/ChildStack.component2|component2(){}[0] @@ -233,6 +234,11 @@ final inline fun <#A: kotlin/Any> (com.arkivanov.decompose.router.stack/StackNav final inline fun <#A: kotlin/Any> (com.arkivanov.decompose.router.stack/StackNavigator<#A>).com.arkivanov.decompose.router.stack/pushToFront(#A, crossinline kotlin/Function0 = ...) // com.arkivanov.decompose.router.stack/pushToFront|pushToFront@com.arkivanov.decompose.router.stack.StackNavigator<0:0>(0:0;kotlin.Function0){0§}[0] final inline fun <#A: kotlin/Any> (com.arkivanov.decompose.router.stack/StackNavigator<#A>).com.arkivanov.decompose.router.stack/replaceAll(kotlin/Array..., crossinline kotlin/Function0 = ...) // com.arkivanov.decompose.router.stack/replaceAll|replaceAll@com.arkivanov.decompose.router.stack.StackNavigator<0:0>(kotlin.Array...;kotlin.Function0){0§}[0] final inline fun <#A: kotlin/Any> (com.arkivanov.decompose.router.stack/StackNavigator<#A>).com.arkivanov.decompose.router.stack/replaceCurrent(#A, crossinline kotlin/Function0 = ...) // com.arkivanov.decompose.router.stack/replaceCurrent|replaceCurrent@com.arkivanov.decompose.router.stack.StackNavigator<0:0>(0:0;kotlin.Function0){0§}[0] +final object com.arkivanov.decompose/DecomposeExperimentFlags { // com.arkivanov.decompose/DecomposeExperimentFlags|null[0] + final var duplicateConfigurationsEnabled // com.arkivanov.decompose/DecomposeExperimentFlags.duplicateConfigurationsEnabled|{}duplicateConfigurationsEnabled[0] + final fun (): kotlin/Boolean // com.arkivanov.decompose/DecomposeExperimentFlags.duplicateConfigurationsEnabled.|(){}[0] + final fun (kotlin/Boolean) // com.arkivanov.decompose/DecomposeExperimentFlags.duplicateConfigurationsEnabled.|(kotlin.Boolean){}[0] +} final val com.arkivanov.decompose.router.slot/child // com.arkivanov.decompose.router.slot/child|@com.arkivanov.decompose.value.Value>{0§;1§}child[0] final fun <#A1: kotlin/Any, #B1: kotlin/Any> (com.arkivanov.decompose.value/Value>).(): com.arkivanov.decompose/Child.Created<#A1, #B1>? // com.arkivanov.decompose.router.slot/child.|@com.arkivanov.decompose.value.Value>(){0§;1§}[0] final val com.arkivanov.decompose.router.stack/active // com.arkivanov.decompose.router.stack/active|@com.arkivanov.decompose.value.Value>{0§;1§}active[0] @@ -258,12 +264,17 @@ sealed class <#A: out kotlin/Any, #B: out kotlin/Any> com.arkivanov.decompose/Ch abstract fun (): #A // com.arkivanov.decompose/Child.configuration.|(){}[0] abstract val instance // com.arkivanov.decompose/Child.instance|{}instance[0] abstract fun (): #B? // com.arkivanov.decompose/Child.instance.|(){}[0] + abstract val key // com.arkivanov.decompose/Child.key|{}key[0] + abstract fun (): kotlin/Any // com.arkivanov.decompose/Child.key.|(){}[0] constructor () // com.arkivanov.decompose/Child.|(){}[0] final class <#A1: out kotlin/Any, #B1: out kotlin/Any> Created : com.arkivanov.decompose/Child<#A1, #B1> { // com.arkivanov.decompose/Child.Created|null[0] constructor (#A1, #B1) // com.arkivanov.decompose/Child.Created.|(1:0;1:1){}[0] + constructor (#A1, #B1, kotlin/Any) // com.arkivanov.decompose/Child.Created.|(1:0;1:1;kotlin.Any){}[0] final fun component1(): #A1 // com.arkivanov.decompose/Child.Created.component1|component1(){}[0] final fun component2(): #B1 // com.arkivanov.decompose/Child.Created.component2|component2(){}[0] - final fun copy(#A1 = ..., #B1 = ...): com.arkivanov.decompose/Child.Created<#A1, #B1> // com.arkivanov.decompose/Child.Created.copy|copy(1:0;1:1){}[0] + final fun component3(): kotlin/Any // com.arkivanov.decompose/Child.Created.component3|component3(){}[0] + final fun copy(#A1 = ..., #B1 = ..., kotlin/Any = ...): com.arkivanov.decompose/Child.Created<#A1, #B1> // com.arkivanov.decompose/Child.Created.copy|copy(1:0;1:1;kotlin.Any){}[0] + final fun copy(#A1, #B1): com.arkivanov.decompose/Child<#A1, #B1> // com.arkivanov.decompose/Child.Created.copy|copy(1:0;1:1){}[0] final fun equals(kotlin/Any?): kotlin/Boolean // com.arkivanov.decompose/Child.Created.equals|equals(kotlin.Any?){}[0] final fun hashCode(): kotlin/Int // com.arkivanov.decompose/Child.Created.hashCode|hashCode(){}[0] final fun toString(): kotlin/String // com.arkivanov.decompose/Child.Created.toString|toString(){}[0] @@ -271,11 +282,16 @@ sealed class <#A: out kotlin/Any, #B: out kotlin/Any> com.arkivanov.decompose/Ch final fun (): #A1 // com.arkivanov.decompose/Child.Created.configuration.|(){}[0] final val instance // com.arkivanov.decompose/Child.Created.instance|{}instance[0] final fun (): #B1 // com.arkivanov.decompose/Child.Created.instance.|(){}[0] + final val key // com.arkivanov.decompose/Child.Created.key|{}key[0] + final fun (): kotlin/Any // com.arkivanov.decompose/Child.Created.key.|(){}[0] } final class <#A1: out kotlin/Any> Destroyed : com.arkivanov.decompose/Child<#A1, kotlin/Nothing> { // com.arkivanov.decompose/Child.Destroyed|null[0] constructor (#A1) // com.arkivanov.decompose/Child.Destroyed.|(1:0){}[0] + constructor (#A1, kotlin/Any) // com.arkivanov.decompose/Child.Destroyed.|(1:0;kotlin.Any){}[0] final fun component1(): #A1 // com.arkivanov.decompose/Child.Destroyed.component1|component1(){}[0] - final fun copy(#A1 = ...): com.arkivanov.decompose/Child.Destroyed<#A1> // com.arkivanov.decompose/Child.Destroyed.copy|copy(1:0){}[0] + final fun component2(): kotlin/Any // com.arkivanov.decompose/Child.Destroyed.component2|component2(){}[0] + final fun copy(#A1 = ..., kotlin/Any = ...): com.arkivanov.decompose/Child.Destroyed<#A1> // com.arkivanov.decompose/Child.Destroyed.copy|copy(1:0;kotlin.Any){}[0] + final fun copy(#A1): com.arkivanov.decompose/Child<#A1, kotlin/Nothing> // com.arkivanov.decompose/Child.Destroyed.copy|copy(1:0){}[0] final fun equals(kotlin/Any?): kotlin/Boolean // com.arkivanov.decompose/Child.Destroyed.equals|equals(kotlin.Any?){}[0] final fun hashCode(): kotlin/Int // com.arkivanov.decompose/Child.Destroyed.hashCode|hashCode(){}[0] final fun toString(): kotlin/String // com.arkivanov.decompose/Child.Destroyed.toString|toString(){}[0] @@ -283,6 +299,8 @@ sealed class <#A: out kotlin/Any, #B: out kotlin/Any> com.arkivanov.decompose/Ch final fun (): #A1 // com.arkivanov.decompose/Child.Destroyed.configuration.|(){}[0] final val instance // com.arkivanov.decompose/Child.Destroyed.instance|{}instance[0] final fun (): kotlin/Nothing? // com.arkivanov.decompose/Child.Destroyed.instance.|(){}[0] + final val key // com.arkivanov.decompose/Child.Destroyed.key|{}key[0] + final fun (): kotlin/Any // com.arkivanov.decompose/Child.Destroyed.key.|(){}[0] } } // Targets: [js, wasmJs] diff --git a/decompose/api/jvm/decompose.api b/decompose/api/jvm/decompose.api index 83717bbf5..9280659b0 100644 --- a/decompose/api/jvm/decompose.api +++ b/decompose/api/jvm/decompose.api @@ -5,30 +5,39 @@ public abstract interface class com/arkivanov/decompose/Cancellation { public abstract class com/arkivanov/decompose/Child { public abstract fun getConfiguration ()Ljava/lang/Object; public abstract fun getInstance ()Ljava/lang/Object; + public abstract fun getKey ()Ljava/lang/Object; } public final class com/arkivanov/decompose/Child$Created : com/arkivanov/decompose/Child { public fun (Ljava/lang/Object;Ljava/lang/Object;)V + public fun (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)V public final fun component1 ()Ljava/lang/Object; public final fun component2 ()Ljava/lang/Object; - public final fun copy (Ljava/lang/Object;Ljava/lang/Object;)Lcom/arkivanov/decompose/Child$Created; - public static synthetic fun copy$default (Lcom/arkivanov/decompose/Child$Created;Ljava/lang/Object;Ljava/lang/Object;ILjava/lang/Object;)Lcom/arkivanov/decompose/Child$Created; + public final fun component3 ()Ljava/lang/Object; + public final synthetic fun copy (Ljava/lang/Object;Ljava/lang/Object;)Lcom/arkivanov/decompose/Child; + public final fun copy (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Lcom/arkivanov/decompose/Child$Created; + public static synthetic fun copy$default (Lcom/arkivanov/decompose/Child$Created;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;ILjava/lang/Object;)Lcom/arkivanov/decompose/Child$Created; public fun equals (Ljava/lang/Object;)Z public fun getConfiguration ()Ljava/lang/Object; public fun getInstance ()Ljava/lang/Object; + public fun getKey ()Ljava/lang/Object; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/arkivanov/decompose/Child$Destroyed : com/arkivanov/decompose/Child { public fun (Ljava/lang/Object;)V + public fun (Ljava/lang/Object;Ljava/lang/Object;)V public final fun component1 ()Ljava/lang/Object; - public final fun copy (Ljava/lang/Object;)Lcom/arkivanov/decompose/Child$Destroyed; - public static synthetic fun copy$default (Lcom/arkivanov/decompose/Child$Destroyed;Ljava/lang/Object;ILjava/lang/Object;)Lcom/arkivanov/decompose/Child$Destroyed; + public final fun component2 ()Ljava/lang/Object; + public final synthetic fun copy (Ljava/lang/Object;)Lcom/arkivanov/decompose/Child; + public final fun copy (Ljava/lang/Object;Ljava/lang/Object;)Lcom/arkivanov/decompose/Child$Destroyed; + public static synthetic fun copy$default (Lcom/arkivanov/decompose/Child$Destroyed;Ljava/lang/Object;Ljava/lang/Object;ILjava/lang/Object;)Lcom/arkivanov/decompose/Child$Destroyed; public fun equals (Ljava/lang/Object;)Z public fun getConfiguration ()Ljava/lang/Object; public synthetic fun getInstance ()Ljava/lang/Object; public fun getInstance ()Ljava/lang/Void; + public fun getKey ()Ljava/lang/Object; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -49,6 +58,12 @@ public abstract interface class com/arkivanov/decompose/ComponentContextFactoryO public abstract fun getComponentContextFactory ()Lcom/arkivanov/decompose/ComponentContextFactory; } +public final class com/arkivanov/decompose/DecomposeExperimentFlags { + public static final field INSTANCE Lcom/arkivanov/decompose/DecomposeExperimentFlags; + public final fun getDuplicateConfigurationsEnabled ()Z + public final fun setDuplicateConfigurationsEnabled (Z)V +} + public final class com/arkivanov/decompose/DefaultComponentContext : com/arkivanov/decompose/ComponentContext { public fun (Lcom/arkivanov/essenty/lifecycle/Lifecycle;)V public fun (Lcom/arkivanov/essenty/lifecycle/Lifecycle;Lcom/arkivanov/essenty/statekeeper/StateKeeper;Lcom/arkivanov/essenty/instancekeeper/InstanceKeeper;Lcom/arkivanov/essenty/backhandler/BackHandler;)V @@ -250,6 +265,7 @@ public final class com/arkivanov/decompose/router/stack/ChildStack { public fun (Lcom/arkivanov/decompose/Child$Created;Ljava/util/List;)V public synthetic fun (Lcom/arkivanov/decompose/Child$Created;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Ljava/lang/Object;Ljava/lang/Object;)V + public fun (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)V public final fun component1 ()Lcom/arkivanov/decompose/Child$Created; public final fun component2 ()Ljava/util/List; public final fun copy (Lcom/arkivanov/decompose/Child$Created;Ljava/util/List;)Lcom/arkivanov/decompose/router/stack/ChildStack; diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/Child.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/Child.kt index 8d14111ae..ca3b5296f 100644 --- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/Child.kt +++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/Child.kt @@ -5,14 +5,42 @@ sealed class Child { abstract val configuration: C abstract val instance: T? - data class Created( + @ExperimentalDecomposeApi + abstract val key: Any + + data class Created @ExperimentalDecomposeApi constructor( override val configuration: C, override val instance: T, - ) : Child() - data class Destroyed( + @property:ExperimentalDecomposeApi + override val key: Any, + ) : Child() { + constructor(configuration: C, instance: T) : this( + configuration = configuration, + instance = instance, + key = configuration, + ) + + @Deprecated(message = "For binary compatibility", level = DeprecationLevel.HIDDEN) + fun copy(configuration: @UnsafeVariance C, instance: @UnsafeVariance T): Child = + copy(configuration = configuration, instance = instance) + } + + data class Destroyed @ExperimentalDecomposeApi constructor( override val configuration: C, + + @property:ExperimentalDecomposeApi + override val key: Any, ) : Child() { + constructor(configuration: C) : this( + configuration = configuration, + key = configuration, + ) + override val instance: Nothing? = null + + @Deprecated(message = "For binary compatibility", level = DeprecationLevel.HIDDEN) + fun copy(configuration: @UnsafeVariance C): Child = + copy(configuration = configuration) } } diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/DecomposeExperimentFlags.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/DecomposeExperimentFlags.kt new file mode 100644 index 000000000..72d3bda2f --- /dev/null +++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/DecomposeExperimentFlags.kt @@ -0,0 +1,7 @@ +package com.arkivanov.decompose + +@ExperimentalDecomposeApi +object DecomposeExperimentFlags { + + var duplicateConfigurationsEnabled: Boolean = false +} diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/GettingList.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/GettingList.kt index 2de29f76f..c60aecd6a 100644 --- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/GettingList.kt +++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/GettingList.kt @@ -8,3 +8,6 @@ internal class GettingList( override fun get(index: Int): T = get.invoke(index) } + +internal inline fun List.mapped(crossinline mapper: (T) -> R): List = + GettingList(size = size) { mapper(get(it)) } diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/Utils.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/Utils.kt index c63206ebd..bf6ec7e93 100644 --- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/Utils.kt +++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/Utils.kt @@ -10,3 +10,14 @@ fun Any.hashString(): String = internal expect val KClass<*>.uniqueName: String? internal val Lifecycle.isDestroyed: Boolean get() = state == Lifecycle.State.DESTROYED + +internal fun List.keyed(configuration: (T) -> C): Map { + val numbers = HashMap() + + return associateBy { item -> + val config = configuration(item) + val number = (numbers[config] ?: 0) + 1 + numbers[config] = number + config to number + } +} diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/children/ChildrenFactory.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/children/ChildrenFactory.kt index 8fb53e2bf..f38772333 100644 --- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/children/ChildrenFactory.kt +++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/children/ChildrenFactory.kt @@ -91,7 +91,8 @@ fun , C : Any, T : Any, E : Any, N : NavState * The navigation state is not saved if `null` is returned. * @param restoreState a function that restores the navigation state from the provided [SerializableContainer]. * If `null` is returned then [initialState] is used instead. - * The restored navigation state must have the same amount of child configurations and in the same order. + * The restored navigation state must have the same amount of child configurations and in the same order, + * otherwise the behaviour is undefined. * The restored child [Statuses][ChildNavState.Status] can be any, e.g. a previously active child may become * destroyed, etc. * @param navTransformer a function that transforms the current navigation state to a new one using the provided diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/children/ChildrenNavigator.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/children/ChildrenNavigator.kt index 17b3addba..34eec122a 100644 --- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/children/ChildrenNavigator.kt +++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/children/ChildrenNavigator.kt @@ -1,6 +1,9 @@ package com.arkivanov.decompose.router.children import com.arkivanov.decompose.Child +import com.arkivanov.decompose.DecomposeExperimentFlags +import com.arkivanov.decompose.keyed +import com.arkivanov.decompose.mapped import com.arkivanov.decompose.router.children.ChildItem.Created import com.arkivanov.decompose.router.children.ChildItem.Destroyed import com.arkivanov.decompose.router.children.ChildNavState.Status @@ -29,14 +32,31 @@ internal class ChildrenNavigator>( val children: List> get() = - items.map { item -> - val instance = item.instance - if (instance != null) { - Child.Created(configuration = item.configuration, instance = instance) - } else { - Child.Destroyed(configuration = item.configuration) - } + if (DecomposeExperimentFlags.duplicateConfigurationsEnabled) { + getChildrenExperimental() + } else { + getChildrenDefault() + } + + private fun getChildrenDefault(): List> = + items.map { item -> + val instance = item.instance + if (instance != null) { + Child.Created(configuration = item.configuration, instance = instance) + } else { + Child.Destroyed(configuration = item.configuration) } + } + + private fun getChildrenExperimental(): List> = + items.keyed { it.configuration }.map { (key, item) -> + val instance = item.instance + if (instance != null) { + Child.Created(key = key, configuration = item.configuration, instance = instance) + } else { + Child.Destroyed(key = key, configuration = item.configuration) + } + } @Suppress("UNCHECKED_CAST") private val retainedInstance = retainedInstanceSupplier { RetainedInstance() } as RetainedInstance @@ -64,6 +84,14 @@ internal class ChildrenNavigator>( } private fun restore(navState: N, savedStates: List) { + if (DecomposeExperimentFlags.duplicateConfigurationsEnabled) { + restoreExperimental(navState, savedStates) + } else { + restoreDefault(navState, savedStates) + } + } + + private fun restoreDefault(navState: N, savedStates: List) { val retainedChildren = retainedInstance.items.associateByTo(HashMap(), Created::configuration) retainedInstance.items.clear() @@ -87,6 +115,35 @@ internal class ChildrenNavigator>( retainedChildren.values.forEach { it.instanceKeeperDispatcher.destroy() } } + private fun restoreExperimental(navState: N, savedStates: List) { + val retainedChildren = HashMap>() + retainedInstance.items.forEachIndexed(retainedChildren::put) + val restoreRetainedChildren = navState.children.mapped { it.configuration } == retainedInstance.items.mapped { it.configuration } + retainedInstance.items.clear() + + navState.children.zip(savedStates).forEachIndexed { index, (childNavState, savedState) -> + items += + restoreItem( + status = childNavState.status, + getDestroyedItem = { Destroyed(configuration = childNavState.configuration, savedState = savedState) }, + getCreatedItem = { + childItemFactory( + configuration = childNavState.configuration, + savedState = savedState, + instanceKeeperDispatcher = retainedChildren + .takeIf { restoreRetainedChildren } + ?.remove(index) + ?.instanceKeeperDispatcher, + ).also { + retainedInstance.items += it + } + } + ) + } + + retainedChildren.values.forEach { it.instanceKeeperDispatcher.destroy() } + } + private inline fun restoreItem( status: Status, getDestroyedItem: () -> Destroyed, @@ -127,18 +184,26 @@ internal class ChildrenNavigator>( } private fun switch(newStates: List>) { + if (DecomposeExperimentFlags.duplicateConfigurationsEnabled) { + switchExperimental(newStates) + } else { + switchDefault(newStates) + } + } + + private fun switchDefault(newStates: List>) { val newConfigurations = newStates.mapTo(HashSet(), ChildNavState::configuration) check(newConfigurations.size == newStates.size) { "Configurations must be unique: ${newStates.map(ChildNavState::configuration)}." } val oldItems = items.associateBy(ChildItem::configuration) - val newItems = prepareNewItems(newStates = newStates, oldItems = oldItems) - destroyOldItems(newConfigurations = newConfigurations, oldItems = oldItems.values) + val newItems = prepareNewItemsDefault(newStates = newStates, oldItems = oldItems) + destroyOldItemsDefault(newConfigurations = newConfigurations, oldItems = oldItems.values) processNewItems(newItems = newItems) } - private fun prepareNewItems( + private fun prepareNewItemsDefault( newStates: List>, oldItems: Map>, ): List, Status>> { @@ -182,7 +247,7 @@ internal class ChildrenNavigator>( return newItems } - private fun destroyOldItems( + private fun destroyOldItemsDefault( newConfigurations: Set, oldItems: Collection>, ) { @@ -194,6 +259,70 @@ internal class ChildrenNavigator>( } } + private fun switchExperimental(newStates: List>) { + val newKeyedStates = newStates.keyed(ChildNavState::configuration) + val oldKeyedItems = items.keyed(ChildItem::configuration) + val newItems = prepareNewItemsExperimental(newStates = newKeyedStates, oldItems = oldKeyedItems) + destroyOldItemsExperimental(newKeys = newKeyedStates.keys, oldItems = oldKeyedItems) + processNewItems(newItems = newItems) + } + + private fun prepareNewItemsExperimental( + newStates: Map>, + oldItems: Map>, + ): List, Status>> { + val newItems = ArrayList, Status>>(newStates.size) + + newStates.forEach { (key, state) -> + newItems += + when (val child = oldItems[key]) { + is Created -> child to state.status + + is Destroyed -> + when (state.status) { + Status.DESTROYED -> child to state.status + + Status.CREATED, + Status.STARTED, + Status.RESUMED -> + Pair( + first = childItemFactory(configuration = state.configuration, savedState = child.savedState) + .apply { lifecycleRegistry.create() }, + second = state.status, + ) + } + + null -> + when (state.status) { + Status.DESTROYED -> Destroyed(configuration = state.configuration) to state.status + + Status.CREATED, + Status.STARTED, + Status.RESUMED -> + Pair( + first = childItemFactory(configuration = state.configuration) + .apply { lifecycleRegistry.create() }, + second = state.status, + ) + } + } + } + + return newItems + } + + private fun destroyOldItemsExperimental( + newKeys: Set, + oldItems: Map>, + ) { + for ((key, item) in oldItems) { + val child = item as? Created ?: continue + if (key !in newKeys) { + child.destroy() + } + } + } + private fun processNewItems(newItems: List, Status>>) { items.clear() retainedInstance.items.clear() diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/ChildStack.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/ChildStack.kt index 53c88f4c2..fb7578979 100644 --- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/ChildStack.kt +++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/ChildStack.kt @@ -1,6 +1,7 @@ package com.arkivanov.decompose.router.stack import com.arkivanov.decompose.Child +import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.GettingList /** @@ -15,15 +16,26 @@ data class ChildStack( ) { /** - * Creates [ChildStack] with only one child with the specified [configuration] and [instance]. + * Creates [ChildStack] with only one child with the specified [configuration], [instance] and [key]. */ - constructor(configuration: C, instance: T) : this( + @ExperimentalDecomposeApi + constructor(configuration: C, instance: T, key: Any) : this( active = Child.Created( configuration = configuration, - instance = instance + instance = instance, + key = key, ), ) + /** + * Creates [ChildStack] with only one child with the specified [configuration] and [instance]. + */ + constructor(configuration: C, instance: T) : this( + configuration = configuration, + instance = instance, + key = configuration, + ) + /** * Returns the full stack of component configurations, ordered from tail to head. */ diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigatorExt.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigatorExt.kt index e3a1d5deb..0fb41e77b 100644 --- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigatorExt.kt +++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigatorExt.kt @@ -11,6 +11,9 @@ fun StackNavigator.navigate(transformer: (stack: List) -> List StackNavigator.push(configuration: C, crossinline onComp * [configuration] is already on top of the stack. * * Decompose will throw an exception if the provided [configuration] is already present in the - * back stack (not at the top of the stack). + * back stack (not at the top of the stack). You can also try enabling the experimental + * [Duplicate Configurations][com.arkivanov.decompose.DecomposeExperimentFlags.duplicateConfigurationsEnabled] feature + * to avoid the error. * * This can be useful when pushing a component on button click, to avoid pushing the same component * if the user clicks the same button quickly multiple times. diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenBasicTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenBasicTest.kt index 7081711e7..40d59143e 100644 --- a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenBasicTest.kt +++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenBasicTest.kt @@ -1,5 +1,6 @@ package com.arkivanov.decompose.router.children +import com.arkivanov.decompose.DecomposeExperimentFlags import com.arkivanov.decompose.DefaultComponentContext import com.arkivanov.decompose.router.TestInstance import com.arkivanov.decompose.router.children.ChildNavState.Status.CREATED @@ -13,6 +14,7 @@ import com.arkivanov.essenty.lifecycle.destroy import com.arkivanov.essenty.lifecycle.doOnDestroy import kotlin.test.Test import kotlin.test.assertContentEquals +import kotlin.test.assertEquals import kotlin.test.assertFalse @Suppress("TestFunctionName") @@ -216,4 +218,100 @@ class ChildrenBasicTest : ChildrenTestBase() { children.assertChildren(1 to 1, 2 to 2) } + + @Test + fun WHEN_add_duplicated_children_THEN_duplicated_children_added() { + DecomposeExperimentFlags.duplicateConfigurationsEnabled = true + val children by context.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) + + navigate { it + listOf(1 by RESUMED, 2 by RESUMED, 3 by RESUMED) } + + children.assertChildren(1 to null, 2 to 2, 3 to 3, 4 to 4, 1 to 1, 2 to 2, 3 to 3) + } + + @Test + fun GIVEN_duplicated_children_WHEN_remove_duplicated_children_from_end_THEN_duplicated_children_removed() { + DecomposeExperimentFlags.duplicateConfigurationsEnabled = true + + val children by context.children( + initialState = stateOf( + 1 by DESTROYED, + 2 by CREATED, + 3 by STARTED, + 4 by RESUMED, + 1 by RESUMED, + 2 by RESUMED, + 3 by RESUMED, + ), + ) + + val instances = children.map { it.instance } + + navigate { it.dropLast(3) } + + children.assertChildren(1 to null, 2 to 2, 3 to 3, 4 to 4) + assertEquals(instances.dropLast(3), children.map { it.instance }) + } + + @Test + fun GIVEN_duplicated_children_WHEN_remove_duplicated_children_from_end_THEN_duplicated_instances_removed_from_end() { + DecomposeExperimentFlags.duplicateConfigurationsEnabled = true + + val children by context.children( + initialState = stateOf( + 1 by DESTROYED, + 2 by CREATED, + 3 by STARTED, + 4 by RESUMED, + 1 by RESUMED, + 2 by RESUMED, + 3 by RESUMED, + ), + ) + + val instances = children.instances() + + navigate { it.dropLast(3) } + + assertEquals(instances.dropLast(3), children.instances()) + } + + @Test + fun GIVEN_duplicated_children_WHEN_remove_duplicated_children_from_start_THEN_duplicated_children_removed() { + DecomposeExperimentFlags.duplicateConfigurationsEnabled = true + + val children by context.children( + initialState = stateOf( + 1 by CREATED, + 2 by STARTED, + 3 by RESUMED, + 1 by RESUMED, + 2 by RESUMED, + ), + ) + + navigate { it.drop(2) } + + children.assertChildren(3 to 3, 1 to 1, 2 to 2) + } + @Test + fun GIVEN_duplicated_children_WHEN_remove_duplicated_children_from_start_THEN_duplicated_instances_removed_from_end() { + DecomposeExperimentFlags.duplicateConfigurationsEnabled = true + + val children by context.children( + initialState = stateOf( + 1 by CREATED, + 2 by STARTED, + 3 by RESUMED, + 1 by RESUMED, + 2 by RESUMED, + ), + ) + + val instances = children.instances() + + navigate { it.drop(2) } + + assertEquals(listOf(instances[2], instances[0], instances[1]), children.instances()) + } } diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenLifecycleTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenLifecycleTest.kt index fe34fcea5..20306a5ea 100644 --- a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenLifecycleTest.kt +++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenLifecycleTest.kt @@ -1,5 +1,6 @@ package com.arkivanov.decompose.router.children +import com.arkivanov.decompose.DecomposeExperimentFlags import com.arkivanov.decompose.router.children.ChildNavState.Status.RESUMED import com.arkivanov.decompose.router.children.ChildNavState.Status.DESTROYED import com.arkivanov.decompose.router.children.ChildNavState.Status.CREATED @@ -280,4 +281,48 @@ class ChildrenLifecycleTest : ChildrenTestBase() { assertEquals(Lifecycle.State.DESTROYED, component3.lifecycle.state) assertEquals(Lifecycle.State.DESTROYED, component4.lifecycle.state) } + + @Test + fun GIVEN_first_and_last_children_duplicated_WHEN_last_child_removed_THEN_last_child_destroyed() { + DecomposeExperimentFlags.duplicateConfigurationsEnabled = true + val children by context.children(initialState = stateOf(1 by CREATED, 2 by STARTED, 1 by RESUMED)) + val child = children.last().requireInstance() + + navigate { it.dropLast(1) } + + assertEquals(Lifecycle.State.DESTROYED, child.lifecycle.state) + } + + @Test + fun GIVEN_first_and_last_children_duplicated_first_child_created_WHEN_last_child_removed_THEN_first_child_created() { + DecomposeExperimentFlags.duplicateConfigurationsEnabled = true + val children by context.children(initialState = stateOf(1 by CREATED, 2 by STARTED, 1 by RESUMED)) + val child = children.first().requireInstance() + + navigate { it.dropLast(1) } + + assertEquals(Lifecycle.State.CREATED, child.lifecycle.state) + } + + @Test + fun GIVEN_first_and_last_children_duplicated_WHEN_first_child_removed_THEN_last_child_destroyed() { + DecomposeExperimentFlags.duplicateConfigurationsEnabled = true + val children by context.children(initialState = stateOf(1 by CREATED, 2 by STARTED, 1 by RESUMED)) + val child = children.last().requireInstance() + + navigate { it.drop(1) } + + assertEquals(Lifecycle.State.DESTROYED, child.lifecycle.state) + } + + @Test + fun GIVEN_first_and_last_children_duplicated_and_last_child_resumed_WHEN_first_child_removed_THEN_first_child_resumed() { + DecomposeExperimentFlags.duplicateConfigurationsEnabled = true + val children by context.children(initialState = stateOf(1 by CREATED, 2 by STARTED, 1 by RESUMED)) + val child = children.first().requireInstance() + + navigate { it.drop(1) } + + assertEquals(Lifecycle.State.RESUMED, child.lifecycle.state) + } } diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenRetainedInstanceTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenRetainedInstanceTest.kt index 1ca246db4..1e3103f62 100644 --- a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenRetainedInstanceTest.kt +++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenRetainedInstanceTest.kt @@ -1,10 +1,11 @@ package com.arkivanov.decompose.router.children +import com.arkivanov.decompose.DecomposeExperimentFlags import com.arkivanov.decompose.DefaultComponentContext import com.arkivanov.decompose.router.TestInstance -import com.arkivanov.decompose.router.children.ChildNavState.Status.RESUMED -import com.arkivanov.decompose.router.children.ChildNavState.Status.DESTROYED import com.arkivanov.decompose.router.children.ChildNavState.Status.CREATED +import com.arkivanov.decompose.router.children.ChildNavState.Status.DESTROYED +import com.arkivanov.decompose.router.children.ChildNavState.Status.RESUMED import com.arkivanov.decompose.router.children.ChildNavState.Status.STARTED import com.arkivanov.decompose.statekeeper.TestStateKeeperDispatcher import com.arkivanov.decompose.value.getValue @@ -22,7 +23,7 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { @Test fun WHEN_child_switched_from_created_to_destroyed_THEN_instance_destroyed() { val children by context.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val instance = children.getByConfig(config = 2).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val instance = children.getByConfig(2).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) navigate { listOf(1 by DESTROYED, 2 by DESTROYED, 3 by STARTED, 4 by RESUMED) } @@ -32,7 +33,7 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { @Test fun WHEN_child_switched_from_started_to_destroyed_THEN_instance_destroyed() { val children by context.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val instance = children.getByConfig(config = 3).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val instance = children.getByConfig(3).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) navigate { listOf(1 by DESTROYED, 2 by CREATED, 3 by DESTROYED, 4 by RESUMED) } @@ -42,7 +43,7 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { @Test fun WHEN_child_switched_from_started_to_created_THEN_instance_not_destroyed() { val children by context.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val instance = children.getByConfig(config = 3).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val instance = children.getByConfig(3).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) navigate { listOf(1 by DESTROYED, 2 by CREATED, 3 by CREATED, 4 by RESUMED) } @@ -52,7 +53,7 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { @Test fun WHEN_child_switched_from_resumed_to_destroyed_THEN_instance_destroyed() { val children by context.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val instance = children.getByConfig(config = 4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val instance = children.getByConfig(4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) navigate { listOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by DESTROYED) } @@ -62,7 +63,7 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { @Test fun WHEN_child_switched_from_resumed_to_created_THEN_instance_not_destroyed() { val children by context.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val instance = children.getByConfig(config = 4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val instance = children.getByConfig(4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) navigate { listOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by CREATED) } @@ -72,7 +73,7 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { @Test fun WHEN_child_switched_from_resumed_to_started_THEN_instance_not_destroyed() { val children by context.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val instance = children.getByConfig(config = 4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val instance = children.getByConfig(4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) navigate { listOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by STARTED) } @@ -82,11 +83,11 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { @Test fun GIVEN_child_switched_from_created_to_destroyed_WHEN_child_switched_to_created_THEN_instance_not_retained() { val children by context.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val oldInstance = children.getByConfig(config = 2).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val oldInstance = children.getByConfig(2).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) navigate { listOf(1 by DESTROYED, 2 by DESTROYED, 3 by STARTED, 4 by RESUMED) } navigate { listOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED) } - val newInstance = children.getByConfig(config = 2).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val newInstance = children.getByConfig(2).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) assertNotSame(oldInstance, newInstance) } @@ -94,11 +95,11 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { @Test fun GIVEN_child_switched_from_started_to_destroyed_WHEN_child_switched_to_started_THEN_instance_not_retained() { val children by context.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val oldInstance = children.getByConfig(config = 3).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val oldInstance = children.getByConfig(3).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) navigate { listOf(1 by DESTROYED, 2 by CREATED, 3 by DESTROYED, 4 by RESUMED) } navigate { listOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED) } - val newInstance = children.getByConfig(config = 3).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val newInstance = children.getByConfig(3).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) assertNotSame(oldInstance, newInstance) } @@ -106,11 +107,11 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { @Test fun GIVEN_child_switched_from_resumed_to_destroyed_WHEN_child_switched_to_resumed_THEN_instance_not_retained() { val children by context.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val oldInstance = children.getByConfig(config = 4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val oldInstance = children.getByConfig(4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) navigate { listOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by DESTROYED) } navigate { listOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED) } - val newInstance = children.getByConfig(config = 4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val newInstance = children.getByConfig(4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) assertNotSame(oldInstance, newInstance) } @@ -121,13 +122,13 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { val instanceKeeper = InstanceKeeperDispatcher() val oldContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = oldStateKeeper, instanceKeeper = instanceKeeper) val oldChildren by oldContext.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val oldInstance = oldChildren.getByConfig(config = 2).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val oldInstance = oldChildren.getByConfig(2).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) val savedState = oldStateKeeper.save() val newStateKeeper = TestStateKeeperDispatcher(savedState) val newContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = newStateKeeper, instanceKeeper = instanceKeeper) val newChildren by newContext.children() - val newInstance = newChildren.getByConfig(config = 2).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val newInstance = newChildren.getByConfig(2).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) assertSame(oldInstance, newInstance) } @@ -138,7 +139,7 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { val instanceKeeper = InstanceKeeperDispatcher() val oldContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = oldStateKeeper, instanceKeeper = instanceKeeper) val oldChildren by oldContext.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val instance = oldChildren.getByConfig(config = 2).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val instance = oldChildren.getByConfig(2).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) val savedState = oldStateKeeper.save() val newStateKeeper = TestStateKeeperDispatcher(savedState) @@ -154,13 +155,13 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { val instanceKeeper = InstanceKeeperDispatcher() val oldContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = oldStateKeeper, instanceKeeper = instanceKeeper) val oldChildren by oldContext.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val oldInstance = oldChildren.getByConfig(config = 3).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val oldInstance = oldChildren.getByConfig(3).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) val savedState = oldStateKeeper.save() val newStateKeeper = TestStateKeeperDispatcher(savedState) val newContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = newStateKeeper, instanceKeeper = instanceKeeper) val newChildren by newContext.children() - val newInstance = newChildren.getByConfig(config = 3).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val newInstance = newChildren.getByConfig(3).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) assertSame(oldInstance, newInstance) } @@ -171,7 +172,7 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { val instanceKeeper = InstanceKeeperDispatcher() val oldContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = oldStateKeeper, instanceKeeper = instanceKeeper) val oldChildren by oldContext.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val instance = oldChildren.getByConfig(config = 3).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val instance = oldChildren.getByConfig(3).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) val savedState = oldStateKeeper.save() val newStateKeeper = TestStateKeeperDispatcher(savedState) @@ -187,13 +188,13 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { val instanceKeeper = InstanceKeeperDispatcher() val oldContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = oldStateKeeper, instanceKeeper = instanceKeeper) val oldChildren by oldContext.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val oldInstance = oldChildren.getByConfig(config = 4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val oldInstance = oldChildren.getByConfig(4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) val savedState = oldStateKeeper.save() val newStateKeeper = TestStateKeeperDispatcher(savedState) val newContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = newStateKeeper, instanceKeeper = instanceKeeper) val newChildren by newContext.children() - val newInstance = newChildren.getByConfig(config = 4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val newInstance = newChildren.getByConfig(4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) assertSame(oldInstance, newInstance) } @@ -204,7 +205,7 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { val instanceKeeper = InstanceKeeperDispatcher() val oldContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = oldStateKeeper, instanceKeeper = instanceKeeper) val oldChildren by oldContext.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val instance = oldChildren.getByConfig(config = 4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val instance = oldChildren.getByConfig(4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) val savedState = oldStateKeeper.save() val newStateKeeper = TestStateKeeperDispatcher(savedState) @@ -220,7 +221,7 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { val instanceKeeper = InstanceKeeperDispatcher() val oldContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = oldStateKeeper, instanceKeeper = instanceKeeper) val oldChildren by oldContext.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val instance = oldChildren.getByConfig(config = 2).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val instance = oldChildren.getByConfig(2).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) val savedState = oldStateKeeper.save() val newStateKeeper = TestStateKeeperDispatcher(savedState) @@ -236,7 +237,7 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { val instanceKeeper = InstanceKeeperDispatcher() val oldContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = oldStateKeeper, instanceKeeper = instanceKeeper) val oldChildren by oldContext.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val instance = oldChildren.getByConfig(config = 3).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val instance = oldChildren.getByConfig(3).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) val savedState = oldStateKeeper.save() val newStateKeeper = TestStateKeeperDispatcher(savedState) @@ -252,7 +253,7 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { val instanceKeeper = InstanceKeeperDispatcher() val oldContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = oldStateKeeper, instanceKeeper = instanceKeeper) val oldChildren by oldContext.children(initialState = stateOf(1 by DESTROYED, 2 by CREATED, 3 by STARTED, 4 by RESUMED)) - val instance = oldChildren.getByConfig(config = 4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val instance = oldChildren.getByConfig(4).requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) val savedState = oldStateKeeper.save() val newStateKeeper = TestStateKeeperDispatcher(savedState) @@ -261,4 +262,44 @@ class ChildrenRetainedInstanceTest : ChildrenTestBase() { assertTrue(instance.isDestroyed) } + + @Test + fun WHEN_duplicated_children_recreated_THEN_instances_retained() { + DecomposeExperimentFlags.duplicateConfigurationsEnabled = true + val oldStateKeeper = TestStateKeeperDispatcher() + val instanceKeeper = InstanceKeeperDispatcher() + val oldContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = oldStateKeeper, instanceKeeper = instanceKeeper) + val oldChildren by oldContext.children(initialState = stateOf(1 by CREATED, 2 by STARTED, 1 by RESUMED)) + val oldInstance1 = oldChildren.first().requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val oldInstance3 = oldChildren.last().requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + + val savedState = oldStateKeeper.save() + val newStateKeeper = TestStateKeeperDispatcher(savedState) + val newContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = newStateKeeper, instanceKeeper = instanceKeeper) + val newChildren by newContext.children() + val newInstance1 = newChildren.first().requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val newInstance3 = newChildren.last().requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + + assertSame(oldInstance1, newInstance1) + assertSame(oldInstance3, newInstance3) + } + + @Test + fun WHEN_duplicated_children_recreated_THEN_instances_not_destroyed() { + DecomposeExperimentFlags.duplicateConfigurationsEnabled = true + val oldStateKeeper = TestStateKeeperDispatcher() + val instanceKeeper = InstanceKeeperDispatcher() + val oldContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = oldStateKeeper, instanceKeeper = instanceKeeper) + val oldChildren by oldContext.children(initialState = stateOf(1 by CREATED, 2 by STARTED, 1 by RESUMED)) + val oldInstance1 = oldChildren.first().requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + val oldInstance3 = oldChildren.last().requireInstance().instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance) + + val savedState = oldStateKeeper.save() + val newStateKeeper = TestStateKeeperDispatcher(savedState) + val newContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = newStateKeeper, instanceKeeper = instanceKeeper) + newContext.children() + + assertFalse(oldInstance1.isDestroyed) + assertFalse(oldInstance3.isDestroyed) + } } diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenSavedStateTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenSavedStateTest.kt index ed64c127d..4adcca78e 100644 --- a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenSavedStateTest.kt +++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenSavedStateTest.kt @@ -1,5 +1,6 @@ package com.arkivanov.decompose.router.children +import com.arkivanov.decompose.DecomposeExperimentFlags import com.arkivanov.decompose.DefaultComponentContext import com.arkivanov.decompose.consume import com.arkivanov.decompose.register @@ -551,4 +552,42 @@ class ChildrenSavedStateTest : ChildrenTestBase() { assertEquals(31, restoredState2) } + + @Test + fun GIVEN_first_and_last_children_duplicated_WHEN_recreated_THEN_states_restored() { + DecomposeExperimentFlags.duplicateConfigurationsEnabled = true + val oldStateKeeper = TestStateKeeperDispatcher() + val oldContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = oldStateKeeper) + val oldChildren by oldContext.children(initialState = stateOf(1 by CREATED, 2 by STARTED, 1 by RESUMED)) + oldChildren.first().requireInstance().stateKeeper.register("key") { 10 } + oldChildren.last().requireInstance().stateKeeper.register("key") { 30 } + + val savedState = oldStateKeeper.save() + val newStateKeeper = TestStateKeeperDispatcher(savedState) + val newContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = newStateKeeper) + val newChildren by newContext.children() + + val restoredState1 = newChildren.first().requireInstance().stateKeeper.consume("key") + val restoredState3 = newChildren.last().requireInstance().stateKeeper.consume("key") + + assertEquals(10, restoredState1) + assertEquals(30, restoredState3) + } + + @Test + fun GIVEN_first_and_last_children_duplicated_WHEN_children_recreated_THEN_states_restored() { + DecomposeExperimentFlags.duplicateConfigurationsEnabled = true + val children by context.children(initialState = stateOf(1 by CREATED, 2 by STARTED, 1 by RESUMED)) + children.first().requireInstance().stateKeeper.register("key") { 10 } + children.last().requireInstance().stateKeeper.register("key") { 30 } + + navigate { listOf(1 by DESTROYED, 2 by STARTED, 1 by DESTROYED) } + navigate { listOf(1 by CREATED, 2 by STARTED, 1 by RESUMED) } + + val restoredState1 = children.first().requireInstance().stateKeeper.consume("key") + val restoredState3 = children.last().requireInstance().stateKeeper.consume("key") + + assertEquals(10, restoredState1) + assertEquals(30, restoredState3) + } } diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenTestBase.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenTestBase.kt index 882a99daa..eb1742d37 100644 --- a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenTestBase.kt +++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/children/ChildrenTestBase.kt @@ -2,6 +2,7 @@ package com.arkivanov.decompose.router.children import com.arkivanov.decompose.Child import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.DecomposeExperimentFlags import com.arkivanov.decompose.DefaultComponentContext import com.arkivanov.decompose.statekeeper.TestStateKeeperDispatcher import com.arkivanov.decompose.value.Value @@ -12,6 +13,7 @@ import com.arkivanov.essenty.lifecycle.resume import com.arkivanov.essenty.statekeeper.SerializableContainer import com.arkivanov.essenty.statekeeper.consumeRequired import kotlinx.serialization.Serializable +import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.assertContentEquals @@ -34,6 +36,11 @@ open class ChildrenTestBase { lifecycle.resume() } + @AfterTest + fun after() { + DecomposeExperimentFlags.duplicateConfigurationsEnabled = false + } + protected fun ComponentContext.children( initialState: TestNavState = TestNavState(), saveState: (state: TestNavState) -> SerializableContainer? = { state -> @@ -107,6 +114,9 @@ open class ChildrenTestBase { protected fun Child<*, Component>.requireInstance(): Component = requireNotNull(instance) + protected fun List>.instances(): List = + map { it.instance } + protected data class TestNavState( override val children: List> = emptyList(), ) : NavState diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/ChildStackIntegrationTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/ChildStackIntegrationTest.kt index 70fb296cc..03912fbfd 100644 --- a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/ChildStackIntegrationTest.kt +++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/ChildStackIntegrationTest.kt @@ -1,6 +1,7 @@ package com.arkivanov.decompose.router.stack import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.DecomposeExperimentFlags import com.arkivanov.decompose.DefaultComponentContext import com.arkivanov.decompose.consume import com.arkivanov.decompose.register @@ -16,6 +17,7 @@ import com.arkivanov.essenty.lifecycle.Lifecycle import com.arkivanov.essenty.lifecycle.LifecycleRegistry import com.arkivanov.essenty.lifecycle.resume import kotlinx.serialization.Serializable +import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContentEquals @@ -46,13 +48,18 @@ class ChildStackIntegrationTest { lifecycle.resume() } + @AfterTest + fun after() { + DecomposeExperimentFlags.duplicateConfigurationsEnabled = false + } + @Test fun GIVEN_one_child_in_stack_WHEN_push_one_child_THEN_two_children_in_stack() { val stack by context.childStack(initialStack = listOf(Config(1))) navigation.push(Config(2)) - stack.assertStack(1 to 1, 2 to 2) + stack.assertStack(1, 2) } @Test @@ -63,7 +70,7 @@ class ChildStackIntegrationTest { navigation.push(Config(3)) navigation.pop() - stack.assertStack(1 to 1, 2 to 2) + stack.assertStack(1, 2) } @Test @@ -72,7 +79,7 @@ class ChildStackIntegrationTest { navigation.pop() - stack.assertStack(1 to 1, 2 to 2) + stack.assertStack(1, 2) } @Test @@ -83,7 +90,7 @@ class ChildStackIntegrationTest { navigation.push(Config(3)) backDispatcher.back() - stack.assertStack(1 to 1, 2 to 2) + stack.assertStack(1, 2) } @Test @@ -92,7 +99,7 @@ class ChildStackIntegrationTest { backDispatcher.back() - stack.assertStack(1 to 1, 2 to 2) + stack.assertStack(1, 2) } @Test @@ -101,7 +108,7 @@ class ChildStackIntegrationTest { navigation.replaceCurrent(Config(3)) - stack.assertStack(1 to 1, 3 to 3) + stack.assertStack(1, 3) } @Test @@ -110,7 +117,7 @@ class ChildStackIntegrationTest { navigation.navigate { it.reversed() } - stack.assertStack(3 to 3, 2 to 2, 1 to 1) + stack.assertStack(3, 2, 1) } @Test @@ -121,7 +128,7 @@ class ChildStackIntegrationTest { navigation.navigate { it.reversed() } - stack.assertStack(3 to 3, 2 to 2, 1 to 1) + stack.assertStack(3, 2, 1) } @Test @@ -142,8 +149,8 @@ class ChildStackIntegrationTest { backDispatcher.back() - stack.assertStack(1 to 1, 2 to 2) - stack.active.instance.stack.assertStack(1 to 1) + stack.assertStack(1, 2) + stack.active.instance.stack.assertStack(1) } @Test @@ -165,7 +172,7 @@ class ChildStackIntegrationTest { backDispatcher.back() - stack.assertStack(1 to 1) + stack.assertStack(1) } @Test @@ -186,7 +193,7 @@ class ChildStackIntegrationTest { backDispatcher.back() - stack.assertStack(1 to 1, 2 to 2) + stack.assertStack(1, 2) } @Test @@ -207,7 +214,7 @@ class ChildStackIntegrationTest { backDispatcher.back() - stack.assertStack(1 to 1) + stack.assertStack(1) } @Test @@ -221,7 +228,7 @@ class ChildStackIntegrationTest { val newContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = newStateKeeper) val newStack by newContext.childStack() - newStack.assertStack(1 to 1, 2 to 2) + newStack.assertStack(1, 2) } @Test @@ -235,7 +242,7 @@ class ChildStackIntegrationTest { val newContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = newStateKeeper) val newStack by newContext.childStack(initialStack = listOf(Config(1))) - newStack.assertStack(1 to 1) + newStack.assertStack(1) } @Test @@ -411,6 +418,28 @@ class ChildStackIntegrationTest { assertFalse(instance.isDestroyed) } + @Test + fun WHEN_push_duplicated_children_THEN_stack_contains_duplicated_children() { + DecomposeExperimentFlags.duplicateConfigurationsEnabled = true + val stack by context.childStack(initialStack = listOf(Config(1), Config(2), Config(3))) + + navigation.push(Config(1)) + navigation.push(Config(2)) + + stack.assertStack(1, 2, 3, 1, 2) + } + + @Test + fun GIVEN_stack_with_duplicated_children_WHEN_remove_duplicated_children_THEN_stack_contains_unique_children() { + DecomposeExperimentFlags.duplicateConfigurationsEnabled = true + val stack by context.childStack(initialStack = listOf(Config(1), Config(2), Config(3), Config(1), Config(2))) + + navigation.pop() + navigation.pop() + + stack.assertStack(1, 2, 3) + } + private fun ComponentContext.childStack( initialStack: List = emptyList(), persistent: Boolean = true, @@ -426,8 +455,8 @@ class ChildStackIntegrationTest { private val ChildStack.children: List> get() = items.map { child -> child.configuration.id to child.instance.id } - private fun ChildStack.assertStack(vararg children: Pair) { - assertEquals(children.toList(), this.children) + private fun ChildStack.assertStack(vararg children: Int) { + assertEquals(children.map { it to it }, this.children) assertEquals(Lifecycle.State.RESUMED, active.instance.lifecycle.state) assertEquals( diff --git a/decompose/src/webTest/kotlin/com/arkivanov/decompose/router/stack/webhistory/DefaultWebHistoryControllerTest.kt b/decompose/src/webTest/kotlin/com/arkivanov/decompose/router/stack/webhistory/DefaultWebHistoryControllerTest.kt index ebf8aced1..2fdd695a1 100644 --- a/decompose/src/webTest/kotlin/com/arkivanov/decompose/router/stack/webhistory/DefaultWebHistoryControllerTest.kt +++ b/decompose/src/webTest/kotlin/com/arkivanov/decompose/router/stack/webhistory/DefaultWebHistoryControllerTest.kt @@ -64,6 +64,21 @@ class DefaultWebHistoryControllerTest { assertStack(listOf("/0", "/1")) } + @Test + fun WHEN_router_push_same_config_THEN_url_pushed_to_history() { + if (isNodeJs()) { + return + } + + val router = TestStackRouter(listOf(Config(0))) + attach(router) + + router.push(Config(0)) + window.runPendingOperations() + + assertStack(listOf("/0", "/0")) + } + @Test fun GIVEN_router_with_initial_stack_of_two_configs_WHEN_router_pop_THEN_history_changed_to_previous_page() { if (isNodeJs()) { @@ -79,6 +94,21 @@ class DefaultWebHistoryControllerTest { history.assertStack(listOf("/0", "/1"), 0) } + @Test + fun GIVEN_router_with_initial_stack_of_two_same_configs_WHEN_router_pop_THEN_history_changed_to_previous_page() { + if (isNodeJs()) { + return + } + + val router = TestStackRouter(listOf(Config(0), Config(0))) + attach(router) + + router.pop() + window.runPendingOperations() + + history.assertStack(listOf("/0", "/0"), 0) + } + @Test fun GIVEN_router_push_WHEN_router_pop_THEN_history_changed_to_previous_page() { if (isNodeJs()) { @@ -96,6 +126,23 @@ class DefaultWebHistoryControllerTest { assertStack(listOf("/0", "/1"), 0) } + @Test + fun GIVEN_router_push_same_config_WHEN_router_pop_THEN_history_changed_to_previous_page() { + if (isNodeJs()) { + return + } + + val router = TestStackRouter(listOf(Config(0))) + attach(router) + router.push(Config(0)) + window.runPendingOperations() + + router.pop() + window.runPendingOperations() + + assertStack(listOf("/0", "/0"), 0) + } + @Test fun GIVEN_router_with_initial_stack_of_tree_configs_WHEN_router_pop_two_THEN_history_changed_to_previous_page() { if (isNodeJs()) { diff --git a/extensions-android/src/main/java/com/arkivanov/decompose/extensions/android/stack/StackRouterView.kt b/extensions-android/src/main/java/com/arkivanov/decompose/extensions/android/stack/StackRouterView.kt index 151ab0896..01bdc1145 100644 --- a/extensions-android/src/main/java/com/arkivanov/decompose/extensions/android/stack/StackRouterView.kt +++ b/extensions-android/src/main/java/com/arkivanov/decompose/extensions/android/stack/StackRouterView.kt @@ -109,14 +109,14 @@ class StackRouterView @JvmOverloads constructor( @Suppress("UNCHECKED_CAST") val currentChild = currentChild as ActiveChild? - if (currentChild?.child?.configuration == activeChild.configuration) { + if (currentChild?.child?.key == activeChild.key) { return } val currentChildView = currentChild?.let { findChildView(it.key) } - if ((currentChildView != null) && stack.backStack.any { it.configuration == currentChild.child.configuration }) { - inactiveChildren[currentChild.key] = InactiveChild(currentChild.child.configuration, currentChildView.saveHierarchyState()) + if ((currentChildView != null) && stack.backStack.any { it.key == currentChild.child.key }) { + inactiveChildren[currentChild.key] = InactiveChild(currentChild.child.key, currentChildView.saveHierarchyState()) } currentChild?.lifecycle?.destroy() @@ -126,7 +126,7 @@ class StackRouterView @JvmOverloads constructor( val newChildView = findNewChildView() - val activeChildKey = activeChild.configuration.hashString() + val activeChildKey = activeChild.key.hashString() newChildView.key = activeChildKey @@ -148,7 +148,7 @@ class StackRouterView @JvmOverloads constructor( this.currentChild = ActiveChild(activeChildKey, activeChild, childViewLifecycle) inactiveChildren.values.removeAll { child -> - stack.backStack.none { it.configuration === child.configuration } + stack.backStack.none { it.key == child.key } } } @@ -165,7 +165,7 @@ class StackRouterView @JvmOverloads constructor( ) private class InactiveChild( - val configuration: Any, + val key: Any, val savedState: SparseArray ) diff --git a/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/pages/Pages.kt b/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/pages/Pages.kt index 815b9bae1..ad26a0b02 100644 --- a/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/pages/Pages.kt +++ b/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/pages/Pages.kt @@ -33,7 +33,7 @@ fun Pages( modifier: Modifier = Modifier, scrollAnimation: PagesScrollAnimation = PagesScrollAnimation.Disabled, pager: Pager = defaultHorizontalPager(), - key: (Child) -> Any = { it.configuration.hashString() }, + key: (Child) -> Any = { it.key.hashString() }, pageContent: @Composable PagerScope.(index: Int, page: T) -> Unit, ) { val state by pages.subscribeAsState() @@ -61,7 +61,7 @@ fun Pages( modifier: Modifier = Modifier, scrollAnimation: PagesScrollAnimation = PagesScrollAnimation.Disabled, pager: Pager = defaultHorizontalPager(), - key: (Child) -> Any = { it.configuration.hashString() }, + key: (Child) -> Any = { it.key.hashString() }, pageContent: @Composable PagerScope.(index: Int, page: T) -> Unit, ) { val selectedIndex = pages.selectedIndex @@ -95,7 +95,7 @@ fun Pages( ) { pageIndex -> val item = pages.items[pageIndex] - val pageRef = remember(item.configuration) { Ref(item.instance) } + val pageRef = remember(item.key) { Ref(item.instance) } if (item.instance != null) { pageRef.value = item.instance } diff --git a/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/Children.kt b/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/Children.kt index 81b8314e7..d5d8015c3 100644 --- a/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/Children.kt +++ b/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/Children.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.saveable.SaveableStateHolder import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.ui.Modifier import com.arkivanov.decompose.Child +import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.extensions.compose.stack.animation.LocalStackAnimationProvider import com.arkivanov.decompose.extensions.compose.stack.animation.StackAnimation import com.arkivanov.decompose.extensions.compose.stack.animation.emptyStackAnimation @@ -15,6 +16,7 @@ import com.arkivanov.decompose.hashString import com.arkivanov.decompose.router.stack.ChildStack import com.arkivanov.decompose.value.Value +@OptIn(ExperimentalDecomposeApi::class) @Composable fun Children( stack: ChildStack, @@ -24,13 +26,13 @@ fun Children( ) { val holder = rememberSaveableStateHolder() - holder.retainStates(stack.getConfigurations()) + holder.retainStates(stack.getKeys()) val animationProvider = LocalStackAnimationProvider.current val anim = animation ?: remember(animationProvider, animationProvider::provide) ?: emptyStackAnimation() anim(stack = stack, modifier = modifier) { child -> - holder.SaveableStateProvider(child.configuration.hashString()) { + holder.SaveableStateProvider(child.key.hashString()) { content(child) } } @@ -53,11 +55,12 @@ fun Children( ) } -private fun ChildStack<*, *>.getConfigurations(): Set = - items.mapTo(HashSet()) { it.configuration.hashString() } +@OptIn(ExperimentalDecomposeApi::class) +private fun ChildStack<*, *>.getKeys(): Set = + items.mapTo(HashSet()) { it.key.hashString() } @Composable -private fun SaveableStateHolder.retainStates(currentKeys: Set) { +private fun SaveableStateHolder.retainStates(currentKeys: Set) { val keys = remember(this) { Keys(currentKeys) } DisposableEffect(this, currentKeys) { diff --git a/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/AbstractStackAnimation.kt b/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/AbstractStackAnimation.kt index b725fbb52..bba80774b 100644 --- a/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/AbstractStackAnimation.kt +++ b/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/AbstractStackAnimation.kt @@ -10,8 +10,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import com.arkivanov.decompose.Child +import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.router.stack.ChildStack +@OptIn(ExperimentalDecomposeApi::class) internal abstract class AbstractStackAnimation( private val disableInputDuringAnimation: Boolean, ) : StackAnimation { @@ -28,22 +30,22 @@ internal abstract class AbstractStackAnimation( var currentStack by remember { mutableStateOf(stack) } var items by remember { mutableStateOf(getAnimationItems(newStack = currentStack, oldStack = null)) } - if (stack.active.configuration != currentStack.active.configuration) { + if (stack.active.key != currentStack.active.key) { val oldStack = currentStack currentStack = stack items = getAnimationItems(newStack = currentStack, oldStack = oldStack) } Box(modifier = modifier) { - items.forEach { (configuration, item) -> - key(configuration) { + items.forEach { (key, item) -> + key(key) { Child( item = item, onFinished = { if (item.direction.isExit) { - items -= configuration + items -= key } else { - items += (configuration to item.copy(otherChild = null)) + items += (key to item.copy(otherChild = null)) } }, content = content, @@ -73,12 +75,12 @@ internal abstract class AbstractStackAnimation( ) } - private fun getAnimationItems(newStack: ChildStack, oldStack: ChildStack?): Map> = + private fun getAnimationItems(newStack: ChildStack, oldStack: ChildStack?): Map> = when { oldStack == null -> listOf(AnimationItem(child = newStack.active, direction = Direction.ENTER_FRONT, isInitial = true)) - (newStack.size < oldStack.size) && (newStack.active.configuration in oldStack.backStack) -> + (newStack.size < oldStack.size) && (newStack.active.key in oldStack.backStack) -> listOf( AnimationItem(child = newStack.active, direction = Direction.ENTER_BACK, otherChild = oldStack.active), AnimationItem(child = oldStack.active, direction = Direction.EXIT_FRONT, otherChild = newStack.active), @@ -89,13 +91,13 @@ internal abstract class AbstractStackAnimation( AnimationItem(child = oldStack.active, direction = Direction.EXIT_BACK, otherChild = newStack.active), AnimationItem(child = newStack.active, direction = Direction.ENTER_FRONT, otherChild = oldStack.active), ) - }.associateBy { it.child.configuration } + }.associateBy { it.child.key } private val ChildStack<*, *>.size: Int get() = items.size - private operator fun Iterable>.contains(config: C): Boolean = - any { it.configuration == config } + private operator fun Iterable>.contains(key: Any): Boolean = + any { it.key == key } protected data class AnimationItem( val child: Child.Created, diff --git a/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/MovableStackAnimation.kt b/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/MovableStackAnimation.kt index f13db480f..31626c195 100644 --- a/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/MovableStackAnimation.kt +++ b/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/MovableStackAnimation.kt @@ -5,12 +5,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.remember import com.arkivanov.decompose.Child +import com.arkivanov.decompose.ExperimentalDecomposeApi internal class MovableStackAnimation( disableInputDuringAnimation: Boolean, private val selector: (child: Child.Created, otherChild: Child.Created, direction: Direction) -> StackAnimator?, ) : AbstractStackAnimation(disableInputDuringAnimation = disableInputDuringAnimation) { + @OptIn(ExperimentalDecomposeApi::class) @Composable override fun Child( item: AnimationItem, @@ -18,7 +20,7 @@ internal class MovableStackAnimation( content: @Composable (child: Child.Created) -> Unit, ) { val animator = - remember(item.child.configuration, item.otherChild?.configuration, item.direction) { + remember(item.child.key, item.otherChild?.key, item.direction) { if (item.otherChild == null) { EmptyStackAnimator } else { diff --git a/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/SimpleStackAnimation.kt b/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/SimpleStackAnimation.kt index 804142335..affb60b73 100644 --- a/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/SimpleStackAnimation.kt +++ b/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/SimpleStackAnimation.kt @@ -4,19 +4,21 @@ import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.arkivanov.decompose.Child +import com.arkivanov.decompose.ExperimentalDecomposeApi internal class SimpleStackAnimation( disableInputDuringAnimation: Boolean, private val selector: (Child.Created) -> StackAnimator?, ) : AbstractStackAnimation(disableInputDuringAnimation = disableInputDuringAnimation) { + @OptIn(ExperimentalDecomposeApi::class) @Composable override fun Child( item: AnimationItem, onFinished: () -> Unit, content: @Composable (child: Child.Created) -> Unit, ) { - val animator = remember(item.child.configuration) { selector(item.child) ?: EmptyStackAnimator } + val animator = remember(item.child.key) { selector(item.child) ?: EmptyStackAnimator } animator( direction = item.direction, diff --git a/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/predictiveback/PredictiveBackAnimation.kt b/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/predictiveback/PredictiveBackAnimation.kt index b8e68e3a9..90874b735 100644 --- a/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/predictiveback/PredictiveBackAnimation.kt +++ b/extensions-compose/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/stack/animation/predictiveback/PredictiveBackAnimation.kt @@ -66,20 +66,20 @@ private class PredictiveBackAnimation( @Composable override fun invoke(stack: ChildStack, modifier: Modifier, content: @Composable (child: Child.Created) -> Unit) { - val activeConfigurations = remember { HashSet() } - val handler = rememberHandler(stack = stack, isGestureEnabled = { activeConfigurations.size == 1 }) + val activeKeys = remember { HashSet() } + val handler = rememberHandler(stack = stack, isGestureEnabled = { activeKeys.size == 1 }) val animationProvider = LocalStackAnimationProvider.current val anim = animation ?: remember(animationProvider, animationProvider::provide) ?: emptyStackAnimation() val childContent = remember(content) { movableContentOf> { child -> - key(child.configuration) { + key(child.key) { content(child) DisposableEffect(Unit) { - activeConfigurations += child.configuration - onDispose { activeConfigurations -= child.configuration } + activeKeys += child.key + onDispose { activeKeys -= child.key } } } } diff --git a/extensions-compose/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/stack/ChildrenTest.kt b/extensions-compose/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/stack/ChildrenTest.kt index 64fb22877..c39fa4ed1 100644 --- a/extensions-compose/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/stack/ChildrenTest.kt +++ b/extensions-compose/src/jvmTest/kotlin/com/arkivanov/decompose/extensions/compose/stack/ChildrenTest.kt @@ -14,6 +14,8 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import com.arkivanov.decompose.Child +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.FaultyDecomposeApi import com.arkivanov.decompose.extensions.compose.stack.animation.StackAnimation import com.arkivanov.decompose.extensions.compose.stack.animation.fade import com.arkivanov.decompose.extensions.compose.stack.animation.plus @@ -37,7 +39,7 @@ class ChildrenTest( @Test fun WHEN_active_child_and_no_back_stack_THEN_active_child_displayed() { - val state = mutableStateOf(routerState(activeConfig = Config.A)) + val state = mutableStateOf(routerState(Config.A)) setContent(state) @@ -46,91 +48,169 @@ class ChildrenTest( @Test fun GIVEN_child_A_displayed_WHEN_push_child_B_THEN_child_B_displayed() { - val state = mutableStateOf(routerState(activeConfig = Config.A)) + val state = mutableStateOf(routerState(Config.A)) setContent(state) - state.setValueOnIdle(routerState(activeConfig = Config.B, backstack = listOf(Config.A))) + state.setValueOnIdle(routerState(Config.A, Config.B)) composeRule.onNodeWithText(text = "ChildB", substring = true).assertExists() } + @Test + fun GIVEN_child_A1_displayed_WHEN_push_child_A2_THEN_child_A2_displayed() { + val state = mutableStateOf(routerState("A1" to Config.A)) + setContent(state) + + state.setValueOnIdle(routerState("A1" to Config.A, "A2" to Config.A)) + + composeRule.onNodeWithText(text = "ChildA2", substring = true).assertExists() + } + @Test fun GIVEN_child_B_displayed_and_child_A_in_back_stack_WHEN_pop_child_B_THEN_child_A_displayed() { - val state = mutableStateOf(routerState(activeConfig = Config.A)) + val state = mutableStateOf(routerState(Config.A)) setContent(state) - state.setValueOnIdle(routerState(activeConfig = Config.B, backstack = listOf(Config.A))) + state.setValueOnIdle(routerState(Config.A, Config.B)) - state.setValueOnIdle(routerState(activeConfig = Config.A)) + state.setValueOnIdle(routerState(Config.A)) composeRule.onNodeWithText(text = "ChildA", substring = true).assertExists() } + @Test + fun GIVEN_child_A2_displayed_and_child_A1_in_back_stack_WHEN_pop_child_A2_THEN_child_A1_displayed() { + val state = mutableStateOf(routerState("A1" to Config.A)) + setContent(state) + state.setValueOnIdle(routerState("A1" to Config.A, "A2" to Config.A)) + + + state.setValueOnIdle(routerState("A1" to Config.A)) + + composeRule.onNodeWithText(text = "ChildA1", substring = true).assertExists() + } + @Test fun GIVEN_child_B_displayed_and_child_A_in_back_stack_WHEN_pop_child_B_THEN_state_restored_for_child_A() { - val state = mutableStateOf(routerState(activeConfig = Config.A)) + val state = mutableStateOf(routerState(Config.A)) setContent(state) composeRule.onNodeWithText(text = "ChildA=0").performClick() - state.setValueOnIdle(routerState(activeConfig = Config.B, backstack = listOf(Config.A))) + state.setValueOnIdle(routerState(Config.A, Config.B)) - state.setValueOnIdle(routerState(activeConfig = Config.A)) + state.setValueOnIdle(routerState(Config.A)) composeRule.onNodeWithText(text = "ChildA=1").assertExists() } + @Test + fun GIVEN_child_A2_displayed_and_child_A1_in_back_stack_WHEN_pop_child_A2_THEN_state_restored_for_child_A1() { + val state = mutableStateOf(routerState("A1" to Config.A)) + setContent(state) + composeRule.onNodeWithText(text = "ChildA1=0").performClick() + state.setValueOnIdle(routerState("A1" to Config.A, "A2" to Config.A)) + + state.setValueOnIdle(routerState("A1" to Config.A)) + + composeRule.onNodeWithText(text = "ChildA1=1").assertExists() + } + @Test fun GIVEN_child_B_displayed_and_child_A_in_back_stack_WHEN_pop_child_B_and_push_child_B_THEN_state_not_restored_for_child_B() { - val state = mutableStateOf(routerState(activeConfig = Config.A)) + val state = mutableStateOf(routerState(Config.A)) setContent(state) - state.setValueOnIdle(routerState(activeConfig = Config.B, backstack = listOf(Config.A))) + state.setValueOnIdle(routerState(Config.A, Config.B)) composeRule.onNodeWithText(text = "ChildB=0").performClick() - state.setValueOnIdle(routerState(activeConfig = Config.A)) - state.setValueOnIdle(routerState(activeConfig = Config.B, backstack = listOf(Config.A))) + state.setValueOnIdle(routerState(Config.A)) + state.setValueOnIdle(routerState(Config.A, Config.B)) composeRule.onNodeWithText(text = "ChildB=0").assertExists() } + @Test + fun GIVEN_child_A2_displayed_and_child_A1_in_back_stack_WHEN_pop_child_A2_and_push_child_A2_THEN_state_not_restored_for_child_A2() { + val state = mutableStateOf(routerState("A1" to Config.A)) + setContent(state) + + state.setValueOnIdle(routerState("A1" to Config.A, "A2" to Config.A)) + composeRule.onNodeWithText(text = "ChildA2=0").performClick() + + state.setValueOnIdle(routerState("A1" to Config.A)) + state.setValueOnIdle(routerState("A1" to Config.A, "A2" to Config.A)) + + composeRule.onNodeWithText(text = "ChildA2=0").assertExists() + } + @Test fun GIVEN_child_A_displayed_WHEN_push_child_B_THEN_child_A_disposed() { - val state = mutableStateOf(routerState(activeConfig = Config.A)) + val state = mutableStateOf(routerState(Config.A)) setContent(state) - state.setValueOnIdle(routerState(activeConfig = Config.B, backstack = listOf(Config.A))) + state.setValueOnIdle(routerState(Config.A, Config.B)) composeRule.onNodeWithText(text = "ChildA", substring = true).assertDoesNotExist() } + @Test + fun GIVEN_child_A1_displayed_WHEN_push_child_A2_THEN_child_A1_disposed() { + val state = mutableStateOf(routerState("A1" to Config.A)) + setContent(state) + + state.setValueOnIdle(routerState("A1" to Config.A, "A2" to Config.A)) + + composeRule.onNodeWithText(text = "ChildA1", substring = true).assertDoesNotExist() + } + @Test fun GIVEN_child_B_displayed_and_child_A_in_back_stack_WHEN_pop_child_B_THEN_child_B_disposed() { - val state = mutableStateOf(routerState(activeConfig = Config.A)) + val state = mutableStateOf(routerState(Config.A)) setContent(state) - state.setValueOnIdle(routerState(activeConfig = Config.B, backstack = listOf(Config.A))) + state.setValueOnIdle(routerState(Config.A, Config.B)) - state.setValueOnIdle(routerState(activeConfig = Config.A)) + state.setValueOnIdle(routerState(Config.A)) composeRule.onNodeWithText(text = "ChildB", substring = true).assertDoesNotExist() } + @Test + fun GIVEN_child_A2_displayed_and_child_A1_in_back_stack_WHEN_pop_child_A2_THEN_child_A2_disposed() { + val state = mutableStateOf(routerState("A1" to Config.A)) + setContent(state) + state.setValueOnIdle(routerState("A1" to Config.A, "A2" to Config.A)) + + state.setValueOnIdle(routerState("A1" to Config.A)) + + composeRule.onNodeWithText(text = "ChildA2", substring = true).assertDoesNotExist() + } + private fun setContent(state: State>) { composeRule.setContent { Children(stack = state.value, animation = animation) { child -> - when (child.configuration) { - Config.A -> Child(name = "A") - Config.B -> Child(name = "B") - }.let {} + Child(name = child.key.toString()) } } composeRule.runOnIdle {} } - private fun routerState(activeConfig: Config, backstack: List = emptyList()): ChildStack = + private fun routerState(vararg stack: Config): ChildStack = ChildStack( - active = Child.Created(configuration = activeConfig, instance = activeConfig), - backStack = backstack.map { Child.Created(configuration = it, instance = it) }, + active = stack.last().toChild(), + backStack = stack.dropLast(1).map { it.toChild() }, ) + private fun Config.toChild(): Child.Created = + Child.Created(configuration = this, instance = this) + + private fun routerState(vararg stack: Pair): ChildStack = + ChildStack( + active = stack.last().toChild(), + backStack = stack.dropLast(1).map { it.toChild() }, + ) + + private fun Pair.toChild(): Child.Created = + Child.Created(configuration = second, instance = second, key = first) + @Composable private fun Child(name: String) { var count by rememberSaveable { mutableStateOf(0) } @@ -152,6 +232,7 @@ class ChildrenTest( fun parameters(): List> = getParameters().map { arrayOf(it) } + @OptIn(FaultyDecomposeApi::class) private fun getParameters(): List?> = listOf( null,