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
@NavigationDestinationis the canonical destination. Fragments and Activities live inenro-compat. @Parcelize→@Serializable. Navigation keys are serialized with kotlinx.serialization so they work on every platform.- Push vs Present is gone. A
NavigationKeyno longer carries presentation semantics. Behaviour like “open as a dialog” now lives in destination metadata and is dispatched by scene strategies. NavigationApplicationis gone. You install the controller fromApplication.onCreate(or the equivalent on other platforms) with a one-liner.NavigationInstructionis nowNavigationOperation. Same idea, more cases (Open,Close,Complete,CompleteFrom,SetBackstack,SideEffect,AggregateOperation).- KSP, not kapt. The processor is
enro-processorand is applied via thecom.google.devtools.kspGradle 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
Navigation keys
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.
NavigationHandle operations
| 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 theNavigationHandleviaby navigationHandle<MyKey>(). - Result channels are still typed;
onCompletedstill 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
@NavigationDestinationannotations needs theenro-processorKSP dependency, and the app module depends transitively on all of them.
Need help?
- Browse the recipes module for working examples of every feature in Enro 3.
- Check the troubleshooting page for common errors.
- File an issue at github.com/isaac-udy/Enro/issues if you hit something not covered here.