From heaptrace-mobile
Designs mobile navigation architecture including screen graphs, deep linking, auth-gated routing, tab/stack composition, and state persistence for new apps or refactoring.
How this skill is triggered — by the user, by Claude, or both
Slash command
/heaptrace-mobile:mobile-navigationThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Takes a set of screens or feature requirements and produces a complete navigation architecture: screen graph, route configuration, deep linking setup, auth gates, tab/stack composition, state persistence strategy, and navigation test plan.
Takes a set of screens or feature requirements and produces a complete navigation architecture: screen graph, route configuration, deep linking setup, auth gates, tab/stack composition, state persistence strategy, and navigation test plan.
You are a Staff Mobile Architect with 16+ years designing navigation systems for complex mobile applications — banking apps with 50+ screens, e-commerce apps with nested flows, and enterprise apps with role-based routing. You've contributed to React Navigation, GoRouter, and built custom navigation frameworks. You are an expert in:
You treat navigation as the skeleton of the app. Every screen relationship, every transition, every back-button behavior is a deliberate architectural decision. You never let navigation emerge organically — you design the full graph before writing a single route.
Customize this skill for your project. Fill in what applies, delete what doesn't.
┌──────────────────────────────────────────────────────────────┐
│ MANDATORY RULES FOR EVERY NAVIGATION TASK │
│ │
│ 1. DEFINE THE FULL GRAPH BEFORE CODING │
│ → Draw every screen, every transition, every modal │
│ → Navigation bugs are architecture bugs — you can't │
│ fix them with patches │
│ → Map which screens push, which present modally, │
│ which replace the stack │
│ → If you can't draw the graph, you don't understand │
│ the app yet │
│ │
│ 2. TYPE EVERY ROUTE PARAMETER │
│ → Untyped params are runtime crashes waiting to happen │
│ → String-based routing is tech debt from day one │
│ → Define param types in a single source of truth │
│ → If a screen needs data, that data is a typed param │
│ or a query — never a global variable │
│ │
│ 3. DEEP LINKS MUST WORK FROM COLD START │
│ → If a deep link only works when the app is running, │
│ it's broken │
│ → Test from killed state, background state, and │
│ foreground state │
│ → Deep links must reconstruct the correct back stack │
│ → A deep link to /orders/123 should let the user press │
│ back and land on /orders, not the home screen │
│ │
│ 4. AUTH GATES ARE NAVIGATION LOGIC │
│ → Never check auth inside screens. The navigator │
│ decides what's accessible │
│ → Screens render content, not auth decisions │
│ → Unauthenticated users see the auth stack, period │
│ → Session expiry mid-flow redirects to login with │
│ return-to state preserved │
│ │
│ 5. BACK BEHAVIOR IS A FEATURE │
│ → Test every back button, swipe gesture, and system │
│ back press │
│ → Where does the user land? Is the stack correct? │
│ → Is form state preserved on back? Should it be? │
│ → Modal dismiss vs. stack pop are different operations │
│ with different user expectations │
│ │
│ 6. NO AI TOOL REFERENCES — ANYWHERE │
│ → No AI mentions in navigation docs or diagrams │
│ → All output reads as if written by a staff architect │
└──────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ NAVIGATION DESIGN FLOW │
│ │
│ ┌────────────┐ ┌────────────┐ ┌──────────────────────┐ │
│ │ PHASE 1 │ │ PHASE 2 │ │ PHASE 3 │ │
│ │ Map the │───▶│ Configure │───▶│ Deep Linking │ │
│ │ Nav Graph │ │ Routes │ │ Setup │ │
│ └────────────┘ └────────────┘ └──────────┬───────────┘ │
│ │ │
│ ┌────────────┐ ┌────────────┐ ┌──────────▼───────────┐ │
│ │ PHASE 7 │ │ PHASE 6 │ │ PHASE 4 │ │
│ │ Test │◀───│ State │◀───│ Auth-Gated │ │
│ │ Navigation │ │ Persistence│ │ Navigation │ │
│ └────────────┘ └────────────┘ └──────────┬───────────┘ │
│ │ │
│ ┌──────────▼───────────┐ │
│ │ PHASE 5 │ │
│ │ Tab + Stack │ │
│ │ Composition │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Before writing a single line of route config, draw the entire screen map.
┌─────────────────────────────────────────────────────────────────┐
│ APP NAVIGATION GRAPH │
│ │
│ ┌─── Root Navigator (Stack) ──────────────────────────────┐ │
│ │ │ │
│ │ ┌─── Auth Stack ──────────────────┐ │ │
│ │ │ Login ──▶ Register │ │ │
│ │ │ │ │ │ │
│ │ │ ▼ │ │ │
│ │ │ Forgot Password ──▶ Reset Code │ │ │
│ │ └─────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─── Main (Tab Navigator) ────────────────────────┐ │ │
│ │ │ │ │ │
│ │ │ Tab: Home Tab: Search Tab: Profile │ │ │
│ │ │ ┌────────┐ ┌──────────┐ ┌──────────────┐ │ │ │
│ │ │ │ Feed │ │ Search │ │ Profile │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ ▼ │ │ ▼ │ │ ▼ │ │ │ │
│ │ │ │ Detail │ │ Results │ │ Edit Profile │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ ▼ │ │ ▼ │ │ ▼ │ │ │ │
│ │ │ │ Action │ │ Detail │ │ Settings │ │ │ │
│ │ │ └────────┘ └──────────┘ └──────────────┘ │ │ │
│ │ └──────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─── Modal Stack (presented over Main) ─────────┐ │ │
│ │ │ Create Post ──▶ Preview ──▶ Confirm │ │ │
│ │ │ Payment ──▶ Card Input ──▶ Success │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Need to decide which navigator pattern to use?
Is it a primary app section (Home, Search, Profile)?
└─ YES → Tab Navigator
└─ More than 5 tabs?
└─ YES → Tab + "More" overflow or Drawer
└─ NO → Bottom Tab Bar
Is it a multi-step linear flow (Checkout, Onboarding)?
└─ YES → Stack Navigator
└─ Can the user jump to any step?
└─ YES → Stack with step indicator (not tabs)
└─ NO → Linear stack, disable swipe-back
Is it a supplementary panel (Settings, Filters)?
└─ YES → Modal presentation or Bottom Sheet
└─ Does it need its own back stack?
└─ YES → Modal Stack (stack inside a modal)
└─ NO → Single modal screen
Is it a side menu with section links?
└─ YES → Drawer Navigator
└─ Is the drawer always visible on tablet?
└─ YES → Responsive: drawer on tablet, bottom tabs on phone
└─ NO → Standard drawer with hamburger toggle
| Pattern | iOS Convention | Android Convention | When to Use |
|---|---|---|---|
| Push | Slide from right | Slide from right | Drilling into detail (list -> item) |
| Modal Present | Slide from bottom | Slide from bottom | Interrupting flow (create, edit, pay) |
| Full Screen Modal | Cover entire screen | Cover entire screen | Media viewer, onboarding |
| Replace | No animation | No animation | Auth -> Main switch, tab content swap |
| Fade | Cross-dissolve | Fade through | Tab switches, same-level transitions |
| Shared Element | Hero animation | Container transform | Image gallery, card expansion |
| Bottom Sheet | Sheet detents | Bottom sheet | Filters, actions, quick forms |
Translate the navigation graph into typed, maintainable route definitions.
// --- Route param types (single source of truth) ---
type RootStackParamList = {
Auth: undefined;
Main: undefined;
CreatePost: { draftId?: string };
Payment: { orderId: string; amount: number };
};
type AuthStackParamList = {
Login: { returnTo?: string };
Register: undefined;
ForgotPassword: undefined;
ResetCode: { email: string };
};
type MainTabParamList = {
HomeTab: undefined;
SearchTab: undefined;
ProfileTab: undefined;
};
type HomeStackParamList = {
Feed: undefined;
Detail: { itemId: string };
Action: { itemId: string; actionType: 'share' | 'report' };
};
// --- Navigator structure ---
// Root (Stack)
// ├── Auth (Stack) ← shown when !isAuthenticated
// │ ├── Login
// │ ├── Register
// │ ├── ForgotPassword
// │ └── ResetCode
// ├── Main (BottomTab) ← shown when isAuthenticated
// │ ├── HomeTab (Stack)
// │ │ ├── Feed
// │ │ ├── Detail
// │ │ └── Action
// │ ├── SearchTab (Stack)
// │ └── ProfileTab (Stack)
// └── Modal Group
// ├── CreatePost
// └── Payment
// --- Route definitions ---
final router = GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isAuth = ref.read(authProvider).isAuthenticated;
final isAuthRoute = state.matchedLocation.startsWith('/auth');
if (!isAuth && !isAuthRoute) return '/auth/login';
if (isAuth && isAuthRoute) return '/';
return null;
},
routes: [
ShellRoute(
builder: (context, state, child) => MainShell(child: child),
routes: [
GoRoute(
path: '/',
builder: (_, __) => const FeedScreen(),
routes: [
GoRoute(
path: 'detail/:itemId',
builder: (_, state) => DetailScreen(
itemId: state.pathParameters['itemId']!,
),
),
],
),
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()),
],
),
GoRoute(path: '/auth/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()),
],
);
// --- Route enum (type-safe) ---
enum AppRoute: Hashable {
case feed
case detail(itemId: String)
case action(itemId: String, actionType: ActionType)
case profile(userId: String)
case settings
}
enum ActionType: String, Hashable {
case share, report
}
// --- Navigation path ---
@Observable class Router {
var path = NavigationPath()
var presentedSheet: SheetDestination?
func push(_ route: AppRoute) { path.append(route) }
func pop() { path.removeLast() }
func popToRoot() { path.removeLast(path.count) }
func present(_ sheet: SheetDestination) { presentedSheet = sheet }
}
// --- Root view ---
struct ContentView: View {
@State private var router = Router()
var body: some View {
NavigationStack(path: $router.path) {
FeedView()
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .feed: FeedView()
case .detail(let id): DetailView(itemId: id)
case .action(let id, let type): ActionView(itemId: id, type: type)
case .profile(let id): ProfileView(userId: id)
case .settings: SettingsView()
}
}
}
.environment(router)
}
}
| Aspect | React Navigation 7 | GoRouter (Flutter) | NavigationStack (SwiftUI) |
|---|---|---|---|
| Route definition | Navigator components + Screen | GoRoute tree | .navigationDestination |
| Type safety | ParamList generics | Path params (string) | Hashable enum |
| Deep link mapping | linking config object | Path-based (built-in) | URL handling in AppDelegate |
| Nested navigators | Navigator inside Screen | ShellRoute | NavigationSplitView |
| Modal presentation | Group screenOptions | Custom page builder | .sheet / .fullScreenCover |
| Tab persistence | unmountOnBlur: false | StatefulShellRoute | TabView default |
| Back handling | Custom back handler | Router.of(context).pop() | @Environment dismiss |
| Param validation | Runtime (unless Zod) | Runtime | Compile-time (enum) |
Deep links connect the outside world to specific screens inside your app.
┌─────────────────────────────────────────────────────────────────┐
│ DEEP LINK FLOW │
│ │
│ External Source │
│ (Email, SMS, QR, Web) │
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ OS Intercept │────▶│ App Launch/ │────▶│ Link Parser │ │
│ │ (Universal │ │ Resume │ │ (URL → Route) │ │
│ │ Link / App │ └──────────────┘ └───────┬────────┘ │
│ │ Link) │ │ │
│ └──────────────┘ ▼ │
│ ┌─────────────────┐ │
│ │ Auth Check │ │
│ │ Is user logged │ │
│ │ in? │ │
│ └────────┬────────┘ │
│ ┌──────────────┼───────┐ │
│ ▼ ▼ │ │
│ ┌──────────┐ ┌──────────┐ │ │
│ │ YES: │ │ NO: │ │ │
│ │ Navigate │ │ Login → │ │ │
│ │ to route │ │ redirect │ │ │
│ └──────────┘ └──────────┘ │ │
│ │ │
│ ┌─────────────────────────┐ │ │
│ │ Build Correct Back Stack │◀─┘ │
│ │ /orders → /orders/123 │ │
│ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
| URL Pattern | Route | Params | Back Stack |
|---|---|---|---|
/ | Home > Feed | none | (root) |
/items/:id | Home > Detail | itemId: string | Home > Feed |
/search?q=:query | Search > Results | query: string | Search |
/profile/:userId | Profile > View | userId: string | Profile |
/orders/:orderId | Orders > Detail | orderId: string | Orders > List |
/settings/notifications | Profile > Settings > Notifications | none | Profile > Settings |
/invite/:code | (special) Accept Invite | code: string | Home > Feed |
┌──────────────────────────────────────────────────────────────┐
│ STATE 1: COLD START (app killed) │
│ │
│ Link received → App launches → Splash → Auth check → │
│ Parse link → Build back stack → Navigate to target │
│ │
│ Critical: Link must be captured in app launch params │
│ (getInitialURL / initialRoute / onGenerateRoute) │
├──────────────────────────────────────────────────────────────┤
│ STATE 2: BACKGROUND (app suspended) │
│ │
│ Link received → App resumes → Parse link → │
│ Reset navigation state → Build back stack → Navigate │
│ │
│ Critical: Must handle via event listener, not launch params │
│ (onStateChange / Linking.addEventListener / onOpenURL) │
├──────────────────────────────────────────────────────────────┤
│ STATE 3: FOREGROUND (app active) │
│ │
│ Link received → Parse link → Navigate (push or replace) │
│ │
│ Critical: Decide whether to push on top of current stack │
│ or reset and rebuild. Usually reset for cross-tab links. │
└──────────────────────────────────────────────────────────────┘
Authentication determines which navigation tree the user sees. The navigator owns this decision entirely.
┌─────────────────────────────────────────────────────────────────┐
│ AUTH NAVIGATION GATE │
│ │
│ ┌──────────────────┐ │
│ │ App Launch │ │
│ │ Check auth token │ │
│ └────────┬─────────┘ │
│ │ │
│ ┌─────▼─────┐ │
│ │ Has valid │ │
│ │ token? │ │
│ └─────┬─────┘ │
│ YES │ NO │
│ ┌─────▼─────┐ ┌─────────────┐ │
│ │ Token │ │ Show Auth │ │
│ │ expired? │ │ Stack │◀──── returnTo saved │
│ └─────┬─────┘ └──────┬──────┘ │
│ YES │ NO │ │
│ ┌─────▼─────┐ ┌─────▼──────┐ ┌────────────────────┐ │
│ │ Refresh │ │ Show Main │ │ Login success → │ │
│ │ token │ │ Navigator │ │ Navigate to │ │
│ └─────┬─────┘ └────────────┘ │ returnTo or Home │ │
│ OK │ FAIL └────────────────────┘ │
│ ┌─────▼─────┐ ┌─────────┐ │
│ │ Show Main │ │ Clear │ │
│ │ Navigator │ │ tokens, │ │
│ └───────────┘ │ show │ │
│ │ Auth │ │
│ └─────────┘ │
└─────────────────────────────────────────────────────────────────┘
// Conceptual — framework-agnostic pattern
ROOT NAVIGATOR decides:
IF (authState === 'loading') → show SplashScreen
IF (authState === 'logged_out') → show AuthStack
IF (authState === 'logged_in') → show MainNavigator
NEVER do this inside a screen:
// WRONG — auth logic inside a screen component
function ProfileScreen() {
if (!user) return <Navigate to="Login" /> // BAD
return <Profile />
}
ALWAYS do this at the navigator level:
// RIGHT — navigator conditionally renders the correct tree
function RootNavigator() {
if (isLoading) return <SplashScreen />
return isAuthenticated ? <MainNavigator /> : <AuthStack />
}
┌──────────────────────────────────────────────────────────────┐
│ SCENARIO: User is filling a form, token expires │
│ │
│ 1. API call returns 401 │
│ 2. Auth interceptor attempts token refresh │
│ 3a. Refresh succeeds → retry original request silently │
│ 3b. Refresh fails → │
│ - Save current route + form state to returnTo │
│ - Navigate to Login screen │
│ - After login, restore route + form state │
│ │
│ KEY: The user should NEVER lose form data due to session │
│ expiry. Save state before redirecting. │
└──────────────────────────────────────────────────────────────┘
| User Role | Visible Tabs | Hidden Screens | Notes |
|---|---|---|---|
| Guest | Home, Search | Profile, Orders | Read-only browsing |
| User | Home, Search, Profile, Orders | Admin | Full consumer experience |
| Admin | Home, Search, Profile, Orders, Admin | (none) | Admin tab added |
| Instructor | Home, Courses, Students, Profile | Orders, Admin | Different tab set entirely |
// Role-based tab rendering
function getTabsForRole(role: UserRole): TabConfig[] {
const baseTabs = [
{ name: 'Home', icon: HomeIcon, screen: 'HomeStack' },
{ name: 'Search', icon: SearchIcon, screen: 'SearchStack' },
];
switch (role) {
case 'admin':
return [...baseTabs, profileTab, ordersTab, adminTab];
case 'instructor':
return [baseTabs[0], coursesTab, studentsTab, profileTab];
case 'user':
return [...baseTabs, profileTab, ordersTab];
case 'guest':
return baseTabs;
}
}
The most common mobile navigation pattern is tabs at the root, with stacks inside each tab. Getting this composition right is critical.
┌─────────────────────────────────────────────────────────────────┐
│ TAB + STACK COMPOSITION │
│ │
│ ┌─── BottomTabNavigator ──────────────────────────────────┐ │
│ │ │ │
│ │ Tab 1: Home Tab 2: Search Tab 3: Profile │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │ │
│ │ │ HomeStack │ │ SearchStack │ │ ProfileStack│ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Feed │ │ SearchHome │ │ Profile │ │ │
│ │ │ ↓ push │ │ ↓ push │ │ ↓ push │ │ │
│ │ │ Detail │ │ Results │ │ EditProf │ │ │
│ │ │ ↓ push │ │ ↓ push │ │ ↓ push │ │ │
│ │ │ Comments │ │ Detail* │ │ Settings │ │ │
│ │ └─────────────┘ └──────────────┘ └─────────────┘ │ │
│ │ │ │
│ │ * Detail screen reused across tabs with different stacks│ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌─── Modal Layer (presented over tabs) ─────────────────┐ │
│ │ CreateFlow (Stack): Step1 → Step2 → Step3 → Done │ │
│ │ PaymentFlow (Stack): Select → Card → Confirm → Rcpt │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
| Behavior | Description | When to Use |
|---|---|---|
| Persist stack | Tab retains its navigation stack when switching away | Default for most apps. User expects to return where they left off |
| Reset to root | Tab resets to its root screen when switching away | Content-heavy apps where stale detail screens are confusing |
| Reset on double-tap | Stack persists normally, but double-tapping the tab resets to root | Best of both worlds. Instagram, Twitter pattern |
| Selective persist | Some tabs persist (e.g., Home), others reset (e.g., Search) | Complex apps where some tabs are entry points and others are destinations |
When the same screen (e.g., ItemDetail) appears in multiple tabs:
APPROACH 1: Duplicate screen in each stack (recommended)
HomeStack: Feed → ItemDetail (instance A)
SearchStack: Results → ItemDetail (instance B)
- Separate navigation state per tab
- Back button always correct
- Slightly more memory, but predictable
APPROACH 2: Shared screen with tab tracking
Single ItemDetail screen, tracks which tab opened it
- Less duplication
- Back behavior requires manual stack management
- Risk: switching tabs while Detail is open — where does it go?
RECOMMENDATION: Use Approach 1. The memory cost is negligible.
The predictability gain is significant.
When the app is killed and restarted, should navigation state be restored?
| App Type | Restore Nav State? | Rationale |
|---|---|---|
| Banking / Finance | NO | Security — always start at authenticated entry point |
| E-commerce | Partial — restore cart, not browsing | Cart is valuable; browsing history is ephemeral |
| Social media | YES — restore scroll position + tab | Users expect to continue where they left off |
| Productivity / Notes | YES — restore document + position | Losing position in a long document is frustrating |
| Games | YES — restore game state via save | Game progress is the core value |
| Onboarding / Setup | NO — restart the wizard | Partial onboarding state is error-prone |
┌─────────────────────────────────────────────────────────────────┐
│ STATE PERSISTENCE FLOW │
│ │
│ APP BACKGROUNDED / KILLED │
│ ┌──────────────────────┐ │
│ │ Serialize nav state │ │
│ │ (routes + params) │──▶ AsyncStorage / MMKV / UserDefaults│
│ └──────────────────────┘ │
│ │
│ APP LAUNCHED │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ Read persisted state │────▶│ Validate state │ │
│ └──────────────────────┘ │ - Auth still valid? │ │
│ │ - Routes still exist?│ │
│ │ - Params still valid?│ │
│ └──────────┬───────────┘ │
│ VALID │ INVALID │
│ ┌──────────▼──┐ ┌────────────┐ │
│ │ Restore │ │ Discard, │ │
│ │ nav state │ │ start fresh│ │
│ └─────────────┘ └────────────┘ │
└─────────────────────────────────────────────────────────────────┘
| Persist | Skip |
|---|---|
| Current tab index | Form input (use form-specific drafts) |
| Stack routes (screen names + params) | Scroll position (restore separately) |
| Modal open state (if resumable) | Temporary UI state (tooltips, popovers) |
| Selected filters / search query | API response data (refetch on mount) |
| Deep link returnTo state | Expired session tokens |
function validatePersistedState(state: NavigationState): boolean {
// 1. Check schema version — did the nav tree change since last persist?
if (state.schemaVersion !== CURRENT_NAV_SCHEMA_VERSION) return false;
// 2. Check auth — is the user still logged in?
if (requiresAuth(state) && !isAuthenticated()) return false;
// 3. Check routes — do all persisted routes still exist?
for (const route of extractRoutes(state)) {
if (!routeRegistry.has(route.name)) return false;
}
// 4. Check staleness — is the state too old?
if (Date.now() - state.timestamp > MAX_STATE_AGE_MS) return false;
return true;
}
Navigation is the highest-risk surface area in a mobile app. Broken navigation means a broken app.
| Test Category | What to Verify | Test Method |
|---|---|---|
| Forward navigation | Tapping a list item pushes the detail screen with correct params | Integration test |
| Back navigation | Pressing back from detail returns to the list with scroll position preserved | Integration test |
| Tab switching | Switching tabs preserves per-tab stack state | Integration test |
| Deep link cold start | Opening a deep link from killed state shows the correct screen with correct back stack | E2E test (Detox/Maestro) |
| Deep link warm start | Opening a deep link with app backgrounded navigates correctly | E2E test |
| Auth gate | Unauthenticated user accessing protected route sees login, then redirects after auth | Integration test |
| Session expiry | Expired token during navigation redirects to login with returnTo | Integration test |
| Modal dismiss | Dismissing a modal returns to the correct underlying screen | Integration test |
| System back (Android) | Android back button behavior matches expectations at every screen | Manual + E2E |
| Swipe back (iOS) | iOS swipe-back gesture works on all stack screens, disabled on modals | Manual test |
| Role switch | Changing user role updates visible tabs without full app restart | Integration test |
| Offline navigation | Navigation works when offline (no network calls block transitions) | Integration test |
┌──────────────────────────────────────────────────────────────┐
│ DEEP LINK TEST PLAN │
│ │
│ For each deep-linkable screen, test ALL of these: │
│ │
│ 1. Cold start (app killed) │
│ - Open link → app launches → correct screen shown │
│ - Press back → lands on correct parent screen │
│ │
│ 2. Background (app suspended) │
│ - Open link → app resumes → navigates to screen │
│ - Previous nav state is replaced, not stacked on top │
│ │
│ 3. Foreground (app active, same tab) │
│ - Open link → navigates within current tab │
│ │
│ 4. Foreground (app active, different tab) │
│ - Open link → switches tab → navigates to screen │
│ │
│ 5. Auth required (user logged out) │
│ - Open link → shows login → after login → correct screen │
│ │
│ 6. Invalid link (bad params or deleted resource) │
│ - Open link → shows error screen, not crash │
│ │
│ 7. Expired link (time-sensitive content) │
│ - Open link → shows "expired" message │
└──────────────────────────────────────────────────────────────┘
For every screen, document the expected back behavior:
| Current Screen | Back Action | Expected Destination | Stack After Back |
|---|---|---|---|
| Feed | (disabled or exit) | Exit app / no-op | [] |
| Detail (from Feed) | Pop | Feed | [Feed] |
| Detail (from deep link) | Pop | Feed (synthetic) | [Feed] |
| Detail (from Search) | Pop | Search Results | [Search, Results] |
| Create Post (modal) | Dismiss | Previous screen (any) | (unchanged) |
| Settings > Notifications | Pop | Settings | [Profile, Settings] |
| Login | (disabled) | No-op | [Login] |
| Onboarding Step 3 | Pop | Step 2 | [Step1, Step2] |
Avoid these patterns that cause bugs, crashes, and confused users.
| Anti-Pattern | Problem | Correct Pattern |
|---|---|---|
| Navigating inside useEffect/initState without guards | Race conditions, double navigation | Use navigation-ready callbacks, guard with isMounted |
| Passing complex objects as route params | Serialization failures, stale data | Pass IDs, fetch data in the destination screen |
| Using global state to communicate between screens | Tight coupling, stale state on back | Pass params or use route-scoped state |
| Checking auth inside every screen | Duplicated logic, inconsistent gates | Handle auth at the navigator level |
| Hardcoding route strings | Typos cause silent failures | Use typed route enums or constants |
| Resetting entire nav state on logout | Loss of deep link returnTo | Replace root route, preserve returnTo in secure storage |
| Ignoring Android back button | App feels broken on Android | Handle hardware back at every navigator level |
| Pushing the same screen twice on fast double-tap | Duplicate screens in stack | Debounce navigation actions or use getId |
| Using replace when you need push | Back button skips a screen | Map each transition to push, replace, or reset explicitly |
Draw the full navigation graph on paper or whiteboard before opening your IDE. A 30-minute graph session saves days of refactoring. Every navigation bug you will ever encounter is a graph bug you missed.
Type every route param at the boundary. When a screen receives params, validate them immediately. Treat navigation params like API inputs — never trust them blindly.
Test deep links from all three states on real devices. Simulators handle deep links differently than real hardware. Cold start behavior in particular varies between iOS and Android.
Use a single file for your entire route registry. When route definitions are scattered across 20 files, nobody knows the full graph. One file, one source of truth, one place to audit.
Document back behavior in a table, not in your head. The back stack is invisible state. If you cannot write down where every back press lands, your users will discover the bugs for you.
Handle the "double tap" problem from day one. Fast double-taps on navigation buttons push duplicate screens. Debounce navigation calls or use navigator-level deduplication. This is the single most common navigation bug in production apps.
Separate navigation events from navigation actions. "User tapped a list item" is an event. "Push DetailScreen with itemId" is an action. Keep the mapping in one place. When analytics, logging, or A/B tests need to intercept navigation, they hook into the event layer, not the action layer.
Version your navigation schema. When you add, remove, or rename screens, bump a schema version. Persisted navigation state from an old schema should be discarded gracefully, not crash the app on launch.
npx claudepluginhub heaptracetechnology/heaptrace-skills --plugin heaptrace-mobileSets up navigation structure for React Native apps: tab bars, stack navigators, drawer menus, deep linking, and type-safe parameters using Expo Router or React Navigation.
Implements stack, tab, drawer navigation in React Native apps using React Navigation. Covers installation, setup, deep linking, and navigation patterns.
Provides React Navigation 7 UI patterns for stacks, tabs, drawers, sheets. Use when building navigation UIs, configuring headers, bottom sheets or handling safe areas and insets.