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
+92 -87
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,6 +131,98 @@ export default function PersonDetailScreen() {
[columnWidth, userStatuses, handleQuickAdd, addingKey], [columnWidth, userStatuses, handleQuickAdd, addingKey],
); );
const listHeader = useMemo(() => {
if (!person) return null;
const departmentLabels: Record<string, string> = {
Acting: t`Actor`,
Directing: t`Director`,
Writing: t`Writer`,
Production: t`Producer`,
Editing: t`Editor`,
};
return (
<>
{/* Profile hero */}
<Animated.View
entering={FadeIn.duration(400)}
className="items-center"
style={{
paddingTop: useAutomaticInsets ? 16 : insets.top + 56,
paddingBottom: 24,
}}
>
<View className="bg-secondary size-[120px] overflow-hidden rounded-full">
{person.profilePath && (
<Image
source={{ uri: person.profilePath }}
thumbHash={person.profileThumbHash}
style={{ width: "100%", height: "100%" }}
contentFit="cover"
/>
)}
</View>
<Text className="font-display text-foreground mt-4 text-center text-3xl">
{person.name}
</Text>
{person.knownForDepartment ? (
<View className="bg-secondary mt-2 rounded-full px-3 py-1">
<Text className="text-muted-foreground text-xs tracking-wider uppercase">
{departmentLabels[person.knownForDepartment] ?? person.knownForDepartment}
</Text>
</View>
) : null}
{person.birthday || person.placeOfBirth ? (
<View className="mt-3 items-center gap-1.5">
{person.birthday ? (
<View className="flex-row items-center gap-1.5">
<ScaledIcon icon={IconCalendar} size={14} color={primaryColor} />
<Text selectable className="text-muted-foreground text-sm">
{formatDate(person.birthday)}
<Text className="text-muted-foreground/60 text-sm">
{(() => {
const age = calculateAge(person.birthday, person.deathday);
return person.deathday ? ` (${t`died at ${age}`})` : ` (${t`age ${age}`})`;
})()}
</Text>
</Text>
</View>
) : null}
{person.placeOfBirth ? (
<View className="flex-row items-center gap-1.5">
<ScaledIcon icon={IconMapPin} size={14} color={primaryColor} />
<Text selectable className="text-muted-foreground text-sm">
{person.placeOfBirth}
</Text>
</View>
) : null}
</View>
) : null}
</Animated.View>
{/* Biography */}
{person.biography ? (
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<View className="mb-6 px-4">
<ExpandableText text={person.biography} maxLines={4} />
</View>
</Animated.View>
) : null}
{/* Filmography section header */}
{filmography.length > 0 && (
<Animated.View entering={FadeInDown.duration(300).delay(200)} className="px-4">
<SectionHeader title={t`Filmography`} icon={IconMovie} />
</Animated.View>
)}
</>
);
}, [person, filmography.length, insets.top, useAutomaticInsets, primaryColor, t]);
if (isPending) { if (isPending) {
return ( return (
<ModalLayout> <ModalLayout>
@@ -202,86 +287,6 @@ export default function PersonDetailScreen() {
); );
} }
const listHeader = (
<>
{/* Profile hero */}
<Animated.View
entering={FadeIn.duration(400)}
className="items-center"
style={{
paddingTop: useAutomaticInsets ? 16 : insets.top + 56,
paddingBottom: 24,
}}
>
<View className="bg-secondary size-[120px] overflow-hidden rounded-full">
{person.profilePath && (
<Image
source={{ uri: person.profilePath }}
thumbHash={person.profileThumbHash}
style={{ width: "100%", height: "100%" }}
contentFit="cover"
/>
)}
</View>
<Text className="font-display text-foreground mt-4 text-center text-3xl">
{person.name}
</Text>
{person.knownForDepartment ? (
<View className="bg-secondary mt-2 rounded-full px-3 py-1">
<Text className="text-muted-foreground text-xs tracking-wider uppercase">
{departmentLabels[person.knownForDepartment] ?? person.knownForDepartment}
</Text>
</View>
) : null}
{person.birthday || person.placeOfBirth ? (
<View className="mt-3 items-center gap-1.5">
{person.birthday ? (
<View className="flex-row items-center gap-1.5">
<ScaledIcon icon={IconCalendar} size={14} color={primaryColor} />
<Text selectable className="text-muted-foreground text-sm">
{formatDate(person.birthday)}
<Text className="text-muted-foreground/60 text-sm">
{(() => {
const age = calculateAge(person.birthday, person.deathday);
return person.deathday ? ` (${t`died at ${age}`})` : ` (${t`age ${age}`})`;
})()}
</Text>
</Text>
</View>
) : null}
{person.placeOfBirth ? (
<View className="flex-row items-center gap-1.5">
<ScaledIcon icon={IconMapPin} size={14} color={primaryColor} />
<Text selectable className="text-muted-foreground text-sm">
{person.placeOfBirth}
</Text>
</View>
) : null}
</View>
) : null}
</Animated.View>
{/* Biography */}
{person.biography ? (
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<View className="mb-6 px-4">
<ExpandableText text={person.biography} maxLines={4} />
</View>
</Animated.View>
) : null}
{/* Filmography section header */}
{filmography.length > 0 && (
<Animated.View entering={FadeInDown.duration(300).delay(200)} className="px-4">
<SectionHeader title={t`Filmography`} icon={IconMovie} />
</Animated.View>
)}
</>
);
return ( return (
<ModalLayout> <ModalLayout>
<FlashList <FlashList
@@ -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,6 +47,32 @@ export function RecentlyViewedList() {
const keyExtractor = useCallback((item: RecentlyViewedItem) => item.id, []); const keyExtractor = useCallback((item: RecentlyViewedItem) => item.id, []);
const listHeaderComponent = useMemo(
() => (
<Animated.View
entering={FadeInDown.duration(300)}
className="mb-1 flex-row items-center justify-between px-4 pt-2 pb-1"
>
<View className="flex-row items-center gap-2">
<IconHistory size={20} color={primaryColor} />
<Text className="font-display text-foreground text-xl tracking-tight">
<Trans>Recently Viewed</Trans>
</Text>
</View>
<Pressable
onPress={handleClear}
hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
>
<Text className="text-primary text-sm">
<Trans>Clear</Trans>
</Text>
</Pressable>
</Animated.View>
),
[primaryColor, handleClear],
);
if (items.length === 0) { if (items.length === 0) {
return ( return (
<Animated.View entering={FadeIn.duration(400)} className="flex-1 items-center justify-center"> <Animated.View entering={FadeIn.duration(400)} className="flex-1 items-center justify-center">
@@ -66,28 +92,7 @@ export function RecentlyViewedList() {
renderItem={renderItem} renderItem={renderItem}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={ ListHeaderComponent={listHeaderComponent}
<Animated.View
entering={FadeInDown.duration(300)}
className="mb-1 flex-row items-center justify-between px-4 pt-2 pb-1"
>
<View className="flex-row items-center gap-2">
<IconHistory size={20} color={primaryColor} />
<Text className="font-display text-foreground text-xl tracking-tight">
<Trans>Recently Viewed</Trans>
</Text>
</View>
<Pressable
onPress={handleClear}
hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
>
<Text className="text-primary text-sm">
<Trans>Clear</Trans>
</Text>
</Pressable>
</Animated.View>
}
/> />
</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";