Migrating from Enro 2

Enro 3 is a substantial rewrite. The conceptual model is the same — screens behave like functions, with typed contracts and decoupled implementations — but most APIs have changed names, shapes, or both, and the library is now Kotlin Multiplatform and Compose-first.

This guide covers the API delta with concrete before/after pairs, and ends with a walkthrough of a small screen converted from v2 to v3.

If you have a working Enro 2 app with Fragments or Activities, you can adopt Enro 3 incrementally by depending on enro-compat, which keeps the Fragment/Activity story working alongside new Compose destinations. See Android platform guide.

Big picture changes

  • Kotlin Multiplatform. Enro 3 targets Android, iOS, Desktop and Web through Compose Multiplatform. Enro 2’s Android-only assumptions (Parcelize, Fragments, Activities) have been replaced with KMP-friendly primitives.
  • Compose-first. A Composable function annotated with @NavigationDestination is the canonical destination. Fragments and Activities live in enro-compat.
  • @Parcelize → @Serializable. Navigation keys are serialized with kotlinx.serialization so they work on every platform.
  • Push vs Present is gone. A NavigationKey no longer carries presentation semantics. Behaviour like “open as a dialog” now lives in destination metadata and is dispatched by scene strategies.
  • NavigationApplication is gone. You install the controller from Application.onCreate (or the equivalent on other platforms) with a one-liner.
  • NavigationInstruction is now NavigationOperation. Same idea, more cases (Open, Close, Complete, CompleteFrom, SetBackstack, SideEffect, AggregateOperation).
  • KSP, not kapt. The processor is enro-processor and is applied via the com.google.devtools.ksp Gradle plugin.

API delta at a glance

Enro 2 Enro 3
NavigationKey.SupportsPush, SupportsPresent, SupportsPresent.WithResult<T> NavigationKey, NavigationKey.WithResult<T>
@Parcelize class Key(...) : NavigationKey.SupportsPush @Serializable data class Key(...) : NavigationKey
navigation.push(key) / navigation.present(key) navigation.open(key)
navigation.closeWithResult(result) navigation.complete(result)
class MyApp : Application(), NavigationApplication { override val navigationController = ... } MyComponent.installNavigationController(this) inside Application.onCreate
NavigationController (singleton on Application) EnroController (per-component)
NavigationInstruction.Open / .Close / .RequestClose NavigationOperation.Open / .Close / .Complete / .SetBackstack / etc.
NavigationExecutor Gone — scene strategies handle dispatch.
NavigationBinding (registered manually or via codegen) NavigationBinding<K> still exists but is generated by KSP from @NavigationDestination annotations. Rarely written by hand.
enroViewModels<MyVm>() property delegate viewModel { createEnroViewModel { MyVm() } } from Compose, or by navigationHandle<MyKey>() inside a regular ViewModel.
kapt("dev.enro:enro-processor:...") ksp("dev.enro:enro-processor:...")
composableDestination<Key> { MyScreen() } (in DSL) Either @NavigationDestination(Key::class) on a Composable, or val foo = navigationDestination<Key> { ... } with destination(foo) in createNavigationModule.
composeEnvironment { content -> ... } in module Deprecated. Use decorator { navigationDestinationDecorator { ... } }.
EnroTestRule (JUnit) Still available on JVM; the KMP form is runEnroTest { ... }.
expectInstruction { ... } test helpers TestNavigationHandle<T> with assertOpened, assertClosed, assertCompleted, assertOperationExecuted. The old helpers remain in enro-test’s compat layer.

Detailed changes

Enro 2 keys carried their presentation intent on the type:

@Parcelize data class ShowProfile(val userId: String) : NavigationKey.SupportsPush
@Parcelize data class SelectDate(val min: LocalDate?) : NavigationKey.SupportsPresent.WithResult<LocalDate>

Enro 3 keys are flat — the type only describes inputs (and optionally a return value):

@Serializable data class ShowProfile(val userId: String) : NavigationKey
@Serializable data class SelectDate(val min: LocalDate?) : NavigationKey.WithResult<LocalDate>

If you previously distinguished “push” and “present” at the call site (navigation.push(...) vs navigation.present(...)), use destination metadata instead. A destination is rendered as a dialog or overlay when its metadata says so:

@NavigationDestination(SelectDate::class)
val selectDateDestination = navigationDestination<SelectDate>(
    metadata = { dialog() }
) {
    // ...
}

See Navigation Destinations for the full metadata story.

Enro 2 Enro 3
navigation.push(key) navigation.open(key)
navigation.present(key) navigation.open(key) (with dialog() / directOverlay() metadata on the destination)
navigation.close() navigation.close() (same)
navigation.requestClose() navigation.requestClose() (same)
navigation.closeWithResult(value) navigation.complete(value)
navigation.execute(NavigationInstruction.Open(...)) navigation.execute(NavigationOperation.Open(...))

The extension functions live in package dev.enro and are imported as dev.enro.open, dev.enro.close, dev.enro.complete, dev.enro.requestClose.

Installing the controller

Enro 2:

@NavigationComponent
class MyApp : Application(), NavigationApplication {
    override val navigationController = installNavigationController {
        // configuration
    }
}

Enro 3:

@NavigationComponent
object MyComponent : NavigationComponentConfiguration(
    module = createNavigationModule {
        // optional configuration: plugins, interceptors, decorators, etc.
    }
)

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        MyComponent.installNavigationController(this)
    }
}

The NavigationApplication interface is gone, and the controller no longer needs to be exposed as a property. The same component object is used to install on other platforms — installNavigationController(document) for Web, installNavigationController(Unit) for Desktop, etc.

Module DSL

The module DSL used by Enro 2 (override fun NavigationModuleScope.configure()) is replaced by createNavigationModule { }, passed into the NavigationComponentConfiguration constructor. Common entries:

createNavigationModule {
    plugin(MyPlugin())
    interceptor { /* NavigationInterceptorBuilder */ }
    decorator { navigationDestinationDecorator { /* wrap every destination */ } }
    destination(myProviderVal)              // for navigationDestination val { } providers
    path(MyDeepLinkPathBinding)
    serializersModule(myKotlinxSerializersModule)
    module(otherFeatureModule)              // compose modules from other libraries
}

composeEnvironment { content -> ... } is deprecated; use decorator { } instead.

Rendering a backstack

Enro 2 created containers like:

val container = rememberNavigationContainer(
    root = HomeKey,
    emptyBehavior = EmptyBehavior.CloseParent,
)

Enro 3:

val container = rememberNavigationContainer(
    backstack = backstackOf(Home.asInstance()),
)
NavigationDisplay(state = container)

asInstance() wraps a key into a NavigationKey.Instance (every appearance of a key in a backstack has its own id and metadata).

NavigationDisplay is the new top-level Composable for rendering a container. It replaces the v2 Container { } Composable.

Results

Enro 2:

class HomeScreen : Fragment() {
    val getDate by registerForNavigationResult<LocalDate> { date -> /* use it */ }

    fun onPickDate() = getDate.present(SelectDate())
}

@NavigationDestination(SelectDate::class)
class SelectDateScreen : Fragment() {
    val navigation by navigationHandle<SelectDate>()
    fun onPicked(date: LocalDate) = navigation.closeWithResult(date)
}

Enro 3:

@Composable
@NavigationDestination(Home::class)
fun HomeScreen() {
    val getDate = registerForNavigationResult<LocalDate>(
        onCompleted = { date -> /* use it */ },
        onClosed = { /* dismissed without a result */ },
    )

    Button(onClick = { getDate.open(SelectDate()) }) { Text("Pick a date") }
}

@Composable
@NavigationDestination(SelectDate::class)
fun SelectDateScreen() {
    val navigation = navigationHandle<SelectDate>()
    // ...
    Button(onClick = { navigation.complete(LocalDate.now()) }) { Text("Use today") }
}

The Compose form of registerForNavigationResult returns a NavigationResultChannel<T> immediately (no by delegate). Inside a ViewModel, the delegate form is still available.

ViewModels

Enro 2 used a custom enroViewModels delegate:

class ProfileViewModel : ViewModel() {
    val navigation by navigationHandle<ShowProfile>()
}

@NavigationDestination(ShowProfile::class)
class ProfileFragment : Fragment() {
    val viewModel by enroViewModels<ProfileViewModel>()
}

Enro 3 uses the standard viewModel { } builder with a small helper that wires the NavigationHandle into the factory:

class ProfileViewModel : ViewModel() {
    val navigation by navigationHandle<ShowProfile>()
}

@Composable
@NavigationDestination(ShowProfile::class)
fun ProfileScreen() {
    val viewModel = viewModel { createEnroViewModel { ProfileViewModel() } }
    // ...
}

The by navigationHandle<MyKey>() delegate inside the ViewModel itself is unchanged.

Testing

Enro 2’s JVM-only EnroTestRule still works for JUnit-based tests:

class ProfileTest {
    @get:Rule(order = 0) val enroRule = EnroTestRule()
    @get:Rule(order = 1) val composeRule = createComposeRule()

    @Test fun example() { /* ... */ }
}

For KMP tests (and as a more flexible alternative in any test), use the runEnroTest { } block:

@Test fun example() = runEnroTest {
    val handle = createTestNavigationHandle(ShowProfile("user-123"))
    handle.open(SelectDate())
    handle.assertOpened<SelectDate>()
}

TestNavigationHandle<T> records every NavigationOperation and exposes matching assertions (assertOpened, assertClosed, assertCompleted, assertOperationExecuted).

For ViewModel tests, putNavigationHandleForViewModel<MyVm, MyKey>(key) remains.

See Testing.

A small worked migration

Take this small Enro 2 screen:

@Parcelize
data class GreetUser(val name: String) : NavigationKey.SupportsPresent.WithResult<String>

@NavigationDestination(GreetUser::class)
class GreetUserFragment : Fragment() {
    val navigation by navigationHandle<GreetUser>()

    override fun onCreateView(...): View {
        return ComposeView(requireContext()).apply {
            setContent {
                Column {
                    Text("Hello, ${navigation.key.name}!")
                    Button(onClick = { navigation.closeWithResult("greeted") }) {
                        Text("Done")
                    }
                }
            }
        }
    }
}

In Enro 3 this becomes a Composable destination, with the dialog presentation moved to metadata:

@Serializable
data class GreetUser(val name: String) : NavigationKey.WithResult<String>

@NavigationDestination(GreetUser::class)
val greetUserDestination = navigationDestination<GreetUser>(
    metadata = { dialog() }   // <-- presentation moves here
) {
    Column {
        Text("Hello, ${navigation.key.name}!")
        Button(onClick = { navigation.complete("greeted") }) {
            Text("Done")
        }
    }
}

Callers change from push/present to open, and from closeWithResult to complete, but the conceptual shape — a screen with inputs and a typed result — is identical.

What didn’t change

  • @NavigationDestination(KeyClass::class) is still the way to bind a screen to its key.
  • Inside a ViewModel, you still get the NavigationHandle via by navigationHandle<MyKey>().
  • Result channels are still typed; onCompleted still fires once with the produced value.
  • Backstacks are still serializable and survive process death.
  • Multi-module apps still work the same way: every module that declares @NavigationDestination annotations needs the enro-processor KSP dependency, and the app module depends transitively on all of them.

Need help?


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