Skip to content

fix(auth): stale one-off AuthState no longer leaks across screen instances#2415

Draft
demolaf wants to merge 7 commits into
version-10.0.0-beta04from
fix/auth-state-stale-notification-reset
Draft

fix(auth): stale one-off AuthState no longer leaks across screen instances#2415
demolaf wants to merge 7 commits into
version-10.0.0-beta04from
fix/auth-state-stale-notification-reset

Conversation

@demolaf

@demolaf demolaf commented Jul 23, 2026

Copy link
Copy Markdown
Member

A one-off AuthState (Error, Cancelled, SMSAutoVerified, PasswordResetLinkSent, EmailSignInLinkSent) written to FirebaseAuthUI's singleton state flow was never reset back to Idle after being consumed, so it got redelivered to a completely new screen/Activity that had nothing to do with the original event — reproduced by triggering a phone-auth error, backing out of the Activity, and re-entering: a stale "Authentication Error" dialog appeared immediately, before touching anything.

FirebaseAuthScreen, EmailAuthScreen, and PhoneAuthScreen now reset auth state back to Idle immediately after consuming Error/Cancelled/SMSAutoVerified in the same effect that reacts to them. TopLevelDialogController no longer keys its remember on the live auth state (or on an unmemoized stringProvider), since either caused the controller — and the dialog it was showing — to be torn down on the very next recomposition.

  • EmailAuthScreen.kt: resetLinkSent/emailSignInLinkSent are now latched into local state instead of derived directly from authState, since their confirmation dialogs latch on the boolean's transition and would otherwise close immediately when the reset above fires.

Added regression coverage in FirebaseAuthUIAuthStateTest and a new TopLevelDialogControllerTest proving stale state doesn't leak to a fresh collector and the dialog survives auth state changes.

@demolaf
demolaf marked this pull request as draft July 23, 2026 08:25

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request refactors TopLevelDialogController to accept a lambda for retrieving the current AuthState, preventing the controller from being recreated on every state change. It also updates the auth screens to reset the auth state to Idle after consumption and introduces local state variables in EmailAuthScreen to manage UI states. Additionally, new unit tests are added to verify state consumption and controller survival. The review feedback highlights a potential race condition in the error dialog deduplication logic due to immediate state resetting, suggests keying the controller's remember block on stringProvider to prevent stale references, and recommends wrapping stringProvider in a remember block in FirebaseAuthScreen to avoid unnecessary recompositions.

) {
// 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) }
    // ...
}

Comment on lines +171 to 173
return remember {
TopLevelDialogController(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

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)
}


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) }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant