IonPage doesn't render after transition occasionally (with Ionic React)

Hi.

My name is Fabian, and I have built an Ionic Capacitor React app for both Android and iOS. However, there is an issue that occurs on the iOS version, where the IonPage occasionally fails to render. This issue doesn’t seem to appear on Android.

There are no errors printed to the console when this issue happens, and the url of the failing page is correct, according to the Sarari inspector, so I don’t think it has to do with the router.

Here are some screenshots:

Page transition starting point (user presses the ‘Browse all videos’ button):

What the next page SHOULD look like:

What the next page occassionally looks like, when the error occurs (~10% of the time):

Tapping one of the tab buttons will fix it, and the correct content will then be rendered

Please help me solve this. I’ve tried several things, like: removing everything within the ion-content tags, checking the state at certain points of execution, reworking the sorting, but I am baffled.

Here is the relevant component code that should renders the IonPage:

import {
  IonBackButton,
  IonButtons,
  IonContent,
  IonHeader,
  IonPage,
  IonSearchbar,
  IonTitle,
  IonToolbar,
  useIonViewDidEnter,
  useIonViewWillEnter,
  useIonViewWillLeave,
} from "@ionic/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useParams } from "react-router";
import { Virtuoso } from "react-virtuoso";
import { useAppSelector } from "../../lib/custom-hooks/useAppSelector";
import { useHomeSubRouting } from "../../lib/custom-hooks/useHomeSubRouting";
import { selectAllCpdVideos } from "../../lib/slices/appContentSlice";
import { CPDListLinkOrigin, CPDVideo, FilterListModalType, SortMode } from "../../lib/types";
import { Utils } from "../../lib/utils";
import { selectAppActivity } from "../../lib/slices/appActivitySlice";
import { Keyboard } from "@capacitor/keyboard";
import VideoFilterButton from "../../shared-components/VideoFilterButton";
import VideoFilterModal from "./VideoFilterModal";
import CPDSortModal from "./CPDSortModal";
import VideoCard from "./VideoCard";
import imgDropdownGreen from "../../assets/images/dropdown_green_n.png";

function CPDHubVideoList() {
  console.log("CPDHubVideoList rendering...");

  const homeSubRoute = useHomeSubRouting();
  const location = useLocation();

  const searchInputRef = useRef<HTMLIonSearchbarElement>(null);
  const filtersContainerRef = useRef<HTMLDivElement | null>(null);
  const prevScrollOffset = useRef(0);

  const params = useParams<{ linkOrigin: CPDListLinkOrigin; optionalParam?: string }>(); // TODO optionalParam will be an event id or category
  const decodedOptionalParam = params.optionalParam ? decodeURIComponent(params.optionalParam) : null;

  const appActivity = useAppSelector(selectAppActivity);
  const allVideos = useAppSelector(selectAllCpdVideos);
  const allVideosForCurrentRoute = useMemo(() => filterAllVideosFromLinkOrigin(), [location.pathname]);

  const [filtersContainerOffset, setFiltersContainerOffset] = useState(0);
  const [searchText, setSearchText] = useState("");
  // TODO use generics here instead of selectedCategories, selectedPresenters etc
  const [selectedCategories, setSelectedCategories] = useState<string[]>(
    decodedOptionalParam ? [decodedOptionalParam] : []
  );
  const [selectedPresenters, setSelectedPresenters] = useState<string[]>([]);
  const [selectedDates, setSelectedDates] = useState<string[]>([]);
  const [sortMode, setSortMode] = useState<SortMode>("Last added");
  const [showSortModal, setShowSortModal] = useState(false);
  const [filterListModal, setFilterListModal] = useState<{
    show: boolean;
    type: FilterListModalType;
    allItems: string[];
  }>({
    show: false,
    type: "",
    allItems: [],
  });

  const uniqueCategories = useMemo(() => Utils.getUniqueCategoriesForVideos(allVideos), []);
  const uniquePresenters = useMemo(() => getUniquePresentersForVideos(), []);
  const uniqueDates = useMemo(() => getUniqueDatesForVideos(), []);

  const filteredVideos = filterVideos();

  const pageTitle = useMemo(() => {
    switch (params.linkOrigin) {
      case "bookmarks":
        return "Bookmarked";
      case "history":
        return "History";
      case "recently-added":
        return "Recently added CPD";
      case "top-rated":
        return "Top Rated";
      case "event":
        return decodedOptionalParam;
      default:
        return "";
    }
  }, []);

  const showSearchInput =
    params.linkOrigin === "search" || params.linkOrigin === "browse" || params.linkOrigin === "category";
  const showFilters =
    params.linkOrigin === "search" ||
    params.linkOrigin === "browse" ||
    params.linkOrigin === "top-rated" ||
    params.linkOrigin === "category" ||
    params.linkOrigin === "event";

  useIonViewDidEnter(() => {
    if (params.linkOrigin === "search") {
      searchInputRef.current?.setFocus();
    }
  }, []);

  useIonViewWillEnter(() => {
    if (filtersContainerRef.current) {
      setFiltersContainerOffset(-filtersContainerRef.current.offsetHeight);
    }
  }, []);

  useIonViewWillLeave(() => {
    setFiltersContainerOffset(filtersContainerRef.current?.offsetHeight!);
  }, []);

  function filterAllVideosFromLinkOrigin() {
    switch (params.linkOrigin) {
      case "bookmarks":
        return [...allVideos].filter((vid) => vid.isBookmarked);
      case "history":
        return [...allVideos].filter((vid) => appActivity.map((it) => it.id).includes(vid.id));
      case "recently-added":
        return [...allVideos].sort((a, b) => b.publishedOrder - a.publishedOrder).slice(0, 10);
      case "event":
        return [...allVideos]; // TODO
      case "top-rated":
        return [...allVideos].sort((a, b) => b.likeCount - a.likeCount).slice(0, 10);
      case "category":
        return [...allVideos].filter((vid) => vid.eventCategory === decodedOptionalParam);
      default:
        return [...allVideos];
    }
  }

  function filterVideos() {
    let videosCopy = [...allVideosForCurrentRoute];

    // sort
    if (sortMode === "Last added") {
      videosCopy = sortVideosByLastAdded(videosCopy);
    } else if (sortMode === "Oldest first") {
      videosCopy = sortVideosOldestFirst(videosCopy);
    } else {
      videosCopy = sortVideosAlphabetically(videosCopy);
    }

    videosCopy = applySearch(videosCopy);
    videosCopy = applyCategoryFiltering(videosCopy);
    videosCopy = applyPresenterFiltering(videosCopy);
    videosCopy = applyDatesFiltering(videosCopy);

    return videosCopy;
  }

  function sortVideosByLastAdded(videos: CPDVideo[]) {
    return [...videos].sort((a, b) => b.publishedOrder - a.publishedOrder);
  }

  function sortVideosOldestFirst(videos: CPDVideo[]) {
    return [...videos].sort((a, b) => a.publishedOrder - b.publishedOrder);
  }

  function sortVideosAlphabetically(videos: CPDVideo[]) {
    return [...videos].sort((a, b) => {
      return a.title.localeCompare(b.title);
    });
  }

  function applySearch(videos: CPDVideo[]) {
    const trimmedText = searchText.toLowerCase().trim();
    if (trimmedText === "") {
      return videos;
    }

    const searched = videos.filter((vid) => {
      return (
        vid.title.toLowerCase().includes(trimmedText) ||
        vid.eventType.toLowerCase().includes(trimmedText) ||
        vid.eventCategory.toLowerCase().includes(trimmedText) ||
        vid.shortDescription.toLowerCase().includes(trimmedText) ||
        vid.presenters.join("").toLowerCase().includes(trimmedText)
      );
    });

    return searched;
  }

  function applyCategoryFiltering(videos: CPDVideo[]) {
    if (!selectedCategories.length) {
      return videos;
    }

    return videos.filter((vid) => selectedCategories.includes(vid.eventCategory));
  }

  function applyPresenterFiltering(videos: CPDVideo[]) {
    if (!selectedPresenters.length) {
      return videos;
    }

    return videos.filter((vid) => {
      return vid.presenters.some((presenterOfVideo) => selectedPresenters.includes(presenterOfVideo));
    });
  }

  function applyDatesFiltering(videos: CPDVideo[]) {
    if (!selectedDates.length) {
      return videos;
    }

    return videos.filter((vid) => selectedDates.includes(vid.eventDate));
  }

  // TODO use generics instead?
  function determineSelectedItemsForFilterModal() {
    switch (filterListModal.type) {
      case "Categories":
        return selectedCategories;
      case "Presenters":
        return selectedPresenters;
      case "Dates":
        return selectedDates;
      default:
        return [];
    }
  }

  function clearSelectedItems() {
    switch (filterListModal.type) {
      case "Categories":
        return setSelectedCategories([]);
      case "Presenters":
        return setSelectedPresenters([]);
      case "Dates":
        return setSelectedDates([]);
    }
  }

  function getUniquePresentersForVideos() {
    const uniqueValues = allVideos
      .flatMap((vid) => vid.presenters)
      .filter((item, index, arr) => arr.indexOf(item) === index);
    return uniqueValues.sort((a, b) => a.localeCompare(b)).filter((it) => it !== "");
  }

  // TODO
  function getUniqueDatesForVideos() {
    const uniqueValues = allVideos
      .map((vid) => vid.eventDate)
      .filter((item, index, arr) => arr.indexOf(item) === index);

    return uniqueValues;
  }

  // show filters container if scrolling up, or scroll position is near top (to work well with iOS bounce). otherwise, hide the filters container
  function handleContentScroll(scrollTop: number) {
    if (filteredVideos.length < 4) return; // don't do anything if there's only a couple of videos to show

    var currentScrollPos = scrollTop;
    if (prevScrollOffset.current > currentScrollPos || currentScrollPos < 10) {
      setFiltersContainerOffset(-filtersContainerRef.current?.offsetHeight!);
    } else {
      setFiltersContainerOffset(filtersContainerRef.current?.offsetHeight!);
    }
    prevScrollOffset.current = currentScrollPos;
  }

  return (
    <IonPage>
      <IonHeader className={"ion-no-border"}>
        <IonToolbar className="t-border">
          <IonButtons
            slot="start"
            class="mr-6"
            style={{
              // Ionic docs recommend using ion-searchbar as the only child to ion-toolbar, but we don't want to do that - which causes back buton misalignment.
              // Add these margins so that the back button is vertically aligned to the centre, like other back buttons. This fix should only be implemented when using a search bar.
              marginTop: showSearchInput ? "var(--padding-top)" : 0,
              marginBottom: showSearchInput ? "var(--padding-bottom)" : 0,
            }}
          >
            <IonBackButton defaultHref={homeSubRoute ? "/app/home/cpd-hub" : "/app/cpd-hub"} />
          </IonButtons>
          {/* TODO fix slight shift in the back button when using this searchbar in the toolbar */}
          {showSearchInput ? (
            <IonSearchbar
              id="video-list-searchbar"
              ref={searchInputRef}
              value={searchText}
              placeholder="Search CPD Hub"
              onIonInput={(e) => setSearchText(e.detail.value!)}
              className="font-normal text-black text-17 leading-27"
            />
          ) : (
            <IonTitle>{pageTitle}</IonTitle>
          )}
        </IonToolbar>
        <div className="relative">
          <div
            className="bg-grey-80 w-full absolute"
            style={{
              height: 300, // number doesn't matter much, just make it high so the background is covered
              bottom: filtersContainerOffset,
            }}
          />
          <div
            ref={filtersContainerRef}
            className="absolute w-full"
            style={{ transition: "bottom 0.2s", bottom: filtersContainerOffset }}
          >
            {showFilters && (
              <div className="t-border bg-grey-80 flex t-border p-2 gap-3 overflow-scroll">
                <VideoFilterButton
                  label="Category"
                  handleClick={() => setFilterListModal({ show: true, type: "Categories", allItems: uniqueCategories })}
                  selectedItemsInFilter={selectedCategories.length}
                />
                <VideoFilterButton
                  label="Presenter"
                  handleClick={() => setFilterListModal({ show: true, type: "Presenters", allItems: uniquePresenters })}
                  selectedItemsInFilter={selectedPresenters.length}
                />
                <VideoFilterButton
                  label="Date"
                  handleClick={() => setFilterListModal({ show: true, type: "Dates", allItems: uniqueDates })}
                  selectedItemsInFilter={selectedDates.length}
                />
              </div>
            )}
            <div
              className="flex justify-between items-center p-3 bg-white"
              style={{ borderBottom: "solid 2px var(--grey-30)" }}
            >
              <div className="flex items-center">
                <div className="text-13 font-semibold text-grey-70">Sort by: &nbsp;</div>
                <button onClick={() => setShowSortModal(true)} className="flex items-center">
                  <div className="text-13 font-semibold text-green-30">{sortMode}</div>
                  <img width={24} height={24} src={imgDropdownGreen} style={{ marginTop: "-4px" }} />
                </button>
              </div>
              <div className="text-13 font-semibold text-grey-70">{`${filteredVideos.length} result${
                filteredVideos.length > 1 ? "s" : ""
              }`}</div>
            </div>
          </div>
        </div>
      </IonHeader>
      <IonContent style={{ "--background": "var(--grey-20)" }} onTouchStart={(e) => Keyboard.hide()}>
        <Virtuoso
          className="t-border"
          data={filteredVideos}
          onScroll={(e) => handleContentScroll(e.currentTarget.scrollTop)}
          itemContent={(index, video) => {
            return (
              // applying padding directly to the virtuoso style prop won't work, so add padding for first item here
              <div style={{ paddingTop: index === 0 ? filtersContainerRef.current?.clientHeight! : "" }}>
                <VideoCard video={video} guestVersion={false} searchText={searchText} />
              </div>
            );
          }}
        />
        <CPDSortModal
          showModal={showSortModal}
          handleClose={() => setShowSortModal(false)}
          handleSortChange={(newSortMode) => setSortMode(newSortMode)}
          currentSortMode={sortMode}
        />
        <VideoFilterModal
          show={filterListModal.show}
          type={filterListModal.type}
          allItems={filterListModal.allItems}
          selectedItems={determineSelectedItemsForFilterModal()}
          resultsCount={filteredVideos.length}
          handleReset={() => clearSelectedItems()}
          handleClose={() => setFilterListModal({ show: false, type: "", allItems: [] })}
          handleSelection={(selectedItem) => {
            switch (filterListModal.type) {
              case "Categories":
                if (selectedCategories.includes(selectedItem)) {
                  // remove from cat list
                  const newItems = [...selectedCategories].filter((cat) => cat !== selectedItem);
                  setSelectedCategories(newItems);
                } else {
                  // add item to cat list
                  setSelectedCategories([...selectedCategories, selectedItem]);
                }
                break;
              case "Presenters":
                if (selectedPresenters.includes(selectedItem)) {
                  const newItems = [...selectedPresenters].filter((cat) => cat !== selectedItem);
                  setSelectedPresenters(newItems);
                } else {
                  setSelectedPresenters([...selectedPresenters, selectedItem]);
                }
                break;
              case "Dates":
                if (selectedDates.includes(selectedItem)) {
                  const newItems = [...selectedDates].filter((cat) => cat !== selectedItem);
                  setSelectedDates(newItems);
                } else {
                  setSelectedDates([...selectedDates, selectedItem]);
                }
                break;
              default:
                throw new Error("Filter type not found");
            }
          }}
        />
      </IonContent>
    </IonPage>
  );
}

export default CPDHubVideoList;

Thanks for any help!

Fabian

Try wrapping this div in your IonHeader in a second IonToolbar. I’ve seen weird transition issues when using an IonToolbar and a sibling element without an IonToolbar.

Thanks for the reply @twestrick, but I’m still getting the issue

FYI, here is my package.json file:

{
  "name": "cop-app",
  "private": true,
  "version": "0.0.1",
  "type": "module",
  "scripts": {
    "build": "ionic capacitor build",
    "preview": "vite preview",
    "test.e2e": "cypress run",
    "test.unit": "vitest",
    "lint": "eslint",
    "sync": "ionic capacitor sync",
    "web": "ionic serve",
    "ios": "ionic capacitor run ios",
    "android": "ionic capacitor run android",
    "ios:l": "ionic capacitor run ios -l --external",
    "android:l": "ionic capacitor run android -l --external",
    "adb": "adb reverse tcp:8081 tcp:8081"
  },
  "dependencies": {
    "@capacitor-community/sqlite": "^5.5.2",
    "@capacitor/android": "5.5.0",
    "@capacitor/app": "5.0.6",
    "@capacitor/browser": "^5.1.0",
    "@capacitor/core": "5.5.0",
    "@capacitor/filesystem": "^5.1.4",
    "@capacitor/haptics": "5.0.6",
    "@capacitor/ios": "5.5.0",
    "@capacitor/keyboard": "5.0.6",
    "@capacitor/network": "^5.0.7",
    "@capacitor/preferences": "^5.0.6",
    "@capacitor/push-notifications": "^5.1.1",
    "@capacitor/screen-orientation": "^5.0.7",
    "@capacitor/share": "^5.0.6",
    "@capacitor/status-bar": "5.0.6",
    "@capacitor/toast": "^5.0.7",
    "@ionic/react": "^7.0.0",
    "@ionic/react-router": "^7.0.0",
    "@reduxjs/toolkit": "^2.1.0",
    "@types/react-router": "^5.1.20",
    "@types/react-router-dom": "^5.3.3",
    "@vimeo/player": "^2.20.1",
    "html-entities": "^2.4.0",
    "html-react-parser": "^5.0.6",
    "ionicons": "^7.0.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-loading-indicators": "^0.2.3",
    "react-redux": "^9.1.0",
    "react-router": "^5.3.4",
    "react-router-dom": "^5.3.4",
    "react-virtuoso": "^4.7.2",
    "zod": "^3.22.5"
  },
  "devDependencies": {
    "@capacitor/cli": "5.5.0",
    "@eslint/js": "^9.0.0",
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^14.0.0",
    "@testing-library/user-event": "^14.4.3",
    "@types/react": "^18.0.27",
    "@types/react-dom": "^18.0.10",
    "@types/vimeo__player": "^2.18.3",
    "@vitejs/plugin-legacy": "^4.0.2",
    "@vitejs/plugin-react": "^4.0.1",
    "cypress": "^12.7.0",
    "eslint": "^8.57.0",
    "eslint-plugin-react": "^7.34.1",
    "globals": "^15.0.0",
    "jsdom": "^22.1.0",
    "typescript": "^5.1.6",
    "typescript-eslint": "^7.7.0",
    "vite": "^4.3.9",
    "vitest": "^0.32.2"
  },
  "description": ""
}

I’ve now reduced my component to this, but the bug still occurs:

import { IonBackButton, IonButtons, IonContent, IonHeader, IonPage, IonToolbar } from "@ionic/react";

function CPDHubVideoList() {
  console.log("CPDHubVideoList rendering...");

  return (
    <IonPage>
      <IonHeader>
        <IonToolbar>
          <IonButtons slot="start">
            <IonBackButton defaultHref={"/app/cpd-hub"} />
          </IonButtons>
        </IonToolbar>
      </IonHeader>
      <IonContent></IonContent>
    </IonPage>
  );
}

export default CPDHubVideoList;

This leads me to believe that my issue is elsewhere.

Here is my app router:

import { IonReactRouter } from "@ionic/react-router";
import { IonIcon, IonLabel, IonRippleEffect, IonRouterOutlet, IonTabBar, IonTabButton, IonTabs } from "@ionic/react";
import { Route, Redirect } from "react-router-dom";
import { selectAuth } from "./lib/slices/authSlice";
import { useAppSelector } from "./lib/custom-hooks/useAppSelector";
import { useState } from "react";
import LandingPage from "./landing/LandingPage";
import HomeTab from "./tabs/home/HomeTab";
import FitnessToPractisePage from "./tabs/members/FitnessToPractisePage";
import ParamedicInsightPage from "./tabs/members/ParamedicInsightPage";
import PastPollsPage from "./tabs/home/PastPollsPage";
import BritishParamedicJournalPage from "./tabs/members/BritishParamedicJournalPage";
import HomeNewsTab from "./tabs/home/HomeNewsTab";
import NewsArticlePage from "./tabs/news/NewsArticlePage";
import NewsTab from "./tabs/news/NewsTab";
import CPDHubTab from "./tabs/cpd-hub/CPDHubTab";
import CPDVideoDetailsPage from "./tabs/cpd-hub/CPDVideoDetailsPage";
import EventsTab from "./tabs/events/EventsTab";
import EventDetailsPage from "./tabs/events/EventDetailsPage";
import MembersTabForGuests from "./tabs/members/MembersTabForGuests";
import MembersTab from "./tabs/members/MembersTab";
import ProfileDetailsPage from "./tabs/members/ProfileDetailsPage";
import CommunicationPreferencesPage from "./tabs/members/CommunicationPreferencesPage";
import LiabilityInsurancePage from "./tabs/members/LiabilityInsurancePage";
import ELearningResourcesPage from "./tabs/members/ELearningResourcesPage";
import MentalHealthAndWellbeingAppPage from "./tabs/members/MentalHealthAndWellbeingAppPage";
import DiscountPage from "./tabs/members/DiscountPage";
import CPDHubVideoList from "./tabs/cpd-hub/CPDHubVideoList";
import CategoryListPage from "./tabs/cpd-hub/CategoryListPage";
import HomeCPDHubPage from "./tabs/home/HomeCPDHubPage";
import HelpAndInfo from "./tabs/members/HelpAndInfo";
import AccessibilitySettings from "./tabs/members/AccessibilitySettings";

function AppRouter() {
  const auth = useAppSelector(selectAuth);

  return (
    <IonReactRouter>
      <IonRouterOutlet mode="ios">
        <Route
          exact
          path={"/"}
          render={(props) =>
            auth.user && !auth.isGuest ? <Redirect to={"/app/home"} /> : <Redirect to={"/landing"} />
          }
        />
        <Route exact path={"/landing"} component={LandingPage} />
        <Route path={"/app"} component={Tabs} />
      </IonRouterOutlet>
    </IonReactRouter>
  );
}

function Tabs() {
  const auth = useAppSelector(selectAuth);

  const [activeTab, setActiveTab] = useState<string>("home");

  return (
    <IonTabs onIonTabsDidChange={(e) => setActiveTab(e.detail.tab)}>
      <IonRouterOutlet mode="ios">
        {/* home tab routes */}
        <Route exact path={"/app/home"} component={HomeTab} />
        <Route exact path={"/app/home/benefits-fitness-to-practise"} component={FitnessToPractisePage} />
        <Route exact path={"/app/home/past-polls"} component={PastPollsPage} />
        <Route exact path={"/app/home/benefits-paramedic-insight"} component={ParamedicInsightPage} />
        <Route exact path={"/app/home/benefits-british-paramedic-journal"} component={BritishParamedicJournalPage} />
        {/* - news sub routes in home tab */}
        <Route exact path={"/app/home/news"} component={HomeNewsTab} />
        <Route exact path={"/app/home/news/article-:articleId"} component={NewsArticlePage} />
        {/* - cpd hub sub routes in home tab */}
        <Route exact path={"/app/home/cpd-hub"} component={HomeCPDHubPage} />
        <Route exact path={"/app/home/cpd-hub/categories"} component={CategoryListPage} />
        <Route exact path={"/app/home/cpd-hub/video-list-:linkOrigin/:optionalParam?"} component={CPDHubVideoList} />
        <Route exact path={"/app/home/cpd-hub/video-detail-:videoId"} component={CPDVideoDetailsPage} />
        {/* events sub routes in home tab */}
        <Route exact path={"/app/home/events"} component={EventsTab} />
        <Route exact path={"/app/home/events/event-:eventId"} component={EventDetailsPage} />
        {/* news tab routes */}
        <Route exact path={"/app/news"} component={NewsTab} />
        <Route exact path={"/app/news/article-:articleId"} component={NewsArticlePage} />
        {/* cpd hub tab routes */}
        <Route exact path={"/app/cpd-hub"} component={CPDHubTab} />
        <Route exact path={"/app/cpd-hub/categories"} component={CategoryListPage} />
        <Route exact path={"/app/cpd-hub/video-list-:linkOrigin/:optionalParam?"} component={CPDHubVideoList} />
        <Route exact path={"/app/cpd-hub/video-detail-:videoId"} component={CPDVideoDetailsPage} />
        {/* events tab routes */}
        <Route exact path={"/app/events"} component={EventsTab} />
        <Route exact path={"/app/events/event-:eventId"} component={EventDetailsPage} />
        {/* members tab routes */}
        <Route
          exact
          path={"/app/members"}
          render={(props) => (auth.isGuest ? <MembersTabForGuests /> : <MembersTab />)}
        />
        <Route exact path={"/app/members/profile-details"} component={ProfileDetailsPage} />
        <Route exact path={"/app/members/profile-accessibility-settings"} component={AccessibilitySettings} />
        <Route exact path={"/app/members/profile-communication-preferences"} component={CommunicationPreferencesPage} />
        <Route exact path={"/app/members/profile-help-and-info"} component={HelpAndInfo} />
        <Route exact path={"/app/members/benefits-paramedic-insight"} component={ParamedicInsightPage} />
        <Route exact path={"/app/members/benefits-british-paramedic-journal"} component={BritishParamedicJournalPage} />
        <Route exact path={"/app/members/benefits-fitness-to-practise"} component={FitnessToPractisePage} />
        <Route exact path={"/app/members/benefits-liability-insurance"} component={LiabilityInsurancePage} />
        <Route exact path={"/app/members/benefits-e-learning-resources"} component={ELearningResourcesPage} />
        <Route
          exact
          path={"/app/members/benefits-mental-health-and-wellbeing-app"}
          component={MentalHealthAndWellbeingAppPage}
        />
        <Route exact path={"/app/members/discounts-:discountId"} component={DiscountPage} />
      </IonRouterOutlet>
      <IonTabBar slot="bottom">
        {/* Note that icons are set via CSS */}
        <IonTabButton tab="home" href="/app/home">
          <IonIcon className="tab-icon" />
          <IonLabel>Home</IonLabel>
          <div className="bg-green-30 w-full absolute top-0 h-3 invisible" />
          <IonRippleEffect />
        </IonTabButton>
        <IonTabButton tab="news" href="/app/news">
          <IonIcon className="tab-icon" />
          <IonLabel>News</IonLabel>
          <div className="bg-green-30 w-full absolute top-0 h-3 invisible" />
          <IonRippleEffect />
        </IonTabButton>
        <IonTabButton tab="cpd-hub" href="/app/cpd-hub">
          <IonIcon className="tab-icon" />
          <IonLabel>CPD Hub</IonLabel>
          <div className="bg-green-30 w-full absolute top-0 h-3 invisible" />
          <IonRippleEffect />
        </IonTabButton>
        <IonTabButton tab="events" href="/app/events">
          <IonIcon className="tab-icon" />
          <IonLabel>Events</IonLabel>
          <div className="bg-green-30 w-full absolute top-0 h-3 invisible" />
          <IonRippleEffect />
        </IonTabButton>
        <IonTabButton tab="members" href="/app/members">
          <IonIcon className="tab-icon" />
          <IonLabel>Members</IonLabel>
          <div className="bg-green-30 w-full absolute top-0 h-3 invisible" />
          <IonRippleEffect />
        </IonTabButton>
      </IonTabBar>
    </IonTabs>
  );
}

export default AppRouter;

… and here is the component that has the nav links:

import { IonRippleEffect, useIonRouter } from "@ionic/react";
import imgDisclosureBlue from "../assets/images/disclosure_right_blue.png";
import { useAppSelector } from "../lib/custom-hooks/useAppSelector";
import { useHomeSubRouting } from "../lib/custom-hooks/useHomeSubRouting";
import { selectAllCpdVideos } from "../lib/slices/appContentSlice";
import { selectAuth } from "../lib/slices/authSlice";
import VideoCard from "../tabs/cpd-hub/VideoCard";
import VideoCardMini from "../tabs/cpd-hub/VideoCardMini";
import GuestVideoCard from "./GuestVideoCard";
import HeadingAndButtonContainer from "./HeadingAndButtonContainer";
import SmallIcon from "./SmallIcon";

function CPDHubContent() {
  const homeSubRoute = useHomeSubRouting();

  const nav = useIonRouter();

  const auth = useAppSelector(selectAuth);
  const allVideos = useAppSelector(selectAllCpdVideos);

  const filteredVideos = auth.isGuest ? allVideos.filter((vid) => vid.isSample) : allVideos;
  const recentlyAdded = [...filteredVideos].sort((a, b) => b.publishedOrder - a.publishedOrder).slice(0, 5);
  const topRated = [...filteredVideos].sort((a, b) => b.likeCount - a.likeCount).slice(0, 5);
  const topCategoriesMapping = getTopFiveCategories();

  function getTopFiveCategories() {
    // build object of key-value (category:count) pairs
    const mapping = allVideos.reduce((counts, item) => {
      const { eventCategory } = item;
      counts[eventCategory] = (counts[eventCategory] || 0) + 1;
      return counts;
    }, {} as { [category: string]: number });

    // sort the object based on the counts
    const sorted = Object.entries(mapping).sort(([, a], [, b]) => b - a);

    // get the categories only
    const topCats = sorted.map((it) => it[0]);

    return topCats.slice(0, 5);
  }

  function getRoute(endpoint: string) {
    return homeSubRoute ? `/app/home/cpd-hub/${endpoint}` : `/app/cpd-hub/${endpoint}`;
  }

  return (
    <div className="p-4">
      {auth.isGuest ? (
        <div id="guest-video-content-container" className="">
          <GuestVideoCard />
          {filteredVideos?.map((video) => (
            <div key={video.id} className="border border-solid border-grey-40  mt-4">
              <VideoCard video={video} guestVersion={true} />
            </div>
          ))}
        </div>
      ) : (
        <>
          <CPDHubLink text="Browse all videos" link={getRoute("video-list-browse")} />
          <CPDHubLink text="Bookmarked" link={getRoute("video-list-bookmarks")} />
          <CPDHubLink text="History" link={getRoute("video-list-history")} />
          <div className="mt-5">
            <HeadingAndButtonContainer
              heading="Recently added"
              btnNavPath={getRoute("video-list-recently-added")}
              buttonText="See all"
            />
            <div className="flex t-border overflow-scroll gap-2" style={{ marginLeft: "-16px", marginRight: "-16px" }}>
              {recentlyAdded.map((video, i, arr) => (
                <div
                  key={video.id}
                  style={{ paddingLeft: i === 0 ? "16px" : "", paddingRight: i === arr.length - 1 ? "16px" : "" }}
                >
                  <VideoCardMini
                    video={video}
                    handleClick={() => {
                      const route = getRoute(`video-detail-${video.id}`);
                      nav.push(route);
                    }}
                  />
                </div>
              ))}
            </div>
          </div>
          <div className="mt-5">
            <HeadingAndButtonContainer
              heading="Top rated"
              btnNavPath={getRoute(`video-list-top-rated`)}
              buttonText="See all"
            />
            <div className="flex t-border overflow-scroll gap-2" style={{ marginLeft: "-16px", marginRight: "-16px" }}>
              {topRated.map((video, i, arr) => (
                <div
                  key={video.id}
                  style={{ paddingLeft: i === 0 ? "16px" : "", paddingRight: i === arr.length - 1 ? "16px" : "" }}
                >
                  <VideoCardMini
                    video={video}
                    handleClick={() =>
                      nav.push(
                        homeSubRoute
                          ? `/app/home/cpd-hub/video-detail-${video.id}`
                          : `/app/cpd-hub/video-detail-${video.id}`
                      )
                    }
                  />
                </div>
              ))}
            </div>
          </div>
          <div className="mt-5">
            <HeadingAndButtonContainer
              heading="Popular Categories"
              btnNavPath={getRoute("categories")}
              buttonText="See all"
            />
            {topCategoriesMapping.map((cat) => {
              // encode category because it could contain slashes (e.g. the category 'Obsterics/Maternity') and other unwanted characters
              return (
                <CPDHubLink key={cat} text={cat} link={getRoute(`video-list-category/${encodeURIComponent(cat)}`)} />
              );
            })}
          </div>
        </>
      )}
    </div>
  );
}

interface CPDHubLinkProps {
  text: string;
  link: string;
}

function CPDHubLink(props: CPDHubLinkProps) {
  const nav = useIonRouter();

  return (
    <button
      className="flex items-center t-border w-full py-1 ion-activatable relative"
      onClick={() => nav.push(props.link)}
      style={{ borderBottom: "solid 2px var(--grey-30)" }}
    >
      <SmallIcon text={props.text} width={24} height={24} />
      <div className="text-20 font-semibold leading-28 flex-1 t-border text-left pl-2">{props.text}</div>
      <img src={imgDisclosureBlue} className="t-border" width={44} height={44} />
      <IonRippleEffect />
    </button>
  );
}

export default CPDHubContent;

Still unsolved, so I’ve posted an issue on GitHub