react-nativeexpo-router

Manually type url redirect navigate before mounting error


I try to do a login authentication with React Native Expo router but while manually enter the url like localhost:8000/dashboard, it shows error: Attempted to navigate before mounting the Root Layout component. Ensure the Root Layout component is rendering a Slot, or other navigator on the first render.

app/_layout.tsx

import Button from "@/components/Button";
import CustomDrawerContent from "@/components/CustomDrawer";
import TopNav from "@/components/TopNav";
import { AuthProvider, useAuth } from "@/context/AuthContext";
import { Slot, Stack, usePathname, useRouter, useSegments } from "expo-router";
import { Drawer } from "expo-router/drawer";
import { useEffect } from "react";
import { Platform, useWindowDimensions } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";

export default function Layout() {
  return (
    <AuthProvider>
      <GestureHandlerRootView style={{ flex: 1 }}>
        <InnerLayout />
      </GestureHandlerRootView>
    </AuthProvider>
  );
}

function InnerLayout() {
  const { authState, onLogout } = useAuth();
  const pathname = usePathname();
  const router = useRouter();
  const segments = useSegments();

  useEffect(() => {
    const inAuthGroup = segments[0] === "(pages)";

    if (
      (authState?.authenticated === null ||
        authState?.authenticated === false) &&
      inAuthGroup
    ) {
      router.replace("/login");
    } else if (authState?.authenticated === true && pathname === "/login") {
      router.replace("/dashboard");
    }
  }, [authState, pathname, segments]);

  const dimensions = useWindowDimensions();
  const isWeb = Platform.OS === "web";
  const isMobileWidth = dimensions.width < 768;
  // Ensure that we always render a navigator to avoid the error
  if (authState?.authenticated === null) {
    // Loading state: render minimal layout with Slot to satisfy the router
    return <Slot />;
  }
  if (isWeb && !isMobileWidth) {
    return (
      <>
        <TopNav />
        <Stack
          screenOptions={{
            headerShown: false,
          }}
        />
      </>
    );
  } else {
    return (
      <Drawer
        drawerContent={CustomDrawerContent}
        screenOptions={{
          drawerActiveTintColor: "red",
          drawerHideStatusBarOnOpen: true,
          headerRight: () => {
            return authState?.authenticated === true ? (
              <Button label="Sign Out" onPress={onLogout} />
            ) : (
              ""
            );
          },
        }}
      ></Drawer>
    );
  }
}

components/TopNav.tsx:

// TopNav.tsx
import { routes } from "@/constants/routes";
import { useAuth } from "@/context/AuthContext";
import { useRouter } from "expo-router";
import React from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import Dropdown from "./Dropdown";

export default function TopNav() {
  const router = useRouter();
  const { authState, onLogout } = useAuth();
  const handleLogout = () => {
    if (onLogout) {
      onLogout(); // call logout
    }
    router.push("/login"); // redirect to login
  };
  const canAccess = (permissions?: string[]) => {
    if (!permissions || permissions.length === 0) return true;
    if (!authState?.role) return false; // no role → deny access
    return permissions.includes(authState.role);
  };
  return (
    <View style={styles.navbar}>
      <Text style={styles.logo}>MyApp</Text>

      <View style={styles.navLinksContainer}>
        {routes.map((route, index) => {
          if (
            authState?.authenticated &&
            (route.name === "Login" || route.name === "Sign Up")
          ) {
            return null;
          }

          if (!canAccess(route.permission)) return null;

          if (route.type === "dropdown") {
            return (
              <Dropdown
                key={`dropdown-${index}`}
                label={route.name}
                items={route.items}
                labelStyle={styles.link}
              />
            );
          }

          return (
            <Pressable
              key={route.path}
              onPress={() =>
                router.push(route.path === "index" ? "/" : `/${route.path}`)
              }
              style={{ marginHorizontal: 10 }}
            >
              <Text style={styles.link}>{route.name}</Text>
            </Pressable>
          );
        })}
        {authState?.authenticated && (
          <Pressable onPress={handleLogout} style={{ marginHorizontal: 10 }}>
            <Text style={styles.link}>Logout</Text>
          </Pressable>
        )}
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  navbar: {
    flexDirection: "row",
    alignItems: "center",
    paddingVertical: 12,
    paddingHorizontal: 24,
    zIndex: 100,
  },
  logo: {
    color: "red",
    fontSize: 20,
    fontWeight: "bold",
  },
  hovered: {},
  navLinksContainer: {
    flexDirection: "row",
    marginLeft: "auto", // Push to the right
    gap: 20,
  },
  link: {
    fontSize: 16,
    fontWeight: "500",
  },
});

context>AuthContext.tsx:

import AsyncStorage from "@react-native-async-storage/async-storage";
import { useRouter } from "expo-router";
import { createContext, useContext, useEffect, useState } from "react";

export enum Role {
  ADMIN = "admin",
  USER = "user",
}

interface AuthProps {
  authState?: {
    // token: string | null;
    authenticated: boolean | null;
    role: Role | null;
  };
  onRegister?: (username: string, password: string) => Promise<any>;
  onLogin?: (
    username: string,
    password: string
    // client_id: number,
    // client_secret: string
  ) => Promise<any>;
  onLogout?: () => Promise<any>;
}

const TOKEN_KEY = "testing-token";
export const API_URL = "http://localhost:8001/v1";
const AuthContext = createContext<AuthProps>({});
const router = useRouter();

export const useAuth = () => {
  return useContext(AuthContext);
};

export const AuthProvider = ({ children }: any) => {
  const [authState, setAuthState] = useState<{
    username: string | null;
    authenticated: boolean | null;
    role: Role | null;
  }>({ username: null, authenticated: null, role: null });

  useEffect(() => {
    // Simulate loading auth state from storage or API
    const initAuth = async () => {
      const storedState = await AsyncStorage.getItem("authState");
      if (storedState) {
        setAuthState(JSON.parse(storedState));
      } else {
        setAuthState({
          authenticated: false,
          username: null,
          role: null,
        });
      }
    };

    initAuth();
  }, []);

  const login = async (username: string, password: string) => {
    try {
      const newState = {
        authenticated: true,
        username: username,
        role: username === "admin" ? Role.ADMIN : Role.USER,
      };
      setAuthState(newState);
      await AsyncStorage.setItem("authState", JSON.stringify(newState));

      return { success: true };
    } catch (e) {
      return {
        error: true,
        msg: (e as any).response?.data?.msg || "Unknown error",
      };
    }
  };

  const logout = async () => {
    setAuthState({
      authenticated: false,
      username: null,
      role: null,
    });
    await AsyncStorage.removeItem("authState");
  };

  const value = {
    // onRegister: register,
    onLogin: login,
    onLogout: logout,
    authState,
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

constants/routes.ts:

export interface LinkRoute {
  type: "link";
  name: string;
  path: string;
  icon?: string;
  permission: Array<string>;
}

export interface DropdownRoute {
  type: "dropdown";
  name: string;
  permission: Array<string>;
  items: {
    title: string;
    href: string;
  }[];
  icon?: string;
}

export type AppRoute = LinkRoute | DropdownRoute;
export const routes: AppRoute[] = [
  {
    type: "link",
    permission: ["admin", "user"],
    name: "Dashboard",
    path: "dashboard",
    icon: "dashboard",
  },
  {
    type: "link",
    permission: [],
    name: "Login",
    path: "(auth)/login",
    icon: "login",
  },
  {
    type: "link",
    permission: [],
    name: "Sign Up",
    path: "(auth)/signup",
    icon: "login",
  },

  {
    type: "dropdown",
    name: "Transaction",
    permission: ["admin"],
    items: [
      { title: "List", href: "(pages)/transactions/list" },
      { title: "Refund", href: "(pages)/transactions/refund" },
      { title: "Void", href: "(pages)/transactions/void" },
    ],
    icon: "more-vert",
  },
  {
    type: "link",
    permission: ["admin", "user"],
    name: "Profile",
    path: "(pages)/profile",
    icon: "person",
  },
  {
    type: "link",
    permission: ["admin", "user"],
    name: "Settings",
    path: "(pages)/settings",
    icon: "settings",
  },
];

I want that if manually enter the url to redirect that page then it will show alert that Access denied if not logged in & Role not accessible


Solution

  • I think the error occurs because Expo Router requires a navigator (Slot, Stack, or Drawer) to be rendered on the first render of the RootLayout. In your InnerLayout, you conditionally return different navigators based on authState and platform, but during the initial render, authState?.authenticated may be null (while loading from AsyncStorage), and the minimal Slot you return might not be sufficient for certain navigation scenarios, especially on the web when a URL is manually entered.

    const [isAuthChecked, setIsAuthChecked] = useState(false);
    
    useEffect(() => {
        if (authState?.authenticated === null) {
          // Still loading auth state
          return;
        }
    
        setIsAuthChecked(true);
    
        const normalizedPath = pathname === "/" ? "index" : pathname.replace(/^\//, "");
        const currentRoute = routes.find(
          (route) => route.path === normalizedPath || (route.path === "index" && pathname === "/")
        );
    
        const inAuthGroup = segments[0] === "(auth)";
        const isProtectedRoute = currentRoute && currentRoute.permission?.length > 0;
    
        if (authState?.authenticated === false && isProtectedRoute) {
          // User is not logged in and trying to access a protected route
          Alert.alert(
            "Access Denied",
            "You must be logged in to access this page.",
            [{ text: "OK", onPress: () => router.replace("/login") }]
          );
        } else if (
          authState?.authenticated === true &&
          isProtectedRoute &&
          currentRoute?.permission &&
          authState.role &&
          !currentRoute.permission.includes(authState.role)
        ) {
          // User is logged in but lacks the required role
          Alert.alert(
            "Access Denied",
            `You do not have the required role (${currentRoute.permission.join(
              " or "
            )}) to access this page.`,
            [{ text: "OK", onPress: () => router.replace("/dashboard") }]
          );
        } else if (
          authState?.authenticated === true &&
          (pathname === "/login" || pathname === "/(auth)/signup")
        ) {
          // Redirect authenticated users away from login/signup
          router.replace("/dashboard");
        } else if (authState?.authenticated === false && !inAuthGroup && isProtectedRoute) {
          // Redirect unauthenticated users to login for protected routes
          router.replace("/login");
        }
      }, [authState, pathname, segments]);
    

    What I did was =>

    1. Added isAuthChecked state to track whether the auth state has been fully initialized.

    2. Normalized pathname to match route paths and checked permissions using the routes array.

    3. Show Alert.alert for unauthenticated users or users lacking the required role, with redirects to /login or /dashboard.