// 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 ( ) }