From d12d62863e1f377d5d3cefd8bcc806e11dec54b2 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Wed, 10 Jun 2026 22:31:45 +0200 Subject: [PATCH] fix(filters): present the filter sheet from the press handler On the new architecture with Reanimated 4, BottomSheetModal.present() called from a useEffect after a state update silently no-ops: the press registered, open flipped to true, the effect called present() on a valid ref - and nothing mounted (no onChange, nothing in the native tree). Sheets that present() directly inside their press handler (downloads, account picker) kept working, which is what pinned it down. FilterSheet now takes a modalRef and the opener presents imperatively from the gesture handler. The [open] effect only handles closing, and never dismisses a modal that was never presented. The sheet also opens immediately with a loader while options load, instead of the old data-loaded press gate that left the button silently dead. This restores genre/year/tag/sort filters in libraries and collections, and the same pattern is applied to the bitrate/media-source/track sheets that share FilterSheet. --- components/BitRateSheet.tsx | 10 ++++++-- components/MediaSourceSheet.tsx | 10 ++++++-- components/TrackSheet.tsx | 10 ++++++-- components/filters/FilterButton.tsx | 16 +++++++++--- components/filters/FilterSheet.tsx | 39 +++++++++++++++++++++++------ 5 files changed, 69 insertions(+), 16 deletions(-) diff --git a/components/BitRateSheet.tsx b/components/BitRateSheet.tsx index 0589cd05..6a9a89c5 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 = ({ = ({ return streams; }, [streams, streamType, noneOption]); const [open, setOpen] = useState(false); + const sheetModalRef = useRef(null); if (isTv || (streams && streams.length === 0)) return null; @@ -58,7 +60,10 @@ export const TrackSheet: React.FC = ({ {title} setOpen(true)} + onPress={() => { + setOpen(true); + sheetModalRef.current?.present(); + }} > {selected === -1 && streamType === "Subtitle" @@ -70,6 +75,7 @@ export const TrackSheet: React.FC = ({ ({ ...props }: FilterButtonProps) => { const [open, setOpen] = useState(false); + const sheetModalRef = useRef(null); - const { data: filters } = useQuery({ + const { data: filters, isLoading } = useQuery({ queryKey: ["filters", title, queryKey, id], queryFn, staleTime: 0, @@ -44,9 +46,15 @@ export const FilterButton = ({ return ( <> + {/* present() must be called here, inside the press handler: calling it + from an effect after a state update silently no-ops on the new + architecture and the sheet never appears. Opening immediately also + replaces the old data-loaded gate that left the button silently + dead while options were still loading (the sheet shows a loader). */} { - filters?.length && setOpen(true); + setOpen(true); + sheetModalRef.current?.present(); }} > ({ title={title} open={open} setOpen={setOpen} + modalRef={sheetModalRef} + loading={isLoading} data={filters} values={values} set={set} diff --git a/components/filters/FilterSheet.tsx b/components/filters/FilterSheet.tsx index 27075f8b..116d8ec5 100644 --- a/components/filters/FilterSheet.tsx +++ b/components/filters/FilterSheet.tsx @@ -19,11 +19,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 +76,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(); @@ -127,13 +139,20 @@ 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); } }, []); @@ -182,9 +201,15 @@ export const FilterSheet = ({ }} > {title} - - {t("search.x_items", { count: _data?.length })} - + {loading ? ( + + + + ) : ( + + {t("search.x_items", { count: _data?.length })} + + )} {showSearch && (