Repository: arkivanov/Decompose
Pull request #137 (2022-07-10 20:36:53)

Extract StackNavigator interface
As part of #134 


Patch:
diff --git a/decompose/api/android/decompose.api b/decompose/api/android/decompose.api
index d566058d..03b8ec2b 100644
--- a/decompose/api/android/decompose.api
+++ b/decompose/api/android/decompose.api
@@ -136,24 +136,30 @@ public final class com/arkivanov/decompose/router/stack/RouterFactoryKt {
 	public static synthetic fun stackRouter$default (Lcom/arkivanov/decompose/ComponentContext;Lkotlin/jvm/functions/Function0;Lkotlin/reflect/KClass;Ljava/lang/String;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/arkivanov/decompose/router/stack/StackRouter;
 }
 
-public abstract interface class com/arkivanov/decompose/router/stack/StackRouter {
-	public abstract fun getStack ()Lcom/arkivanov/decompose/value/Value;
+public abstract interface class com/arkivanov/decompose/router/stack/StackNavigator {
 	public abstract fun navigate (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V
 }
 
+public final class com/arkivanov/decompose/router/stack/StackNavigatorExtKt {
+	public static final fun bringToFront (Lcom/arkivanov/decompose/router/stack/StackNavigator;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
+	public static synthetic fun bringToFront$default (Lcom/arkivanov/decompose/router/stack/StackNavigator;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
+	public static final fun navigate (Lcom/arkivanov/decompose/router/stack/StackNavigator;Lkotlin/jvm/functions/Function1;)V
+	public static final fun pop (Lcom/arkivanov/decompose/router/stack/StackNavigator;Lkotlin/jvm/functions/Function1;)V
+	public static synthetic fun pop$default (Lcom/arkivanov/decompose/router/stack/StackNavigator;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
+	public static final fun popWhile (Lcom/arkivanov/decompose/router/stack/StackNavigator;Lkotlin/jvm/functions/Function1;)V
+	public static final fun popWhile (Lcom/arkivanov/decompose/router/stack/StackNavigator;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
+	public static final fun push (Lcom/arkivanov/decompose/router/stack/StackNavigator;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
+	public static synthetic fun push$default (Lcom/arkivanov/decompose/router/stack/StackNavigator;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
+	public static final fun replaceCurrent (Lcom/arkivanov/decompose/router/stack/StackNavigator;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
+	public static synthetic fun replaceCurrent$default (Lcom/arkivanov/decompose/router/stack/StackNavigator;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
+}
+
+public abstract interface class com/arkivanov/decompose/router/stack/StackRouter : com/arkivanov/decompose/router/stack/StackNavigator {
+	public abstract fun getStack ()Lcom/arkivanov/decompose/value/Value;
+}
+
 public final class com/arkivanov/decompose/router/stack/StackRouterExtKt {
-	public static final fun bringToFront (Lcom/arkivanov/decompose/router/stack/StackRouter;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
-	public static synthetic fun bringToFront$default (Lcom/arkivanov/decompose/router/stack/StackRouter;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
 	public static final fun getActiveChild (Lcom/arkivanov/decompose/router/stack/StackRouter;)Lcom/arkivanov/decompose/Child$Created;
-	public static final fun navigate (Lcom/arkivanov/decompose/router/stack/StackRouter;Lkotlin/jvm/functions/Function1;)V
-	public static final fun pop (Lcom/arkivanov/decompose/router/stack/StackRouter;Lkotlin/jvm/functions/Function1;)V
-	public static synthetic fun pop$default (Lcom/arkivanov/decompose/router/stack/StackRouter;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
-	public static final fun popWhile (Lcom/arkivanov/decompose/router/stack/StackRouter;Lkotlin/jvm/functions/Function1;)V
-	public static final fun popWhile (Lcom/arkivanov/decompose/router/stack/StackRouter;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
-	public static final fun push (Lcom/arkivanov/decompose/router/stack/StackRouter;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
-	public static synthetic fun push$default (Lcom/arkivanov/decompose/router/stack/StackRouter;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
-	public static final fun replaceCurrent (Lcom/arkivanov/decompose/router/stack/StackRouter;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
-	public static synthetic fun replaceCurrent$default (Lcom/arkivanov/decompose/router/stack/StackRouter;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
 }
 
 public abstract interface class com/arkivanov/decompose/router/stack/webhistory/WebHistoryController {
diff --git a/decompose/api/jvm/decompose.api b/decompose/api/jvm/decompose.api
index 79e0a0ad..f2d2d7bd 100644
--- a/decompose/api/jvm/decompose.api
+++ b/decompose/api/jvm/decompose.api
@@ -123,24 +123,30 @@ public final class com/arkivanov/decompose/router/stack/RouterFactoryKt {
 	public static synthetic fun stackRouter$default (Lcom/arkivanov/decompose/ComponentContext;Lkotlin/jvm/functions/Function0;Lkotlin/reflect/KClass;Ljava/lang/String;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/arkivanov/decompose/router/stack/StackRouter;
 }
 
-public abstract interface class com/arkivanov/decompose/router/stack/StackRouter {
-	public abstract fun getStack ()Lcom/arkivanov/decompose/value/Value;
+public abstract interface class com/arkivanov/decompose/router/stack/StackNavigator {
 	public abstract fun navigate (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V
 }
 
+public final class com/arkivanov/decompose/router/stack/StackNavigatorExtKt {
+	public static final fun bringToFront (Lcom/arkivanov/decompose/router/stack/StackNavigator;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
+	public static synthetic fun bringToFront$default (Lcom/arkivanov/decompose/router/stack/StackNavigator;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
+	public static final fun navigate (Lcom/arkivanov/decompose/router/stack/StackNavigator;Lkotlin/jvm/functions/Function1;)V
+	public static final fun pop (Lcom/arkivanov/decompose/router/stack/StackNavigator;Lkotlin/jvm/functions/Function1;)V
+	public static synthetic fun pop$default (Lcom/arkivanov/decompose/router/stack/StackNavigator;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
+	public static final fun popWhile (Lcom/arkivanov/decompose/router/stack/StackNavigator;Lkotlin/jvm/functions/Function1;)V
+	public static final fun popWhile (Lcom/arkivanov/decompose/router/stack/StackNavigator;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
+	public static final fun push (Lcom/arkivanov/decompose/router/stack/StackNavigator;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
+	public static synthetic fun push$default (Lcom/arkivanov/decompose/router/stack/StackNavigator;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
+	public static final fun replaceCurrent (Lcom/arkivanov/decompose/router/stack/StackNavigator;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
+	public static synthetic fun replaceCurrent$default (Lcom/arkivanov/decompose/router/stack/StackNavigator;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
+}
+
+public abstract interface class com/arkivanov/decompose/router/stack/StackRouter : com/arkivanov/decompose/router/stack/StackNavigator {
+	public abstract fun getStack ()Lcom/arkivanov/decompose/value/Value;
+}
+
 public final class com/arkivanov/decompose/router/stack/StackRouterExtKt {
-	public static final fun bringToFront (Lcom/arkivanov/decompose/router/stack/StackRouter;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
-	public static synthetic fun bringToFront$default (Lcom/arkivanov/decompose/router/stack/StackRouter;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
 	public static final fun getActiveChild (Lcom/arkivanov/decompose/router/stack/StackRouter;)Lcom/arkivanov/decompose/Child$Created;
-	public static final fun navigate (Lcom/arkivanov/decompose/router/stack/StackRouter;Lkotlin/jvm/functions/Function1;)V
-	public static final fun pop (Lcom/arkivanov/decompose/router/stack/StackRouter;Lkotlin/jvm/functions/Function1;)V
-	public static synthetic fun pop$default (Lcom/arkivanov/decompose/router/stack/StackRouter;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
-	public static final fun popWhile (Lcom/arkivanov/decompose/router/stack/StackRouter;Lkotlin/jvm/functions/Function1;)V
-	public static final fun popWhile (Lcom/arkivanov/decompose/router/stack/StackRouter;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
-	public static final fun push (Lcom/arkivanov/decompose/router/stack/StackRouter;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
-	public static synthetic fun push$default (Lcom/arkivanov/decompose/router/stack/StackRouter;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
-	public static final fun replaceCurrent (Lcom/arkivanov/decompose/router/stack/StackRouter;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
-	public static synthetic fun replaceCurrent$default (Lcom/arkivanov/decompose/router/stack/StackRouter;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
 }
 
 public abstract interface class com/arkivanov/decompose/router/stack/webhistory/WebHistoryController {
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/RouterFactory.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/RouterFactory.kt
index 58247288..d2452d54 100644
--- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/RouterFactory.kt
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/RouterFactory.kt
@@ -41,7 +41,7 @@ fun <C : Parcelable, T : Any> ComponentContext.stackRouter(
             instanceKeeper = instanceKeeper,
             routerEntryFactory = routerEntryFactory
         ),
-        navigator = StackNavigatorImpl(routerEntryFactory = routerEntryFactory),
+        controller = StackControllerImpl(routerEntryFactory = routerEntryFactory),
     )
 }
 
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackController.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackController.kt
new file mode 100644
index 00000000..3688f07f
--- /dev/null
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackController.kt
@@ -0,0 +1,9 @@
+package com.arkivanov.decompose.router.stack
+
+internal interface StackController<C : Any, T : Any> {
+
+    fun navigate(
+        oldStack: RouterStack<C, T>,
+        transformer: (stack: List<C>) -> List<C>,
+    ): RouterStack<C, T>
+}
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigatorImpl.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackControllerImpl.kt
similarity index 92%
rename from decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigatorImpl.kt
rename to decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackControllerImpl.kt
index f26cd939..5f9d4a1d 100644
--- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigatorImpl.kt
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackControllerImpl.kt
@@ -6,11 +6,14 @@ import com.arkivanov.essenty.lifecycle.pause
 import com.arkivanov.essenty.lifecycle.resume
 import com.arkivanov.essenty.lifecycle.stop
 
-internal class StackNavigatorImpl<C : Any, T : Any>(
+internal class StackControllerImpl<C : Any, T : Any>(
     private val routerEntryFactory: RouterEntryFactory<C, T>
-) : StackNavigator<C, T> {
+) : StackController<C, T> {
 
-    override fun navigate(oldStack: RouterStack<C, T>, transformer: (stack: List<C>) -> List<C>): RouterStack<C, T> {
+    override fun navigate(
+        oldStack: RouterStack<C, T>,
+        transformer: (stack: List<C>) -> List<C>,
+    ): RouterStack<C, T> {
         val oldConfigurationStack = oldStack.configurationStack
         val newConfigurationStack = transformer(oldConfigurationStack)
 
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigator.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigator.kt
index 8f51111a..e6dfd11d 100644
--- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigator.kt
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigator.kt
@@ -1,6 +1,29 @@
 package com.arkivanov.decompose.router.stack
 
-internal interface StackNavigator<C : Any, T : Any> {
+interface StackNavigator<C : Any> {
 
-    fun navigate(oldStack: RouterStack<C, T>, transformer: (stack: List<C>) -> List<C>): RouterStack<C, T>
+    /**
+     * Transforms the current stack of configurations into a new one.
+     * The stack is represented as [List], where the last element is the top of the stack,
+     * and the first element is the bottom of the stack. The returned stack must not be empty.
+     *
+     * During the navigation process, the `StackRouter` compares the new stack of configurations with
+     * the previous one. the `StackRouter` ensures that all removed components are destroyed, and that
+     * there is only one component resumed at a time - the top one. All components in the back stack
+     * are always either stopped or destroyed.
+     *
+     * The `StackRouter` usually performs the navigation synchronously, which means that by the time
+     * the `navigate` method returns, the navigation is finished and all component lifecycles are
+     * moved into required states. However the navigation is performed asynchronously in case of
+     * recursive invocations - e.g. `pop` is called from `onResume` lifecycle callback of a
+     * component being pushed. All recursive invocations are queued and performed one by one once
+     * the current navigation is finished.
+     *
+     * @param transformer transforms the current configuration stack to a new one.
+     * @param onComplete called when the navigation is finished (either synchronously or asynchronously).
+     */
+    fun navigate(
+        transformer: (stack: List<C>) -> List<C>,
+        onComplete: (newStack: List<C>, oldStack: List<C>) -> Unit,
+    )
 }
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
new file mode 100644
index 00000000..b0aa7f47
--- /dev/null
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigatorExt.kt
@@ -0,0 +1,77 @@
+package com.arkivanov.decompose.router.stack
+
+/**
+ * A convenience method for [StackRouter.navigate].
+ */
+fun <C : Any> StackNavigator<C>.navigate(transformer: (stack: List<C>) -> List<C>) {
+    navigate(transformer = transformer, onComplete = { _, _ -> })
+}
+
+/**
+ * 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> StackNavigator<C>.push(configuration: C, onComplete: () -> Unit = {}) {
+    navigate(transformer = { it + configuration }, onComplete = { _, _ -> onComplete() })
+}
+
+/**
+ * Pops the latest configuration at the top of the stack.
+ *
+ * @param onComplete called when the navigation is finished (either synchronously or asynchronously).
+ * The `isSuccess` argument is `true` if the stack size was greater than 1 and a component was popped,
+ * `false` otherwise.
+ */
+fun <C : Any> StackNavigator<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) },
+    )
+}
+
+/**
+ * Drops the configurations at the top of the stack while the [predicate] returns `true`.
+ */
+inline fun <C : Any> StackNavigator<C>.popWhile(crossinline predicate: (C) -> Boolean) {
+    popWhile(predicate = predicate, onComplete = {})
+}
+
+/**
+ * Drops the configurations at the top of the stack while the [predicate] returns true
+ *
+ * @param onComplete called when the navigation is finished (either synchronously or asynchronously).
+ * The `isSuccess` argument is `true` if at least one component has been popped.
+ */
+inline fun <C : Any> StackNavigator<C>.popWhile(
+    crossinline predicate: (C) -> Boolean,
+    crossinline onComplete: (isSuccess: Boolean) -> Unit,
+) {
+    navigate(
+        transformer = { stack -> stack.dropLastWhile(predicate) },
+        onComplete = { newStack, oldStack -> onComplete(newStack.size < oldStack.size) },
+    )
+}
+
+/**
+ * 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> StackNavigator<C>.replaceCurrent(configuration: C, onComplete: () -> Unit = {}) {
+    navigate(
+        transformer = { it.dropLast(1) + configuration },
+        onComplete = { _, _ -> 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> StackNavigator<C>.bringToFront(configuration: C, onComplete: () -> Unit = {}) {
+    navigate(
+        transformer = { stack -> stack.filterNot { it::class == configuration::class } + configuration },
+        onComplete = { _, _ -> onComplete() },
+    )
+}
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackRouter.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackRouter.kt
index 2b3903a2..08c5f32d 100644
--- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackRouter.kt
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackRouter.kt
@@ -2,35 +2,10 @@ package com.arkivanov.decompose.router.stack
 
 import com.arkivanov.decompose.value.Value
 
-interface StackRouter<C : Any, out T : Any> {
+interface StackRouter<C : Any, out T : Any> : StackNavigator<C> {
 
     /**
-     * Returns the current stack of components, which can also be observed for changes
+     * Returns the current stack of components, which can also be observed for changes.
      */
     val stack: Value<ChildStack<C, T>>
-
-    /**
-     * Transforms the current stack of configurations into a new one.
-     * The stack is represented as [List], where the last element is the top of the stack,
-     * and the first element is the bottom of the stack. The returned stack must not be empty.
-     *
-     * During the navigation process, the `StackRouter` compares the new stack of configurations with
-     * the previous one. the `StackRouter` ensures that all removed components are destroyed, and that
-     * there is only one component resumed at a time - the top one. All components in the back stack
-     * are always either stopped or destroyed.
-     *
-     * The `StackRouter` usually performs the navigation synchronously, which means that by the time
-     * the `navigate` method returns, the navigation is finished and all component lifecycles are
-     * moved into required states. However the navigation is performed asynchronously in case of
-     * recursive invocations - e.g. `pop` is called from `onResume` lifecycle callback of a
-     * component being pushed. All recursive invocations are queued and performed one by one once
-     * the current navigation is finished.
-     *
-     * @param transformer transforms the current configuration stack to a new one.
-     * @param onComplete called when the navigation is finished (either synchronously or asynchronously).
-     */
-    fun navigate(
-        transformer: (stack: List<C>) -> List<C>,
-        onComplete: (newStack: List<C>, oldStack: List<C>) -> Unit,
-    )
 }
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackRouterExt.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackRouterExt.kt
index 2198fe64..31ffc386 100644
--- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackRouterExt.kt
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackRouterExt.kt
@@ -2,80 +2,4 @@ package com.arkivanov.decompose.router.stack
 
 import com.arkivanov.decompose.Child
 
-/**
- * A convenience method for [StackRouter.navigate].
- */
-fun <C : Any> StackRouter<C, *>.navigate(transformer: (stack: List<C>) -> List<C>) {
-    navigate(transformer = transformer, onComplete = { _, _ -> })
-}
-
-/**
- * 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> StackRouter<C, *>.push(configuration: C, onComplete: () -> Unit = {}) {
-    navigate(transformer = { it + configuration }, onComplete = { _, _ -> onComplete() })
-}
-
-/**
- * Pops the latest configuration at the top of the stack.
- *
- * @param onComplete called when the navigation is finished (either synchronously or asynchronously).
- * The `isSuccess` argument is `true` if the stack size was greater than 1 and a component was popped,
- * `false` otherwise.
- */
-fun <C : Any> StackRouter<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) },
-    )
-}
-
-/**
- * Drops the configurations at the top of the stack while the [predicate] returns `true`.
- */
-inline fun <C : Any> StackRouter<C, *>.popWhile(crossinline predicate: (C) -> Boolean) {
-    popWhile(predicate = predicate, onComplete = {})
-}
-
-/**
- * Drops the configurations at the top of the stack while the [predicate] returns true
- *
- * @param onComplete called when the navigation is finished (either synchronously or asynchronously).
- * The `isSuccess` argument is `true` if at least one component has been popped.
- */
-inline fun <C : Any> StackRouter<C, *>.popWhile(
-    crossinline predicate: (C) -> Boolean,
-    crossinline onComplete: (isSuccess: Boolean) -> Unit,
-) {
-    navigate(
-        transformer = { stack -> stack.dropLastWhile(predicate) },
-        onComplete = { newStack, oldStack -> onComplete(newStack.size < oldStack.size) },
-    )
-}
-
-/**
- * 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> StackRouter<C, *>.replaceCurrent(configuration: C, onComplete: () -> Unit = {}) {
-    navigate(
-        transformer = { it.dropLast(1) + configuration },
-        onComplete = { _, _ -> 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> StackRouter<C, *>.bringToFront(configuration: C, onComplete: () -> Unit = {}) {
-    navigate(
-        transformer = { stack -> stack.filterNot { it::class == configuration::class } + configuration },
-        onComplete = { _, _ -> onComplete() },
-    )
-}
-
 val <C : Any, T : Any> StackRouter<C, T>.activeChild: Child.Created<C, T> get() = stack.value.active
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackRouterImpl.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackRouterImpl.kt
index 30cbd6e8..61928841 100644
--- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackRouterImpl.kt
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackRouterImpl.kt
@@ -13,7 +13,7 @@ internal class StackRouterImpl<C : Any, T : Any>(
     private val backPressedHandler: BackPressedHandler,
     private val popOnBackPressed: Boolean,
     private val stackHolder: StackHolder<C, T>,
-    private val navigator: StackNavigator<C, T>,
+    private val controller: StackController<C, T>,
 ) : StackRouter<C, T> {
 
     init {
@@ -33,13 +33,16 @@ internal class StackRouterImpl<C : Any, T : Any>(
         backPressedHandler.unregister(onBackPressedHandler)
     }
 
-    override fun navigate(transformer: (stack: List<C>) -> List<C>, onComplete: (newStack: List<C>, oldStack: List<C>) -> Unit) {
+    override fun navigate(
+        transformer: (stack: List<C>) -> List<C>,
+        onComplete: (newStack: List<C>, oldStack: List<C>) -> Unit
+    ) {
         queue.offer(NavigationItem(transformer = transformer, onComplete = onComplete))
     }
 
     private fun navigateActual(item: NavigationItem<C>) {
         val oldStack = stackHolder.stack
-        val newStack = navigator.navigate(oldStack = oldStack, transformer = item.transformer)
+        val newStack = controller.navigate(oldStack = oldStack, transformer = item.transformer)
         stackHolder.stack = newStack
         stack.value = newStack.toState()
         item.onComplete(newStack.configurationStack, oldStack.configurationStack)


Test patch:
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterBringToFrontTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterBringToFrontTest.kt
index 4969971a..e1fa1116 100644
--- a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterBringToFrontTest.kt
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterBringToFrontTest.kt
@@ -8,74 +8,74 @@ class RouterBringToFrontTest {
 
     @Test
     fun WHEN_does_not_contain_THEN_new_configuration_pushed() {
-        val router = TestRouter(listOf(Config.A, Config.B))
+        val navigator = TestStackNavigator(listOf(Config.A, Config.B))
 
-        router.bringToFront(Config.C(0))
+        navigator.bringToFront(Config.C(0))
 
-        assertEquals(listOf(Config.A, Config.B, Config.C(0)), router.configs)
+        assertEquals(listOf(Config.A, Config.B, Config.C(0)), navigator.configurations)
     }
 
     @Test
     fun WHEN_exists_with_same_value_at_start_THEN_existing_configuration_moved_to_end() {
-        val router = TestRouter(listOf(Config.C(0), Config.A, Config.B))
+        val navigator = TestStackNavigator(listOf(Config.C(0), Config.A, Config.B))
 
-        router.bringToFront(Config.C(0))
+        navigator.bringToFront(Config.C(0))
 
-        assertEquals(listOf(Config.A, Config.B, Config.C(0)), router.configs)
+        assertEquals(listOf(Config.A, Config.B, Config.C(0)), navigator.configurations)
     }
 
     @Test
     fun WHEN_exists_with_same_value_in_the_middle_THEN_existing_configuration_moved_to_end() {
-        val router = TestRouter(listOf(Config.A, Config.C(0), Config.B))
+        val navigator = TestStackNavigator(listOf(Config.A, Config.C(0), Config.B))
 
-        router.bringToFront(Config.C(0))
+        navigator.bringToFront(Config.C(0))
 
-        assertEquals(listOf(Config.A, Config.B, Config.C(0)), router.configs)
+        assertEquals(listOf(Config.A, Config.B, Config.C(0)), navigator.configurations)
     }
 
     @Test
     fun WHEN_exists_with_same_value_at_the_end_THEN_existing_configuration_moved_to_end() {
-        val router = TestRouter(listOf(Config.A, Config.B, Config.C(0)))
+        val navigator = TestStackNavigator(listOf(Config.A, Config.B, Config.C(0)))
 
-        router.bringToFront(Config.C(0))
+        navigator.bringToFront(Config.C(0))
 
-        assertEquals(listOf(Config.A, Config.B, Config.C(0)), router.configs)
+        assertEquals(listOf(Config.A, Config.B, Config.C(0)), navigator.configurations)
     }
 
     @Test
     fun WHEN_exists_with_different_value_at_start_THEN_removed_and_new_added_to_end() {
-        val router = TestRouter(listOf(Config.C(0), Config.A, Config.B))
+        val navigator = TestStackNavigator(listOf(Config.C(0), Config.A, Config.B))
 
-        router.bringToFront(Config.C(1))
+        navigator.bringToFront(Config.C(1))
 
-        assertEquals(listOf(Config.A, Config.B, Config.C(1)), router.configs)
+        assertEquals(listOf(Config.A, Config.B, Config.C(1)), navigator.configurations)
     }
 
     @Test
     fun WHEN_exists_with_different_value_in_the_middle_THEN_removed_and_new_added_to_end() {
-        val router = TestRouter(listOf(Config.A, Config.C(0), Config.B))
+        val navigator = TestStackNavigator(listOf(Config.A, Config.C(0), Config.B))
 
-        router.bringToFront(Config.C(1))
+        navigator.bringToFront(Config.C(1))
 
-        assertEquals(listOf(Config.A, Config.B, Config.C(1)), router.configs)
+        assertEquals(listOf(Config.A, Config.B, Config.C(1)), navigator.configurations)
     }
 
     @Test
     fun WHEN_exists_with_different_value_at_the_end_THEN_removed_and_new_added_to_end() {
-        val router = TestRouter(listOf(Config.A, Config.B, Config.C(0)))
+        val navigator = TestStackNavigator(listOf(Config.A, Config.B, Config.C(0)))
 
-        router.bringToFront(Config.C(1))
+        navigator.bringToFront(Config.C(1))
 
-        assertEquals(listOf(Config.A, Config.B, Config.C(1)), router.configs)
+        assertEquals(listOf(Config.A, Config.B, Config.C(1)), navigator.configurations)
     }
 
     @Test
     fun WHEN_multiple_exist_THEN_all_removed_and_new_added_to_end() {
-        val router = TestRouter(listOf(Config.C(0), Config.A, Config.C(1), Config.B))
+        val navigator = TestStackNavigator(listOf(Config.C(0), Config.A, Config.C(1), Config.B))
 
-        router.bringToFront(Config.C(1))
+        navigator.bringToFront(Config.C(1))
 
-        assertEquals(listOf(Config.A, Config.B, Config.C(1)), router.configs)
+        assertEquals(listOf(Config.A, Config.B, Config.C(1)), navigator.configurations)
     }
 
     private sealed class Config {
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterPopTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterPopTest.kt
index d0e5fca5..baaf9bf2 100644
--- a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterPopTest.kt
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterPopTest.kt
@@ -10,39 +10,38 @@ class RouterPopTest {
 
     @Test
     fun GIVEN_stack_size_2_WHEN_pop_THEN_popped() {
-        val router = TestRouter(listOf(Config.A, Config.B))
+        val navigator = TestStackNavigator(listOf(Config.A, Config.B))
 
-        router.pop()
+        navigator.pop()
 
-        assertEquals(listOf(Config.A), router.configs)
+        assertEquals(listOf(Config.A), navigator.configurations)
     }
 
     @Test
     fun GIVEN_stack_size_2_WHEN_pop_THEN_onComplete_success() {
-        val router = TestRouter(listOf(Config.A, Config.B))
+        val navigator = TestStackNavigator(listOf(Config.A, Config.B))
         var isSuccess = false
 
-        router.pop { isSuccess = it }
+        navigator.pop { isSuccess = it }
 
         assertTrue(isSuccess)
     }
 
     @Test
     fun GIVEN_stack_size_1_WHEN_pop_THEN_not_popped() {
-        val router = TestRouter(listOf(Config.A))
+        val navigator = TestStackNavigator(listOf(Config.A))
 
-        router.pop()
+        navigator.pop()
 
-        assertEquals(listOf(Config.A), router.configs)
+        assertEquals(listOf(Config.A), navigator.configurations)
     }
 
-
     @Test
     fun GIVEN_stack_size_1_WHEN_pop_THEN_onComplete_not_success() {
-        val router = TestRouter(listOf(Config.A))
+        val navigator = TestStackNavigator(listOf(Config.A))
         var isSuccess = true
 
-        router.pop { isSuccess = it }
+        navigator.pop { isSuccess = it }
 
         assertFalse(isSuccess)
     }
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterPopWhileTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterPopWhileTest.kt
index 9c68383a..a1440871 100644
--- a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterPopWhileTest.kt
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterPopWhileTest.kt
@@ -9,19 +9,19 @@ class RouterPopWhileTest {
 
     @Test
     fun WHEN_popWhile_THEN_popped() {
-        val router = TestRouter(listOf(1, 2, 3, 4))
+        val navigator = TestStackNavigator(listOf(1, 2, 3, 4))
 
-        router.popWhile { it != 2 }
+        navigator.popWhile { it != 2 }
 
-        assertEquals(listOf(1, 2), router.configs)
+        assertEquals(listOf(1, 2), navigator.configurations)
     }
 
     @Test
     fun WHEN_popWhile_THEN_onComplete_called() {
-        val router = TestRouter(listOf(1, 2, 3, 4))
+        val navigator = TestStackNavigator(listOf(1, 2, 3, 4))
         var isCalled = false
 
-        router.popWhile(predicate = { it != 2 }, onComplete = { isCalled = true })
+        navigator.popWhile(predicate = { it != 2 }, onComplete = { isCalled = true })
 
         assertTrue(isCalled)
     }
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterPushTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterPushTest.kt
index 4ad17aa7..c8057800 100644
--- a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterPushTest.kt
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterPushTest.kt
@@ -9,19 +9,19 @@ class RouterPushTest {
 
     @Test
     fun WHEN_push_THEN_pushed() {
-        val router = TestRouter(listOf(1, 2))
+        val navigator = TestStackNavigator(listOf(1, 2))
 
-        router.push(3)
+        navigator.push(3)
 
-        assertEquals(listOf(1, 2, 3), router.configs)
+        assertEquals(listOf(1, 2, 3), navigator.configurations)
     }
 
     @Test
     fun WHEN_push_THEN_onComplete_called() {
-        val router = TestRouter(listOf(1, 2))
+        val navigator = TestStackNavigator(listOf(1, 2))
         var isCalled = false
 
-        router.push(3) { isCalled = true }
+        navigator.push(3) { isCalled = true }
 
         assertTrue(isCalled)
     }
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterReplaceCurrentTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterReplaceCurrentTest.kt
index 6f6b0c42..0c22a0d5 100644
--- a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterReplaceCurrentTest.kt
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterReplaceCurrentTest.kt
@@ -9,19 +9,19 @@ class RouterReplaceCurrentTest {
 
     @Test
     fun WHEN_replaceCurrent_THEN_last_configuration_replaced() {
-        val router = TestRouter(listOf(1, 2, 3))
+        val navigator = TestStackNavigator(listOf(1, 2, 3))
 
-        router.replaceCurrent(4)
+        navigator.replaceCurrent(4)
 
-        assertEquals(listOf(1, 2, 4), router.configs)
+        assertEquals(listOf(1, 2, 4), navigator.configurations)
     }
 
     @Test
     fun WHEN_replaceCurrent_THEN_onComplete_called() {
-        val router = TestRouter(listOf(1, 2, 3))
+        val navigator = TestStackNavigator(listOf(1, 2, 3))
         var isCalled = false
 
-        router.replaceCurrent(4) { isCalled = true }
+        navigator.replaceCurrent(4) { isCalled = true }
 
         assertTrue(isCalled)
     }
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterTest.kt
index 973840a8..6fcb6339 100644
--- a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterTest.kt
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/RouterTest.kt
@@ -50,10 +50,10 @@ class RouterTest {
         val config1 = Config()
         val config2 = Config()
         val config3 = Config()
-        val navigator = TestStackNavigator()
-        val router = router(initialStack = listOf(config1), navigator = navigator)
+        val controller = TestStackController()
+        val router = router(initialStack = listOf(config1), controller = controller)
 
-        navigator.onNavigate =
+        controller.onNavigate =
             { newConfigs ->
                 if (newConfigs == listOf(config2)) {
                     router.navigate { listOf(config3) }
@@ -71,11 +71,11 @@ class RouterTest {
         val config1 = Config()
         val config2 = Config()
         val config3 = Config()
-        val navigator = TestStackNavigator()
-        val router = router(initialStack = listOf(config1), navigator = navigator)
+        val controller = TestStackController()
+        val router = router(initialStack = listOf(config1), controller = controller)
 
         var isCalledSynchronously = false
-        navigator.onNavigate =
+        controller.onNavigate =
             { newConfigs ->
                 if (newConfigs == listOf(config2)) {
                     var isCalled = false
@@ -97,12 +97,12 @@ class RouterTest {
         val config1 = Config()
         val config2 = Config()
         val config3 = Config()
-        val navigator = TestStackNavigator()
-        val router = router(initialStack = listOf(config1), navigator = navigator)
+        val controller = TestStackController()
+        val router = router(initialStack = listOf(config1), controller = controller)
 
         var resultNewStack: List<Config>? = null
         var resultOldStack: List<Config>? = null
-        navigator.onNavigate =
+        controller.onNavigate =
             { newConfigs ->
                 if (newConfigs == listOf(config2)) {
                     router.navigate(transformer = { listOf(config3) }) { newStack, oldStack ->
@@ -120,14 +120,14 @@ class RouterTest {
 
     private fun router(
         initialStack: List<Config>,
-        navigator: TestStackNavigator = TestStackNavigator(),
+        controller: TestStackController = TestStackController(),
     ): StackRouter<Config, Config> =
         StackRouterImpl(
             lifecycle = LifecycleRegistry(),
             backPressedHandler = BackPressedDispatcher(),
             popOnBackPressed = false,
             stackHolder = TestStackHolder(routerStack(initialStack)),
-            navigator = navigator,
+            controller = controller,
         )
 
     private companion object {
@@ -160,7 +160,7 @@ class RouterTest {
         override var stack: RouterStack<Config, Config>
     ) : StackHolder<Config, Config>
 
-    private class TestStackNavigator : StackNavigator<Config, Config> {
+    private class TestStackController : StackController<Config, Config> {
         var onNavigate: (newConfigs: List<Config>) -> Unit = {}
 
         override fun navigate(
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/StackNavigatorTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/StackControllerTest.kt
similarity index 93%
rename from decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/StackNavigatorTest.kt
rename to decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/StackControllerTest.kt
index 78b5e11c..8042b695 100644
--- a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/StackNavigatorTest.kt
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/StackControllerTest.kt
@@ -19,16 +19,16 @@ import kotlin.test.assertSame
 import kotlin.test.assertTrue
 
 @Suppress("TestFunctionName")
-class StackNavigatorTest {
+class StackControllerTest {
 
-    private val navigator = StackNavigatorImpl(routerEntryFactory = TestRouterEntryFactory())
+    private val controller = StackControllerImpl(routerEntryFactory = TestRouterEntryFactory())
 
     @Test
     fun GIVEN_empty_back_stack_WHEN_push_THEN_active_entry_is_new() {
         val newConfig = Config()
 
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = Config()),
                     backStack = emptyList()
@@ -44,7 +44,7 @@ class StackNavigatorTest {
         val oldConfig = Config()
 
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = oldConfig),
                     backStack = emptyList()
@@ -62,7 +62,7 @@ class StackNavigatorTest {
         val oldConfig3 = Config()
 
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = oldConfig3),
                     backStack = listOf(createdEntry(configuration = oldConfig1), createdEntry(configuration = oldConfig2))
@@ -81,7 +81,7 @@ class StackNavigatorTest {
                 backStack = emptyList()
             )
 
-        val newStack = navigator.navigate(oldStack = oldStack, transformer = { it + Config() })
+        val newStack = controller.navigate(oldStack = oldStack, transformer = { it + Config() })
 
         assertSame(newStack.backStack[0].asCreated().instance, oldStack.active.instance)
     }
@@ -94,7 +94,7 @@ class StackNavigatorTest {
                 backStack = listOf(createdEntry(configuration = Config()), createdEntry(configuration = Config()))
             )
 
-        val newStack = navigator.navigate(oldStack = oldStack, transformer = { it + Config() })
+        val newStack = controller.navigate(oldStack = oldStack, transformer = { it + Config() })
 
         assertSame(oldStack.backStack[0].asCreated().instance, newStack.backStack[0].asCreated().instance)
         assertSame(oldStack.backStack[1].asCreated().instance, newStack.backStack[1].asCreated().instance)
@@ -106,7 +106,7 @@ class StackNavigatorTest {
         val stateKeeperDispatcher = TestStateKeeperDispatcher()
 
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = Config(), stateKeeperDispatcher = stateKeeperDispatcher),
                     backStack = emptyList()
@@ -121,7 +121,7 @@ class StackNavigatorTest {
     @Test
     fun WHEN_push_THEN_new_active_component_resumed() {
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = Config()),
                     backStack = emptyList()
@@ -135,7 +135,7 @@ class StackNavigatorTest {
     @Test
     fun WHEN_push_THEN_old_active_component_stopped() {
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = Config()),
                     backStack = emptyList()
@@ -149,7 +149,7 @@ class StackNavigatorTest {
     @Test
     fun WHEN_push_THEN_old_components_not_recreated() {
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = Config()),
                     backStack = emptyList()
@@ -166,7 +166,7 @@ class StackNavigatorTest {
         val backConfig = Config()
 
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = Config()),
                     backStack = listOf(createdEntry(configuration = backConfig))
@@ -180,7 +180,7 @@ class StackNavigatorTest {
     @Test
     fun GIVEN_back_stack_with_one_entry_WHEN_pop_THEN_back_stack_is_empty() {
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = Config()),
                     backStack = listOf(createdEntry(configuration = Config()))
@@ -195,7 +195,7 @@ class StackNavigatorTest {
     fun GIVEN_back_stack_WHEN_pop_THEN_old_active_component_destroyed() {
         val lifecycleRegistry = LifecycleRegistry().apply { resume() }
 
-        navigator.navigate(
+        controller.navigate(
             oldStack = RouterStack(
                 active = activeEntry(configuration = Config(), lifecycleRegistry = lifecycleRegistry),
                 backStack = listOf(createdEntry(configuration = Config()))
@@ -212,7 +212,7 @@ class StackNavigatorTest {
         val instance = TestInstance()
         instanceKeeperDispatcher.put("key", instance)
 
-        navigator.navigate(
+        controller.navigate(
             oldStack = RouterStack(
                 active = activeEntry(configuration = Config(), instanceKeeperDispatcher = instanceKeeperDispatcher),
                 backStack = listOf(createdEntry(configuration = Config()))
@@ -226,7 +226,7 @@ class StackNavigatorTest {
     @Test
     fun GIVEN_back_stack_WHEN_pop_THEN_new_active_component_resumed() {
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = Config()),
                     backStack = listOf(createdEntry(configuration = Config()))
@@ -245,7 +245,7 @@ class StackNavigatorTest {
                 backStack = listOf(createdEntry(configuration = Config()), createdEntry(configuration = Config()))
             )
 
-        val newStack = navigator.navigate(oldStack = oldStack, transformer = { it.dropLast(1) })
+        val newStack = controller.navigate(oldStack = oldStack, transformer = { it.dropLast(1) })
 
         assertSame(oldStack.backStack[0].asCreated().instance, newStack.backStack[0].asCreated().instance)
         assertSame(oldStack.backStack[1].asCreated().instance, newStack.active.instance)
@@ -254,7 +254,7 @@ class StackNavigatorTest {
     @Test
     fun GIVEN_back_stack_with_created_entry_and_saved_state_WHEN_pop_THEN_new_active_component_without_saved_state() {
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = Config()),
                     backStack = listOf(createdEntry(configuration = Config(), savedState = TestParcelableContainer()))
@@ -270,7 +270,7 @@ class StackNavigatorTest {
         val savedState = TestParcelableContainer()
 
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = Config()),
                     backStack = listOf(destroyedEntry(configuration = Config(), savedState = savedState))
@@ -284,7 +284,7 @@ class StackNavigatorTest {
     @Test
     fun GIVEN_back_stack_with_destroyed_entry_WHEN_pop_THEN_new_active_component_without_saved_state() {
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = Config()),
                     backStack = listOf(destroyedEntry(configuration = Config()))
@@ -299,7 +299,7 @@ class StackNavigatorTest {
     fun WHEN_navigate_to_different_stack_THEN_old_active_component_destroyed() {
         val lifecycleRegistry = LifecycleRegistry().apply { resume() }
 
-        navigator.navigate(
+        controller.navigate(
             oldStack = RouterStack(
                 active = activeEntry(configuration = Config(), lifecycleRegistry = lifecycleRegistry),
                 backStack = listOf(createdEntry(configuration = Config()))
@@ -314,7 +314,7 @@ class StackNavigatorTest {
     fun WHEN_navigate_to_different_stack_THEN_old_back_stack_components_destroyed() {
         val lifecycleRegistry = LifecycleRegistry().apply { create() }
 
-        navigator.navigate(
+        controller.navigate(
             oldStack = RouterStack(
                 active = activeEntry(configuration = Config()),
                 backStack = listOf(createdEntry(configuration = Config(), lifecycleRegistry = lifecycleRegistry))
@@ -331,7 +331,7 @@ class StackNavigatorTest {
         val newConfigurationStack = listOf(Config(), Config())
 
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = Config(), lifecycleRegistry = lifecycleRegistry),
                     backStack = listOf(createdEntry(configuration = Config()))
@@ -345,7 +345,7 @@ class StackNavigatorTest {
     @Test
     fun WHEN_navigate_to_different_stack_THEN_new_active_component_resumed() {
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = Config()),
                     backStack = listOf(createdEntry(configuration = Config()))
@@ -359,7 +359,7 @@ class StackNavigatorTest {
     @Test
     fun WHEN_navigate_to_different_stack_THEN_new_back_stack_components_not_created() {
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = Config()),
                     backStack = listOf(createdEntry(configuration = Config()))
@@ -377,7 +377,7 @@ class StackNavigatorTest {
         val newConfigurationStack = listOf(Config(), sameConfig1, sameConfig2, Config(), Config())
 
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = Config()),
                     backStack = listOf(
@@ -410,7 +410,7 @@ class StackNavigatorTest {
             )
 
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = oldStack,
                 transformer = { listOf(Config(), sameConfig1, sameConfig2, Config(), Config()) }
             )
@@ -427,7 +427,7 @@ class StackNavigatorTest {
         val newConfigurationStack = listOf(Config(), sameConfig1, sameConfig2, Config(), sameConfig3)
 
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = RouterStack(
                     active = activeEntry(configuration = sameConfig3),
                     backStack = listOf(
@@ -461,7 +461,7 @@ class StackNavigatorTest {
             )
 
         val newStack =
-            navigator.navigate(
+            controller.navigate(
                 oldStack = oldStack,
                 transformer = { listOf(Config(), sameConfig1, sameConfig2, Config(), sameConfig3) }
             )
@@ -482,7 +482,7 @@ class StackNavigatorTest {
                 backStack = listOf(createdEntry(configuration = sameConfig2))
             )
 
-        val newStack = navigator.navigate(oldStack = oldStack, transformer = { listOf(sameConfig2, sameConfig1) })
+        val newStack = controller.navigate(oldStack = oldStack, transformer = { listOf(sameConfig2, sameConfig1) })
 
         assertEquals(oldStack, newStack)
     }
@@ -498,7 +498,7 @@ class StackNavigatorTest {
                 backStack = listOf(createdEntry(configuration = sameConfig2))
             )
 
-        val newStack = navigator.navigate(oldStack = oldStack, transformer = { it })
+        val newStack = controller.navigate(oldStack = oldStack, transformer = { it })
 
         assertEquals(oldStack, newStack)
     }
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/TestRouter.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/TestRouter.kt
deleted file mode 100644
index 8eb97cee..00000000
--- a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/TestRouter.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.arkivanov.decompose.router.stack
-
-import com.arkivanov.decompose.Child
-import com.arkivanov.decompose.value.MutableValue
-
-class TestRouter<C : Any>(stack: List<C>) : StackRouter<C, Any> {
-
-    override val stack: MutableValue<ChildStack<C, Any>> = MutableValue(stack.toRouterState())
-
-    var configs: List<C>
-        get() = stack.value.items.map { it.configuration }
-        set(value) {
-            stack.value = value.toRouterState()
-        }
-
-    private fun List<C>.toRouterState(): ChildStack<C, Any> =
-        ChildStack<C, Any>(
-            active = Child.Created(
-                configuration = last(),
-                instance = last(),
-            ),
-            backStack = dropLast(1).map {
-                Child.Created(
-                    configuration = it,
-                    instance = it,
-                )
-            }
-        )
-
-    override fun navigate(transformer: (stack: List<C>) -> List<C>, onComplete: (newStack: List<C>, oldStack: List<C>) -> Unit) {
-        val oldConfigs = this.configs
-        val newConfigs = transformer(this.configs)
-        this.configs = newConfigs
-        onComplete(newConfigs, oldConfigs)
-    }
-}
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/TestStackNavigator.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/TestStackNavigator.kt
new file mode 100644
index 00000000..b2039a13
--- /dev/null
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/TestStackNavigator.kt
@@ -0,0 +1,25 @@
+package com.arkivanov.decompose.router.stack
+
+import kotlin.properties.Delegates
+
+open class TestStackNavigator<C : Any>(
+    configurations: List<C>,
+) : StackNavigator<C> {
+
+    var configurations: List<C> by Delegates.observable(configurations) { _, _, newValue ->
+        onConfigurationsChanged(newValue)
+    }
+
+    protected open fun onConfigurationsChanged(configurations: List<C>) {
+    }
+
+    override fun navigate(
+        transformer: (stack: List<C>) -> List<C>,
+        onComplete: (newStack: List<C>, oldStack: List<C>) -> Unit
+    ) {
+        val oldStack = configurations
+        val newStack = transformer(configurations)
+        configurations = newStack
+        onComplete(newStack, oldStack)
+    }
+}
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/TestStackRouter.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/TestStackRouter.kt
new file mode 100644
index 00000000..f8461832
--- /dev/null
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/stack/TestStackRouter.kt
@@ -0,0 +1,31 @@
+package com.arkivanov.decompose.router.stack
+
+import com.arkivanov.decompose.Child
+import com.arkivanov.decompose.value.MutableValue
+import com.arkivanov.decompose.value.Value
+
+class TestStackRouter<C : Any>(stack: List<C>) : TestStackNavigator<C>(stack), StackRouter<C, Any> {
+
+    private val _stack = MutableValue(stack.toRouterState())
+    override val stack: Value<ChildStack<C, Any>> = _stack
+
+    override fun onConfigurationsChanged(configurations: List<C>) {
+        super.onConfigurationsChanged(configurations)
+
+        _stack.value = configurations.toRouterState()
+    }
+
+    private fun List<C>.toRouterState(): ChildStack<C, Any> =
+        ChildStack<C, Any>(
+            active = Child.Created(
+                configuration = last(),
+                instance = last(),
+            ),
+            backStack = dropLast(1).map {
+                Child.Created(
+                    configuration = it,
+                    instance = it,
+                )
+            }
+        )
+}
diff --git a/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/stack/webhistory/DefaultWebHistoryControllerTest.kt b/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/stack/webhistory/DefaultWebHistoryControllerTest.kt
new file mode 100644
index 00000000..12975faa
--- /dev/null
+++ b/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/stack/webhistory/DefaultWebHistoryControllerTest.kt
@@ -0,0 +1,431 @@
+package com.arkivanov.decompose.router.stack.webhistory
+
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+import com.arkivanov.decompose.router.stack.TestStackRouter
+import com.arkivanov.decompose.router.stack.navigate
+import com.arkivanov.decompose.router.stack.pop
+import com.arkivanov.decompose.router.stack.push
+import com.arkivanov.decompose.router.stack.replaceCurrent
+import com.arkivanov.essenty.parcelable.Parcelable
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+@OptIn(ExperimentalDecomposeApi::class)
+@Suppress("TestFunctionName")
+class DefaultWebHistoryControllerTest {
+
+    private val window = TestWindow()
+    private val history = window.history
+    private val controller = DefaultWebHistoryController(window)
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_one_config_WHEN_attach_THEN_history_stack_is_correct() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0)))
+
+        attach(router)
+
+        history.assertStack(listOf("/0"))
+    }
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_two_configs_WHEN_attach_THEN_history_stack_is_correct() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0), Config(1)))
+
+        attach(router)
+
+        history.assertStack(listOf("/0", "/1"))
+    }
+
+    @Test
+    fun WHEN_router_push_THEN_url_pushed_to_history() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0)))
+        attach(router)
+
+        router.push(Config(1))
+        window.runPendingOperations()
+
+        history.assertStack(listOf("/0", "/1"))
+    }
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_two_configs_WHEN_router_pop_THEN_history_changed_to_previous_page() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0), Config(1)))
+        attach(router)
+
+        router.pop()
+        window.runPendingOperations()
+
+        history.assertStack(listOf("/0", "/1"), 0)
+    }
+
+    @Test
+    fun GIVEN_router_push_WHEN_router_pop_THEN_history_changed_to_previous_page() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0)))
+        attach(router)
+        router.push(Config(1))
+        window.runPendingOperations()
+
+        router.pop()
+        window.runPendingOperations()
+
+        history.assertStack(listOf("/0", "/1"), 0)
+    }
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_tree_configs_WHEN_router_pop_two_THEN_history_changed_to_previous_page() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0), Config(1), Config(2)))
+        attach(router)
+
+        router.navigate { it.dropLast(2) }
+        window.runPendingOperations()
+
+        history.assertStack(listOf("/0", "/1", "/2"), 0)
+    }
+
+    @Test
+    fun GIVEN_router_push_two_WHEN_router_pop_two_THEN_history_changed_to_previous_page() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0)))
+        attach(router)
+        router.navigate { it + listOf(Config(1), Config(2)) }
+        window.runPendingOperations()
+
+        router.navigate { it.dropLast(2) }
+        window.runPendingOperations()
+
+        history.assertStack(listOf("/0", "/1", "/2"), 0)
+    }
+
+    @Test
+    fun GIVEN_router_push_and_pop_WHEN_router_push_same_config_THEN_history_changed_to_next_page() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0)))
+        attach(router)
+        router.push(Config(1))
+        window.runPendingOperations()
+        router.pop()
+        window.runPendingOperations()
+
+        router.push(Config(1))
+        window.runPendingOperations()
+
+        history.assertStack(listOf("/0", "/1"))
+    }
+
+    @Test
+    fun GIVEN_router_push_and_pop_WHEN_router_push_another_config_THEN_history_changed_to_next_page() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0)))
+        attach(router)
+        router.push(Config(1))
+        window.runPendingOperations()
+        router.pop()
+        window.runPendingOperations()
+
+        router.push(Config(2))
+        window.runPendingOperations()
+
+        history.assertStack(listOf("/0", "/2"))
+    }
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_a_b_c_WHEN_navigate_to_a_d_THEN_history_is_a_d_and_d_is_active() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0), Config(1), Config(2)))
+        attach(router)
+
+        router.navigate { listOf(Config(0), Config(3)) }
+        window.runPendingOperations()
+
+        history.assertStack(listOf("/0", "/3"))
+    }
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_a_b_c_WHEN_navigate_to_a_d_e_THEN_history_is_a_d_e_and_e_is_active() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0), Config(1), Config(2)))
+        attach(router)
+
+        router.navigate { listOf(Config(0), Config(3), Config(4)) }
+        window.runPendingOperations()
+
+        history.assertStack(listOf("/0", "/3", "/4"))
+    }
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_a_b_WHEN_navigate_to_a_c_THEN_history_is_a_c_and_c_is_active() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0), Config(1)))
+        attach(router)
+
+        router.navigate { listOf(Config(0), Config(2)) }
+        window.runPendingOperations()
+
+        history.assertStack(listOf("/0", "/2"))
+    }
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_a_b_WHEN_navigate_to_a_c_d_THEN_history_is_a_c_d_and_d_is_active() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0), Config(1)))
+        attach(router)
+
+        router.navigate { listOf(Config(0), Config(2), Config(3)) }
+        window.runPendingOperations()
+
+        history.assertStack(listOf("/0", "/2", "/3"))
+    }
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_a_b_WHEN_navigate_to_c_THEN_history_is_c_b_and_c_is_active() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0), Config(1)))
+        attach(router)
+
+        router.navigate { listOf(Config(2)) }
+        window.runPendingOperations()
+
+        // Corner case: old pages remain in the history
+        history.assertStack(listOf("/2", "/1"), 0)
+    }
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_a_b_WHEN_navigate_to_c_d_THEN_history_is_c_d_and_d_is_active() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0), Config(1)))
+        attach(router)
+
+        router.navigate { listOf(Config(2), Config(3)) }
+        window.runPendingOperations()
+
+        history.assertStack(listOf("/2", "/3"))
+    }
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_a_WHEN_navigate_to_b_THEN_history_is_b_and_b_is_active() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0)))
+        attach(router)
+
+        router.navigate { listOf(Config(1)) }
+        window.runPendingOperations()
+
+        history.assertStack(listOf("/1"))
+    }
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_a_WHEN_navigate_to_b_c_THEN_history_is_b_c_and_c_is_active() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0)))
+        attach(router)
+
+        router.navigate { listOf(Config(1), Config(2)) }
+        window.runPendingOperations()
+
+        history.assertStack(listOf("/1", "/2"))
+    }
+
+    // Browser buttons tests
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_a_and_pushed_b_WHEN_go_back_THEN_stack_is_a() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0)))
+        attach(router)
+        router.push(Config(1))
+        window.runPendingOperations()
+
+        window.history.go(delta = -1)
+        window.runPendingOperations()
+
+        assertEquals(listOf(Config(0)), router.configurations)
+    }
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_a_and_pushed_b_and_go_back_WHEN_go_forward_THEN_history_is_a_b_and_b_is_active() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0)))
+        attach(router)
+        router.push(Config(1))
+        window.runPendingOperations()
+        window.history.go(delta = -1)
+        window.runPendingOperations()
+
+        window.history.go(delta = 1)
+        window.runPendingOperations()
+
+        window.history.assertStack(listOf("/0", "/1"))
+    }
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_a_and_pushed_b_and_go_back_WHEN_go_forward_THEN_stack_is_a_b() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0)))
+        attach(router)
+        router.push(Config(1))
+        window.runPendingOperations()
+        window.history.go(delta = -1)
+        window.runPendingOperations()
+
+        window.history.go(delta = 1)
+        window.runPendingOperations()
+
+        assertEquals(listOf(Config(0), Config(1)), router.configurations)
+    }
+
+    @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 = TestStackRouter(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 = TestStackRouter(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.configurations)
+    }
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_a_b_and_navigate_to_c_WHEN_go_forward_THEN_stack_is_c_b() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0), Config(1)))
+        attach(router)
+        router.navigate { listOf(Config(2)) }
+        window.runPendingOperations()
+
+        window.history.go(delta = 1)
+        window.runPendingOperations()
+
+        assertEquals(listOf(Config(2), Config(1)), router.configurations)
+    }
+
+    @Test
+    fun GIVEN_router_with_initial_stack_of_a_b_and_navigate_to_c_WHEN_go_forward_THEN_history_is_c_b_and_b_is_active() {
+        if (isNode()) {
+            return
+        }
+
+        val router = TestStackRouter(listOf(Config(0), Config(1)))
+        attach(router)
+        router.navigate { listOf(Config(2)) }
+        window.runPendingOperations()
+
+        window.history.go(delta = 1)
+        window.runPendingOperations()
+
+        history.assertStack(listOf("/2", "/1"))
+    }
+
+    private fun attach(router: TestStackRouter<Config>) {
+        controller.attach(
+            router = router,
+            getPath = { "/${it.value}" },
+            getConfiguration = { Config(it.removePrefix("/").toInt()) },
+        )
+
+        window.runPendingOperations()
+    }
+
+    private fun isNode(): Boolean = jsTypeOf(kotlinx.browser.window) == "undefined"
+
+    private data class Config(val value: Int) : Parcelable
+}
diff --git a/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/stack/webhistory/TestHistory.kt b/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/stack/webhistory/TestHistory.kt
new file mode 100644
index 00000000..c96d30fd
--- /dev/null
+++ b/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/stack/webhistory/TestHistory.kt
@@ -0,0 +1,56 @@
+package com.arkivanov.decompose.router.stack.webhistory
+
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+import org.w3c.dom.PopStateEvent
+import org.w3c.dom.PopStateEventInit
+import kotlin.test.assertEquals
+
+@OptIn(ExperimentalDecomposeApi::class)
+class TestHistory(
+    private val scheduleOperation: (() -> Unit) -> Unit,
+    private val onPopState: (PopStateEvent) -> Unit,
+) : DefaultWebHistoryController.History {
+
+    private val stack: MutableList<Entry> = mutableListOf(Entry())
+    private var index = 0
+
+    override val state: Any? get() = stack[index].data
+
+    override fun go(delta: Int) {
+        scheduleOperation {
+            index += delta
+
+            onPopState(
+                PopStateEvent(
+                    type = "PopStateEvent",
+                    eventInitDict = PopStateEventInit(state = stack[index].data)
+                )
+            )
+        }
+    }
+
+    override fun pushState(data: Any?, url: String?) {
+        while (stack.lastIndex > index) {
+            stack.removeLast()
+        }
+
+        stack += Entry(data = data?.serializeAndDeserialize(), url = url)
+        index++
+    }
+
+    override fun replaceState(data: Any?, url: String?) {
+        stack[index] = Entry(data = data?.serializeAndDeserialize(), url = url)
+    }
+
+    fun assertStack(urls: List<String?>, index: Int = urls.lastIndex) {
+        assertEquals(urls, stack.map(Entry::url))
+        assertEquals(index, this.index)
+    }
+
+    private fun Any.serializeAndDeserialize(): Any = JSON.parse(JSON.stringify(this))
+
+    class Entry(
+        val data: Any? = null,
+        val url: String? = null,
+    )
+}
diff --git a/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/stack/webhistory/TestWindow.kt b/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/stack/webhistory/TestWindow.kt
new file mode 100644
index 00000000..bb891796
--- /dev/null
+++ b/decompose/src/jsTest/kotlin/com/arkivanov/decompose/router/stack/webhistory/TestWindow.kt
@@ -0,0 +1,24 @@
+package com.arkivanov.decompose.router.stack.webhistory
+
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+import org.w3c.dom.PopStateEvent
+
+@OptIn(ExperimentalDecomposeApi::class)
+class TestWindow : DefaultWebHistoryController.Window {
+
+    private val pendingOperations = ArrayList<() -> Unit>()
+
+    override var onPopState: ((PopStateEvent) -> Unit)? = null
+
+    override val history: TestHistory =
+        TestHistory(
+            scheduleOperation = pendingOperations::add,
+            onPopState = { onPopState?.invoke(it) }
+        )
+
+    fun runPendingOperations() {
+        val operations = pendingOperations.toList()
+        pendingOperations.clear()
+        operations.forEach { it() }
+    }
+}