java.lang.IllegalStateException: You cannot access the NavBackStackEntry's ViewModels until it is added to the NavController's back stack

3.1k views Asked by At

I have created a composable called ResolveAuth. ResolveAuth is the first screen when user opens the app after Splash. All it does is check whether an email is present in Datastore or not. If yes redirect to main screen and if not then redirect to tutorial screen

Here is my composable and viewmodel code

@Composable
fun ResolveAuth(resolveAuthViewModel: ResolveAuthViewModel, navController: NavController) {

Scaffold(content = {
    ProgressBar()

    when {
        resolveAuthViewModel.userEmail.value != "" -> {
            navController.navigate(Screen.Main.route) {
                popUpTo(0)
            }
            resolveAuthViewModel.userEmail.value = null
        }
        resolveAuthViewModel.userEmail.value == "" -> {
            navController.navigate(Screen.Tutorial.route) {
                popUpTo(0)
            }
            resolveAuthViewModel.userEmail.value = null
        }
    }
})
}


@HiltViewModel
class ResolveAuthViewModel @Inject constructor(
    private val dataStoreManager: DataStoreManager): ViewModel(){

    val userEmail = MutableLiveData<String>()

    init {
        viewModelScope.launch{
           val job = async {dataStoreManager.email.first()}
           val email = job.await()
            if(email != ""){
                userEmail.value = email
            }
        }
    }

}

But I keep getting an exception saying

java.lang.IllegalStateException: You cannot access the NavBackStackEntry's ViewModels until it is added to the NavController's back stack (i.e., the Lifecycle of the NavBackStackEntry reaches the CREATED state).

I am using below jetpack lib for navigation

 implementation("androidx.navigation:navigation-compose:2.4.0-rc01")

There is no issue in my Main and Tutorial screen as I tried to run them separately and it works fine.

1

There are 1 answers

0
Richard Onslow Roper On

Easily resolvable, just add this when call to a Side-Effect instead.

LaunchedEffect(Unit){

    while(!isNavStackReady) // Hold execution while the NavStack populates. 
      delay(16) // Keeps the resources free for other threads.

    when {
        resolveAuthViewModel.userEmail.value != "" -> {
            navController.navigate(Screen.Main.route) {
                popUpTo(0)
            }
            resolveAuthViewModel.userEmail.value = null
        }
        resolveAuthViewModel.userEmail.value == "" -> {
            navController.navigate(Screen.Tutorial.route) {
                popUpTo(0)
            }
            resolveAuthViewModel.userEmail.value = null
        }
    }
}

Here, the call to navigate is made only after the currentBackStackEntry has been completely filled, so it yields no error. The original error occurred since you were calling navigate before the concerned composable was even made available to the nav stack.

As for how to update the isNavStackReady variable to reflect the correct state of the navStack, it is fairly simple. Create the variable at a top-level declaration, such that only the required components may access it. May as well throw it inside a viewModel if you please. Set the default value of the var to false, for obvious reasons. Here's the update mechanism.

@Composable
fun StartDestination(){
  isNavStackReady = true
}

That's it, that's really it. If you could successfully navigate to your start destination that you define in the nav graph, it means the navStack has likely been populated well. Hence, you just update this variable here, and the LaunchedEffect block up there will respond to this update, and the while loop that's been holding execution off, will finally break. It will then call the navigate on the appropriate destination route. Remember, however, that the isNavStackReady variable, for this mechanism to work, needs to be a state-holder, i.e., initialised with mutableStateOf(false). Using delegates, of course, is completely fine (personally encouraged).

Now, all this is fine, but actually, it's not quite the right implementation. You see, this entire thing is taken care of completely internally by the navigation APIs for us, but it breaks because we are trying to do its job, and we suck at it.

We are creating an intermediate route to land on, at the start of the app, and from there, immediately navigating to another screen based on calculations. So, all we want is to open the app at a desired page, that is, start the navigator on a desired page when it is first created. We have a handy parameter called startDestination, just for that.

Hence, the ideal, simple, beautiful solution would be to just

startDestination = when {
        resolveAuthViewModel.userEmail.value != "" -> {
            navController.navigate(Screen.Main.route) {
                popUpTo(0)
            }
            resolveAuthViewModel.userEmail.value = null
        }
        resolveAuthViewModel.userEmail.value == "" -> {
            navController.navigate(Screen.Tutorial.route) {
                popUpTo(0)
            }
            resolveAuthViewModel.userEmail.value = null
        }
    }

in your NavBuilder's arguments. Tiniest silliest logical flaw, that so many people couldn't get. It's intriguing to think how the human mind works...

Happy New Year,