// Root layout — wires the Voltro client, deep-link routing, and a connection
// banner around the whole app.
//
// The boot ORDER here is load-bearing, and it is the one thing to preserve if
// you rewrite this file:
//
// 1. hydrate persisted stores, THEN install them as the backing store;
// 2. only then render the app.
//
// A store reads during render and a render cannot await, so rendering first
// shows empty state and flickers into the saved state a frame later. The splash
// screen stays up until step 1 finishes — that is what it is for.
//
// Connecting is deliberately NOT part of that gate. The app must open offline.
import { useEffect, useState } from 'react'
import { Stack, useRouter } from 'expo-router'
import * as Linking from 'expo-linking'
import * as SplashScreen from 'expo-splash-screen'
import { StatusBar } from 'expo-status-bar'
import { FrameworkRuntimesProvider, type ApiHandle, type ResolvedClient } from '@voltro/client'
import { useMobileConnectionStatus } from '@voltro/react-native'
import { Text, View } from 'react-native'
import { connectApis, handlesFor, onlineSource, preparePersistence } from '../client'
import { handleDeepLink, makeDeepLinks } from '../lib/deeplinks'
void SplashScreen.preventAutoHideAsync()
function ConnectionBanner() {
// Reachability comes from NetInfo. The default source reads `navigator.onLine`,
// which React Native does not have — so on a device it would answer "online"
// forever, including in airplane mode.
const { status } = useMobileConnectionStatus({ onlineSource })
if (status === 'connected') return null
return (
{status === 'offline' ? 'Offline — changes will sync when you reconnect' : 'Reconnecting…'}
)
}
export default function RootLayout() {
const [ready, setReady] = useState(false)
const [apis, setApis] = useState>(() => new Map())
const router = useRouter()
// 1. Persisted state, before anything renders.
useEffect(() => {
let live = true
void preparePersistence()
.catch(() => { /* a device with no readable storage still gets an app */ })
.finally(() => {
if (!live) return
setReady(true)
void SplashScreen.hideAsync()
})
return () => { live = false }
}, [])
// 2. Connect, and keep connected. The supervisor re-dials with backoff; a
// dropped socket needs no handling here.
useEffect(() => {
const supervisor = connectApis((clients: ReadonlyMap) => {
setApis(handlesFor(clients))
})
return () => supervisor.dispose()
}, [])
// Route inbound URLs (universal links, custom scheme, push taps) through the
// ONE typed link table — the same `matchFirstDeepLink` path push will use.
useEffect(() => {
const links = makeDeepLinks((href) => router.push(href as never))
const sub = Linking.addEventListener('url', ({ url }) => {
handleDeepLink(links, url)
})
void Linking.getInitialURL().then((url) => {
if (url) handleDeepLink(links, url)
})
return () => sub.remove()
}, [router])
if (!ready) return null
return (
)
}