From b6d050d9162071154310627ace676bc34fac1a23 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 27 Jul 2026 14:15:27 -0400 Subject: [PATCH] fix(session): keep feature-flag-derived state across logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionState.isTippingEnabled (and vibrateOnScan / showNetworkOffline) are fed only by featureFlagController.observe(flag) — hot StateFlows that dedup and re-emit only on a value change. On logout, stateHolder.reset() blanket- reset SessionState() to defaults, forcing these to false. The observer never re-pushes its unchanged value, so the field stayed stuck at the default even though the underlying flag was still enabled. Result: log out with tipping on, log into another account, and the beta flag is still on but the scanner Tips tab is gone — SessionState desynced from the flag. reset() now clears only account-scoped state and preserves the device-scoped, feature-flag-derived fields, letting observe(flag) remain their single source of truth. Account/token/settings-derived fields still reset; their sources re-emit on account switch and self-heal. --- .../session/internal/SessionStateHolder.kt | 22 ++++++++++++++++-- .../internal/SessionStateHolderTest.kt | 23 ++++++++++++++++--- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/SessionStateHolder.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/SessionStateHolder.kt index b64518593..1e43937a0 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/SessionStateHolder.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/SessionStateHolder.kt @@ -29,6 +29,24 @@ class SessionStateHolder @Inject constructor() { /** Atomically update the state via a transform function. */ fun update(transform: (SessionState) -> SessionState) { _state.update(transform) } - /** Reset to the default [SessionState] (e.g. on logout). */ - fun reset() { _state.value = SessionState() } + /** + * Reset account-scoped state on logout, preserving device-scoped fields that are + * derived purely from feature flags via `observe(flag)`. + * + * Those flag observers are hot [StateFlow]s that only re-emit on a *value change*. + * A blanket `SessionState()` reset would clobber these fields to their defaults, and + * the observer would not re-push its unchanged current value to repopulate them — + * leaving the UI desynced from the still-persisted flag (e.g. Tipping flag on, but the + * scanner Tips tab gone). Account/token/settings-derived fields are safe to reset: + * their sources re-emit when the account changes, so they self-heal. + */ + fun reset() { + _state.update { prev -> + SessionState( + vibrateOnScan = prev.vibrateOnScan, + showNetworkOffline = prev.showNetworkOffline, + isTippingEnabled = prev.isTippingEnabled, + ) + } + } } diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionStateHolderTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionStateHolderTest.kt index fee75dd09..a94ae59e9 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionStateHolderTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionStateHolderTest.kt @@ -41,11 +41,28 @@ class SessionStateHolderTest { } @Test - fun `reset returns to default state`() { + fun `reset clears account-scoped state`() { val holder = holder() - holder.update { it.copy(vibrateOnScan = true, hasGiveableBalance = true) } + holder.update { it.copy(hasGiveableBalance = true, contactDmUnreadCount = 3, isPhoneNumberSendEnabled = true) } holder.reset() - assertEquals(SessionState(), holder.state.value) + val state = holder.state.value + assertEquals(false, state.hasGiveableBalance) + assertEquals(0, state.contactDmUnreadCount) + assertEquals(false, state.isPhoneNumberSendEnabled) + } + + @Test + fun `reset preserves device-scoped feature-flag state`() { + // These are driven only by observe(flag) StateFlows, which won't re-emit an + // unchanged value to repopulate them after a blanket reset — so logout must + // keep them, or the UI desyncs from the still-persisted flag (e.g. Tips tab). + val holder = holder() + holder.update { it.copy(isTippingEnabled = true, vibrateOnScan = true, showNetworkOffline = true) } + holder.reset() + val state = holder.state.value + assertTrue(state.isTippingEnabled) + assertTrue(state.vibrateOnScan) + assertTrue(state.showNetworkOffline) } @Test