Files
colanode/apps/mobile/app/_layout.tsx
Ylber Gashi 8d3020e6e5 Unify mobile data access hooks with shared packages/ui (#363)
Mobile duplicated useLiveQuery, useQuery, useMutation, and buildQueryClient
from packages/ui with the only difference being the executor mechanism
(appService.mediator vs window.colanode). This consolidates them by:

- Adding a DOM-free core-api module to packages/ui with getColanode() and
  getEventBus() helpers that access globalThis, avoiding Window/DOM types
- Updating shared hooks and buildQueryClient to use these helpers instead
  of window.colanode/window.eventBus directly
- Installing a globalThis.colanode bridge in mobile's _layout.tsx that
  delegates to appService.mediator (matching the web/desktop pattern)
- Migrating all 41 mobile files to import hooks from @colanode/ui
- Deleting 4 duplicate mobile files (3 hooks + query-client)
- Adding Metro resolver override to force react/react-native resolution
  from the mobile app's node_modules (packages/ui ships a separate
  web-only React 19.2.4 that conflicts with mobile's RN-compatible 19.1.0)

Also standardizes useQuery to use buildQueryKey instead of inline sha256.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:11:18 +01:00

192 lines
5.7 KiB
TypeScript

import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { modelName } from 'expo-device';
import { Slot, useRouter, useSegments } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
import { useEffect, useRef, useState } from 'react';
import { eventBus } from '@colanode/client/lib';
import { AppMeta, AppService } from '@colanode/client/services';
import { LoadingScreen } from '@colanode/mobile/components/loading-screen';
import { ErrorBoundary } from '@colanode/mobile/components/ui/error-boundary';
import { ErrorState } from '@colanode/mobile/components/ui/error-state';
import {
AppServiceContext,
useAppService,
} from '@colanode/mobile/contexts/app-service';
import { ThemeProvider, useTheme } from '@colanode/mobile/contexts/theme';
import { ToastProvider } from '@colanode/mobile/components/ui/toast';
import { copyAssets } from '@colanode/mobile/lib/assets';
import { MOBILE_WINDOW_ID } from '@colanode/mobile/lib/constants';
import { MobileFileSystem } from '@colanode/mobile/services/file-system';
import { MobileKyselyService } from '@colanode/mobile/services/kysely-service';
import { MobilePathService } from '@colanode/mobile/services/path-service';
import { buildQueryClient } from '@colanode/ui/lib/query';
SplashScreen.preventAutoHideAsync();
function ThemeStatusBar() {
const { scheme } = useTheme();
return <StatusBar style={scheme === 'dark' ? 'light' : 'dark'} />;
}
export default function RootLayout() {
const [appService, setAppService] = useState<AppService | null>(null);
const [queryClient, setQueryClient] = useState<QueryClient | null>(null);
const [ready, setReady] = useState(false);
const [initError, setInitError] = useState<string | null>(null);
const [initAttempt, setInitAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
(async () => {
try {
setInitError(null);
setReady(false);
setAppService(null);
setQueryClient(null);
const paths = new MobilePathService();
await copyAssets(paths);
const appMeta: AppMeta = {
type: 'mobile',
platform: modelName ?? 'unknown',
};
const service = new AppService(
appMeta,
new MobileFileSystem(),
new MobileKyselyService(),
paths
);
await service.init();
(globalThis as any).colanode = {
executeMutation: (input: any) =>
service.mediator.executeMutation(input),
executeQuery: (input: any) => service.mediator.executeQuery(input),
executeQueryAndSubscribe: (key: string, input: any) =>
service.mediator.executeQueryAndSubscribe(
key,
MOBILE_WINDOW_ID,
input
),
unsubscribeQuery: (key: string) =>
service.mediator.unsubscribeQuery(key, MOBILE_WINDOW_ID),
};
(globalThis as any).eventBus = eventBus;
const client = buildQueryClient();
if (cancelled) {
return;
}
setAppService(service);
setQueryClient(client);
setReady(true);
// Seed default public servers without blocking app startup.
void Promise.allSettled([
service.createServer(new URL('https://eu.colanode.com/config')),
service.createServer(new URL('https://us.colanode.com/config')),
]).catch((error) => {
console.error('Failed to seed default servers:', error);
});
} catch (error) {
console.error('Failed to initialize AppService:', error);
if (!cancelled) {
setInitError(
error instanceof Error ? error.message : 'Failed to initialize app.'
);
}
} finally {
if (!cancelled) {
await SplashScreen.hideAsync();
}
}
})();
return () => {
cancelled = true;
};
}, [initAttempt]);
if (initError) {
return (
<ThemeProvider>
<ThemeStatusBar />
<ErrorState
message={initError}
onRetry={() => setInitAttempt((current) => current + 1)}
/>
</ThemeProvider>
);
}
if (!ready || !appService || !queryClient) {
return (
<ThemeProvider>
<ThemeStatusBar />
<LoadingScreen />
</ThemeProvider>
);
}
return (
<ThemeProvider>
<ThemeStatusBar />
<ToastProvider>
<AppServiceContext.Provider value={{ appService }}>
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<RootNavigator />
</ErrorBoundary>
</QueryClientProvider>
</AppServiceContext.Provider>
</ToastProvider>
</ThemeProvider>
);
}
function RootNavigator() {
const { appService } = useAppService();
const segments = useSegments();
const router = useRouter();
const hasNavigated = useRef(false);
useEffect(() => {
if (hasNavigated.current) {
return;
}
const accounts = appService.getAccounts();
const workspaces = appService.getWorkspaces();
const inAuthGroup = segments[0] === '(auth)';
if (accounts.length === 0) {
// No accounts -> go to server selection
if (!inAuthGroup) {
router.replace('/(auth)/');
}
} else if (workspaces.length > 0) {
// Has account + workspace -> go to app
router.replace('/(app)/(home)');
} else {
// Has account but no workspace -> create workspace
const firstAccount = accounts[0]!;
router.replace({
pathname: '/(auth)/create-workspace',
params: { accountId: firstAccount.account.id },
});
}
hasNavigated.current = true;
}, [appService, segments, router]);
return <Slot />;
}