diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx
index 5fd125c96..c46fa00b3 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx
@@ -23,6 +23,7 @@ import {
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
+import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
@@ -134,6 +135,12 @@ const page: React.FC = () => {
useEffect(() => {
navigation.setOptions({ title: collection?.Name || "" });
setSortOrder([SortOrderOption.Ascending]);
+ // Collections open with a clean filter slate: the genre/year/tag atoms are
+ // global, so without this the previously viewed library's selection bleeds
+ // in (libraries now keep their own per-library memory).
+ setSelectedGenres([]);
+ setSelectedYears([]);
+ setSelectedTags([]);
if (!collection) return;
@@ -204,40 +211,39 @@ const page: React.FC = () => {
],
);
- const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
- useInfiniteQuery({
- queryKey: [
- "collection-items",
- collectionId,
- selectedGenres,
- selectedYears,
- selectedTags,
- sortBy,
- sortOrder,
- ],
- queryFn: fetchItems,
- getNextPageParam: (lastPage, pages) => {
- if (
- !lastPage?.Items ||
- !lastPage?.TotalRecordCount ||
- lastPage?.TotalRecordCount === 0
- )
- return undefined;
-
- const totalItems = lastPage.TotalRecordCount;
- const accumulatedItems = pages.reduce(
- (acc, curr) => acc + (curr?.Items?.length || 0),
- 0,
- );
-
- if (accumulatedItems < totalItems) {
- return lastPage?.Items?.length * pages.length;
- }
+ const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
+ queryKey: [
+ "collection-items",
+ collectionId,
+ selectedGenres,
+ selectedYears,
+ selectedTags,
+ sortBy,
+ sortOrder,
+ ],
+ queryFn: fetchItems,
+ getNextPageParam: (lastPage, pages) => {
+ if (
+ !lastPage?.Items ||
+ !lastPage?.TotalRecordCount ||
+ lastPage?.TotalRecordCount === 0
+ )
return undefined;
- },
- initialPageParam: 0,
- enabled: !!api && !!user?.Id && !!collection,
- });
+
+ const totalItems = lastPage.TotalRecordCount;
+ const accumulatedItems = pages.reduce(
+ (acc, curr) => acc + (curr?.Items?.length || 0),
+ 0,
+ );
+
+ if (accumulatedItems < totalItems) {
+ return lastPage?.Items?.length * pages.length;
+ }
+ return undefined;
+ },
+ initialPageParam: 0,
+ enabled: !!api && !!user?.Id && !!collection,
+ });
const flatData = useMemo(() => {
return (
@@ -326,7 +332,7 @@ const page: React.FC = () => {
data={[
{
key: "reset",
- component: ,
+ component: ,
},
{
key: "genre",
@@ -466,7 +472,6 @@ const page: React.FC = () => {
setSortBy,
sortOrder,
setSortOrder,
- isFetching,
],
);
@@ -633,43 +638,45 @@ const page: React.FC = () => {
// Mobile return
if (!Platform.isTV) {
return (
-
-
- {t("search.no_results")}
-
-
- }
- extraData={[
- selectedGenres,
- selectedYears,
- selectedTags,
- sortBy,
- sortOrder,
- ]}
- contentInsetAdjustmentBehavior='automatic'
- data={flatData}
- renderItem={renderItem}
- keyExtractor={keyExtractor}
- numColumns={nrOfCols}
- onEndReached={() => {
- if (hasNextPage) {
- fetchNextPage();
+
+
+
+ {t("search.no_results")}
+
+
}
- }}
- onEndReachedThreshold={0.5}
- ListHeaderComponent={ListHeaderComponent}
- contentContainerStyle={{ paddingBottom: 24 }}
- ItemSeparatorComponent={() => (
-
- )}
- />
+ extraData={[
+ selectedGenres,
+ selectedYears,
+ selectedTags,
+ sortBy,
+ sortOrder,
+ ]}
+ contentInsetAdjustmentBehavior='automatic'
+ data={flatData}
+ renderItem={renderItem}
+ keyExtractor={keyExtractor}
+ numColumns={nrOfCols}
+ onEndReached={() => {
+ if (hasNextPage) {
+ fetchNextPage();
+ }
+ }}
+ onEndReachedThreshold={0.5}
+ ListHeaderComponent={ListHeaderComponent}
+ contentContainerStyle={{ paddingBottom: 24 }}
+ ItemSeparatorComponent={() => (
+
+ )}
+ />
+
);
}
diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
index cdb1a181f..89e1261c8 100644
--- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
+++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
@@ -9,7 +9,7 @@ import {
getItemsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
-import { FlashList } from "@shopify/flash-list";
+import { FlashList, type FlashListRef } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import {
@@ -35,6 +35,7 @@ import {
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
+import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
@@ -44,6 +45,7 @@ import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
+import { useFilterReset } from "@/hooks/useFilterReset";
import { useOrientation } from "@/hooks/useOrientation";
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
@@ -55,7 +57,9 @@ import {
FilterByPreferenceAtom,
filterByAtom,
genreFilterAtom,
+ genrePreferenceAtom,
getFilterByPreference,
+ getMultiFilterPreference,
getSortByPreference,
getSortOrderPreference,
SortByOption,
@@ -66,11 +70,12 @@ import {
sortOrderAtom,
sortOrderOptions,
sortOrderPreferenceAtom,
+ tagPreferenceAtom,
tagsFilterAtom,
useFilterOptions,
yearFilterAtom,
+ yearPreferenceAtom,
} from "@/utils/atoms/filters";
-import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -108,6 +113,9 @@ const Page = () => {
const [sortOrderPreference, setOrderByPreference] = useAtom(
sortOrderPreferenceAtom,
);
+ const [genrePreference, setGenrePreference] = useAtom(genrePreferenceAtom);
+ const [yearPreference, setYearPreference] = useAtom(yearPreferenceAtom);
+ const [tagPreference, setTagPreference] = useAtom(tagPreferenceAtom);
const { orientation } = useOrientation();
@@ -205,6 +213,13 @@ const Page = () => {
const fp = getFilterByPreference(libraryId, filterByPreference);
_setFilterBy(fp ? [fp] : []);
}
+
+ // Genres / years / tags: per-library saved preference (no URL params), so
+ // switching libraries restores each library's own selection instead of
+ // bleeding the previous one.
+ setSelectedGenres(getMultiFilterPreference(libraryId, genrePreference));
+ setSelectedYears(getMultiFilterPreference(libraryId, yearPreference));
+ setSelectedTags(getMultiFilterPreference(libraryId, tagPreference));
}, [
libraryId,
sortOrderPreference,
@@ -213,6 +228,12 @@ const Page = () => {
_setSortBy,
filterByPreference,
_setFilterBy,
+ genrePreference,
+ yearPreference,
+ tagPreference,
+ setSelectedGenres,
+ setSelectedYears,
+ setSelectedTags,
searchParams.sortBy,
searchParams.sortOrder,
searchParams.filterBy,
@@ -257,6 +278,32 @@ const Page = () => {
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
);
+ // Genres / years / tags: save the per-library memory then update the active
+ // atom (mirrors setSortBy; avoids a save-effect that would corrupt on switch).
+ const setGenres = useCallback(
+ (genres: string[]) => {
+ setGenrePreference({ ...genrePreference, [libraryId]: genres });
+ setSelectedGenres(genres);
+ },
+ [libraryId, genrePreference, setGenrePreference, setSelectedGenres],
+ );
+
+ const setYears = useCallback(
+ (years: string[]) => {
+ setYearPreference({ ...yearPreference, [libraryId]: years });
+ setSelectedYears(years);
+ },
+ [libraryId, yearPreference, setYearPreference, setSelectedYears],
+ );
+
+ const setTags = useCallback(
+ (tags: string[]) => {
+ setTagPreference({ ...tagPreference, [libraryId]: tags });
+ setSelectedTags(tags);
+ },
+ [libraryId, tagPreference, setTagPreference, setSelectedTags],
+ );
+
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// TV uses flexWrap, so nrOfCols is just for mobile
@@ -415,6 +462,29 @@ const Page = () => {
);
}, [data]);
+ const flashListRef = useRef>(null);
+
+ // Jump the grid to the top when the filters/sort change (incl. reset).
+ const filterSignature = `${selectedGenres}|${selectedYears}|${selectedTags}|${sortBy[0]}|${sortOrder[0]}|${filterBy}`;
+ const pendingScrollTopRef = useRef(false);
+
+ // Instant feedback: pin to the top the moment the filters change, without
+ // waiting for the new fetch — and flag a re-pin for once it settles.
+ useEffect(() => {
+ flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
+ pendingScrollTopRef.current = true;
+ }, [filterSignature]);
+
+ // Safety net: FlashList can restore the previous offset as the filtered list
+ // grows, so re-pin once the fetch settles. Pagination keeps the same
+ // signature, so it never re-pins.
+ useEffect(() => {
+ if (pendingScrollTopRef.current && !isFetching) {
+ pendingScrollTopRef.current = false;
+ flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
+ }
+ }, [isFetching, flatData]);
+
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
{
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const generalFilters = useFilterOptions();
- const settings = useSettings();
const ListHeaderComponent = useCallback(
() => (
{
data={[
{
key: "reset",
- component: ,
+ component: ,
},
{
key: "genre",
@@ -564,7 +633,7 @@ const Page = () => {
});
return response.data.Genres || [];
}}
- set={setSelectedGenres}
+ set={setGenres}
values={selectedGenres}
title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()}
@@ -591,7 +660,7 @@ const Page = () => {
});
return response.data.Years || [];
}}
- set={setSelectedYears}
+ set={setYears}
values={selectedYears}
title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()}
@@ -616,7 +685,7 @@ const Page = () => {
});
return response.data.Tags || [];
}}
- set={setSelectedTags}
+ set={setTags}
values={selectedTags}
title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()}
@@ -696,35 +765,23 @@ const Page = () => {
api,
user?.Id,
selectedGenres,
- setSelectedGenres,
+ setGenres,
selectedYears,
- setSelectedYears,
+ setYears,
selectedTags,
- setSelectedTags,
+ setTags,
sortBy,
setSortBy,
sortOrder,
setSortOrder,
- isFetching,
filterBy,
setFilter,
- settings,
],
);
- // TV Filter bar header
- const hasActiveFilters =
- selectedGenres.length > 0 ||
- selectedYears.length > 0 ||
- selectedTags.length > 0 ||
- filterBy.length > 0;
-
- const resetAllFilters = useCallback(() => {
- setSelectedGenres([]);
- setSelectedYears([]);
- setSelectedTags([]);
- _setFilterBy([]);
- }, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
+ // Filter bar reset + visibility, shared with the mobile ResetFiltersButton so
+ // sort/order can't be forgotten on one path (it used to be reset on neither).
+ const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId);
// TV Filter options - with "All" option for clearable filters
const tvGenreFilterOptions = useMemo(
@@ -818,15 +875,15 @@ const Page = () => {
options: tvGenreFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
- setSelectedGenres([]);
+ setGenres([]);
} else if (selectedGenres.includes(value)) {
- setSelectedGenres(selectedGenres.filter((g) => g !== value));
+ setGenres(selectedGenres.filter((g) => g !== value));
} else {
- setSelectedGenres([...selectedGenres, value]);
+ setGenres([...selectedGenres, value]);
}
},
});
- }, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
+ }, [showOptions, t, tvGenreFilterOptions, selectedGenres, setGenres]);
const handleShowYearFilter = useCallback(() => {
showOptions({
@@ -834,15 +891,15 @@ const Page = () => {
options: tvYearFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
- setSelectedYears([]);
+ setYears([]);
} else if (selectedYears.includes(value)) {
- setSelectedYears(selectedYears.filter((y) => y !== value));
+ setYears(selectedYears.filter((y) => y !== value));
} else {
- setSelectedYears([...selectedYears, value]);
+ setYears([...selectedYears, value]);
}
},
});
- }, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
+ }, [showOptions, t, tvYearFilterOptions, selectedYears, setYears]);
const handleShowTagFilter = useCallback(() => {
showOptions({
@@ -850,15 +907,15 @@ const Page = () => {
options: tvTagFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
- setSelectedTags([]);
+ setTags([]);
} else if (selectedTags.includes(value)) {
- setSelectedTags(selectedTags.filter((tag) => tag !== value));
+ setTags(selectedTags.filter((tag) => tag !== value));
} else {
- setSelectedTags([...selectedTags, value]);
+ setTags([...selectedTags, value]);
}
},
});
- }, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
+ }, [showOptions, t, tvTagFilterOptions, selectedTags, setTags]);
const handleShowSortByFilter = useCallback(() => {
showOptions({
@@ -906,42 +963,45 @@ const Page = () => {
// Mobile return
if (!Platform.isTV) {
return (
-
-
- {t("library.no_results")}
-
-
- }
- contentInsetAdjustmentBehavior='automatic'
- data={flatData}
- renderItem={renderItem}
- extraData={[orientation, nrOfCols]}
- keyExtractor={keyExtractor}
- numColumns={nrOfCols}
- onEndReached={() => {
- if (hasNextPage) {
- fetchNextPage();
+
+
+
+ {t("library.no_results")}
+
+
}
- }}
- onEndReachedThreshold={1}
- ListHeaderComponent={ListHeaderComponent}
- contentContainerStyle={{
- paddingBottom: 24,
- paddingLeft: insets.left,
- paddingRight: insets.right,
- }}
- ItemSeparatorComponent={() => (
-
- )}
- />
+ contentInsetAdjustmentBehavior='automatic'
+ data={flatData}
+ renderItem={renderItem}
+ extraData={[orientation, nrOfCols]}
+ keyExtractor={keyExtractor}
+ numColumns={nrOfCols}
+ onEndReached={() => {
+ if (hasNextPage) {
+ fetchNextPage();
+ }
+ }}
+ onEndReachedThreshold={1}
+ ListHeaderComponent={ListHeaderComponent}
+ contentContainerStyle={{
+ paddingBottom: 24,
+ paddingLeft: insets.left,
+ paddingRight: insets.right,
+ }}
+ ItemSeparatorComponent={() => (
+
+ )}
+ />
+
);
}
diff --git a/components/BitRateSheet.tsx b/components/BitRateSheet.tsx
index 0589cd059..6a9a89c5c 100644
--- a/components/BitRateSheet.tsx
+++ b/components/BitRateSheet.tsx
@@ -1,4 +1,5 @@
-import { useMemo, useState } from "react";
+import type { BottomSheetModal } from "@gorhom/bottom-sheet";
+import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
@@ -61,6 +62,7 @@ export const BitrateSheet: React.FC = ({
const isTv = Platform.isTV;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
+ const sheetModalRef = useRef(null);
const sorted = useMemo(() => {
if (inverted)
@@ -92,7 +94,10 @@ export const BitrateSheet: React.FC = ({
setOpen(true)}
+ onPress={() => {
+ setOpen(true);
+ sheetModalRef.current?.present();
+ }}
>
{BITRATES.find((b) => b.value === selected?.value)?.key}
@@ -103,6 +108,7 @@ export const BitrateSheet: React.FC = ({
= ({
const isTv = Platform.isTV;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
+ const sheetModalRef = useRef(null);
const getDisplayName = useCallback((source: MediaSourceInfo) => {
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
@@ -44,7 +46,10 @@ export const MediaSourceSheet: React.FC = ({
{t("item_card.video")}
setOpen(true)}
+ onPress={() => {
+ setOpen(true);
+ sheetModalRef.current?.present();
+ }}
>
{selectedName}
@@ -53,6 +58,7 @@ export const MediaSourceSheet: React.FC = ({
extends ViewProps {
id: string;
@@ -33,22 +35,63 @@ export const FilterButton = ({
icon = "filter",
...props
}: FilterButtonProps) => {
- const [open, setOpen] = useState(false);
+ // When a FilterSheetProvider is present (library / collections), all buttons
+ // share one sheet so two can never stack. Outside a provider (e.g. logs,
+ // discover), fall back to this button's own standalone sheet.
+ const shared = useFilterSheet();
- const { data: filters } = useQuery({
+ const { data: filters, isLoading } = useQuery({
queryKey: ["filters", title, queryKey, id],
queryFn,
staleTime: 0,
enabled: !!id && !!queryFn && !!queryKey,
});
+ // Standalone-mode state (unused in shared mode).
+ const [open, setOpen] = useState(false);
+ const sheetModalRef = useRef(null);
+
+ const onButtonPress = useCallback(() => {
+ if (shared) {
+ shared.openFilter({
+ key: `${id}:${queryKey}`,
+ id,
+ queryKey,
+ queryFn,
+ title,
+ values: values as unknown[],
+ set: set as (value: unknown[]) => void,
+ renderItemLabel: renderItemLabel as (item: unknown) => React.ReactNode,
+ searchFilter: searchFilter as
+ | ((item: unknown, query: string) => boolean)
+ | undefined,
+ disableSearch,
+ multiple,
+ });
+ return;
+ }
+ // present() must run from the press handler: from an effect after a state
+ // update it silently no-ops on the new architecture and the sheet never
+ // appears.
+ setOpen(true);
+ sheetModalRef.current?.present();
+ }, [
+ shared,
+ id,
+ queryKey,
+ queryFn,
+ title,
+ values,
+ set,
+ renderItemLabel,
+ searchFilter,
+ disableSearch,
+ multiple,
+ ]);
+
return (
<>
- {
- filters?.length && setOpen(true);
- }}
- >
+
({
)}
-
- title={title}
- open={open}
- setOpen={setOpen}
- data={filters}
- values={values}
- set={set}
- renderItemLabel={renderItemLabel}
- searchFilter={searchFilter}
- disableSearch={disableSearch}
- multiple={multiple}
- />
+ {!shared && (
+
+ title={title}
+ open={open}
+ setOpen={setOpen}
+ modalRef={sheetModalRef}
+ loading={isLoading}
+ data={filters}
+ values={values}
+ set={set}
+ renderItemLabel={renderItemLabel}
+ searchFilter={searchFilter}
+ disableSearch={disableSearch}
+ multiple={multiple}
+ />
+ )}
>
);
};
diff --git a/components/filters/FilterSheet.tsx b/components/filters/FilterSheet.tsx
index 27075f8b5..8f87ac221 100644
--- a/components/filters/FilterSheet.tsx
+++ b/components/filters/FilterSheet.tsx
@@ -7,7 +7,14 @@ import {
} from "@gorhom/bottom-sheet";
import { isEqual } from "lodash";
import type React from "react";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ useCallback,
+ useDeferredValue,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import { useTranslation } from "react-i18next";
import {
StyleSheet,
@@ -19,11 +26,21 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Button } from "../Button";
import { Input } from "../common/Input";
+import { Loader } from "../Loader";
interface Props extends ViewProps {
open: boolean;
setOpen: (open: boolean) => void;
+ /**
+ * Modal ref the opener must use to present() the sheet from inside its
+ * press handler. On the new architecture with Reanimated 4, present()
+ * called from an effect after a state update silently no-ops — the sheet
+ * mounts nothing. Presenting straight from the gesture handler works.
+ */
+ modalRef: React.RefObject;
data?: T[] | null;
+ /** True while the options are loading — shows a loader inside the sheet. */
+ loading?: boolean;
values: T[];
set: (value: T[]) => void;
title: string;
@@ -66,16 +83,18 @@ const LIMIT = 100;
export const FilterSheet = ({
values,
data: _data,
+ loading = false,
open,
set,
setOpen,
+ modalRef,
title,
searchFilter,
renderItemLabel,
disableSearch = false,
multiple = false,
}: Props) => {
- const bottomSheetModalRef = useRef(null);
+ const bottomSheetModalRef = modalRef;
const snapPoints = useMemo(() => ["85%"], []);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -84,19 +103,24 @@ export const FilterSheet = ({
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState("");
+ // Filtering and re-rendering the option list on every keystroke blocks the
+ // JS thread on large lists (2000+ tags); the controlled input then snaps the
+ // native text back to a stale value (lost/reappearing letters). Deferring the
+ // value keeps the keystroke render cheap and runs the list update after.
+ const deferredSearch = useDeferredValue(search);
const [showSearch, setShowSearch] = useState(false);
const filteredData = useMemo(() => {
- if (!search) return _data;
+ if (!deferredSearch) return _data;
const results = [];
for (let i = 0; i < (_data?.length || 0); i++) {
- if (_data && searchFilter?.(_data[i], search)) {
+ if (_data && searchFilter?.(_data[i], deferredSearch)) {
results.push(_data[i]);
}
}
return results.slice(0, 100);
- }, [search, _data, searchFilter]);
+ }, [deferredSearch, _data, searchFilter]);
useEffect(() => {
if (!data || data.length === 0 || disableSearch) return;
@@ -127,21 +151,28 @@ export const FilterSheet = ({
setData(newData);
}, [offset, _data]);
+ // Opening is imperative (see the modalRef prop); this effect only closes.
+ // It also never calls dismiss() on a modal that was never presented.
+ const wasPresentedRef = useRef(false);
useEffect(() => {
- if (open) bottomSheetModalRef.current?.present();
- else bottomSheetModalRef.current?.dismiss();
+ if (!open && wasPresentedRef.current) {
+ bottomSheetModalRef.current?.dismiss();
+ }
}, [open]);
const handleSheetChanges = useCallback((index: number) => {
- if (index === -1) {
+ if (index >= 0) {
+ wasPresentedRef.current = true;
+ } else if (index === -1) {
+ wasPresentedRef.current = false;
setOpen(false);
}
}, []);
const renderData = useMemo(() => {
- if (search.length > 0 && showSearch) return filteredData;
+ if (deferredSearch.length > 0 && showSearch) return filteredData;
return data;
- }, [search, filteredData, data]);
+ }, [deferredSearch, showSearch, filteredData, data]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -154,6 +185,54 @@ export const FilterSheet = ({
[],
);
+ // Memoized so typing in the search input (urgent render with an unchanged
+ // deferred value) doesn't rebuild up to 100 row elements per keystroke.
+ const renderedRows = useMemo(
+ () =>
+ renderData?.map((item, index) => (
+
+ {
+ // Match the deep-equality rule used to render the selected
+ // state below — option objects are recreated across renders,
+ // so reference checks would re-add an already selected item.
+ const isSelected = values.some((value) => isEqual(value, item));
+ if (multiple) {
+ if (!isSelected) set(values.concat(item));
+ else set(values.filter((value) => !isEqual(value, item)));
+
+ setTimeout(() => {
+ setOpen(false);
+ }, 250);
+ } else {
+ if (!isSelected) {
+ set([item]);
+ setTimeout(() => {
+ setOpen(false);
+ }, 250);
+ }
+ }
+ }}
+ className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
+ >
+ {renderItemLabel(item)}
+ {values.some((i) => isEqual(i, item)) ? (
+
+ ) : (
+
+ )}
+
+
+
+ )),
+ [renderData, values, multiple, set, setOpen, renderItemLabel],
+ );
+
return (
({
}}
>
{title}
-
- {t("search.x_items", { count: _data?.length })}
-
+ {loading ? (
+
+
+
+ ) : (
+
+ {t("search.x_items", { count: _data?.length })}
+
+ )}
{showSearch && (
({
}}
className='mb-4 flex flex-col rounded-xl overflow-hidden'
>
- {renderData?.map((item, index) => (
-
- {
- if (multiple) {
- if (!values.includes(item)) set(values.concat(item));
- else set(values.filter((v) => v !== item));
-
- setTimeout(() => {
- setOpen(false);
- }, 250);
- } else {
- if (!values.includes(item)) {
- set([item]);
- setTimeout(() => {
- setOpen(false);
- }, 250);
- }
- }
- }}
- className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
- >
- {renderItemLabel(item)}
- {values.some((i) => isEqual(i, item)) ? (
-
- ) : (
-
- )}
-
-
-
- ))}
+ {renderedRows}
{data.length < (_data?.length || 0) && (