programadorthi / kotlin-routing

An extensible and multiplatform routing system powered by Ktor
https://github.com/programadorthi/kotlin-routing/wiki
Apache License 2.0
51 stars 3 forks source link

voyager implementation with path based navigation #3

Closed bmc08gt closed 3 months ago

bmc08gt commented 5 months ago

Im setting up a compose multiplatform project using Voyager targeting web to start and im not seeing routes be appended to the URL when push()'ing them nor is the screen's defined by path/name navigatable via URL. Is there something i am missing in the config/setup or is my understanding of this incorrect?

val router = routing {
    screen("/home", name = TeaserScreen.key) {
        TeaserScreen
    }

    screen("/form", name = WaitlistFlow.key) {
        WaitlistFlow
    }
}

pushing is done via LocalVoyagerRouting.current.pushNamed(TeaserScreen.key) or LocalVoyagerRouting.current.push(path ="/home")

I would expect localhost:8080/home to resolve while the server is running. (UI is updating appropriately just not browser paths).

bmc08gt commented 5 months ago

Was able to kang the browser history from js integration into voyagers and get it partially working.

However GET requests for a screen/path don't resolve yet (push's update the history).

in VoyagerRoutingBuilder.kt:

@KtorDsl
public fun Route.screen(body: suspend PipelineContext<Unit, ApplicationCall>.() -> Screen) {
    handle {
        screen {
            body(this)
        }

+       call.handleScreenTransition()
    }
}

+ expect suspend fun ApplicationCall.handleScreenTransition()

in my jsMain

actual suspend fun ApplicationCall.handleScreenTransition() {
    when (routeMethod) {
        RouteMethod.Push -> {
            window.history.pushState(
                title = "routing",
                url = uri,
                data = serialize()
            )
        }
        RouteMethod.Replace -> {
            window.history.replaceState(
                title = "routing",
                url = uri,
                data = serialize(),
            )
        }
        RouteMethod.ReplaceAll -> {
            while (true) {
                window.history.replaceState(
                    title = "",
                    url = null,
                    data = null,
                )
                window.history.go(-1)
                val forceBreak =
                    runCatching {
                        withTimeout(1_000) {
                            suspendCoroutine { continuation ->
                                window.onpopstate = { event ->
                                    val state = event.state.deserialize()
                                    continuation.resume(state == null)
                                }
                            }
                        }
                    }.getOrDefault(true)
                if (forceBreak) {
                    break
                }
            }

            window.history.replaceState(
                title = "routing",
                url = uri,
                data = serialize(),
            )
        }
    }
}

@Serializable
internal data class JavascriptRoutingState(
    val routeMethod: String,
    val name: String,
    val uri: String,
    val parameters: Map<String, List<String>>,
)

internal fun ApplicationCall.serialize(): String {
    val state =
        JavascriptRoutingState(
            routeMethod = routeMethod.value,
            name = name,
            uri = uri,
            parameters = parameters.toMap(),
        )
    return Json.encodeToString(state)
}

internal fun Any?.deserialize(): JavascriptRoutingState? =
    when (this) {
        is String -> toState()
        else -> null
    }

private fun String.toState(): JavascriptRoutingState? =
    runCatching {
        Json.decodeFromString<JavascriptRoutingState>(this)
    }.getOrNull()

and in my mobileMain (custom group for ios/android)

actual suspend fun ApplicationCall.handleScreenTransition() {}
programadorthi commented 5 months ago

The Voyager integration module has no support to web history and the version in javascript module is experimental. My knowledge in browser history is too limited. I made based on MDN documentation.

About your sample code it is correct. We need do on each voyager navigation a browser history update.

To solve starting from a GET call, it is not too simple because you need to tell the Voyager about the path. Also check for history to avoid clean it. Something like https://github.com/programadorthi/kotlin-routing/blob/c4c327159703a525478dd835591c5190e288cb6c/integration/javascript/js/src/dev/programadorthi/routing/javascript/JavascriptRoutingStateManager.kt#L63

Maybe doing:

fun main() {
    val someLocker = ...
    val router = routing (...) { ... }

    VoyagerRouting(
        routing = router,
        content = {
            CurrentScreen()
            SideEffect {
                someLocker.unLock()
            }
        }
    )

    // Is onpageshow called on browser or tab opened? I don't know.
    window.onpageshow = {
        someLocker.wait()
        val location = window.location
        // https://developer.mozilla.org/en-US/docs/Web/API/Location#examples
        val path = location.pathname + location.search + location.hash
        router.push|replace(path = path)
    }
}
bmc08gt commented 5 months ago

The Voyager integration module has no support to web history and the version in javascript module is experimental. My knowledge in browser history is too limited. I made based on MDN documentation.

About your sample code it is correct. We need do on each voyager navigation a browser history update.

To solve starting from a GET call, it is not too simple because you need to tell the Voyager about the path. Also check for history to avoid clean it. Something like

https://github.com/programadorthi/kotlin-routing/blob/c4c327159703a525478dd835591c5190e288cb6c/integration/javascript/js/src/dev/programadorthi/routing/javascript/JavascriptRoutingStateManager.kt#L63

Maybe doing:

fun main() {
    val someLocker = ...
    val router = routing (...) { ... }

    VoyagerRouting(
        routing = router,
        content = {
            CurrentScreen()
            SideEffect {
                someLocker.unLock()
            }
        }
    )

    // Is onpageshow called on browser or tab opened? I don't know.
    window.onpageshow = {
        someLocker.wait()
        val location = window.location
        // https://developer.mozilla.org/en-US/docs/Web/API/Location#examples
        val path = location.pathname + location.search + location.hash
        router.push|replace(path = path)
    }
}

yep I actually updated the state manager for JS to match what the JS integration has more closely. I turned it into an expect/actual to make it work cross-platform.

internal expect object VoyagerRoutingStateManager {

    fun init(routing: Routing, initialScreen: Screen)

    suspend fun replaceAll(
        call: ApplicationCall,
        routing: Routing,
    )
}

@Composable
public fun VoyagerRouting(
    routing: Routing,
    initialScreen: Screen,
    disposeBehavior: NavigatorDisposeBehavior = NavigatorDisposeBehavior(),
    onBackPressed: OnBackPressed = { true },
    key: String = compositionUniqueId(),
    content: NavigatorContent = { CurrentScreen() },
) {
    CompositionLocalProvider(LocalVoyagerRouting provides routing) {
        DisposableEffect(routing) {
            VoyagerRoutingStateManager.init(routing, initialScreen)
            onDispose {
                routing.dispose()
            }
        }

        Navigator(
            screen = initialScreen,
            disposeBehavior = disposeBehavior,
            onBackPressed = onBackPressed,
            key = key,
        ) { navigator ->
            SideEffect {
                routing.application.voyagerNavigator = navigator
            }
            content(navigator)
        }
    }
}

with JS being:

internal actual object VoyagerRoutingStateManager {

    actual fun init(routing: Routing, initialScreen: Screen) {
        // First time or page refresh we try continue from last state
        val notified = routing.tryNotifyTheRoute(state = window.history.state)

        resetOnPopStateEvent(routing)

        if (!notified) {
            val location = window.location
            val path = location.pathname + location.search + location.hash
            println(path)
            routing.push(path = path)
        }
    }

    actual suspend fun replaceAll(
        call: ApplicationCall,
        routing: Routing,
    ) {
        while (true) {
            window.history.replaceState(
                title = "",
                url = null,
                data = null,
            )
            window.history.go(-1)
            val forceBreak =
                runCatching {
                    withTimeout(1_000) {
                        suspendCoroutine { continuation ->
                            window.onpopstate = { event ->
                                val state = event.state.deserialize()
                                continuation.resume(state == null)
                            }
                        }
                    }
                }.getOrDefault(true)
            if (forceBreak) {
                break
            }
        }

        window.history.replaceState(
            title = "routing",
            url = call.uri,
            data = call.serialize(),
        )

        resetOnPopStateEvent(routing)
    }

    private fun resetOnPopStateEvent(routing: Routing) {
        window.onpopstate = { event ->
            routing.tryNotifyTheRoute(state = event.state)
        }
    }

    private fun Routing.tryNotifyTheRoute(state: Any?): Boolean {
        val javascriptState = state.deserialize() ?: return false
        val call =
            ApplicationCall(
                application = application,
                name = javascriptState.name,
                uri = javascriptState.uri,
                routeMethod = RouteMethod.parse(javascriptState.routeMethod),
                parameters = parametersOf(javascriptState.parameters),
            )
        call.neglect = true
        execute(call)

        window.history.replaceState(
            title = "routing",
            url = call.uri,
            data = call.serialize(),
        )
        push(javascriptState.uri)
        return true
    }
}
programadorthi commented 5 months ago

Nice. I updated the javascript integration to support history modes. Hash /#/, Html5 History and Memory. Most of these behaviors I will export to voyager integration too.

programadorthi commented 4 months ago

Checkout release 0.0.15.

programadorthi commented 3 months ago

Latest releases starting from 0.0.17 should have support. There is no support for WASM but it will be tracked in another issue.