Web

Enro runs in the browser on the wasmJs and Kotlin/JS targets. The install pattern mirrors the other platforms: call installNavigationController once at start-up, then host a container inside an EnroBrowserContent (Compose UI) or an EnroHtmlContent (Compose HTML, see below).

Minimal install

fun main() {
    MyComponent.installNavigationController(document)

    ComposeViewport {
        EnroBrowserContent {
            val container = rememberNavigationContainer(
                backstack = rememberInitialBackstackFromUrl {
                    backstackOf(Home.asInstance())
                },
            )
            InstallWebHistoryPlugin(container)
            NavigationDisplay(state = container)
        }
    }
}

The pieces:

  • installNavigationController(document) ties the controller to the browser’s document. This is currently the only supported binding — Enro runs in the browser via Compose for Web.
  • ComposeViewport is the standard Compose Multiplatform entry point for the browser; it mounts your Compose tree at the page’s root.
  • EnroBrowserContent { } provides the Compose locals Enro needs for browser-specific behaviour. Treat it like Compose Multiplatform’s outermost theme wrapper.
  • rememberInitialBackstackFromUrl { ... } reads window.location once on first composition and tries to resolve it to a backstack via the controller’s registered path bindings. If the URL matches a @NavigationPath, the app boots straight into that destination; if it doesn’t, the lambda’s default is used.
  • InstallWebHistoryPlugin(container) wires the container tree under your root container into the browser history API. The URL bar reflects the deepest active destination, and the browser’s back/forward buttons navigate whichever container the entry changed.

Rendering with Compose HTML

enro-html (Kotlin/JS only) renders destinations as DOM elements with Compose HTML instead of drawing them on a canvas. It depends on enro-runtime, not on Compose UI, so the bundle contains no skiko.

fun main() {
    MyComponent.installNavigationController(document)

    renderComposable(rootElementId = "root") {
        EnroHtmlContent {
            val container = rememberNavigationContainer(
                backstack = rememberInitialBackstackFromUrl {
                    backstackOf(Home.asInstance())
                },
            )
            InstallWebHistoryPlugin(container)
            HtmlNavigationDisplay(container)
        }
    }
}
  • EnroHtmlContent { } replaces EnroBrowserContent. renderComposable provides no lifecycle, ViewModelStore or SavedStateRegistry owner, so EnroHtmlContent provides one for the page.
  • HtmlNavigationDisplay(container) replaces NavigationDisplay. Scenes change without an animation.
  • Destinations are navigationDestinationContent { } providers or @Composable functions that emit Compose HTML elements, annotated with @NavigationDestination.PlatformOverride (see below). navigationDestination { } and a function with a NavigationDestinationScope receiver are Compose UI and need NavigationDisplay.
  • htmlDialog() metadata renders a destination in a modal <dialog>. Escape and a backdrop click call requestClose(). Style it with dialog and dialog::backdrop.
  • directOverlay() destinations render over the current scene; directOverlayWithFade() fades them in and out.
  • A closed dialog or overlay stays in the DOM until its CSS transitions end, so a dialog:not([open]) transition runs on close.
@NavigationDestination.PlatformOverride(ConfirmDelete::class)
val confirmDeleteDestination: NavigationDestinationProvider<ConfirmDelete> =
    navigationDestinationContent(metadata = { htmlDialog() }) {
        H2 { Text("Delete ${navigation.key.item}?") }
        Button({ onClick { navigation.complete(true) } }) { Text("Delete") }
    }

tests/html-application is a complete example.

Compose UI screens in common code

A module can keep its Compose UI screens in commonMain and add Compose HTML screens for the same keys in jsMain. Annotate each Compose HTML screen with @NavigationDestination.PlatformOverride:

// commonMain
@Composable
@NavigationDestination(Home::class)
fun HomeScreen() { /* Compose UI */ }

// jsMain
@Composable
@NavigationDestination.PlatformOverride(Home::class)
fun HomeHtmlScreen() { /* Compose HTML */ }

When enro-html is on the Kotlin/JS classpath of the module that declares the @NavigationComponent, the processor:

  • registers only the override for each overridden key, so dead-code elimination removes the Compose UI screen from the bundle;
  • fails the build with a list of @Composable function destinations that have no override;
  • warns about property destinations that have no override, because the processor can’t see whether they use Compose UI.

The overrides can also live in a separate module, as long as the @NavigationComponent module depends on it.

URL routing model

Browser history mirrors the whole container tree beneath the root container — the one you create with rememberNavigationContainer directly inside EnroBrowserContent:

  • A push on the root container is a history entry, and so is a push inside a nested container hosted by the destination on top of the root (a screen opened within a tab, a detail pane), recursively.
  • Switching which nested container a destination has active — a tab switch — is a history entry too.
  • Browser back/forward restores the recorded tree: every container’s backstack and each destination’s active container.
  • Closing screens inside the app walks the browser history back the same way it does for the root, so back never resurrects a screen the app has already closed.

To keep nested navigation session-local instead — the root-only model of earlier releases — pass InstallWebHistoryPlugin(container, nestedContainerHistory = false): only the root container’s backstack is recorded, and the URL is the root destination’s path.

Two things are deliberately not navigation. A nested container appearing for the first time — a destination composing its tabs, a deep link seeding a tab several screens deep — fills in the entry it belongs to rather than pushing one on top of it. And a change that removes or swaps entries (a root reset such as loading → home) replaces the current entry, since the state it overwrites is no longer reachable in the app.

What gets a URL

A NavigationKey annotated with @NavigationPath participates in URL routing:

@Serializable
@NavigationPath("/products/{productId}?source={source?}")
data class ProductDetail(
    val productId: String,
    val source: String? = null,
) : NavigationKey

The URL bar shows the path of the deepest active destination that has one, walking from the root container’s top destination through each destination’s active container. If ProductDetail is on top of the root, or on top of the active tab inside a shell on the root, the URL bar shows /products/abc?source=email.

When no destination on that walk has a @NavigationPath, the URL bar doesn’t change — it keeps whatever path was last set by an annotated destination (or the URL the user originally landed on, if no annotated destination has been active yet). pushState still fires, so browser back/forward continues to work through history.state; the URL just doesn’t pretend to identify state that isn’t bookmarkable.

Cold loading from a URL

rememberInitialBackstackFromUrl { default() } reads the address bar on first composition and resolves it through the controller’s path bindings. The resolved key becomes a single-entry backstack on the root container. If you bookmark /products/abc-123 and reopen it, the app boots directly into the ProductDetail("abc-123") screen — provided that destination is something you’re willing to host at the root.

If you also want pretty URLs for state that lives inside a nested container (e.g. a list/detail pane), the synthetic-backstack approach from the Advanced Deep Link recipe is the recommended pattern: read the URL yourself, derive the parent context, and seed the backstack manually.

What the URL bar shows

The plugin uses two slots in window.history:

  • URL (location.pathname + location.search) — the @NavigationPath of the deepest active destination that has one. This is the part users see and share.
  • history.state — the container tree under the root as JSON: each container’s backstack and each destination’s active container. Used for accurate back/forward restoration mid-session.

A full-page reload starts from the URL alone, so only what the URL encodes survives it. If nested state needs to survive a reload, handle it via your own saveable/rememberSaveable storage as you would on other platforms.

What Enro provides on Web

  • A container tree under the root that mirrors browser history — nested pushes and tab switches included.
  • The full common API: NavigationKey, NavigationKey.WithResult<T>, navigationHandle<T>(), registerForNavigationResult, NavigationDisplay, scene strategies, plugins, decorators.
  • Deep linking from a URL on cold load via rememberInitialBackstackFromUrl + @NavigationPath bindings on root destinations.
  • Saved state across in-page navigation. Full-page reloads start fresh except for what the URL itself encodes — if you need persistence across reload, write to localStorage / sessionStorage yourself.

Known limitations

  • Cold loading nested state: rememberInitialBackstackFromUrl resolves a URL to a single root entry. A URL whose destination lives inside a nested container needs the synthetic-backstack approach above to seed its parents; the plugin then records that seeded tree as the first entry.
  • Manual address-bar edits: if the user edits the URL by hand without a full-page reload, the plugin no-ops on the resulting popstate. Reloading the page applies the new URL via the cold-load path.

See also


This site uses Just the Docs, a documentation theme for Jekyll.