Repository: arkivanov/Decompose
Pull request #130 (2022-06-26 21:33:14)

Merge master into compose-darwin


Patch:
diff --git a/decompose/api/android/decompose.api b/decompose/api/android/decompose.api
index 53eec195..00777a28 100644
--- a/decompose/api/android/decompose.api
+++ b/decompose/api/android/decompose.api
@@ -76,15 +76,21 @@ public abstract interface class com/arkivanov/decompose/router/Router {
 }
 
 public final class com/arkivanov/decompose/router/RouterExtKt {
-	public static final fun bringToFront (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V
+	public static final synthetic fun bringToFront (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V
+	public static final fun bringToFront (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
+	public static synthetic fun bringToFront$default (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
 	public static final fun getActiveChild (Lcom/arkivanov/decompose/router/Router;)Lcom/arkivanov/decompose/Child$Created;
 	public static final fun navigate (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;)V
 	public static final fun pop (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;)V
 	public static synthetic fun pop$default (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
 	public static final fun popWhile (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;)V
 	public static final fun popWhile (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
-	public static final fun push (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V
-	public static final fun replaceCurrent (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V
+	public static final synthetic fun push (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V
+	public static final fun push (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
+	public static synthetic fun push$default (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
+	public static final synthetic fun replaceCurrent (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V
+	public static final fun replaceCurrent (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
+	public static synthetic fun replaceCurrent$default (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
 }
 
 public final class com/arkivanov/decompose/router/RouterFactoryExtKt {
diff --git a/decompose/api/jvm/decompose.api b/decompose/api/jvm/decompose.api
index 2e2f40a0..53ad1858 100644
--- a/decompose/api/jvm/decompose.api
+++ b/decompose/api/jvm/decompose.api
@@ -63,15 +63,21 @@ public abstract interface class com/arkivanov/decompose/router/Router {
 }
 
 public final class com/arkivanov/decompose/router/RouterExtKt {
-	public static final fun bringToFront (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V
+	public static final synthetic fun bringToFront (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V
+	public static final fun bringToFront (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
+	public static synthetic fun bringToFront$default (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
 	public static final fun getActiveChild (Lcom/arkivanov/decompose/router/Router;)Lcom/arkivanov/decompose/Child$Created;
 	public static final fun navigate (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;)V
 	public static final fun pop (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;)V
 	public static synthetic fun pop$default (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
 	public static final fun popWhile (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;)V
 	public static final fun popWhile (Lcom/arkivanov/decompose/router/Router;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
-	public static final fun push (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V
-	public static final fun replaceCurrent (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V
+	public static final synthetic fun push (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V
+	public static final fun push (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
+	public static synthetic fun push$default (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
+	public static final synthetic fun replaceCurrent (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;)V
+	public static final fun replaceCurrent (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
+	public static synthetic fun replaceCurrent$default (Lcom/arkivanov/decompose/router/Router;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
 }
 
 public final class com/arkivanov/decompose/router/RouterFactoryExtKt {
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/RouterExt.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/RouterExt.kt
index dbab31a7..b15da097 100644
--- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/RouterExt.kt
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/RouterExt.kt
@@ -9,11 +9,18 @@ fun <C : Any> Router<C, *>.navigate(transformer: (stack: List<C>) -> List<C>) {
     navigate(transformer = transformer, onComplete = { _, _ -> })
 }
 
+@Deprecated(message = "For binary compatibility", level = DeprecationLevel.HIDDEN)
+fun <C : Any> Router<C, *>.push(configuration: C) {
+    push(configuration = configuration, onComplete = {})
+}
+
 /**
- * Pushes the provided [configuration] at the top of the stack..
+ * Pushes the provided [configuration] at the top of the stack.
+ *
+ * @param onComplete called when the navigation is finished (either synchronously or asynchronously).
  */
-fun <C : Any> Router<C, *>.push(configuration: C) {
-    navigate { it + configuration }
+fun <C : Any> Router<C, *>.push(configuration: C, onComplete: () -> Unit = {}) {
+    navigate(transformer = { it + configuration }, onComplete = { _, _ -> onComplete() })
 }
 
 /**
@@ -26,20 +33,15 @@ fun <C : Any> Router<C, *>.push(configuration: C) {
 fun <C : Any> Router<C, *>.pop(onComplete: (isSuccess: Boolean) -> Unit = {}) {
     navigate(
         transformer = { stack -> stack.takeIf { it.size > 1 }?.dropLast(1) ?: stack },
-        onComplete = { newStack, oldStack -> onComplete(newStack.size < oldStack.size) }
+        onComplete = { newStack, oldStack -> onComplete(newStack.size < oldStack.size) },
     )
 }
 
 /**
- * Drops the configurations at the top of the stack while the [predicate] returns true
+ * Drops the configurations at the top of the stack while the [predicate] returns `true`.
  */
-inline fun <C : Any> Router<C, *>.popWhile(
-    crossinline predicate: (C) -> Boolean
-) {
-    popWhile(
-        predicate = predicate,
-        onComplete = {}
-    )
+inline fun <C : Any> Router<C, *>.popWhile(crossinline predicate: (C) -> Boolean) {
+    popWhile(predicate = predicate, onComplete = {})
 }
 
 /**
@@ -50,29 +52,45 @@ inline fun <C : Any> Router<C, *>.popWhile(
  */
 inline fun <C : Any> Router<C, *>.popWhile(
     crossinline predicate: (C) -> Boolean,
-    crossinline onComplete: (isSuccess: Boolean) -> Unit
+    crossinline onComplete: (isSuccess: Boolean) -> Unit,
 ) {
     navigate(
         transformer = { stack -> stack.dropLastWhile(predicate) },
-        onComplete = { newStack, oldStack -> onComplete(newStack.size < oldStack.size) }
+        onComplete = { newStack, oldStack -> onComplete(newStack.size < oldStack.size) },
     )
 }
 
+@Deprecated(message = "For binary compatibility", level = DeprecationLevel.HIDDEN)
+fun <C : Any> Router<C, *>.replaceCurrent(configuration: C) {
+    replaceCurrent(configuration = configuration, onComplete = {})
+}
+
 /**
  * Replaces the current configuration at the top of the stack with the provided [configuration].
+ *
+ * @param onComplete called when the navigation is finished (either synchronously or asynchronously).
  */
-fun <C : Any> Router<C, *>.replaceCurrent(configuration: C) {
-    navigate { it.dropLast(1) + configuration }
+fun <C : Any> Router<C, *>.replaceCurrent(configuration: C, onComplete: () -> Unit = {}) {
+    navigate(
+        transformer = { it.dropLast(1) + configuration },
+        onComplete = { _, _ -> onComplete() },
+    )
+}
+
+@Deprecated(message = "For binary compatibility", level = DeprecationLevel.HIDDEN)
+fun <C : Any> Router<C, *>.bringToFront(configuration: C) {
+    bringToFront(configuration = configuration, onComplete = {})
 }
 
 /**
  * Removes all components with configurations of [configuration]'s class, and adds the provided [configuration] to the top of the stack.
  * The operation is performed as one transaction. If there is already a component with the same configuration, it will not be recreated.
  */
-fun <C : Any> Router<C, *>.bringToFront(configuration: C) {
-    navigate { stack ->
-        stack.filterNot { it::class == configuration::class } + configuration
-    }
+fun <C : Any> Router<C, *>.bringToFront(configuration: C, onComplete: () -> Unit = {}) {
+    navigate(
+        transformer = { stack -> stack.filterNot { it::class == configuration::class } + configuration },
+        onComplete = { _, _ -> onComplete() },
+    )
 }
 
 val <C : Any, T : Any> Router<C, T>.activeChild: Child.Created<C, T> get() = state.value.activeChild
diff --git a/decompose/src/jsMain/kotlin/com/arkivanov/decompose/router/webhistory/DefaultWebHistoryController.kt b/decompose/src/jsMain/kotlin/com/arkivanov/decompose/router/webhistory/DefaultWebHistoryController.kt
index 4bea900d..2ba38454 100644
--- a/decompose/src/jsMain/kotlin/com/arkivanov/decompose/router/webhistory/DefaultWebHistoryController.kt
+++ b/decompose/src/jsMain/kotlin/com/arkivanov/decompose/router/webhistory/DefaultWebHistoryController.kt
@@ -11,16 +11,17 @@ import com.arkivanov.decompose.router.subscribe
 import org.w3c.dom.PopStateEvent
 
 @ExperimentalDecomposeApi
-class DefaultWebHistoryController(
+class DefaultWebHistoryController internal constructor(
     private val window: Window,
 ) : WebHistoryController {
 
+    @Suppress("unused") // Public API
     constructor() : this(WindowImpl())
 
     override fun <C : Any> attach(
         router: Router<C, *>,
         getPath: (configuration: C) -> String,
-        getConfiguration: (path: String) -> C
+        getConfiguration: (path: String) -> C,
     ) {
         val impl = Impl(router, window, getPath, getConfiguration)
         router.state.subscribe(impl::onStateChanged)
@@ -118,90 +119,52 @@ class DefaultWebHistoryController(
 
         fun onPopState(event: PopStateEvent) {
             val newData = event.getData() ?: return
-            val stack = router.state.value.configurations()
-            val newConfigurationKey = newData.configurationKey
-
-            val indexInStack = stack.indexOfLast { it.hashCode() == newConfigurationKey }
-            if (indexInStack >= 0) {
-                if (indexInStack < stack.lastIndex) { // History popped, pop from the Router
-                    isStateObserverEnabled = false
-                    router.navigate { stack.take(indexInStack + 1) }
-                    isStateObserverEnabled = true
+            val newConfigurationKey = newData.last().configurationKey
+
+            isStateObserverEnabled = false
+
+            router.navigate { stack ->
+                val indexInStack = stack.indexOfLast { it.hashCode() == newConfigurationKey }
+                if (indexInStack >= 0) {
+                    // History popped, pop from the Router
+                    stack.take(indexInStack + 1)
+                } else {
+                    // History pushed, push to the Router
+                    stack + newData.drop(stack.size).map { getConfiguration(it.path) }
                 }
-            } else { // History pushed, push to the Router
-                val nextPaths = getNextPaths(currentConfiguration = stack.last(), nextData = newData)
-                val nextConfigurations = nextPaths.map(getConfiguration)
-                isStateObserverEnabled = false
-                router.navigate { stack + nextConfigurations }
-                isStateObserverEnabled = true
-            }
-        }
-
-        private fun getNextPaths(currentConfiguration: C, nextData: PageData): List<String> {
-            val paths = ArrayList<String>()
-            val currentConfigurationKey = currentConfiguration.hashCode()
-            var data: PageData? = nextData
-
-            while ((data != null) && (data.configurationKey != currentConfigurationKey)) {
-                paths += data.path
-                data = data.prev
             }
 
-            return paths.asReversed()
+            isStateObserverEnabled = true
         }
 
         private fun History.pushState(configuration: C) {
-            val currentData: PageData? = window.history.getData()
-
-            val nextData =
-                PageData(
-                    configurationKey = configuration.hashCode(),
-                    path = getPath(configuration),
-                    prev = currentData,
-                )
-
-            currentData?.next = nextData
-
-            pushState(data = nextData, url = nextData.path)
+            val nextItem = PageItem(configurationKey = configuration.hashCode(), path = getPath(configuration))
+            val newData = getCurrentData() + nextItem
+            pushState(data = newData, url = nextItem.path)
         }
 
         private fun History.replaceState(configuration: C) {
-            val currentData: PageData? = window.history.getData()
-            val prevData: PageData? = currentData?.prev
-            val nextData: PageData? = currentData?.next
-
-            val newData =
-                PageData(
-                    configurationKey = configuration.hashCode(),
-                    path = getPath(configuration),
-                    prev = prevData,
-                    next = nextData,
-                )
-
-            prevData?.next = nextData
-            nextData?.prev = newData
-
-            replaceState(data = newData, url = newData.path)
+            val newItem = PageItem(configurationKey = configuration.hashCode(), path = getPath(configuration))
+            val newData = getCurrentData().dropLast(1).toTypedArray() + newItem
+            replaceState(data = newData, url = newItem.path)
         }
 
-        private fun History.getData(): PageData? = state?.unsafeCast<PageData>()
+        private fun getCurrentData(): Array<PageItem> = window.history.state?.unsafeCast<Array<PageItem>>() ?: emptyArray()
 
-        private fun PopStateEvent.getData(): PageData? = state?.unsafeCast<PageData>()
+        private fun PopStateEvent.getData(): Array<PageItem>? = state?.unsafeCast<Array<PageItem>>()
     }
 
-    private data class PageData(
+    private data class PageItem(
         val configurationKey: Int,
         val path: String,
-        var prev: PageData? = null,
-        var next: PageData? = null,
     )
 
-    interface Window {
+    internal interface Window {
         val history: History
         var onPopState: ((PopStateEvent) -> Unit)?
     }
 
-    interface History {
+    internal interface History {
         val state: Any?
 
         fun go(delta: Int)
diff --git a/deps.versions.toml b/deps.versions.toml
index 692febaa..4e6e0a74 100644
--- a/deps.versions.toml
+++ b/deps.versions.toml
@@ -1,6 +1,6 @@
 [versions]
 
-decompose = "0.6.0"
+decompose = "0.7.0"
 kotlin = "1.6.21"
 essenty = "0.2.2"
 reaktive = "1.2.1"


Test patch:
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterPopWhileTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterPopWhileTest.kt
new file mode 100644
index 00000000..edd55965
--- /dev/null
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterPopWhileTest.kt
@@ -0,0 +1,28 @@
+package com.arkivanov.decompose.router
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@Suppress("TestFunctionName")
+class RouterPopWhileTest {
+
+    @Test
+    fun WHEN_popWhile_THEN_popped() {
+        val router = TestRouter(listOf(1, 2, 3, 4))
+
+        router.popWhile { it != 2 }
+
+        assertEquals(listOf(1, 2), router.stack)
+    }
+
+    @Test
+    fun WHEN_popWhile_THEN_onComplete_called() {
+        val router = TestRouter(listOf(1, 2, 3, 4))
+        var isCalled = false
+
+        router.popWhile(predicate = { it != 2 }, onComplete = { isCalled = true })
+
+        assertTrue(isCalled)
+    }
+}
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterPushTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterPushTest.kt
new file mode 100644
index 00000000..d29a8a61
--- /dev/null
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterPushTest.kt
@@ -0,0 +1,28 @@
+package com.arkivanov.decompose.router
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@Suppress("TestFunctionName")
+class RouterPushTest {
+
+    @Test
+    fun WHEN_push_THEN_pushed() {
+        val router = TestRouter(listOf(1, 2))
+
+        router.push(3)
+
+        assertEquals(listOf(1, 2, 3), router.stack)
+    }
+
+    @Test
+    fun WHEN_push_THEN_onComplete_called() {
+        val router = TestRouter(listOf(1, 2))
+        var isCalled = false
+
+        router.push(3) { isCalled = true }
+
+        assertTrue(isCalled)
+    }
+}
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterReplaceCurrentTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterReplaceCurrentTest.kt
new file mode 100644
index 00000000..6008ae9f
--- /dev/null
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/RouterReplaceCurrentTest.kt
@@ -0,0 +1,28 @@
+package com.arkivanov.decompose.router
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@Suppress("TestFunctionName")
+class RouterReplaceCurrentTest {
+
+    @Test
+    fun WHEN_replaceCurrent_THEN_last_configuration_replaced() {
+        val router = TestRouter(listOf(1, 2, 3))
+
+        router.replaceCurrent(4)
+
+        assertEquals(listOf(1, 2, 4), router.stack)
+    }
+
+    @Test
+    fun WHEN_replaceCurrent_THEN_onComplete_called() {
+        val router = TestRouter(listOf(1, 2, 3))
+        var isCalled = false
+
+        router.replaceCurrent(4) { isCalled = true }
+
+        assertTrue(isCalled)
+    }
+}
diff --git a/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/webhistory/DefaultWebHistoryControllerTest.kt b/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/webhistory/DefaultWebHistoryControllerTest.kt
index 1e229e1c..68a4ab97 100644
--- a/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/webhistory/DefaultWebHistoryControllerTest.kt
+++ b/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/webhistory/DefaultWebHistoryControllerTest.kt
@@ -5,6 +5,7 @@ import com.arkivanov.decompose.router.TestRouter
 import com.arkivanov.decompose.router.navigate
 import com.arkivanov.decompose.router.pop
 import com.arkivanov.decompose.router.push
+import com.arkivanov.decompose.router.replaceCurrent
 import com.arkivanov.essenty.parcelable.Parcelable
 import kotlin.test.Test
 import kotlin.test.assertEquals
@@ -338,6 +339,48 @@ class DefaultWebHistoryControllerTest {
         assertEquals(listOf(Config(0), Config(1)), router.stack)
     }
 
+    @Test
+    fun GIVEN_router_with_initial_stack_of_a_and_pushed_b_and_go_back_and_replace_current_with_c_WHEN_go_forward_THEN_history_is_c_b_and_b_is_active() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestRouter(listOf(Config(0)))
+        attach(router)
+        router.push(Config(1))
+        window.runPendingOperations()
+        window.history.go(delta = -1)
+        window.runPendingOperations()
+        router.replaceCurrent(Config(2))
+        window.runPendingOperations()
+
+        window.history.go(delta = 1)
+        window.runPendingOperations()
+
+        window.history.assertStack(listOf("/2", "/1"))
+    }
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_a_and_pushed_b_and_go_back_and_replace_current_with_c_WHEN_go_forward_THEN_stack_is_c_b() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestRouter(listOf(Config(0)))
+        attach(router)
+        router.push(Config(1))
+        window.runPendingOperations()
+        window.history.go(delta = -1)
+        window.runPendingOperations()
+        router.replaceCurrent(Config(2))
+        window.runPendingOperations()
+
+        window.history.go(delta = 1)
+        window.runPendingOperations()
+
+        assertEquals(listOf(Config(2), Config(1)), router.stack)
+    }
+
     @Test
     fun GIVEN_router_with_initial_stack_of_a_b_and_navigate_to_c_WHEN_go_forward_THEN_stack_is_c_b() {
         if (isNode()) {
diff --git a/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/webhistory/TestHistory.kt b/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/webhistory/TestHistory.kt
index 85e77dfc..df10b53f 100644
--- a/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/webhistory/TestHistory.kt
+++ b/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/webhistory/TestHistory.kt
@@ -35,12 +35,12 @@ class TestHistory(
             stack.removeLast()
         }
 
-        stack += Entry(data = data, url = url)
+        stack += Entry(data = data?.serializeAndDeserialize(), url = url)
         index++
     }
 
     override fun replaceState(data: Any?, url: String?) {
-        stack[index] = Entry(data = data, url = url)
+        stack[index] = Entry(data = data?.serializeAndDeserialize(), url = url)
     }
 
     fun assertStack(urls: List<String?>, index: Int = urls.lastIndex) {
@@ -48,6 +48,8 @@ class TestHistory(
         assertEquals(index, this.index)
     }
 
+    private fun Any.serializeAndDeserialize(): Any = JSON.parse(JSON.stringify(this))
+
     class Entry(
         val data: Any? = null,
         val url: String? = null,