Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,18 @@ val LocalTopLevelDialogController = compositionLocalOf<TopLevelDialogController?
* **Usage:**
* ```kotlin
* // At the root of your auth flow (FirebaseAuthScreen):
* val dialogController = rememberTopLevelDialogController(stringProvider)
*
* val dialogController = rememberTopLevelDialogController(stringProvider) { authState }
*
* CompositionLocalProvider(LocalTopLevelDialogController provides dialogController) {
* // Your auth screens...
*
*
* // Show dialog at root level (only one instance)
* dialogController.CurrentDialog()
* }
*
*
* // In any child screen (EmailAuthScreen, PhoneAuthScreen, etc.):
* val dialogController = LocalTopLevelDialogController.current
*
*
* LaunchedEffect(error) {
* error?.let { exception ->
* dialogController?.showErrorDialog(
Expand All @@ -68,7 +68,7 @@ val LocalTopLevelDialogController = compositionLocalOf<TopLevelDialogController?
*/
class TopLevelDialogController(
private val stringProvider: AuthUIStringProvider,
private val authState: AuthState
private val currentAuthState: () -> AuthState
) {
private var dialogState by mutableStateOf<DialogState?>(null)
private val shownErrorStates = mutableSetOf<AuthState.Error>()
Expand All @@ -89,7 +89,7 @@ class TopLevelDialogController(
onDismiss: () -> Unit = {}
) {
// Get current error state
val currentErrorState = authState as? AuthState.Error
val currentErrorState = currentAuthState() as? AuthState.Error

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a subtle race condition in the error dialog deduplication logic. Because authUI.updateAuthState(AuthState.Idle) is called immediately after showErrorDialog in the observing screens (e.g., EmailAuthScreen and FirebaseAuthScreen), the first observer to run will reset the auth state to Idle. When the second observer runs and calls showErrorDialog, currentAuthState() will return AuthState.Idle instead of AuthState.Error. This causes currentErrorState to be null, bypassing the deduplication check and allowing the second call to overwrite the dialog state (potentially losing custom onRetry or onRecover callbacks).

To fix this, consider adding an optional errorState: AuthState.Error? = null parameter to showErrorDialog and using it for deduplication:

fun showErrorDialog(
    exception: AuthException,
    errorState: AuthState.Error? = null,
    onRetry: (AuthException) -> Unit = {},
    onRecover: ((AuthException) -> Unit)? = null,
    onDismiss: () -> Unit = {}
) {
    val currentErrorState = errorState ?: (currentAuthState() as? AuthState.Error)
    if (currentErrorState != null && currentErrorState in shownErrorStates) {
        return
    }
    currentErrorState?.let { shownErrorStates.add(it) }
    // ...
}


// If this exact error state has already been shown, skip
if (currentErrorState != null && currentErrorState in shownErrorStates) {
Expand Down Expand Up @@ -162,13 +162,17 @@ class TopLevelDialogController(

/**
* Creates and remembers a [TopLevelDialogController].
*
* [authState] is a lambda rather than a snapshot value so the controller can read the
* live auth state on every [TopLevelDialogController.showErrorDialog] call without being
* recreated (and losing its de-duplication history) whenever the auth state changes.
*/
@Composable
fun rememberTopLevelDialogController(
stringProvider: AuthUIStringProvider,
authState: AuthState
authState: () -> AuthState
): TopLevelDialogController {
return remember(stringProvider, authState) {
return remember {
TopLevelDialogController(stringProvider, authState)
}
Comment on lines +175 to 177

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In rememberTopLevelDialogController, stringProvider is passed as a parameter but is not used as a key in remember. If stringProvider changes (e.g., due to locale or configuration changes), the controller will hold a stale reference.

We should key the remember block on stringProvider so that the controller is recreated if the string provider changes.

Suggested change
return remember {
TopLevelDialogController(stringProvider, authState)
}
return remember(stringProvider) {
TopLevelDialogController(stringProvider, authState)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ fun FirebaseAuthScreen(
val navController = rememberNavController()

val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle)
val dialogController = rememberTopLevelDialogController(stringProvider, authState)
val dialogController = rememberTopLevelDialogController(stringProvider) { authState }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

stringProvider is currently instantiated on every recomposition of FirebaseAuthScreen (line 138: val stringProvider = DefaultAuthUIStringProvider(context)). Because it is not remembered, any remember block keying on it (such as uiContext on line 275) will be invalidated and recreated on every single recomposition.

Please wrap stringProvider in a remember block on line 138:

val stringProvider = remember(context) { DefaultAuthUIStringProvider(context) }

val lastSuccessfulUserId = remember { mutableStateOf<String?>(null) }
val pendingLinkingCredential = remember { mutableStateOf<AuthCredential?>(null) }
val pendingResolver = remember { mutableStateOf<MultiFactorResolver?>(null) }
Expand Down Expand Up @@ -542,6 +542,8 @@ fun FirebaseAuthScreen(
// Keep external cancellation reporting centralized here so child screens
// can handle local navigation without triggering duplicate callbacks.
onSignInCancelled()
// Consumed so this doesn't leak to a freshly created screen.
authUI.updateAuthState(AuthState.Idle)
}

is AuthState.Idle -> {
Expand Down Expand Up @@ -632,6 +634,8 @@ fun FirebaseAuthScreen(
// Dialog dismissed
}
)
// Consumed immediately so this doesn't leak to a freshly created screen.
authUI.updateAuthState(AuthState.Idle)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.AuthState
import com.firebase.ui.auth.FirebaseAuthUI
Expand Down Expand Up @@ -167,8 +168,11 @@ fun EmailAuthScreen(
val authCredentialForLinking = remember { credentialForLinking }
val errorMessage =
if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null
val resetLinkSent = authState is AuthState.PasswordResetLinkSent
val emailSignInLinkSent = authState is AuthState.EmailSignInLinkSent

// Latched locally since these get consumed (reset to Idle) below — deriving directly from
// authState would close ResetPasswordUI/SignInEmailLinkUI's dialogs as soon as it resets.
var resetLinkSentLocal by rememberSaveable { mutableStateOf(false) }
var emailSignInLinkSentLocal by rememberSaveable { mutableStateOf(false) }

// Track if credentials were retrieved from Credential Manager
val retrievedCredential = remember { mutableStateOf<Pair<String, String>?>(null) }
Expand Down Expand Up @@ -229,10 +233,24 @@ fun EmailAuthScreen(
// Dialog dismissed
}
)
// Consumed immediately so this doesn't leak to a freshly created screen.
authUI.updateAuthState(AuthState.Idle)
}

is AuthState.Cancelled -> {
onCancel()
// Consumed so this doesn't leak to a freshly created screen.
authUI.updateAuthState(AuthState.Idle)
}

is AuthState.PasswordResetLinkSent -> {
resetLinkSentLocal = true
authUI.updateAuthState(AuthState.Idle)
}

is AuthState.EmailSignInLinkSent -> {
emailSignInLinkSentLocal = true
authUI.updateAuthState(AuthState.Idle)
}

else -> Unit
Expand All @@ -247,8 +265,8 @@ fun EmailAuthScreen(
confirmPassword = confirmPasswordTextValue.value,
isLoading = isLoading,
error = errorMessage,
resetLinkSent = resetLinkSent,
emailSignInLinkSent = emailSignInLinkSent,
resetLinkSent = resetLinkSentLocal,
emailSignInLinkSent = emailSignInLinkSentLocal,
onEmailChange = { email ->
emailTextValue.value = email
},
Expand Down Expand Up @@ -286,6 +304,7 @@ fun EmailAuthScreen(
}
},
onSignInEmailLinkClick = {
emailSignInLinkSentLocal = false
coroutineScope.launch {
try {
if (emailLinkFromDifferentDevice != null) {
Expand Down Expand Up @@ -327,6 +346,7 @@ fun EmailAuthScreen(
}
},
onSendResetLinkClick = {
resetLinkSentLocal = false
coroutineScope.launch {
try {
authUI.sendPasswordResetEmail(
Expand All @@ -346,14 +366,17 @@ fun EmailAuthScreen(
onGoToSignIn = {
textValues.forEach { it.value = "" }
mode.value = EmailAuthMode.SignIn
emailSignInLinkSentLocal = false
},
onGoToResetPassword = {
textValues.forEach { it.value = "" }
mode.value = EmailAuthMode.ResetPassword
resetLinkSentLocal = false
},
onGoToEmailLinkSignIn = {
textValues.forEach { it.value = "" }
mode.value = EmailAuthMode.EmailLinkSignIn
emailSignInLinkSentLocal = false
},
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@ fun PhoneAuthScreen(
pendingVerificationPhoneNumber.value = null
verificationStartTime.value = null

// Consumed before the async sign-in call so it can't be clobbered by that call's own state.
authUI.updateAuthState(AuthState.Idle)

coroutineScope.launch {
try {
authUI.signInWithPhoneAuthCredential(
Expand Down Expand Up @@ -228,10 +231,14 @@ fun PhoneAuthScreen(
// Dialog dismissed
}
)
// Consumed immediately so this doesn't leak to a freshly created screen.
authUI.updateAuthState(AuthState.Idle)
}

is AuthState.Cancelled -> {
onCancel()
// Consumed so this doesn't leak to a freshly created screen.
authUI.updateAuthState(AuthState.Idle)
}

else -> Unit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,53 @@ class FirebaseAuthUIAuthStateTest {
assertThat(states[2]).isEqualTo(AuthState.Cancelled) // After second update
}

// =============================================================================================
// Stale one-off AuthState regression tests
// =============================================================================================

@Test
fun `Error does not leak to a fresh collector after being consumed`() = runBlocking {
`when`(mockFirebaseAuth.currentUser).thenReturn(null)

authUI.updateAuthState(AuthState.Error(Exception("boom")))
authUI.updateAuthState(AuthState.Idle)

// A brand-new collector (simulating a freshly created Activity) must see Idle.
assertThat(authUI.authStateFlow().first()).isEqualTo(AuthState.Idle)
}

@Test
fun `Cancelled does not leak to a fresh collector after being consumed`() = runBlocking {
`when`(mockFirebaseAuth.currentUser).thenReturn(null)

authUI.updateAuthState(AuthState.Cancelled)
authUI.updateAuthState(AuthState.Idle)

assertThat(authUI.authStateFlow().first()).isEqualTo(AuthState.Idle)
}

@Test
fun `SMSAutoVerified does not leak to a fresh collector after being consumed`() = runBlocking {
`when`(mockFirebaseAuth.currentUser).thenReturn(null)
val credential = mock(com.google.firebase.auth.PhoneAuthCredential::class.java)

authUI.updateAuthState(AuthState.SMSAutoVerified(credential))
authUI.updateAuthState(AuthState.Idle)

assertThat(authUI.authStateFlow().first()).isEqualTo(AuthState.Idle)
}

@Test
fun `Error left uncleared still leaks to a fresh collector (pins down the bug being fixed)`() =
runBlocking {
`when`(mockFirebaseAuth.currentUser).thenReturn(null)

// No consuming reset here — documents the pre-fix leaking behavior.
authUI.updateAuthState(AuthState.Error(Exception("boom")))

assertThat(authUI.authStateFlow().first()).isInstanceOf(AuthState.Error::class.java)
}

// =============================================================================================
// AuthState Class Tests
// =============================================================================================
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package com.firebase.ui.auth.ui.components

import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.core.app.ApplicationProvider
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.AuthState
import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

/**
* Unit tests for [TopLevelDialogController] and [rememberTopLevelDialogController].
*
* These cover the fix for a bug where keying `remember` on the live `authState` value recreated
* the controller (and wiped its `shownErrorStates` de-duplication set) on every state change —
* which, combined with screens resetting `AuthState` back to `Idle` immediately after consuming
* an `Error`, would tear down and discard the just-shown dialog on the very next recomposition.
*
* @suppress Internal test class
*/
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [34])
class TopLevelDialogControllerTest {

@get:Rule
val composeTestRule = createComposeRule()

private lateinit var stringProvider: DefaultAuthUIStringProvider

@Test
fun `controller survives an authState change instead of being recreated`() {
stringProvider = DefaultAuthUIStringProvider(ApplicationProvider.getApplicationContext())
var state: AuthState = AuthState.Idle
lateinit var controller: TopLevelDialogController

composeTestRule.setContent {
controller = rememberTopLevelDialogController(stringProvider) { state }
controller.CurrentDialog()
}

val error = AuthState.Error(Exception("boom"))
composeTestRule.runOnIdle {
state = error
controller.showErrorDialog(
exception = AuthException.from(error.exception, stringProvider)
)
}
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertExists()

// Mirrors the fixed screens resetting authState right after showing the dialog.
composeTestRule.runOnIdle {
state = AuthState.Idle
}
composeTestRule.waitForIdle()

composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertExists()
}

@Test
fun `showErrorDialog does not re-show the same error state twice`() {
stringProvider = DefaultAuthUIStringProvider(ApplicationProvider.getApplicationContext())
var state: AuthState = AuthState.Idle
lateinit var controller: TopLevelDialogController

composeTestRule.setContent {
controller = rememberTopLevelDialogController(stringProvider) { state }
controller.CurrentDialog()
}

val error = AuthState.Error(Exception("boom"))
val exception = AuthException.from(error.exception, stringProvider)

composeTestRule.runOnIdle {
state = error
controller.showErrorDialog(exception = exception)
}
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertExists()

composeTestRule.runOnIdle {
controller.dismissDialog()
}
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertDoesNotExist()

// Same Error instance again — must be a no-op, the de-dup set persists across calls.
composeTestRule.runOnIdle {
controller.showErrorDialog(exception = exception)
}
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertDoesNotExist()
}
}
Loading