appInitFirebase function
Initializes the default FirebaseApp (idempotent across gate-retry re-runs).
settleTimeout — the "hung init but app registered" recovery
On web, Firebase.initializeApp() can REGISTER the app (so it appears in
Firebase.apps) while its returned Future never settles. CONFIRMED root
cause (biblequiz, Sentry, iOS WebKit): initializeApp registers the JS app
and then awaits each plugin's ensurePluginInitialized; firebase_auth_web's
ends with await onWaitInitState(), which blocks on the first
onAuthStateChanged. On iOS WebKit the persisted-session IndexedDB read of
firebaseLocalStorageDb never fires its callback, so onWaitInitState — and
thus initializeApp — hangs FOREVER (a 90s watcher never saw it settle or
error). Desktop Chrome/Blink is unaffected; a brand-new device (no persisted
session) resolves the initial state immediately. A manual retry "fixes it
instantly" because it hits the Firebase.apps.isNotEmpty guard and
short-circuits to Firebase.app(), skipping the hung initializeApp.
When settleTimeout is supplied, the FIRST attempt does the same thing the
retry would, via a two-tier bound (see _awaitFirebaseInit):
recoverIfRegisteredAfter(short grace, e.g. 4s): if the init hasn't settled by here but the app IS registered, recover immediately — this catches the permanent hang fast (the app registers near-instantly; a plugin init hangs);settleTimeout(outer bound, e.g. 30s): if the app is NOT registered at the grace (a slow-but-healthy SDK load still loading), keep waiting the remainder rather than false-tripping a slow cold start into a retry.
A registered-app recovery is reported as an error (not silently absorbed)
via reportBootstrapDiagnostic, so it reaches the backend whether the
reporter attached before Firebase (Sentry early-attach → reports now) or
after (Crashlytics → deferred + flushed on attach) — see dreamicBootstrap's
attachErrorReportingFirst.
If the init genuinely hangs with NO app registered (within settleTimeout),
a descriptive TimeoutException is thrown so the caller's timeout/retry/error
path handles it. settleTimeout null (the default) keeps the unbounded
behavior (await to completion) for callers that don't want the bound (and for
direct test callers); recoverIfRegisteredAfter is ignored then.
Implementation
Future<FirebaseApp> appInitFirebase(
FirebaseOptions options, {
Duration? settleTimeout,
Duration? recoverIfRegisteredAfter,
}) async {
// Guard against a gate-retry re-run: `Firebase.initializeApp` throws
// `[core/duplicate-app]` on a second unconditional call. If the default app
// already exists, reuse it directly; otherwise initialize (Issue 17 /
// idempotency). The `duplicate-app` catch is a belt-and-suspenders fallback
// for the case where the platform layer has the default app registered while
// the Dart-side `Firebase.apps` cache is momentarily empty.
FirebaseApp fbApp;
if (Firebase.apps.isNotEmpty) {
fbApp = Firebase.app();
} else {
try {
logBreadcrumb(
'appInitFirebase: calling Firebase.initializeApp '
'(settleTimeout=${settleTimeout?.inSeconds ?? "none"}s, '
'recoverIfRegisteredAfter=${recoverIfRegisteredAfter?.inSeconds ?? "none"}s)',
category: 'bootstrap',
);
final initFuture = Firebase.initializeApp(
// options: DefaultFirebaseOptions.currentPlatform,
options: options,
);
if (settleTimeout == null) {
fbApp = await initFuture;
} else {
fbApp = await _awaitFirebaseInit(initFuture, settleTimeout, recoverIfRegisteredAfter);
}
} on FirebaseException catch (e) {
if (e.code.contains('duplicate-app')) {
fbApp = Firebase.app();
} else {
rethrow;
}
}
}
// Mark Firebase as initialized and store the app reference for the rest of the package
AppConfigBase.isFirebaseInitialized = true;
AppConfigBase.firebaseApp = fbApp;
return fbApp;
}