dreamicBootstrap function
- required FirebaseOptions firebaseOptions,
- required DreamicServicesInitializer servicesInitializer,
- Map<
String, dynamic> ? additionalRemoteConfigDefaults, - DreamicBootstrapHook? afterFirebaseInit,
- DreamicBootstrapHook? registerBeforeServices,
- DreamicBootstrapHook? registerAfterServices,
- DreamicBootstrapHook? captureEntryIntents,
- bool appCubitNetworkRequired = true,
- Uri? appCubitEntranceUri,
- Duration? bootstrapTimeout = const Duration(seconds: 45),
- bool? attachErrorReportingFirst,
- Duration? firebaseInitTimeout = const Duration(seconds: 30),
- Duration? firebaseRecoverIfRegisteredAfter = const Duration(seconds: 4),
- AppCheckConfig? appCheck,
Runs the cold-start init chain that today sits in main() — Firebase, error
backend attach, remote config, app configs base, emulator connect,
DreamicServices.initialize, appInitAppCubit — now behind the splash
rather than before runApp. Returns a single Future<void> the
DreamicAppInitHost/DreamicAppInitGate gates the router on.
Ordering (fixed)
appInitFirebase
→ [hook] afterFirebaseInit (Firebase/Firestore instance settings)
→ appInitAppCheck (dreamic-owned, opt-in via [appCheck])
→ appInitErrorHandling (attach reporter + FLUSH early buffer)
→ appInitRemoteConfig
→ appInitAppConfigsBase
→ appInitConnectToFirebaseEmulatorIfNecessary
→ [hook] registerBeforeServices (DDS-006: AppRouter + UserRepoInt)
→ servicesInitializer (DreamicServices.initialize)
→ [hook] registerAfterServices
→ appInitAppCubit (network check inside the splash)
→ [hook] captureEntryIntents (cold deep-link capture)
The whole sequence is wrapped in an outer hang-timeout
(bootstrapTimeout, default 45s, nullable to disable) so a true hang
becomes an init-error (gate errorWidget) rather than an infinite splash.
On expiry the thrown TimeoutException names the step that was in flight
(the → [stepName] stamped above), so a hang is diagnosable in the backend.
Early error-reporting attach (attachErrorReportingFirst)
appInitErrorHandling (the reporter attach + early-buffer flush) can run at
one of two points:
- step 2, AFTER Firebase init + the
afterFirebaseInithook — required for a Firebase-dependent reporter (e.g. Crashlytics), which cannot attach until Firebase exists; but a hang or throw in step 1 / that hook then reports only into the early buffer, which never transmits if the bootstrap then hangs (the gate'slogefires before any backend is attached); - step 0, BEFORE Firebase — possible for a self-contained reporter
(Sentry and the like) that needs no Firebase, catching the most startup
errors (a step-1 /
afterFirebaseInitfailure, including the outer hang-timeout firing during them, reaches the backend). The step-2 attach is then skipped (single init).
Great default (no app wiring): when attachErrorReportingFirst is left
null (the default), dreamic derives the right choice from the configured
errorReportingConfig — attach at step 0 iff there is a reporter that
does NOT require Firebase (reporterRequiresFirebase: false, e.g. Sentry),
else step 2. So a self-contained (Sentry-style) consumer gets maximal startup
coverage for free; a Firebase-dependent reporter (e.g. Crashlytics, declared
with reporterRequiresFirebase: true) keeps the post-Firebase attach. Pass an
explicit true/false only to override this derivation. For the derivation
to see it, configureErrorReporting(...) must run before dreamicBootstrap —
the canonical pre-runApp main() line.
Per-step Firebase-init recovery (firebaseInitTimeout +
firebaseRecoverIfRegisteredAfter)
Bounds step 1 (appInitFirebase) — the CONFIRMED hang site on a returning
iOS WebKit device: Firebase.initializeApp registers the app, then auth's
onWaitInitState permanently hangs on the persisted-session IndexedDB read of
firebaseLocalStorageDb. These thread into appInitFirebase as a two-tier
recovery (no per-app tuning needed):
firebaseRecoverIfRegisteredAfter(short grace, default 4s): if init hasn't settled but the app IS registered, recover viaFirebase.app()on the FIRST attempt — fast, no error screen / retry cycle. Reports the occurrence (which reaches the backend whether the reporter attached before or after Firebase).firebaseInitTimeout(outer settle bound, default 30s): if the app is NOT registered at the grace (a slow-but-healthy SDK load), keep waiting the remainder rather than false-tripping a slow cold start into a retry; on the bound, recover if finally registered, else throw a diagnosableTimeoutExceptioninto the host's auto-retry / error path.
These defaults make the recovery universal — every consumer (incl. Crashlytics
apps) benefits with zero per-app config. The outer bootstrapTimeout remains a
coarse whole-sequence backstop. Pass firebaseInitTimeout: null to disable the
bound entirely (the grace is then ignored).
App Check (appCheck)
dreamic owns App Check activation as a first-class capability (like Remote
Config): pass an AppCheckConfig (with the web reCAPTCHA site key + optional
provider overrides) and dreamic selects providers (debug fallbacks +
keyless-web guard), activates bounded + non-critical, and enables
auto-refresh. Opt-in by presence — a null appCheck (the default) skips it.
Activation NEVER blocks boot: App Check is consumed lazily (token fetched on
the first attested call; enforcement is server-side), so a timeout/failure is
reported (defer/flush, so Crashlytics consumers see it) and boot continues.
Per-task failure policy (Issues 81/84)
- Fatal (uncaught throw aborts the Future → gate retry):
appInitFirebase,appInitErrorHandling,appInitAppConfigsBase, emulator connect,servicesInitializer(DreamicServices.initialize),appInitAppCubit, and any uncaught throw from an app hook — dreamic-core does not wrap hooks. - Non-fatal dreamic-core-owned: only
appInitRemoteConfig, which already internally swallows its fetch error and falls back to defaults, so it needs no extra wrapper here. Non-critical hook work is the app's owntry/catchresponsibility.
Idempotency (REQUIRED for retry to recover)
Every dreamic-core step is re-runnable: Firebase init is guarded,
remote-config / app-cubit registrations are isRegistered-guarded, the
emulator-connect and isolate-listener adds are apply-once. App hooks must be
idempotent too (the app's responsibility).
Implementation
Future<void> dreamicBootstrap({
required FirebaseOptions firebaseOptions,
required DreamicServicesInitializer servicesInitializer,
Map<String, dynamic>? additionalRemoteConfigDefaults,
DreamicBootstrapHook? afterFirebaseInit,
DreamicBootstrapHook? registerBeforeServices,
DreamicBootstrapHook? registerAfterServices,
DreamicBootstrapHook? captureEntryIntents,
bool appCubitNetworkRequired = true,
Uri? appCubitEntranceUri,
Duration? bootstrapTimeout = const Duration(seconds: 45),
bool? attachErrorReportingFirst,
Duration? firebaseInitTimeout = const Duration(seconds: 30),
Duration? firebaseRecoverIfRegisteredAfter = const Duration(seconds: 4),
AppCheckConfig? appCheck,
}) {
final future = _runBootstrap(
firebaseOptions: firebaseOptions,
servicesInitializer: servicesInitializer,
additionalRemoteConfigDefaults: additionalRemoteConfigDefaults,
afterFirebaseInit: afterFirebaseInit,
registerBeforeServices: registerBeforeServices,
registerAfterServices: registerAfterServices,
captureEntryIntents: captureEntryIntents,
appCubitNetworkRequired: appCubitNetworkRequired,
appCubitEntranceUri: appCubitEntranceUri,
attachErrorReportingFirst:
resolveAttachErrorReportingFirst(attachErrorReportingFirst),
firebaseInitTimeout: firebaseInitTimeout,
firebaseRecoverIfRegisteredAfter: firebaseRecoverIfRegisteredAfter,
appCheck: appCheck,
);
// Compose the outer hang-timeout INSIDE the bootstrap Future (the gate has no
// internal timeout). On expiry a `TimeoutException` is thrown → gate
// `errorWidget`. Null disables (tests, special cases). No in-Future
// retry-with-backoff — recovery is the user-facing idempotent re-mount
// (Issue 81). Note: `Future.timeout` does NOT cancel the underlying work
// (accepted stop-gap, Issue 53).
if (bootstrapTimeout == null) {
return future;
}
// The `onTimeout` names the step that was in flight so the resulting error is
// diagnosable. A pure `Future.timeout(d)` throws a bare `TimeoutException`
// ("Future not completed") with no clue where the sequence stalled — useless
// for a hang that, by definition, leaves no stack trace. With
// `attachErrorReportingFirst` the reporter is already attached when this
// throws, so the named message reaches the backend (Sentry/Crashlytics) via
// the gate's `onError → loge`.
return future.timeout(
bootstrapTimeout,
onTimeout: () => throw TimeoutException(
'dreamicBootstrap hung for ${bootstrapTimeout.inSeconds}s '
'during step "$_currentBootstrapStep"',
bootstrapTimeout,
),
);
}