Table of Contents

  1. The Startup Architecture — Two-Gate System
  2. Parallel Init — Never Block on Sequential Awaits
  3. The Provider Tree — Order Matters
  4. Session State Machine
  5. Auth Flows — Login & Create Account
  6. The Shell Big Bang — Background Services on Login
  7. Push Notification Pipeline
  8. Notification Delivery Strategy
  9. Notification Unread Count Polling
  10. DM Messaging Bus Architecture
  11. Analytics — Batching & Passive Tracking
  12. Onboarding State Machine
  13. Home Feed — Polling & Live Updates
  14. Multi-Account Architecture
  15. Error & Session Drop Handling
  16. The Complete System Map
  17. Must-Follow Rules — Derived from Real Patterns

1. The Startup Architecture — Two-Gate System

One of the key finding from the analysis is that the UI visibility depends on two independent gates. Understanding and implementing both correctly is essential.

Gate 1: App() component - Storage + Geo + Device ID

src/App.native.tsx
File: src/App.native.tsxLines 218222

Promise.all([
  initPersistedState(),   // Opens MMKV, reads all saved state into memory
  Geo.resolve(),          // Country detection (for feature gating)
  setupDeviceId,          // Reads/generates anonymous analytics ID from SecureStore
]).then(() => setIsReady(true))

The app waits for all three to finish before setting isReady to true. Until then, App() returns null, so no React UI is rendered and the native splash screen remains visible.

This is intentional. It guarantees that persisted state, location-based feature flags, and the device identifier are all available before the application begins rendering, preventing inconsistent or partially initialized state during startup.

Gate 2 — InnerApp() component: Session Resume + Landing Check

src/App.native.tsx
File: src/App.native.tsxLine 148

<Splash isReady={isReady && hasCheckedLanding}>

The splash only hides once both of these conditions are true:

  • isReady — The session resume process has finished, regardless of whether it succeeded or failed. This flag is typically set in a finally block so startup can continue even if session restoration fails.
  • hasCheckedLanding — The app has finished resolving its initial destination, including any deep links, notification taps, or starter pack invites.
Why a Second Gate?
Completing app initialization isn't enough. Before the UI becomes visible, the app also needs to know where the user should land.
If someone opens the app from a push notification or a starter pack invite, briefly rendering the default home screen before redirecting creates a noticeable flicker and a poor user experience. Even a 100 ms flash of the wrong screen feels broken.
By waiting until both the session state and the landing destination are resolved, the splash screen stays visible until the app can navigate directly to the correct screen. The user sees the right destination from the very first frame, with no intermediate UI or unnecessary redirects.

What Fires Before Any Component (Module Level)

text
T=0ms:  Sentry initialized (import side-effect — first line of the file)
T=2ms:  SplashScreen.preventAutoHideAsync() — splash LOCKED
T=3ms:  iOS: SystemUI.setBackgroundColorAsync('black') — prevents white flash
T=3ms:  Android: ScreenOrientation.lockAsync(PORTRAIT_UP)
T=5ms:  void Geo.resolve()               — HTTP request, fire-and-forget
T=5ms:  void prefetchAgeAssuranceConfig() — HTTP request, fire-and-forget
T=5ms:  void prefetchLiveEvents()         — HTTP request, fire-and-forget
T=5ms:  void prefetchAppConfig()          — HTTP request, fire-and-forget
Why use void?
These network requests are intentionally not awaited. Prefixing them with void makes it explicit that they run independently and their completion is not required for startup.

This has two major benefits:
React initialization is never blocked. Component mounting, provider setup, and state restoration continue immediately.
Data is often ready before it's needed. By the time components request these resources, the responses are frequently already cached, reducing perceived load times.

The guiding principle is simple: if a task isn't required to render the first screen, don't block startup waiting for it. Start it as early as possible and let it complete in the background.
Rule: If a task isn't required to render the first screen, don't await it. Start it as early as possible using void so it runs in parallel with React initialization. By the time the UI needs the data, it's often already cached, reducing startup latency without blocking the app.

2. Parallel Init — Never Block on Sequential Awaits

A consistent pattern throughout the app is that independent tasks always run in parallel. Startup time is determined by the slowest task, not the sum of every task.

What runs in parallel at app boot

typescript
//❌ Wrong (Sequential)

await initPersistedState();
await Geo.resolve();
await setupDeviceId();

//✅ Correct (Parallel)

await Promise.all([
  initPersistedState(),
  Geo.resolve(),
  setupDeviceId(),
]);

What runs in parallel when hasSession becomes true (Shell mounts)

At the moment hasSession = true, ALL of these fire simultaneously:

  • useIntentHandler() — Deep link URL parser
  • setSystemUITheme() — Status bar color sync
  • <PassiveAnalytics /> — AppState foreground/background listener
  • useNotificationsRegistration() — APNs/FCM token fetch + server registration
  • useNotificationsHandler() — 11 Android notification channels created, tap listener registered
  • BackHandler (Android) — Hardware back button registered
  • UnreadNotifsProvider — Immediate notification count fetch + 30s interval started
  • MessagesEventBus — DM polling initialized + 2s interval started

The UI is shown immediately while these services initialize in the background.

Design principle: Do not wait for any of these to complete before showing the UI. They are all "best-effort" background services. The user sees the feed immediately.

At post-auth initialization, run in parallel

typescript
// After resumeSession or login succeeds — never await these in sequence
await Promise.allSettled([
  fetchCurrentProfile(),        // Needed for nav header
  fetchPreferences(),           // Feed pins, content labels, language
  fetchNotificationCount(),     // Tab bar badge
  fetchUnreadConvoCount(),      // DM badge
  registerPushToken(),          // Non-blocking background
  checkServerCapabilities(),    // Feature flags
])
// allSettled — never block the UI if one fails
// Promise.allSettled() ensures one failed request never blocks the rest of the app.
Rule: If tasks don't depend on each other, run them in parallel. Use Promise.all() when every task is required, and Promise.allSettled() when failures shouldn't block the user experience.

3. The Provider Tree — Order Matters

From the analysis, the app mounts 30+ Context Providers in a specific order. The provider hierarchy is a dependency graph, not an arbitrary nesting. A provider can consume values from providers above it, but never from those below.

typescript
Geo.Provider                    ← country data
  AppConfigProvider             ← remote feature flags (reads Geo for gating)
    A11yProvider                ← accessibility settings
      KeyboardControllerProvider
        OnboardingProvider      ← reads MMKV: onboarding.completed
          AnalyticsContextMetricsClient + 10s flush timer STARTED HERE
            SessionProviderSessionStore, reads MMKV accounts synchronously
              PrefsStateProvider ← theme, feeds, content labels
                I18nProvider    ← translations for device locale
                  ShellStateProvider
                    DialogStateProvider
                      PortalProvider   ← outlet for modals outside component tree
                        BottomSheetProvider
                          LandingProvider  ← deep link / starter pack detection
                            SafeAreaProvider ← notch/home bar insets (synchronous)
                              <InnerApp />

Key Dependencies

  • AppConfigProvider reads country information from Geo.Provider.
  • SessionProvider is nested inside AnalyticsContext so authentication events can be tracked.
  • PrefsStateProvider depends on session state.
  • PortalProvider stays near the root so dialogs, modals, and onboarding flows can render above the entire app.
  • SafeAreaProvider wraps the app just before rendering to provide device insets.
Critical: SessionProvider must be inside AnalyticsContext so auth events can be tracked. PortalProvider must be near the root so modals (including Onboarding) can render over everything.

4. Session State Machine

The session is not stored in React state — it uses useSyncExternalStore against a custom SessionStore class. This is a key architectural decision.

Why SessionStore instead of React state?

typescript
// SessionStore.dispatch() does:
1. Runs reducer (pure function, synchronous)
2. Updates this.state in memory
3. If needsPersist: writes to MMKV synchronously (no React cycle)
4. Calls all useSyncExternalStore subscribers → React re-renders

// Result: MMKV is always written BEFORE the React re-render.
// No race condition where UI updates but storage hasn't saved yet.
// Because MMKV is written before React re-renders, the UI and persisted state never fall out of sync.

Session State Transitions

text
App opens
  │
  ├── readLastActiveAccount() from MMKV
  │     │
  │     ├── account found → resumeSession(account)
  │     │     ├── createAgentAndResume()
  │     │     │     └── agent.resumeSession() → HTTP: com.atproto.server.getSession
  │     │     │           └── if expired → automatic: com.atproto.server.refreshSession
  │     │     └── store.dispatch('switched-to-account') → hasSession = true
  │     │
  │     └── no account → features.init() → hasSession stays false
  │
  ├── hasSession = false → LoggedOutView
  └── hasSession = true → Shell mounts

Token events (onAgentSessionChange callback):
  'create'new session, write tokens to MMKV
  'update'  → token refreshed, update MMKV silently
  'expired' → emitSessionDropped() → toast → user sent to login
  'create-failed' → emitSessionDropped()

The hasSession Computation

src/state/session/index.tsx
// src/state/session/index.tsx — Lines 350–359
const stateContext = useMemo(() => ({
  accounts: state.accounts,
  currentAccount: state.accounts.find(a => a.did === state.currentAgentState.did),
  hasSession: !!state.currentAgentState.did,
}), [state])

When hasSession flips false → true, the entire navigation switches from LoggedOutView to the home feed. This single boolean is the most important value in the app.

Rule: Keep session state outside React and persist it before notifying subscribers. A single source of truth (hasSession) should control the entire authentication flow.

5. Auth Flows — Login & Create Account

Login Flow (LoginForm.tsx)

Key implementation decisions from the analysis:
The login and signup flows prioritize responsiveness, clear error handling, and preventing unnecessary re-renders.

1. Use useRef not useState for text inputs

typescript
const identifierValueRef = useRef<string>(initialHandle || '')
const passwordValueRef = useRef<string>('')
// Text input changes do NOT trigger re-renders.
// Only read on form submit. Significant performance gain on low-end devices.

2. Auto-complete handles

typescript
// User types "alice" → gets "alice.bsky.social" automatically
if (!identifier.includes('@') && !identifier.includes('.')) {
  fullIdent = createFullHandle(identifier, serviceDescription.availableUserDomains[0])
}

3. Request push permissions immediately after login

typescript
await login(params, 'LoginForm')
onAttemptSuccess()                           // Analytics metric
setShowLoggedOut(false)                      // Reveal home feed
requestNotificationsPermission('Login')      // Show system dialog now

4. Granular error handling (never one generic "error")

text
AuthFactorTokenRequiredError → reveal 2FA input field (don't show error, just reveal field)
'Token is invalid'           → "Invalid 2FA code"
'Invalid identifier or password' → "Incorrect username or password"
isNetworkError(e)            → "Unable to contact your service"

Create Account Flow (state machine pattern)

The signup uses useReducer with a full state machine — not multiple useState calls.

src/screens/Signup/state.ts
// src/screens/Signup/state.ts
enum SignupStep { INFO = 0, HANDLE = 1, CAPTCHA = 2 }

// Every field error fires an analytics metric:
case 'setError':
  next.fieldErrors[action.field]++          // Count how many times each field failed
  analytics.metric('signup:fieldError', {   // → Bluesky knows which fields confuse users
    field: action.field,
    errorCount: next.fieldErrors[action.field],
    activeStep: next.activeStep,
  })

Double-submit prevention with mutable flag (not state dispatch)

typescript
// The CAPTCHA step dispatches pendingSubmit, this effect watches for it:
useEffect(() => {
  if (state.pendingSubmit && !state.pendingSubmit.mutableProcessed) {
    state.pendingSubmit.mutableProcessed = true  // Direct mutation — no re-render
    void submit(state, dispatch)
  }
}, [state, dispatch, submit])
// mutableProcessed is mutated directly so it doesn't cause another render
// and trigger a second submit.

Android: warm up Play Integrity API early

typescript
// When Signup screen appears — not when CAPTCHA step appears
// So it's warm by the time the user needs it
useEffect(() => {
  if (!IS_ANDROID) return
  ReactNativeDeviceAttest.warmupIntegrity(GCP_PROJECT_ID).catch(...)
}, [])

Onboarding only starts AFTER successful account creation

typescript
// Inside useSubmitSignup() — Lines 334–340
await createAccount({...})   // Throws on failure
// Reached only on success:
onboardingDispatch({type: 'start'})  // Now safe to show onboarding
Rule: Keep authentication flows fast and predictable. Avoid unnecessary re-renders, handle each failure explicitly, prevent duplicate submissions, and defer post-login features until authentication succeeds.

6. The Shell Big Bang — Background Services on Login

The moment hasSession = true and Shell mounts, a cascade of background services starts. This is the "Big Bang" moment.

text
Shell() mounts:
  useIntentHandler()          → URL intent parser (deep links) STARTED
  setSystemUITheme()          → system bar colors synced to theme

ShellInner() mounts:
  useNotificationsRegistration()  → push token pipeline STARTED
  useNotificationsHandler()       → notification routing STARTED
  BackHandler.addEventListener()  → [Android] hardware back button REGISTERED
  navigation.addListener('state') → [Android] video player release on navigate

Already mounted (from InnerApp):
  UnreadNotifsProvider            → 30s polling for badge count STARTED
  MessagesProvider                → 2s polling for DMs STARTED
  <PassiveAnalytics />            → AppState session tracking STARTED
Rule: None of these can fail silently and block the UI. Wrap each in their own try/catch. The user should see the feed even if services like push notifications registration fail.

7. Push Notification Pipeline

From the analysis — a complete picture of how push tokens flow from device to server:

text
ShellInner mounts
  │
  useNotificationsRegistration()
  │
  getAndRegisterPushToken()
  │
  ├── if (!IS_NATIVE || IS_DEV) return   ← Web/Dev skip entirely
  │
  ├── Notifications.getPermissionsAsync()
  │     └── if NOT granted → return (skip registration, ask later)
  │
  ├── Notifications.getDevicePushTokenAsync()
  │     ├── iOS → contacts Apple APNs servers → 64-byte hex token
  │     └── Android → contacts Google FCM servers → registration token
  │
  └── agent.app.bsky.notification.registerPush({
        serviceDid: PUBLIC_APPVIEW_DID,
        platform: 'ios' | 'android',
        token: token.data,
        appId: 'xyz.blueskyweb.app',
        ageRestricted: false
      })
      │
      └── HTTP POST → AppView server → Courier microservice
            └── stores (userDid → deviceToken) in Courier DB
                  └── future notifications for this user delivered here

Token Rotation Handling

typescript
// APNs/FCM occasionally issue new tokens — auto-handled:
Notifications.addPushTokenListener(async token => {
  registerPushToken({ token, isAgeRestricted })  // Re-register new token
})

Debounce registration calls

text
// Wrapped in debounce(100ms) — prevents duplicate registration
// in React Strict Mode or other double-mount scenarios

8. Notification Delivery Strategy

From the handler analysis — deliberate choices that should be copied:

Foreground: Suppress banners, update badge silently

typescript
// setNotificationHandler is called while app is OPEN
handleNotification: async (e) => {
  if (isChatNotification && payload.convoId === currentConvoId) {
    // User is already IN this conversation — suppress everything
    return { shouldShowBanner: false, shouldShowList: false,
             shouldPlaySound: false, shouldSetBadge: false }
  }
  // All other foreground notifications:
  invalidateCachedUnreadPage()   // Force badge count re-fetch
  return { shouldShowBanner: false, shouldSetBadge: true }
  //        ^^^^^ intentionally false — never interrupt the user
}
Design decision: Never show notification banners when the app is foregrounded. Only update the badge. Users are already using the app — the interruption has negative UX impact.

Background tap routing

typescript
// Complete routing map from the analysis:
'like' | 'repost'Navigate to the specific post
'reply' | 'quote' | 'mention'Navigate to the reply/mention post
'follow' | 'starterpack-joined'Navigate to follower's profile
'chat-message' | 'chat-reaction' → Navigate to specific conversation
'chat-removed-from-group'      → Navigate to messages list (no longer has access)

// Multi-account notification routing:
if (payload.recipientDid !== currentAccount.did) {
  storePayloadForAccountSwitch(payload)   // Save navigation target
  onPressSwitchAccount(matchedAccount)    // Switch to correct account
  // After tree remount: stored payload is consumed and navigation fires
}

Android Notification Channels (must declare all upfront)

typescript
// Must run when ShellInner mounts — before any notification arrives
// Android 8+ requires pre-declared channels
// Users can disable individual channels in system settings

Notifications.setNotificationChannelAsync('chat-messages', {
  sound: 'dm.mp3',           // Custom sound bundled in APK
  vibrationPattern: [250],
  importance: AndroidImportance.MAX,
})

// One channel per notification type:
// 'like', 'repost', 'reply', 'mention', 'quote', 'follow',
// 'like-via-repost', 'repost-via-repost', 'subscribed-post'
// + 2 chat variants (with/without sound) = 11 total channels

9. Notification Unread Count Polling

From the UnreadNotifsProvider analysis — a sophisticated polling system with multiple optimizations:

text
On mount:     checkUnread() fires immediately (no waiting for first interval)
Every 30s:    setInterval(() => checkUnread({isPoll: true}), 30_000)

Smart poll-skipping logic (load reduction)

typescript
// Guard 1: App is backgrounded
if (AppState.currentState !== 'active') return

// Guard 2: Badge already at max — no point fetching
if (isPoll && unreadCount >= 30) return

// Guard 3: Random 50% skip when there are unread items
// Halves server load for users who aren't clearing notifications
if (isPoll && Math.random() >= 0.5) return

// Only if all guards pass:
await fetchPage({ agent, limit: 40, ... })
setNumUnread(countStr)            // Updates tab bar badge
broadcast.postMessage({event: countStr})  // Updates other browser tabs
Rule: Always apply these three guards to polling. The random 50% skip is particularly clever — it reduces server load proportionally to how many users have pending notifications.

10. DM Messaging Bus Architecture

The DM system uses a two-layer architecture discovered in the analysis:

Layer 1: Global MessagesEventBus (one per login session)

text
MessagesProvider mounts
  │
  new MessagesEventBus({ agent })
  │
  init():
    GET getLog({})           → no cursorreturns current latestRev
    this.latestRev = "rev_00000100abc"
    dispatch(Ready) → startPoll()
  │
  setInterval(poll, 2_000)  ← Active: every 2s
  setInterval(poll, slower) ← Backgrounded: much slower (battery saving)
  │
  poll():
    GET getLog({ cursor: this.latestRev })
    → Server returns only events newer than this rev
    → If events found: advance latestRev, emit('event', {type:'logs', logs})
    → Individual Convo agents receive this via their listeners

Layer 2: Individual Convo agent (one per open conversation)

text
User opens DM screen
  │
  new Convo({ convoId, agent, events: globalEventBus })
  │
  subscribe() → first subscriber activates:
    listenProfileShadowUpdate()   → watches for profile changes (blocks, etc.)
    init() → dispatch(Init)
      │
      ├── setup() async:
      │     void this.fetchMemberList()          ← parallel, non-blocking
      │     const convo = await this.fetchConvo() ← blocking
      │     dispatch(Ready) → fetchMessageHistory()
      │           └── getMessages({ limit: 50 }) → fills pastMessages Map
      │
      ├── setupFirehose()
      │     this.events.on(handler, { convoId })  ← filtered to THIS convo
      │     New messages → ingestFirehose() → commit() → UI re-renders
      │
      └── requestPollInterval(ACTIVE_POLL_INTERVAL)
            ← Tells the global bus to poll faster (takes min of all requests)

User closes DM screen:
  → unsubscribe() → subscribers.length === 0withdrawPollIntervalRequest()  ← global bus slows back down

11. Analytics — Batching & Passive Tracking

MetricsClient flush strategy

typescript
// Created when AnalyticsContext provider mounts (very early in boot)
setInterval(() => this.flush(), 10_000)  // Every 10 seconds

// Also flush on:
if (queue.length >= 100) this.flush()    // Burst protection
// On app background → navigator.sendBeacon() on web ensures delivery

Batched HTTP send structure

typescript
fetch(`${METRICS_API_HOST}/t`, {
  body: JSON.stringify({
    events: batch,          // Array of { name, timestamp, properties }
    deviceId,               // Anonymous stable device ID from SecureStore
    appVersion,
    platform: Platform.OS,
  })
})

Passive AppState tracking

typescript
// <PassiveAnalytics /> — invisible component in Shell
onAppStateChange(state => {
  if (state === 'active') {
    lastActive.current = performance.now()
    ax.metric('state:foreground', {})
  } else if (lastActive.current !== null) {
    ax.metric('state:background', {
      secondsActive: Math.round((performance.now() - lastActive.current) / 1e3)
    })
  }
})

This is how to get DAU, WAU, MAU and session duration without any external SDK.

Track every signup field error

typescript
// In signup reducer — every field error is tracked
analytics.metric('signup:fieldError', {
  field: 'email' | 'handle' | 'password',
  errorCount: fieldErrors[field],  // How many times this field failed
  activeStep: state.activeStep,
})
// → Reveals which steps/fields cause the most user drop-off

12. Onboarding State Machine

Onboarding renders as a Portal — not inside the navigation stack. It covers the entire screen including the Shell.

Step computation at initialization

typescript
// createInitialOnboardingState is the THIRD argument to useReducer
// It receives options and computes which steps to include — once
const [state, dispatch] = useReducer(
  reducer,
  { starterPacksStepEnabled: showSuggestedStarterpacks,
    findContactsStepEnabled: showFindContacts },
  createInitialOnboardingState,  // ← Called once, not on every render
)

Four-gate "Find Contacts" step

typescript
const showFindContacts =
  ENV !== 'e2e'                                          // Not in test
  && IS_NATIVE                                           // Web has no contacts
  && findContactsEnabled                                 // Country is legal
  && !ax.features.enabled(ImportContactsOnboardingDisable) // Feature flag not off

Step sequence (all API calls happen per step)

  • Step 'profile' — agent.upsertProfile({avatar, displayName})
  • Step 'interests' — agent.app.bsky.actor.putPreferences({interests})
  • Step 'suggested-accounts' — agent.app.bsky.graph.follow({subject}) × N
  • Step 'suggested-starterpacks' — Bulk follow all members
  • Step 'find-contacts' — Match phone numbers to AT Protocol handles
  • Step 'finished' — onboardingDispatch({type: 'finished'}) → save to MMKV + server prefs

Completion persistence

typescript
// onboardingDispatch({type: 'finished'}) does TWO things:
1. MMKV: persisted.write('onboarding', { completed: true })
2. Server: agent.app.bsky.actor.putPreferences({ onboardingCompleted: true })
// → Survives uninstall if server preferences are restored on re-login

13. Home Feed — Polling & Live Updates

Two-layer update system

typescript
// Layer 1: Poll for "there are new posts" every 60 seconds
setInterval(checkForNew, 60_000)
// checkForNew() → calls pollLatest endpoint → lightweight, returns only top post ID
// If newer than current top → hasNew = true → "Load new posts" button appears

// Layer 2: User posts → immediate invalidation (no waiting for 60s poll)
listenPostCreated(() => {
  queryClient.invalidateQueries({ queryKey: feedKey })
})
// emitPostCreated() fires from the composer after a successful post
Rule: Never rely solely on intervals for live data that the user just created themselves. Use EventEmitter to immediately invalidate queries when the local user takes an action.

Feed page structure

text
Home.tsx
  ↓ reads pinned feeds from preferences
  Pager (swipeable tabs)
    ↓
    FeedPage (per feed)
      ├── "Load new posts" button (conditional, slides in)
      ├── FAB (compose button)
      └── PostFeed
            ↓
            usePostFeedQuery(feedUri)
              ├── "Following": agent.app.bsky.feed.getTimeline()
              └── Custom: agent.app.bsky.feed.getFeed({ feed: feedUri })

14. Multi-Account Architecture

From the analysis — the Fragment key pattern prevents state leaks between accounts:

typescript
// InnerApp renders:
<Fragment key={currentAccount?.did}>
  {/* Everything inside here */}
</Fragment>

When currentAccount.did changes (account switch), React unmounts and remounts the entire tree below. This guarantees:

  • No stale TanStack Query cache from the previous user
  • No stale agent making API calls for the old user
  • No UI state carrying over

Old agent disposal on switch

typescript
useEffect(() => {
  if (currentAgentRef.current !== agent) {
    const prevAgent = currentAgentRef.current
    currentAgentRef.current = agent
    prevAgent.dispose()   // Stops its token refresh loop
    // Prevents old agent from competing for refresh tokens with new one
  }
}, [agent])

Notification cross-account routing

typescript
// If a push notification arrives for account B while account A is active:
if (payload.recipientDid !== currentAccount.did) {
  storePayloadForAccountSwitch(payload)   // Save navigation intent
  onPressSwitchAccount(matchedAccount)    // Switch to account B
  // After Fragment key change + tree remount:
  // Stored payload is consumed and navigation fires for the correct screen
}

15. Error & Session Drop Handling

The sessionDropped EventEmitter pattern

typescript
// Registered on InnerApp mount, lives for entire app session
useEffect(() => {
  return listenSessionDropped(() => {
    Toast.show(l`Sorry! Your session expired. Please sign in again.`, {type: 'info'})
  })
}, [l])

This fires when onAgentSessionChange('expired') is called — meaning the refresh token failed. Causes:

  • Account was banned/suspended
  • Token was revoked from another device
  • Server error during refresh
  • refreshJwt expired (too long since last use)

finally pattern for gates

typescript
// CRITICAL: Always use finally for gate-opening
async function onLaunch(account?: SessionAccount) {
  try {
    if (account) {
      await resumeSession(account)
    }
  } catch (e) {
    logger.error('session: resume failed', {message: e})
    // User goes to LoggedOut view — handled by hasSession = false
  } finally {
    setIsReady(true)  // ← ALWAYS runs, app never gets stuck on splash
  }
}
Rule: Any gate that blocks the UI must open in finally. A crashed session resume must still show the login screen, not a perpetual splash.

16. The Complete System Map

This is the combined map from all three analyzed documents:

text
T=0ms  JS BUNDLE EXECUTES
       ├─ [SYNC] Sentry crash reporter initialized
       ├─ [SYNC] Splash screen locked
       ├─ [SYNC] iOS: black system UI / Android: portrait locked
       └─ [ASYNC FIRE] Geo, AgeConfig, LiveEvents, AppConfig — 4 parallel HTTP requests

App() mounts → returns null (splash showing)
       │
       └─ [PARALLEL WAIT] initPersistedState + Geo.resolve + setupDeviceId

All 3 done → 30+ providers mount in dependency order
       ├─ AnalyticsContext    → MetricsClient 10s flush timer STARTED
       ├─ SessionProvider     → SessionStore, MMKV accounts loaded synchronously
       ├─ OnboardingProvider  → onboarding.completed read from MMKV
       └─ 27 more providers init their state

InnerApp() mounts
       ├─ listenSessionDropped() EventEmitter REGISTERED
       ├─ readLastActiveAccount() → resumeSession() → AT Proto token refresh
       └─ setIsReady(true) + hasCheckedLanding → splash hides

       ┌─────────────────────────────────────────────────────────┐
       │ hasSession = FALSE → LoggedOutView                       │
       │                                                          │
       │   Create Account path:                                   │
       │   Signup (INFO → HANDLE → CAPTCHA state machine)        │
       │   → createAccount() → com.atproto.server.createAccount  │
       │   → store.dispatch('switched-to-account')               │
       │   → hasSession = true                                    │
       │   → onboardingDispatch('start')                          │
       │                                                          │
       │   Sign In path:                                          │
       │   LoginForm → login() → com.atproto.server.createSession │
       │   → store.dispatch('switched-to-account')               │
       │   → hasSession = true                                    │
       │   → setShowLoggedOut(false)                              │
       │   → requestNotificationsPermission()                     │
       └─────────────────────────────────────────────────────────┘

hasSession = TRUE → Shell mounts (THE BIG BANG — all fire simultaneously)
       │
       ├─ useIntentHandler()        → deep link parser STARTED
       ├─ setSystemUITheme()        → status bar colors synced
       ├─ <PassiveAnalytics />      → AppState session tracking STARTED
       │
       ShellInner mounts
       ├─ useNotificationsRegistration()
       │    ├─ getDevicePushTokenAsync() → APNs/FCM token fetched
       │    ├─ agent.registerPush() → token → AppView → Courier DB
       │    └─ addPushTokenListener() → auto-reregister on token rotation
       │
       ├─ useNotificationsHandler()
       │    ├─ [Android] 11 notification channels CREATED
       │    ├─ setNotificationHandler() → foreground interceptor REGISTERED
       │    └─ addNotificationResponseReceivedListener() → tap router REGISTERED
       │
       ├─ BackHandler [Android] REGISTERED
       └─ navigation state listener [Android] REGISTERED

UnreadNotifsProvider (in InnerApp tree)
       ├─ checkUnread() fires immediately
       └─ setInterval(30s) STARTED (with 3-guard skip logic)

MessagesProvider (in InnerApp tree)
       ├─ MessagesEventBus init() → latestRev fetched from server
       └─ setInterval(2s) STARTED for DM polling

Onboarding Portal (new accounts only)
       ├─ StepProfile → StepInterests → StepSuggestedAccounts → StepFinished
       └─ onboardingDispatch('finished') → saved to MMKV + server → Portal unmounts

Home Feed visible
       ├─ usePostFeedQuery() → agent.getTimeline() → posts rendered
       ├─ setInterval(60s) STARTED → new posts polling
       └─ listenPostCreated() REGISTERED → immediate invalidation on compose

User opens DM screen
       └─ new Convo(convoId)
            ├─ listenProfileShadowUpdate() REGISTERED
            ├─ fetchConvo() + fetchMemberList() [PARALLEL]
            ├─ setupFirehose() → subscribes to MessagesEventBus for this convoId
            ├─ requestPollInterval(ACTIVE) → global bus polls at 2s
            └─ fetchMessageHistory() → last 50 messages loaded → UI renders

17. Must-Follow Rules — Derived from Real Patterns

These rules are not theoretical. Each one comes from a specific pattern discovered in the analysis.

🔴 Critical — Ship Blockers

1. Always use finally to open splash/gate states

src/App.native.tsx
// From: src/App.native.tsx — Lines 118–134
// If session resume fails, the app must NOT be stuck on the splash screen.
} finally { setIsReady(true) }

2. Two gates for splash dismiss, not one

src/App.native.tsx
// From: src/App.native.tsx — Line 148
// isReady alone is not enough. Deep link must also be parsed.
<Splash isReady={isReady && hasCheckedLanding}>

3. Use Fragment key={currentAccount?.did} for multi-account state isolation

src/App.native.tsx
// From: src/App.native.tsx — Lines 152–153
// Without this, switching accounts leaks cache and component state.
<Fragment key={currentAccount?.did}>

4. Dispose the old agent on account switch

src/state/session/index.tsx
// From: src/state/session/index.tsx — Lines 386–398
// Two agents competing for the same refreshJwt will corrupt token state.
prevAgent.dispose()

5. Never await sequential startup tasks that can be parallel

src/App.native.tsx
// From: src/App.native.tsx — Lines 218–222
// Sequential awaits add up. Promise.all() is always correct here.
await Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId])

6. Suppress notification banners in foreground

typescript
// From: useNotificationsHandler — Lines 307–338
// Notification popups while the user is actively reading = terrible UX.
return { shouldShowBanner: false, shouldSetBadge: true }

7. Debounce push token registration

text
// React Strict Mode double-mounts, rapid re-renders can fire registration twice.
// Debounce(100ms) prevents duplicate token storage on the server.

🟡 Important — Quality Signals

8. Start Sentry FIRST — before any other import

src/App.native.tsx
// Line 1 of App.native.tsx is an import side-effect, not a function call.
// If the app crashes during init, Sentry must already be active.
import '#/logger/sentry/setup'

9. Lock splash screen before React renders anything

typescript
// Line 88 — module level, synchronous, before App() mounts
void SplashScreen.preventAutoHideAsync()

10. Prefetch everything you need at boot before components mount

typescript
// Lines 104–107 — fire-and-forget via void, parallel, no waiting
void Geo.resolve()
void prefetchAgeAssuranceConfig()
void prefetchLiveEvents()
void prefetchAppConfig()

11. Use three guards on notification polling

typescript
if (AppState.currentState !== 'active') return      // Battery
if (isPoll && unreadCount >= 30) return              // Max badge
if (isPoll && Math.random() >= 0.5) return          // 50% skip = server load halved

12. DM polling uses a min-interval request system

Individual conversations request faster poll when active. Bus takes minimum of all requests. Conversation closing withdraws its request — rate returns to default.

13. Onboarding dispatches start ONLY after successful createAccount()

typescript
await createAccount({...})          // Can throw
onboardingDispatch({type: 'start'}) // Only reached on success

14. Use useRef not useState for text inputs in login forms

Text changes in refs don't re-render. Only read on submit. Matters on low-end Android devices under load.

🟢 Excellence — Differentiators

15. Track signup field errors with analytics

typescript
// Know which field causes the most user drop-off.
// Use this to simplify or provide better placeholder text.
analytics.metric('signup:fieldError', { field, errorCount, activeStep })

16. Warm up Play Integrity API before the CAPTCHA step

typescript
// On Signup screen mount, not on CAPTCHA mount.
// Cold-start delay on CAPTCHA would cause form abandonment.
ReactNativeDeviceAttest.warmupIntegrity(GCP_PROJECT_ID)

17. Cross-account notification routing via stored payload

typescript
// Don't drop the notification if it's for a different account.
// Store it → switch account → consume it after remount.
storePayloadForAccountSwitch(payload)
onPressSwitchAccount(matchedAccount)

18. Use broadcast.postMessage for cross-tab notification sync

typescript
// After fetching unread count, broadcast to other browser tabs.
// Other tabs update their badge instantly without their own polling.
broadcast.postMessage({ event: unreadCountStr })

19. Suppress all foreground notifications for the conversation the user is currently in

typescript
// From useNotificationsHandler
if (isChatNotification && payload.convoId === currentConvoId) {
  return { shouldShowBanner: false, shouldSetBadge: false, shouldPlaySound: false }
}

20. Use useSyncExternalStore for session state, not React useState

typescript
// SessionStore writes to MMKV synchronously BEFORE React re-renders.
// With useState, there's a window where UI updated but storage hasn't.
const state = useSyncExternalStore(store.subscribe, store.getState)

18. Executive Summary

This architecture is built around a simple principle: show the UI as quickly as possible while everything else happens in parallel.

Startup is controlled by a two-gate system that ensures the app has the minimum required state before rendering. Independent initialization tasks run concurrently using Promise.all(), while non-critical work begins immediately in the background without delaying the first screen.

Session management relies on useSyncExternalStore with MMKV, keeping in-memory state and persisted storage synchronized and making authentication and account switching predictable. Multi-account support is isolated using <Fragment key={did}>, ensuring each account gets a fresh provider tree and preventing state leakage.

Once a session is established, the app enters its "Big Bang" phase, where push notifications, messaging, analytics, and other background services start simultaneously without blocking the user. Polling is optimized with techniques such as AppState checks and randomized polling to reduce unnecessary network traffic and battery usage.

The result is an architecture that remains responsive under load by separating critical startup work from background services, parallelizing independent operations, and treating every non-essential task as asynchronous.