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: </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