Fix recurring empty-list-on-login race at the root

addAccountActivatedListener/addAccountReadyListener fired once, to
whichever listeners were registered at that instant. Most per-tab
controllers are lazy providers, only constructed (and so only
registering) whenever something first reads them - if that happened
after the one-shot event already fired (e.g. a cold-start connectivity
misdetection delaying a tab's construction until after login
verified), that controller's initial fetch never ran, leaving its list
permanently empty. Both listeners now call back immediately on
registration if the account is already in the state being subscribed
to, closing the race regardless of construction timing.

Documents the failure class and the guardrail for future controllers
in architecture.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 23:51:16 -04:00
co-authored by Claude Sonnet 5
parent a127d72d56
commit 4e6477a3ae
3 changed files with 184 additions and 9 deletions
+28 -9
View File
@@ -27,8 +27,7 @@ enum LoginFlowStatus { idle, initiating, awaitingBrowser, error }
/// full rationale.
class SessionController extends ChangeNotifier with WidgetsBindingObserver {
final ConnectivityController connectivity;
final Future<SharedPreferences> prefsFuture =
SharedPreferences.getInstance();
final Future<SharedPreferences> prefsFuture = SharedPreferences.getInstance();
final AccountStore accountStore = AccountStore();
// True from the moment a saved session is restored (or a network
@@ -96,10 +95,32 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
final List<VoidCallback> _accountReadyListeners = [];
void addAccountClearedListener(VoidCallback cb) =>
_accountClearedListeners.add(cb);
void addAccountActivatedListener(VoidCallback cb) =>
_accountActivatedListeners.add(cb);
void addAccountReadyListener(VoidCallback cb) =>
_accountReadyListeners.add(cb);
// Both `activated` and `ready` are one-shot, fire-and-forget calls, not a
// replayable stream - so a controller isn't guaranteed to be *listening*
// yet when the real event fires. Most controllers are registered with
// `lazy: false` in main.dart specifically so they exist before that can
// happen, but one that isn't (built lazily, on first read, like
// FilesController) can lose the race if nothing reads it until after
// login already finished verifying - e.g. `ConnectivityController`
// misreporting offline right at cold start collapses the bottom nav to
// just the Offline tab (see `main.dart`), so Files' tab, and the
// `FilesController` it lazily creates, never gets built during that
// window. Calling back immediately here if the account is *already* in
// the state being subscribed to closes that race for every current and
// future subscriber, without each one needing its own view-level
// fallback reload - see `.claude/context/architecture.md`'s "State
// management" section for the guardrail this exists to enforce.
void addAccountActivatedListener(VoidCallback cb) {
_accountActivatedListeners.add(cb);
if (_isLoggedIn && !_isProvisionalLogin) cb();
}
void addAccountReadyListener(VoidCallback cb) {
_accountReadyListeners.add(cb);
if (_isLoggedIn) cb();
}
void _notifyAccountCleared() {
for (final cb in _accountClearedListeners) {
cb();
@@ -141,9 +162,7 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
// one-time unlock at cold start would give the feature no real
// security value, since the realistic threat is someone else picking
// up an already-running, unlocked phone.
if (state == AppLifecycleState.paused &&
_loginLockEnabled &&
_isUnlocked) {
if (state == AppLifecycleState.paused && _loginLockEnabled && _isUnlocked) {
_isUnlocked = false;
notifyListeners();
}