perf(native): memoize list headers and card components to reduce unnecessary re-renders

- Wrap `ContinueWatchingCard` with `memo()` so it only re-renders when its `item` prop changes
- Extract `ListHeaderComponent` in `RecentlyViewedList` into a `useMemo` to avoid recreating the JSX tree on every render
- Move `listHeader` in the person detail screen from a plain JSX variable to `useMemo`, and relocate `departmentLabels` inside it so the `t` reference is stable
- Store the "copied" reset timer in a `useRef` in `IntegrationCard` and clear it on unmount to prevent setState-after-unmount warnings
This commit is contained in:
2026-03-19 16:53:14 -04:00
parent 1670c6f494
commit 4b4d35ce31
8 changed files with 171 additions and 143 deletions
+74 -69
View File
@@ -46,13 +46,6 @@ function calculateAge(birthday: string, deathday?: string | null): number {
export default function PersonDetailScreen() { export default function PersonDetailScreen() {
const { t } = useLingui(); const { t } = useLingui();
const departmentLabels: Record<string, string> = {
Acting: t`Actor`,
Directing: t`Director`,
Writing: t`Writer`,
Production: t`Producer`,
Editing: t`Editor`,
};
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const headerHeight = useHeaderHeight(); const headerHeight = useHeaderHeight();
@@ -138,71 +131,18 @@ export default function PersonDetailScreen() {
[columnWidth, userStatuses, handleQuickAdd, addingKey], [columnWidth, userStatuses, handleQuickAdd, addingKey],
); );
if (isPending) { const listHeader = useMemo(() => {
return ( if (!person) return null;
<ModalLayout>
<View className="flex-1 items-center" style={{ paddingTop: headerHeight + 24 }}>
{/* Profile photo skeleton */}
<Skeleton width={120} height={120} borderRadius={60} />
{/* Name skeleton */}
<Skeleton width={180} height={28} borderRadius={6} style={{ marginTop: 16 }} />
{/* Department badge skeleton */}
<Skeleton width={80} height={24} borderRadius={12} style={{ marginTop: 8 }} />
{/* Bio skeleton */}
<View className="mt-6 gap-2 self-stretch px-4">
<Skeleton width="100%" height={14} />
<Skeleton width="100%" height={14} />
<Skeleton width="60%" height={14} />
</View>
</View>
</ModalLayout>
);
}
if (isError && !data) { const departmentLabels: Record<string, string> = {
return ( Acting: t`Actor`,
<ModalLayout> Directing: t`Director`,
<View className="flex-1 items-center justify-center" style={{ paddingTop: insets.top }}> Writing: t`Writer`,
<Animated.View entering={FadeIn.duration(400)} className="items-center"> Production: t`Producer`,
<IconAlertTriangle size={48} color={mutedForeground} /> Editing: t`Editor`,
<Text className="font-display text-foreground mt-3 text-xl"> };
<Trans>Something went wrong</Trans>
</Text>
<Text className="text-muted-foreground mt-1 text-center text-sm">
<Trans>Could not load person details</Trans>
</Text>
<Pressable onPress={() => back()} className="mt-4">
<Text className="text-primary">
<Trans>Go back</Trans>
</Text>
</Pressable>
</Animated.View>
</View>
</ModalLayout>
);
}
if (!person) {
return ( return (
<ModalLayout>
<View className="flex-1 items-center justify-center" style={{ paddingTop: insets.top }}>
<Animated.View entering={FadeIn.duration(400)} className="items-center">
<IconUser size={48} color={mutedForeground} />
<Text className="font-display text-foreground mt-3 text-xl">
<Trans>Person not found</Trans>
</Text>
<Pressable onPress={() => back()} className="mt-4">
<Text className="text-primary">
<Trans>Go back</Trans>
</Text>
</Pressable>
</Animated.View>
</View>
</ModalLayout>
);
}
const listHeader = (
<> <>
{/* Profile hero */} {/* Profile hero */}
<Animated.View <Animated.View
@@ -281,6 +221,71 @@ export default function PersonDetailScreen() {
)} )}
</> </>
); );
}, [person, filmography.length, insets.top, useAutomaticInsets, primaryColor, t]);
if (isPending) {
return (
<ModalLayout>
<View className="flex-1 items-center" style={{ paddingTop: headerHeight + 24 }}>
{/* Profile photo skeleton */}
<Skeleton width={120} height={120} borderRadius={60} />
{/* Name skeleton */}
<Skeleton width={180} height={28} borderRadius={6} style={{ marginTop: 16 }} />
{/* Department badge skeleton */}
<Skeleton width={80} height={24} borderRadius={12} style={{ marginTop: 8 }} />
{/* Bio skeleton */}
<View className="mt-6 gap-2 self-stretch px-4">
<Skeleton width="100%" height={14} />
<Skeleton width="100%" height={14} />
<Skeleton width="60%" height={14} />
</View>
</View>
</ModalLayout>
);
}
if (isError && !data) {
return (
<ModalLayout>
<View className="flex-1 items-center justify-center" style={{ paddingTop: insets.top }}>
<Animated.View entering={FadeIn.duration(400)} className="items-center">
<IconAlertTriangle size={48} color={mutedForeground} />
<Text className="font-display text-foreground mt-3 text-xl">
<Trans>Something went wrong</Trans>
</Text>
<Text className="text-muted-foreground mt-1 text-center text-sm">
<Trans>Could not load person details</Trans>
</Text>
<Pressable onPress={() => back()} className="mt-4">
<Text className="text-primary">
<Trans>Go back</Trans>
</Text>
</Pressable>
</Animated.View>
</View>
</ModalLayout>
);
}
if (!person) {
return (
<ModalLayout>
<View className="flex-1 items-center justify-center" style={{ paddingTop: insets.top }}>
<Animated.View entering={FadeIn.duration(400)} className="items-center">
<IconUser size={48} color={mutedForeground} />
<Text className="font-display text-foreground mt-3 text-xl">
<Trans>Person not found</Trans>
</Text>
<Pressable onPress={() => back()} className="mt-4">
<Text className="text-primary">
<Trans>Go back</Trans>
</Text>
</Pressable>
</Animated.View>
</View>
</ModalLayout>
);
}
return ( return (
<ModalLayout> <ModalLayout>
@@ -1,5 +1,6 @@
import { useLingui } from "@lingui/react/macro"; import { useLingui } from "@lingui/react/macro";
import { Link } from "expo-router"; import { Link } from "expo-router";
import { memo } from "react";
import { Pressable, View } from "react-native"; import { Pressable, View } from "react-native";
import { GestureDetector } from "react-native-gesture-handler"; import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated"; import Animated from "react-native-reanimated";
@@ -27,7 +28,11 @@ export interface ContinueWatchingItem {
} | null; } | null;
} }
export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) { export const ContinueWatchingCard = memo(function ContinueWatchingCard({
item,
}: {
item: ContinueWatchingItem;
}) {
const { t } = useLingui(); const { t } = useLingui();
const { animatedStyle, gesture: tapGesture } = usePressAnimation(); const { animatedStyle, gesture: tapGesture } = usePressAnimation();
@@ -115,4 +120,4 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
); );
} });
@@ -1,7 +1,7 @@
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { IconHistory, IconSearch } from "@tabler/icons-react-native"; import { IconHistory, IconSearch } from "@tabler/icons-react-native";
import { useCallback } from "react"; import { useCallback, useMemo } from "react";
import { Alert, Pressable, View } from "react-native"; import { Alert, Pressable, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated"; import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useCSSVariable } from "uniwind"; import { useCSSVariable } from "uniwind";
@@ -47,26 +47,8 @@ export function RecentlyViewedList() {
const keyExtractor = useCallback((item: RecentlyViewedItem) => item.id, []); const keyExtractor = useCallback((item: RecentlyViewedItem) => item.id, []);
if (items.length === 0) { const listHeaderComponent = useMemo(
return ( () => (
<Animated.View entering={FadeIn.duration(400)} className="flex-1 items-center justify-center">
<IconSearch size={64} color={mutedForeground} />
<Text className="text-muted-foreground mt-3 text-base">
<Trans>Search for movies, shows, or people</Trans>
</Text>
</Animated.View>
);
}
return (
<Animated.View entering={FadeIn.duration(400)} className="flex-1">
<FlashList
data={items}
keyExtractor={keyExtractor}
renderItem={renderItem}
keyboardShouldPersistTaps="handled"
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={
<Animated.View <Animated.View
entering={FadeInDown.duration(300)} entering={FadeInDown.duration(300)}
className="mb-1 flex-row items-center justify-between px-4 pt-2 pb-1" className="mb-1 flex-row items-center justify-between px-4 pt-2 pb-1"
@@ -87,7 +69,30 @@ export function RecentlyViewedList() {
</Text> </Text>
</Pressable> </Pressable>
</Animated.View> </Animated.View>
),
[primaryColor, handleClear],
);
if (items.length === 0) {
return (
<Animated.View entering={FadeIn.duration(400)} className="flex-1 items-center justify-center">
<IconSearch size={64} color={mutedForeground} />
<Text className="text-muted-foreground mt-3 text-base">
<Trans>Search for movies, shows, or people</Trans>
</Text>
</Animated.View>
);
} }
return (
<Animated.View entering={FadeIn.duration(400)} className="flex-1">
<FlashList
data={items}
keyExtractor={keyExtractor}
renderItem={renderItem}
keyboardShouldPersistTaps="handled"
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={listHeaderComponent}
/> />
</Animated.View> </Animated.View>
); );
@@ -9,7 +9,7 @@ import {
} from "@tabler/icons-react-native"; } from "@tabler/icons-react-native";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Pressable, View } from "react-native"; import { Alert, Pressable, View } from "react-native";
import Animated, { import Animated, {
FadeIn, FadeIn,
@@ -52,6 +52,8 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
const [setupOpen, setSetupOpen] = useState(false); const [setupOpen, setSetupOpen] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const chevronRotation = useSharedValue(0); const chevronRotation = useSharedValue(0);
const setupChevronRotation = useSharedValue(0); const setupChevronRotation = useSharedValue(0);
@@ -110,12 +112,19 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
const url = connection ? config.buildUrl(connection.token) : null; const url = connection ? config.buildUrl(connection.token) : null;
useEffect(() => {
return () => {
if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
};
}, []);
const handleCopy = useCallback(async () => { const handleCopy = useCallback(async () => {
if (!url) return; if (!url) return;
await Clipboard.setStringAsync(url); await Clipboard.setStringAsync(url);
setCopied(true); setCopied(true);
toast.success(t`URL copied to clipboard`); toast.success(t`URL copied to clipboard`);
setTimeout(() => setCopied(false), 2000); if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = setTimeout(() => setCopied(false), 2000);
}, [url, t]); }, [url, t]);
const handleRegenerate = useCallback(() => { const handleRegenerate = useCallback(() => {
@@ -1,37 +1,41 @@
import { useLingui } from "@lingui/react/macro"; import { useLingui } from "@lingui/react/macro";
import { IconCircleCheckFilled, IconCircleDashed } from "@tabler/icons-react-native"; import { IconCircleCheckFilled, IconCircleDashed } from "@tabler/icons-react-native";
import { memo, useCallback } from "react";
import { Pressable, View } from "react-native"; import { Pressable, View } from "react-native";
import { ScaledIcon } from "@/components/ui/scaled-icon"; import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
export function EpisodeRow({ export const EpisodeRow = memo(function EpisodeRow({
episode, episodeId,
episodeNumber,
name,
airDate,
isWatched, isWatched,
onToggle, onToggle,
accentColor, accentColor,
mutedColor, mutedColor,
}: { }: {
episode: { episodeId: string;
id: string;
episodeNumber: number; episodeNumber: number;
name: string | null; name: string | null;
airDate: string | null; airDate: string | null;
};
isWatched: boolean; isWatched: boolean;
onToggle: () => void; onToggle: (episodeId: string) => void;
accentColor: string; accentColor: string;
mutedColor: string; mutedColor: string;
}) { }) {
const { t } = useLingui(); const { t } = useLingui();
const episodeLabel = episode.name ?? t`Episode ${episode.episodeNumber}`; const episodeLabel = name ?? t`Episode ${episodeNumber}`;
const handleToggle = useCallback(() => onToggle(episodeId), [onToggle, episodeId]);
return ( return (
<Pressable <Pressable
onPress={onToggle} onPress={handleToggle}
accessibilityRole="checkbox" accessibilityRole="checkbox"
accessibilityState={{ checked: isWatched }} accessibilityState={{ checked: isWatched }}
accessibilityLabel={t`Episode ${episode.episodeNumber}, ${episodeLabel}`} accessibilityLabel={t`Episode ${episodeNumber}, ${episodeLabel}`}
className="border-border flex-row items-center border-b px-4 py-3" className="border-border flex-row items-center border-b px-4 py-3"
style={{ borderBottomWidth: 0.5 }} style={{ borderBottomWidth: 0.5 }}
> >
@@ -45,12 +49,10 @@ export function EpisodeRow({
className={`font-sans text-sm font-medium ${isWatched ? "text-muted-foreground" : "text-foreground"}`} className={`font-sans text-sm font-medium ${isWatched ? "text-muted-foreground" : "text-foreground"}`}
numberOfLines={1} numberOfLines={1}
> >
{episode.episodeNumber}. {episode.name ?? t`Episode ${episode.episodeNumber}`} {episodeNumber}. {name ?? t`Episode ${episodeNumber}`}
</Text> </Text>
{episode.airDate ? ( {airDate ? <Text className="text-muted-foreground mt-0.5 text-xs">{airDate}</Text> : null}
<Text className="text-muted-foreground mt-0.5 text-xs">{episode.airDate}</Text>
) : null}
</View> </View>
</Pressable> </Pressable>
); );
} });
@@ -157,9 +157,12 @@ export function SeasonAccordion({
{episodes.slice(0, visibleCount).map((episode) => ( {episodes.slice(0, visibleCount).map((episode) => (
<EpisodeRow <EpisodeRow
key={episode.id} key={episode.id}
episode={episode} episodeId={episode.id}
episodeNumber={episode.episodeNumber}
name={episode.name}
airDate={episode.airDate}
isWatched={watchedEpisodeIds.has(episode.id)} isWatched={watchedEpisodeIds.has(episode.id)}
onToggle={() => handleEpisodeToggle(episode.id)} onToggle={handleEpisodeToggle}
accentColor={titleAccentColor} accentColor={titleAccentColor}
mutedColor={mutedFgColor} mutedColor={mutedFgColor}
/> />
@@ -10,7 +10,7 @@ import {
IconStarFilled, IconStarFilled,
} from "@tabler/icons-react-native"; } from "@tabler/icons-react-native";
import { Link } from "expo-router"; import { Link } from "expo-router";
import { useCallback } from "react"; import { memo, useCallback } from "react";
import { Pressable, View } from "react-native"; import { Pressable, View } from "react-native";
import { GestureDetector } from "react-native-gesture-handler"; import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated"; import Animated from "react-native-reanimated";
@@ -40,7 +40,7 @@ interface PosterCardProps {
isAdding?: boolean; isAdding?: boolean;
} }
export function PosterCard({ export const PosterCard = memo(function PosterCard({
id, id,
title, title,
type, type,
@@ -243,7 +243,7 @@ export function PosterCard({
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
); );
} });
export function PosterCardSkeleton({ width = 140 }: { width?: number }) { export function PosterCardSkeleton({ width = 140 }: { width?: number }) {
const imageHeight = width * 1.5; const imageHeight = width * 1.5;
+6 -7
View File
@@ -4,8 +4,8 @@
* Load order matters — each polyfill depends on the ones before it. * Load order matters — each polyfill depends on the ones before it.
* See: https://formatjs.github.io/docs/guides/react-native-hermes * See: https://formatjs.github.io/docs/guides/react-native-hermes
* *
* We use `polyfill-force` to always install the polyfill regardless of * We use `polyfill` (not `polyfill-force`) so Hermes's native Intl
* partial Hermes Intl support, ensuring consistent behavior. * implementations are preserved when available, and only gaps are filled.
*/ */
// 1. getCanonicalLocales (no dependencies) // 1. getCanonicalLocales (no dependencies)
@@ -13,7 +13,7 @@ import "@formatjs/intl-getcanonicallocales/polyfill.js";
// 2. Locale (depends on getCanonicalLocales) // 2. Locale (depends on getCanonicalLocales)
import "@formatjs/intl-locale/polyfill.js"; import "@formatjs/intl-locale/polyfill.js";
// 3. PluralRules (depends on Locale) // 3. PluralRules (depends on Locale)
import "@formatjs/intl-pluralrules/polyfill-force.js"; import "@formatjs/intl-pluralrules/polyfill.js";
import "@formatjs/intl-pluralrules/locale-data/en.js"; import "@formatjs/intl-pluralrules/locale-data/en.js";
import "@formatjs/intl-pluralrules/locale-data/fr.js"; import "@formatjs/intl-pluralrules/locale-data/fr.js";
import "@formatjs/intl-pluralrules/locale-data/de.js"; import "@formatjs/intl-pluralrules/locale-data/de.js";
@@ -21,7 +21,7 @@ import "@formatjs/intl-pluralrules/locale-data/es.js";
import "@formatjs/intl-pluralrules/locale-data/it.js"; import "@formatjs/intl-pluralrules/locale-data/it.js";
import "@formatjs/intl-pluralrules/locale-data/pt.js"; import "@formatjs/intl-pluralrules/locale-data/pt.js";
// 4. NumberFormat (depends on PluralRules, Locale) // 4. NumberFormat (depends on PluralRules, Locale)
import "@formatjs/intl-numberformat/polyfill-force.js"; import "@formatjs/intl-numberformat/polyfill.js";
import "@formatjs/intl-numberformat/locale-data/en.js"; import "@formatjs/intl-numberformat/locale-data/en.js";
import "@formatjs/intl-numberformat/locale-data/fr.js"; import "@formatjs/intl-numberformat/locale-data/fr.js";
import "@formatjs/intl-numberformat/locale-data/de.js"; import "@formatjs/intl-numberformat/locale-data/de.js";
@@ -29,16 +29,15 @@ import "@formatjs/intl-numberformat/locale-data/es.js";
import "@formatjs/intl-numberformat/locale-data/it.js"; import "@formatjs/intl-numberformat/locale-data/it.js";
import "@formatjs/intl-numberformat/locale-data/pt.js"; import "@formatjs/intl-numberformat/locale-data/pt.js";
// 5. DateTimeFormat (depends on Locale) // 5. DateTimeFormat (depends on Locale)
import "@formatjs/intl-datetimeformat/polyfill-force.js"; import "@formatjs/intl-datetimeformat/polyfill.js";
import "@formatjs/intl-datetimeformat/locale-data/en.js"; import "@formatjs/intl-datetimeformat/locale-data/en.js";
import "@formatjs/intl-datetimeformat/locale-data/fr.js"; import "@formatjs/intl-datetimeformat/locale-data/fr.js";
import "@formatjs/intl-datetimeformat/locale-data/de.js"; import "@formatjs/intl-datetimeformat/locale-data/de.js";
import "@formatjs/intl-datetimeformat/locale-data/es.js"; import "@formatjs/intl-datetimeformat/locale-data/es.js";
import "@formatjs/intl-datetimeformat/locale-data/it.js"; import "@formatjs/intl-datetimeformat/locale-data/it.js";
import "@formatjs/intl-datetimeformat/locale-data/pt.js"; import "@formatjs/intl-datetimeformat/locale-data/pt.js";
import "@formatjs/intl-datetimeformat/add-all-tz.js";
// 6. RelativeTimeFormat (depends on PluralRules, Locale) // 6. RelativeTimeFormat (depends on PluralRules, Locale)
import "@formatjs/intl-relativetimeformat/polyfill-force.js"; import "@formatjs/intl-relativetimeformat/polyfill.js";
import "@formatjs/intl-relativetimeformat/locale-data/en.js"; import "@formatjs/intl-relativetimeformat/locale-data/en.js";
import "@formatjs/intl-relativetimeformat/locale-data/fr.js"; import "@formatjs/intl-relativetimeformat/locale-data/fr.js";
import "@formatjs/intl-relativetimeformat/locale-data/de.js"; import "@formatjs/intl-relativetimeformat/locale-data/de.js";