Currently experiencing an issue where in my React Native app, on android only, when first entering with expo, the UI is not correct, when doing any change in _layout the UI will snap to place
When debugging with expo, elements appear in one place altough the UI debugger will show them in another
"dependencies": {
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/netinfo": "11.4.1",
"@react-navigation/native": "^7.0.0",
"@rn-primitives/alert-dialog": "^1.1.0",
"@rn-primitives/avatar": "~1.1.0",
"@rn-primitives/dropdown-menu": "^1.1.0",
"@rn-primitives/label": "^1.1.0",
"@rn-primitives/popover": "^1.1.0",
"@rn-primitives/portal": "~1.1.0",
"@rn-primitives/progress": "~1.1.0",
"@rn-primitives/select": "^1.1.0",
"@rn-primitives/separator": "^1.1.0",
"@rn-primitives/slot": "~1.1.0",
"@rn-primitives/toggle": "^1.1.0",
"@rn-primitives/toggle-group": "^1.1.0",
"@rn-primitives/tooltip": "~1.1.0",
"@rn-primitives/types": "~1.1.0",
"@shopify/flash-list": "1.7.3",
"@tanstack/react-query": "^5.69.0",
"axios": "^1.8.4",
"axois": "^0.0.1-security",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"expo": "~52.0.46",
"expo-apple-authentication": "~7.1.3",
"expo-auth-session": "~6.0.3",
"expo-clipboard": "~7.0.1",
"expo-device": "~7.0.3",
"expo-linking": "~7.0.4",
"expo-location": "~18.0.10",
"expo-navigation-bar": "~4.0.9",
"expo-notifications": "~0.29.14",
"expo-router": "~4.0.20",
"expo-secure-store": "~14.0.1",
"expo-splash-screen": "~0.29.24",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.9",
"i18next": "^24.2.3",
"lottie-react-native": "7.1.0",
"lucide-react-native": "^0.378.0",
"nativewind": "^4.1.23",
"qs": "^6.14.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-i18next": "^15.4.1",
"react-native": "0.76.9",
"react-native-gesture-handler": "~2.20.2",
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-svg": "15.8.0",
"react-native-svg-transformer": "^1.5.0",
"react-native-toast-message": "^2.2.1",
"react-native-web": "~0.19.13",
"react-phone-number-input": "^3.4.12",
"tailwind-merge": "^2.2.1",
"tailwindcss": "3.3.5",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.2",
"zustand": "^4.4.7",
"expo-dev-client": "~5.0.20"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@types/qs": "^6.9.18",
"@types/react": "~18.3.12",
"typescript": "^5.3.3"
},
My current project folder structure:
Root layout:
import "~/global.css";
import {
DarkTheme,
DefaultTheme,
Theme,
ThemeProvider,
} from "@react-navigation/native";
import { Slot } from "expo-router";
import { StatusBar } from "expo-status-bar";
import * as React from "react";
import { AppState, AppStateStatus, Dimensions, Platform } from "react-native";
import { NAV_THEME } from "~/lib/constants";
import { useColorScheme } from "~/lib/useColorScheme";
import { PortalHost } from "@rn-primitives/portal";
import { setAndroidNavigationBar } from "~/lib/android-navigation-bar";
import AuthContextProvider from "~/features/auth/AuthContextProvider";
import "../i18n";
import NetInfo from "@react-native-community/netinfo";
import {
onlineManager,
QueryClient,
QueryClientProvider,
focusManager,
} from "@tanstack/react-query";
import {
configureReanimatedLogger,
ReanimatedLogLevel,
} from "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import OrderContextProvider from "~/features/order/context/OrderContextProvider";
import Toast from "react-native-toast-message";
import AppSettingsContextProvider from "~/features/appSettings/AppSettingsContextProvider";
import {
SafeAreaFrameContext,
SafeAreaInsetsContext,
SafeAreaView,
SafeAreaContext,
} from "react-native-safe-area-context";
// This is the default configuration
configureReanimatedLogger({
level: ReanimatedLogLevel.warn,
strict: false, // Reanimated runs in strict mode by default
});
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
setOnline(!!state.isConnected);
});
});
function onAppStateChange(status: AppStateStatus) {
if (Platform.OS !== "web") {
focusManager.setFocused(status === "active");
}
}
const LIGHT_THEME: Theme = {
...DefaultTheme,
colors: NAV_THEME.light,
};
const DARK_THEME: Theme = {
...DarkTheme,
colors: NAV_THEME.dark,
};
const queryClient = new QueryClient();
export {
// Catch any errors thrown by the Layout component.
ErrorBoundary,
} from "expo-router";
export default function RootLayout() {
const hasMounted = React.useRef(false);
const { colorScheme, isDarkColorScheme } = useColorScheme();
const [isColorSchemeLoaded, setIsColorSchemeLoaded] = React.useState(false);
useIsomorphicLayoutEffect(() => {
if (hasMounted.current) {
return;
}
if (Platform.OS === "web") {
// Adds the background color to the html element to prevent white background on overscroll.
document.documentElement.classList.add("bg-background");
}
setAndroidNavigationBar(colorScheme);
setIsColorSchemeLoaded(true);
hasMounted.current = true;
}, []);
React.useEffect(() => {
const subscription = AppState.addEventListener("change", onAppStateChange);
return () => subscription.remove();
}, []);
if (!isColorSchemeLoaded || !hasMounted.current) {
return null;
}
return (
<GestureHandlerRootView>
<QueryClientProvider client={queryClient}>
<AppSettingsContextProvider>
<AuthContextProvider>
<ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
<OrderContextProvider>
<Slot />
<Toast />
<StatusBar style={isDarkColorScheme ? "light" : "dark"} />
<PortalHost />
</OrderContextProvider>
</ThemeProvider>
</AuthContextProvider>
</AppSettingsContextProvider>
</QueryClientProvider>
</GestureHandlerRootView>
);
}
const useIsomorphicLayoutEffect =
Platform.OS === "web" && typeof window === "undefined"
? React.useEffect
: React.useLayoutEffect;
First child of root layout
import { Stack } from "expo-router";
import usePushNotifications from "~/shared/hooks/usePushNotifications";
export default function RootLayout() {
usePushNotifications();
return (
<Stack initialRouteName="(auth)" screenOptions={{ headerShown: false }}>
{/* Initial route name auth else tabs */}
<Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="(order)"
options={{
presentation: "modal", // Enables modal behavior
animation: "fade_from_bottom", // Smooth transition
gestureEnabled: true, // Enables swipe gestures
}}
/>
</Stack>
);
}
Auth layout(example issue screen parent layout)
import { Stack, useRouter } from "expo-router";
import { Pressable } from "react-native";
import { ArrowLeft } from "lucide-react-native";
import GuestRoute from "~/features/auth/components/GuestRoute";
export default function RootLayout() {
const { back } = useRouter();
return (
<GuestRoute>
<Stack
initialRouteName="login"
screenOptions={{
headerLeft: ({ canGoBack }) =>
canGoBack && (
<Pressable onPress={back}>
<ArrowLeft className="text-foreground" />
</Pressable>
),
title: "",
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="login" />
<Stack.Screen name="register" />
</Stack>
</GuestRoute>
);
}
Login screen
import { AppleAuthenticationButtonType } from "expo-apple-authentication";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, Pressable, SafeAreaView, View } from "react-native";
import { emailLoginSchema, TEmailLogin } from "~/features/auth/auth.types";
import AppleAuthButton from "~/features/auth/components/AppleAuthButton";
import useAuth from "~/features/auth/hooks/useAuth";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Separator } from "~/components/ui/separator";
import { Text } from "~/components/ui/text";
import useForm from "~/shared/hooks/useForm";
import GuestButton from "~/features/auth/components/GuestButton";
export default function LoginScreen() {
const { t } = useTranslation();
const { navigate } = useRouter();
const { form, errors, handleChange, submit } = useForm<TEmailLogin>(
undefined,
emailLoginSchema
);
const { handleLogin, isPendingLogin } = useAuth();
const handleSubmit = () => {
submit(handleLogin);
};
return (
<SafeAreaView className="bg-secondary/30 flex-1">
<View className="flex-1 p-6">
<Text className="text-4xl font-semibold mb-12">
{t("auth.greetingLogin")}
</Text>
<View className="flex-1 gap-y-4">
<Input
label={t("auth.email")}
keyboardType="email-address"
autoComplete="email"
autoCapitalize="none"
placeholder={t("auth.enterEmail")}
value={form.email}
onChangeText={(val) => handleChange("email", val)}
error={errors.email}
/>
<Input
label={t("auth.password")}
placeholder={t("auth.enterPassword")}
secureTextEntry
value={form.password}
onChangeText={(val) => handleChange("password", val)}
error={errors.password}
/>
<Pressable className="ml-auto">
<Text className="text-right text-muted-foreground">
{t("auth.forgotPass")}
</Text>
</Pressable>
<Button
size="lg"
onPress={handleSubmit}
disabled={!(form.email && form.password) || isPendingLogin}
>
<Text>{t("auth.login")}</Text>
</Button>
<View className="flex flex-row items-center mt-6 mb-2">
<Separator className="flex-1" />
<Text className="text-muted-foreground px-4">
{t("auth.otherLogin")}
</Text>
<Separator className="flex-1" />
</View>
{Platform.select({
ios: (
<AppleAuthButton type={AppleAuthenticationButtonType.SIGN_IN} />
),
android: null,
// <GoogleAuthButton />
})}
<View className="flex flex-row gap-x-2 justify-center mt-auto mb-2 items-center">
<Text>{t("auth.noAccount")}</Text>
<Pressable onPress={() => navigate("/register")}>
<Text className="font-bold text-blue-500">
{t("auth.registerNow")}
</Text>
</Pressable>
</View>
<GuestButton />
</View>
</View>
</SafeAreaView>
);
}
I tried returning the screen directly, skipping the rendering of navigator to see if it would work if no Navigator container, and turns out it works perfectly without a navigator, so my current guess is that Stack is causing some issues regarding it's wrapper