fix: correct dependency arrays and add null checks

Fixes missing dependencies in useMemo and useCallback hooks to prevent stale closures and potential bugs.

Adds null/undefined guards before navigation in music components to prevent crashes when attempting to navigate with missing IDs.

Corrects query key from "company" to "genre" in genre page to ensure proper cache invalidation.

Updates Jellyseerr references to Seerr throughout documentation and error messages for consistency.

Improves type safety by adding error rejection handling in SeerrApi and memoizing components to optimize re-renders.
This commit is contained in:
Uruk
2026-01-14 10:24:57 +01:00
parent 32f4bbcc7d
commit 12047cbe12
24 changed files with 68 additions and 53 deletions

View File

@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Seerr integration.
## Development Commands

View File

@@ -57,7 +57,7 @@ export default function CompanyPage() {
),
"id",
) ?? [],
[data],
[data, isSeerrMovieOrTvResult],
);
const backdrops = useMemo(

View File

@@ -20,7 +20,7 @@ export default function GenrePage() {
};
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["seerr", "company", type, genreId],
queryKey: ["seerr", "genre", type, genreId],
queryFn: async ({ pageParam }) => {
const params: any = {
page: Number(pageParam),

View File

@@ -26,7 +26,7 @@ export const TrackSheet: React.FC<Props> = ({
const streams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
[source],
[source, streamType],
);
const selectedSteam = useMemo(

View File

@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
api,
item: library,
}),
[library],
[api, library],
);
const itemType = useMemo(() => {

View File

@@ -23,6 +23,7 @@ export const MusicAlbumCard: React.FC<Props> = ({ album, width = 130 }) => {
);
const handlePress = useCallback(() => {
if (!album.Id) return;
router.push(`/music/album/${album.Id}`);
}, [router, album.Id]);

View File

@@ -24,6 +24,7 @@ export const MusicAlbumRowCard: React.FC<Props> = ({ album }) => {
);
const handlePress = useCallback(() => {
if (!album.Id) return;
router.push(`/music/album/${album.Id}`);
}, [router, album.Id]);

View File

@@ -25,6 +25,7 @@ export const MusicArtistCard: React.FC<Props> = ({ artist }) => {
);
const handlePress = useCallback(() => {
if (!artist.Id) return;
router.push(`/music/artist/${artist.Id}`);
}, [router, artist.Id]);

View File

@@ -170,7 +170,7 @@ const SeerrPoster: React.FC<Props> = ({
className='absolute right-1 top-1 text-right bg-black border border-neutral-800/50'
text={mediaRequest?.requestedBy.displayName}
/>
{requestedSeasons.length > 0 && (
{(requestedSeasons?.length ?? 0) > 0 && (
<Tags
className='absolute bottom-1 left-0.5 w-32'
tagProps={{

View File

@@ -20,12 +20,13 @@ const CastSlide: React.FC<
horizontal
showsHorizontalScrollIndicator={false}
data={details?.credits.cast}
estimatedItemSize={112}
ItemSeparatorComponent={() => <View className='w-2' />}
keyExtractor={(item) => item?.id?.toString()}
keyExtractor={(item) => item?.id?.toString() ?? ""}
contentContainerStyle={{ paddingHorizontal: 16 }}
renderItem={({ item }) => (
<PersonPoster
id={item.id.toString()}
id={item?.id?.toString() ?? ""}
posterPath={item.profilePath}
name={item.name}
subName={item.character}

View File

@@ -58,7 +58,7 @@ const DetailFacts: React.FC<
(details as MovieDetails)?.releases?.results.find(
(r: TmdbRelease) => r.iso_3166_1 === region,
)?.release_dates as TmdbRelease["release_dates"],
[details],
[details, region],
);
// Release date types:
@@ -82,7 +82,7 @@ const DetailFacts: React.FC<
if (firstAirDate) {
return new Date(firstAirDate).toLocaleDateString(locale, dateOpts);
}
}, [details]);
}, [details, locale]);
const nextAirDate = useMemo(() => {
const firstAirDate = (details as TvDetails)?.firstAirDate;
@@ -90,7 +90,7 @@ const DetailFacts: React.FC<
if (nextAirDate && firstAirDate !== nextAirDate) {
return new Date(nextAirDate).toLocaleDateString(locale, dateOpts);
}
}, [details]);
}, [details, locale]);
const revenue = useMemo(
() =>
@@ -98,7 +98,7 @@ const DetailFacts: React.FC<
style: "currency",
currency: "USD",
}),
[details],
[details, locale],
);
const budget = useMemo(
@@ -107,7 +107,7 @@ const DetailFacts: React.FC<
style: "currency",
currency: "USD",
}),
[details],
[details, locale],
);
const streamingProviders = useMemo(
@@ -115,7 +115,7 @@ const DetailFacts: React.FC<
details?.watchProviders?.find(
(provider) => provider.iso_3166_1 === region,
)?.flatrate,
[details],
[details, region],
);
const networks = useMemo(() => (details as TvDetails)?.networks, [details]);

View File

@@ -1,16 +1,10 @@
import React from "react";
import { View } from "react-native";
interface Props {
index: number;
}
// Dev note might be a good idea to standardize skeletons across the app and have one "file" for it.
export const GridSkeleton: React.FC<Props> = ({ index }) => {
export const GridSkeleton = React.memo(() => {
return (
<View
key={index}
className='flex flex-col mr-2 h-auto'
style={{ width: "30.5%" }}
>
<View className='flex flex-col mr-2 h-auto' style={{ width: "30.5%" }}>
<View className='relative rounded-lg overflow-hidden border border-neutral-900 w-full mt-4 aspect-[10/15] bg-neutral-800' />
<View className='mt-2 flex flex-col w-full'>
<View className='h-4 bg-neutral-800 rounded mb-1' />
@@ -18,4 +12,4 @@ export const GridSkeleton: React.FC<Props> = ({ index }) => {
</View>
</View>
);
};
});

View File

@@ -40,6 +40,8 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
</View>
</TouchableOpacity>
);
return null;
};
export default PersonPoster;

View File

@@ -38,7 +38,16 @@ const RequestModal = forwardRef<
Props & Omit<ViewProps, "id">
>(
(
{ id, title, requestBody, type, isAnime = false, onRequested, onDismiss },
{
id,
title,
requestBody,
type,
isAnime = false,
is4k,
onRequested,
onDismiss,
},
ref,
) => {
const { seerrApi, seerrUser, requestMedia } = useSeerr();
@@ -258,7 +267,8 @@ const RequestModal = forwardRef<
const request = useCallback(() => {
const body = {
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
is4k:
is4k ?? defaultService?.is4k ?? defaultServiceDetails?.server.is4k,
profileId: defaultProfile?.id,
rootFolder: defaultFolder?.path,
tags: defaultTags.map((t) => t.id),
@@ -274,11 +284,18 @@ const RequestModal = forwardRef<
onRequested,
);
}, [
is4k,
defaultService?.is4k,
defaultServiceDetails?.server.is4k,
requestBody,
requestOverrides,
defaultProfile,
defaultFolder,
defaultTags,
requestMedia,
seasonTitle,
title,
onRequested,
]);
return (

View File

@@ -113,7 +113,7 @@ export const SeerrIndexPage: React.FC<Props> = ({
],
order || "desc",
),
[seerrResults, sortingType, order],
[seerrResults, sortingType, order, searchQuery],
);
const seerrTvResults = useMemo(
@@ -125,7 +125,7 @@ export const SeerrIndexPage: React.FC<Props> = ({
],
order || "desc",
),
[seerrResults, sortingType, order],
[seerrResults, sortingType, order, searchQuery],
);
const seerrPersonResults = useMemo(
@@ -137,7 +137,7 @@ export const SeerrIndexPage: React.FC<Props> = ({
],
order || "desc",
),
[seerrResults, sortingType, order],
[seerrResults, sortingType, order, searchQuery],
);
if (!searchQuery.length)

View File

@@ -26,7 +26,7 @@ const CompanySlide: React.FC<
pathname: `/(auth)/(tabs)/${from}/seerr/company/${id}` as any,
params: { id, image, name, type: slide.type },
}),
[slide],
[router, from, slide.type],
);
return (

View File

@@ -23,7 +23,6 @@ const Discover: React.FC<Props> = ({ sliders }) => {
sortBy(
(sliders ?? []).filter((s) => s.enabled),
"order",
"asc",
),
[sliders],
);

View File

@@ -1,6 +1,6 @@
import { Image, type ImageContentFit } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import type React from "react";
import React from "react";
import { StyleSheet, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
@@ -67,4 +67,4 @@ const GenericSlideCard: React.FC<
</>
);
export default GenericSlideCard;
export default React.memo(GenericSlideCard);

View File

@@ -23,9 +23,8 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
pathname: `/(auth)/(tabs)/${from}/seerr/genre/${genre.id}` as any,
params: { type: slide.type, name: genre.name },
}),
[slide],
[router, from, slide.type],
);
const { data } = useQuery({
queryKey: ["seerr", "discover", slide.type, slide.id],
queryFn: async () => {

View File

@@ -64,7 +64,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
.flatMap((p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r))),
"id",
),
[data],
[data, isSeerrMovieOrTvResult],
);
return (

View File

@@ -14,10 +14,7 @@ export interface SlideProps {
interface Props<T> extends SlideProps {
data: T[];
renderItem: (
item: T,
index: number,
) => React.ComponentType<any> | React.ReactElement | null | undefined;
renderItem: (item: T, index: number) => React.ReactElement | null;
keyExtractor: (item: T) => string;
onEndReached?: (() => void) | null | undefined;
}

View File

@@ -424,6 +424,7 @@ export class SeerrApi {
},
(error) => {
console.error("Seerr request error", error);
return Promise.reject(error);
},
);
}
@@ -448,7 +449,7 @@ export const useSeerr = () => {
clearSeerrStorageData();
setSeerrUser(undefined);
updateSettings({ seerrServerUrl: undefined });
}, []);
}, [setSeerrUser, updateSettings]);
const requestMedia = useCallback(
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
@@ -474,18 +475,20 @@ export const useSeerr = () => {
}
});
},
[seerrApi],
[seerrApi, queryClient],
);
const isSeerrMovieOrTvResult = (
items: any | null | undefined,
): items is MovieResult | TvResult => {
return (
items &&
Object.hasOwn(items, "mediaType") &&
(items.mediaType === MediaType.MOVIE || items.mediaType === MediaType.TV)
);
};
const isSeerrMovieOrTvResult = useCallback(
(items: any | null | undefined): items is MovieResult | TvResult => {
return (
items &&
Object.hasOwn(items, "mediaType") &&
(items.mediaType === MediaType.MOVIE ||
items.mediaType === MediaType.TV)
);
},
[],
);
const getTitle = (
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,

View File

@@ -176,7 +176,7 @@ function runTypeCheck() {
} catch (error) {
const errorOutput = (error && (error.stderr || error.stdout)) || "";
// Filter out jellyseerr utils errors - this is a third-party git submodule
// Filter out seerr utils errors - this is a third-party git submodule
// that generates a large volume of known type errors
const filteredLines = errorOutput.split("\n").filter((line) => {
const trimmedLine = line.trim();

View File

@@ -719,7 +719,7 @@
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"toasts": {
"seer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"seerr_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"seerr_test_failed": "Seerr test failed. Please try again.",
"failed_to_test_seerr_server_url": "Failed to test Seerr server URL",
"issue_submitted": "Issue Submitted!",