Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support init route and direct navigation in a browser #1640

Merged
merged 4 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions mpp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ val testWebJs = tasks.register("testWebJs") {
dependsOn(":compose:runtime:runtime:jsTest")
dependsOn(":compose:ui:ui-text:compileTestKotlinJs")
dependsOn(":compose:ui:ui:compileTestKotlinJs")
dependsOn(":navigation:navigation-runtime:jsTest")
}

val testWebWasm = tasks.register("testWebWasm") {
Expand All @@ -215,6 +216,7 @@ val testWebWasm = tasks.register("testWebWasm") {
dependsOn(":compose:runtime:runtime:wasmJsTest")
dependsOn(":compose:ui:ui-text:wasmJsTest")
dependsOn(":compose:ui:ui:wasmJsTest")
dependsOn(":navigation:navigation-runtime:wasmJsTest")
}

tasks.register("testUIKit") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,30 @@ import org.w3c.dom.Window
/**
* Binds the browser window state to the given navigation controller.
*
* If `getBackStackEntryRoute` is null, then:
* 1) if a browser url contains a destination route on a start then navigates to destination
* 2) if a user puts a new destination route to the browser address field then navigates to the new destination
*
* If there is a custom `getBackStackEntryRoute` implementation,
* then we don't have a knowledge how to parse urls to support direct navigation via browser address input.
* In that case, it should be done on the app's side:
* ```
* window.addEventListener("popstate") { event ->
* event as PopStateEvent
* if (event.state == null) { // empty state means manually entered address
* val url = window.location.toString()
* navController.navigate(...)
* }
* }
* ```
*
* @param navController The [NavController] instance to bind to browser window navigation.
* @param getBackStackEntryRoute An optional function that returns the route to show for a given [NavBackStackEntry].
*/
@ExperimentalBrowserHistoryApi
suspend fun Window.bindToNavigation(
eymar marked this conversation as resolved.
Show resolved Hide resolved
navController: NavController,
getBackStackEntryRoute: (entry: NavBackStackEntry) -> String = {
it.getRouteWithArgs()?.let { r -> "#$r" }.orEmpty()
}
getBackStackEntryRoute: ((entry: NavBackStackEntry) -> String)? = null
) {
@Suppress("UNCHECKED_CAST_TO_EXTERNAL_INTERFACE")
(this as BrowserWindow).bindToNavigation(navController, getBackStackEntryRoute)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.test.Test
import kotlinx.browser.window
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.w3c.dom.AddEventListenerOptions

@OptIn(ExperimentalBrowserHistoryApi::class, ExperimentalCoroutinesApi::class)
class BrowserHistoryTest {

private fun NavController.createGraph() =
Expand All @@ -50,7 +52,7 @@ class BrowserHistoryTest {

@Test
fun checkBrowserHistoryStateSynchronizedWithNavigation() = runTest {
cleanWindowHistory()
val initHistoryLength = goToBrowserRoot()
eymar marked this conversation as resolved.
Show resolved Hide resolved
val navController = NavHostController().apply {
navigatorProvider.addNavigator(TestNavigator())
}
Expand All @@ -60,7 +62,7 @@ class BrowserHistoryTest {
navController.setGraph(navController.createGraph(), null)
advanceUntilIdle()

assertThat(window.history.length).isEqualTo(1)
assertThat(window.history.length).isEqualTo(initHistoryLength)
assertThat(window.history.state.toString()).isEqualTo("screen_1")
assertThat(window.location.toString()).isEqualTo("$appAddress#screen_1")

Expand Down Expand Up @@ -100,16 +102,20 @@ class BrowserHistoryTest {

@Test
fun checkNavigationSynchronizedWithBrowserHistoryState() = runTest {
cleanWindowHistory()
val initHistoryLength = goToBrowserRoot()
val navController = NavHostController().apply {
navigatorProvider.addNavigator(TestNavigator())
}
val appAddress = with(window.location) { origin + pathname }
navController.setGraph(navController.createGraph(), null)

val appAddress = with(window.location) { origin + pathname }
val bind = launch { window.bindToNavigation(navController) }
navController.setGraph(navController.createGraph(), null)
advanceUntilIdle()

assertThat(window.history.length).isEqualTo(initHistoryLength)
assertThat(window.history.state.toString()).isEqualTo("screen_1")
assertThat(window.location.toString()).isEqualTo("$appAddress#screen_1")

navController.navigate("screen_2")
advanceUntilIdle()

Expand All @@ -133,35 +139,31 @@ class BrowserHistoryTest {
.inOrder()
assertThat(window.location.toString()).isEqualTo("$appAddress#screen_6/123?q=")

window.history.back()
waitHistoryStateUpdate()
browserBack()

assertThat(window.history.length).isEqualTo(6)
assertThat(window.history.state.toString().lines())
.containsExactly("screen_5", "screen_2")
.inOrder()
assertThat(window.location.toString()).isEqualTo("$appAddress#screen_2")

window.history.back()
waitHistoryStateUpdate()
browserBack()

assertThat(window.history.length).isEqualTo(6)
assertThat(window.history.state.toString().lines())
.containsExactly("screen_5")
.inOrder()
assertThat(window.location.toString()).isEqualTo("$appAddress#screen_5")

window.history.back()
waitHistoryStateUpdate()
browserBack()

assertThat(window.history.length).isEqualTo(6)
assertThat(window.history.state.toString().lines())
.containsExactly("screen_1", "screen_2", "screen_4")
.inOrder()
assertThat(window.location.toString()).isEqualTo("$appAddress#screen_4")

window.history.back()
waitHistoryStateUpdate()
browserBack()

assertThat(window.history.length).isEqualTo(6)
assertThat(window.history.state.toString().lines())
Expand All @@ -178,17 +180,15 @@ class BrowserHistoryTest {
.inOrder()
assertThat(window.location.toString()).isEqualTo("$appAddress#screen_2")

window.history.back()
waitHistoryStateUpdate()
browserBack()

assertThat(window.history.length).isEqualTo(3)
assertThat(window.history.state.toString().lines())
.containsExactly("screen_1", "screen_2")
.inOrder()
assertThat(window.location.toString()).isEqualTo("$appAddress#screen_2")

window.history.forward()
waitHistoryStateUpdate()
browserForward()

assertThat(window.history.length).isEqualTo(3)
assertThat(window.history.state.toString().lines())
Expand All @@ -201,16 +201,20 @@ class BrowserHistoryTest {

@Test
fun checkBrowserUrlCustomization() = runTest {
cleanWindowHistory()
val initHistoryLength = goToBrowserRoot()
val navController = NavHostController().apply {
navigatorProvider.addNavigator(TestNavigator())
}
val appAddress = with(window.location) { origin + pathname }
navController.setGraph(navController.createGraph(), null)

val appAddress = with(window.location) { origin + pathname }
val bind = launch { window.bindToNavigation(navController) { "" } }
navController.setGraph(navController.createGraph(), null)
advanceUntilIdle()

assertThat(window.history.length).isEqualTo(initHistoryLength)
assertThat(window.history.state.toString()).isEqualTo("screen_1")
assertThat(window.location.toString()).isEqualTo(appAddress)

navController.navigate("screen_2")
advanceUntilIdle()

Expand All @@ -220,28 +224,113 @@ class BrowserHistoryTest {
.inOrder()
assertThat(window.location.toString()).isEqualTo(appAddress)

window.history.back()
waitHistoryStateUpdate()
browserBack()

assertThat(window.history.length).isEqualTo(2)
assertThat(window.history.state.toString().lines())
.containsExactly("screen_1")
.inOrder()
assertThat(window.location.toString()).isEqualTo(appAddress)

val nextAddress = "screen_6/123?q=456"
window.open("$appAddress#$nextAddress", "_self")!! //like a manual new url loading
advanceUntilIdle()

//compose navigation didn't happen
assertThat(window.history.length).isEqualTo(2)
assertThat(window.history.state).isNull()
assertThat(window.location.toString()).isEqualTo("$appAddress#$nextAddress")
assertThat(navController.currentDestination?.route.orEmpty()).isEqualTo("screen_1")

navController.navigate("screen_3")
advanceUntilIdle()

//and the state was rewritten by the next navigation
assertThat(window.history.length).isEqualTo(2)
assertThat(window.history.state.toString().lines())
.containsExactly("screen_1", "screen_3")
.inOrder()
assertThat(window.location.toString()).isEqualTo(appAddress)

browserBack()

assertThat(window.history.length).isEqualTo(2)
assertThat(window.history.state.toString().lines())
.containsExactly("screen_1")
.inOrder()
assertThat(window.location.toString()).isEqualTo(appAddress)

browserForward()

assertThat(window.history.length).isEqualTo(2)
assertThat(window.history.state.toString().lines())
.containsExactly("screen_1", "screen_3")
.inOrder()
assertThat(window.location.toString()).isEqualTo(appAddress)

bind.cancel()
}

@Test
fun checkInitScreenAndDirectNavigation() = runTest {
val initHistoryLength = goToBrowserRoot()
val navController = NavHostController().apply {
navigatorProvider.addNavigator(TestNavigator())
}
navController.setGraph(navController.createGraph(), null)

val appAddress = with(window.location) { origin + pathname }
val initAddress = "$appAddress#screen_4"
window.history.replaceState(null, "", initAddress)

val bind = launch { window.bindToNavigation(navController) }
advanceUntilIdle()

assertThat(window.history.length).isEqualTo(initHistoryLength)
assertThat(window.history.state.toString().lines())
.containsExactly("screen_1", "screen_4")
.inOrder()
assertThat(window.location.toString()).isEqualTo(initAddress)
assertThat(navController.currentDestination?.route.orEmpty()).isEqualTo("screen_4")

val nextAddress = "screen_6/123?q=456"
window.open("$appAddress#$nextAddress", "_self") //like a manual new url loading
advanceUntilIdle()

assertThat(window.history.length).isEqualTo(2)
assertThat(window.history.state.toString().lines())
.containsExactly("screen_1", "screen_4", nextAddress)
.inOrder()
assertThat(window.location.toString()).isEqualTo("$appAddress#$nextAddress")
assertThat(navController.currentDestination?.route.orEmpty()).isEqualTo("screen_6/{pathId}?q={queryId}")

bind.cancel()
}

private suspend fun cleanWindowHistory() {
private suspend fun goToBrowserRoot(): Int {
with(window.history) {
if (length > 1) {
val size = length - 1
go(-size)
waitHistoryStateUpdate()
waitHistoryPopState()
}
}
val appAddress = with(window.location) { origin + pathname }
window.history.replaceState(null, "", appAddress)
return window.history.length
}

private suspend fun browserBack() {
window.history.back()
waitHistoryPopState()
}

private suspend fun browserForward() {
window.history.forward()
waitHistoryPopState()
}

private suspend fun waitHistoryStateUpdate() = suspendCoroutine { cont ->
private suspend fun waitHistoryPopState() = suspendCoroutine { cont ->
window.addEventListener(
type = "popstate",
callback = { cont.resume(Unit) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,30 @@ import org.w3c.dom.Window
/**
* Binds the browser window state to the given navigation controller.
*
* If `getBackStackEntryRoute` is null, then:
* 1) if a browser url contains a destination route on a start then navigates to destination
* 2) if a user puts a new destination route to the browser address field then navigates to the new destination
*
* If there is a custom `getBackStackEntryRoute` implementation,
* then we don't have a knowledge how to parse urls to support direct navigation via browser address input.
* In that case, it should be done on the app's side:
* ```
* window.addEventListener("popstate") { event ->
* event as PopStateEvent
* if (event.state == null) { // empty state means manually entered address
* val url = window.location.toString()
* navController.navigate(...)
* }
* }
* ```
*
* @param navController The [NavController] instance to bind to browser window navigation.
* @param getBackStackEntryRoute An optional function that returns the route to show for a given [NavBackStackEntry].
*/
@ExperimentalBrowserHistoryApi
suspend fun Window.bindToNavigation(
navController: NavController,
getBackStackEntryRoute: (entry: NavBackStackEntry) -> String = {
it.getRouteWithArgs()?.let { r -> "#$r" }.orEmpty()
}
getBackStackEntryRoute: ((entry: NavBackStackEntry) -> String)? = null
) {
@Suppress("UNCHECKED_CAST_TO_EXTERNAL_INTERFACE")
(this as BrowserWindow).bindToNavigation(navController, getBackStackEntryRoute)
Expand Down
Loading
Loading