Ionic Troubleshooting - xopherdeep/do-it-for-the-xp GitHub Wiki

Ionic & Vue Troubleshooting Guide

This document captures solutions to common but complex issues encountered during development, specifically related to Ionic's router, component structure, and state management.

1. Navigation & Modals (The "Save & Quit" Race Condition)

Problem: Triggering navigation (e.g., router.push) while a modal is dismissing can cause a race condition where the router loses track of the active page view. This manifests as runtime errors like TypeError: Cannot read properties of undefined (reading 'ionPageElement') or simply a broken UI.

Solution: Always ensure the modal is fully closed/dismissed before triggering navigation, or use the modal's lifecycle hooks.

Anti-Pattern:

const confirmAction = () => {
  router.push('/new-page'); // Navigation starts
  modalController.dismiss(); // Dismissal overlaps
};

Best Practice:

const confirmAction = () => {
  // Use a flag or promise chain
  shouldNavigate.value = true;
  modalController.dismiss();
};

// In onDidDismiss or a watcher
if (shouldNavigate.value) {
   router.replace('/new-page');
}

2. Ionic Component Structure

Problem: Incorrect nesting of Ionic layout components (like ion-content inside another ion-content, or ion-tabs inside ion-content) violates Ionic's strict structural requirements. This confuses the ion-router-outlet and breaks page transitions.

Rules:

  • ion-page is the root of every view.
  • ion-content handles scrolling and should only contain scrollable content.
  • ion-tabs must be a direct child of ion-page (or a layout wrapper), NEVER inside ion-content.

Invalid Structure:

<ion-page>
  <ion-content>
    <ion-tabs> ... </ion-tabs> <!-- INVALID: Tabs cannot scroll -->
  </ion-content>
</ion-page>

Valid Structure:

<ion-page>
  <ion-tabs> ... </ion-tabs> <!-- Valid -->
  <ion-content> ... </ion-content> <!-- Valid for page content -->
</ion-page>

3. Router History Management (push vs replace)

Problem: Using router.push('/login') or router.push('/select-profile') stacks state on top of the current history. If the previous state had modal overlays or complex routing, this can lead to corruption or "back button" weirdness.

Solution: For actions that "reset" the user flow (like Logging Out or Switching Profiles), use router.replace or router-direction="root". This clears the relevant history stack and forces a fresh render of the target root.

4. State Hydration on Direct Navigation

Problem: When a user navigates directly to a deep link (e.g., /my-portal/:id/home) via a bookmark or refresh, the app skips the "Login/Profile Select" flow. If the global store (e.g., UserStore) isn't hydrated, components accessing user.stats will crash or show a black screen.

Solution: Implement a hydration check in the onBeforeMount hook of your main layout/wrapper component.

onBeforeMount(async () => {
  if (!userStore.currentUser) {
    await userStore.loadUsers();
    // If still failing, redirect to safe entry point
    if (!userStore.currentUser) router.replace('/login');
  }
});

5. Nested Navigation & Router Outlet Keys

Problem: When using nested routes (e.g., ion-tabs inside a feature shell), navigating between a "List View" and a "Detail/Config View" might fail to trigger a visual update, or clicking an already active tab can cause a full app reload if the router path doesn't precisely match the href.

Solution:

  1. Granular :key: Use a computed property for the :key on ion-router-outlet. This forces the component to re-mount when moving between high-level states (like Index vs. Config) while remaining stable when switching sub-tabs within the same configuration.
  2. Intercept Clicks: Use @click on ion-tab-button to handle "Return to Root" logic explicitly. This prevents the browser from interpreting the click as a full-page link even when the app is already "there."

Example Implementation (XpCompendium.vue):

// Granular key prevents stale views
const outletKey = computed(() => {
  const parts = route.path.split("/");
  const tab = parts[4] || "dashboard";
  const isConfig = parts[5] === "config";
  const id = isConfig ? parts[6] : "";

  // Remount if we enter a config or change quests, 
  // but stay stable during sub-tab navigation.
  return isConfig ? `${tab}-config-${id}` : tab;
});

// Intercept clicks to return to root index
const handleTabClick = (tab: string, event: Event) => {
  const targetPath = `/game-master/compendium/setup/${tab}`;
  if (this.route.path.includes(targetPath)) {
    if (this.route.path !== targetPath) {
      event.preventDefault(); // Stop default link behavior
      this.router.replace(targetPath); // SPA navigation back to root
    } else {
      event.preventDefault(); // Prevent app reload if already at root
    }
  }
};

Anti-Pattern:

6. Inline Modals vs modalController

Problem: Using an inline <ion-modal> managed by a parent component's boolean ref (e.g., isOpen) can cause race conditions during root navigation. If the parent component is destroyed (e.g., via router.navigate(..., "root")) before the modal fully closes, the modal may get "stuck" on screen or fail to clean up properly.

Case Study: Profile Loading Modal. When selecting a profile, we triggered a root navigation to the dashboard. The ProfileLoadingModal (inline components) was still "open" when SwitchProfile was unmounted. This left the modal overlay on screen with no component to dismiss it.

Solution: For modals that need to persist across a navigation boundary or during a "destructive" navigation (like switching root), use modalController programmatically. This decouples the modal's lifecycle from the parent view.

Best Practice:

const modal = await modalController.create({ component: MyModal });
await modal.present();

// ... perform async work ...

// Navigate away (destroying current view)
await ionRouter.navigate('/new-root', 'root');

// Dismiss modal manually
await modal.dismiss();
⚠️ **GitHub.com Fallback** ⚠️