Optimize frontend: memoization, modern React hooks, reduced render overhead

- useTimeAgo: replace per-instance intervals with shared useSyncExternalStore ticker
- useTiltEffect: gate to fine-pointer devices, skip motion graphs on touch
- Carousel: bake WheelGesturesPlugin into primitive, remove from all consumers
- TitleSeasons: use memoized Set + precomputed progress map instead of Array.includes
- useSearch: stabilize results identity with useMemo
- CommandPalette: hoist shortcut grouping to module scope, stabilize effect deps
- StarRating: hoist static spring transition to module constant
- Settings toggles: use useOptimistic + useTransition for auto-rollback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 16:15:08 -05:00
co-authored by Claude Opus 4.6
parent d805b8b738
commit 7a3052250d
13 changed files with 122 additions and 94 deletions
@@ -1,6 +1,5 @@
"use client"; "use client";
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
import { import {
Carousel, Carousel,
CarouselContent, CarouselContent,
@@ -19,7 +18,6 @@ export function ContinueWatchingList({
return ( return (
<Carousel <Carousel
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }} opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
className="-mx-4 sm:-mx-0" className="-mx-4 sm:-mx-0"
> >
<CarouselContent className="px-4 sm:px-0"> <CarouselContent className="px-4 sm:px-0">
@@ -1,6 +1,5 @@
"use client"; "use client";
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
import { createStore, Provider, useAtom, useAtomValue } from "jotai"; import { createStore, Provider, useAtom, useAtomValue } from "jotai";
import { useState } from "react"; import { useState } from "react";
import { TitleCardSkeleton } from "@/components/skeletons"; import { TitleCardSkeleton } from "@/components/skeletons";
@@ -162,7 +161,6 @@ function FilterableTitleRowInner({
dragFree: true, dragFree: true,
containScroll: "trimSnaps", containScroll: "trimSnaps",
}} }}
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
className="-mx-6 sm:-mx-2 carousel-tilt" className="-mx-6 sm:-mx-2 carousel-tilt"
> >
<CarouselContent className="px-6 sm:px-2"> <CarouselContent className="px-6 sm:px-2">
@@ -1,6 +1,5 @@
"use client"; "use client";
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
import { TitleCard } from "@/components/title-card"; import { TitleCard } from "@/components/title-card";
import { import {
Carousel, Carousel,
@@ -44,7 +43,6 @@ export function TitleRow({
</div> </div>
<Carousel <Carousel
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }} opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
className="-mx-6 sm:-mx-2 carousel-tilt" className="-mx-6 sm:-mx-2 carousel-tilt"
> >
<CarouselContent className="px-6 sm:px-2"> <CarouselContent className="px-6 sm:px-2">
@@ -1,7 +1,7 @@
"use client"; "use client";
import { IconDoorEnter } from "@tabler/icons-react"; import { IconDoorEnter } from "@tabler/icons-react";
import { useState } from "react"; import { useOptimistic, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card"; import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
@@ -15,21 +15,20 @@ export function RegistrationSection({
const [registrationOpen, setRegistrationOpen] = useState( const [registrationOpen, setRegistrationOpen] = useState(
initialRegistrationOpen, initialRegistrationOpen,
); );
const [toggling, setToggling] = useState(false); const [optimisticOpen, setOptimisticOpen] = useOptimistic(registrationOpen);
const [isPending, startTransition] = useTransition();
async function handleToggle(checked: boolean) { function handleToggle(checked: boolean) {
const previous = registrationOpen; startTransition(async () => {
setRegistrationOpen(checked); setOptimisticOpen(checked);
setToggling(true); try {
try { await toggleRegistration(checked);
await toggleRegistration(checked); setRegistrationOpen(checked);
toast.success(checked ? "Registration opened" : "Registration closed"); toast.success(checked ? "Registration opened" : "Registration closed");
} catch { } catch {
setRegistrationOpen(previous); toast.error("Failed to update registration setting");
toast.error("Failed to update registration setting"); }
} finally { });
setToggling(false);
}
} }
return ( return (
@@ -47,9 +46,9 @@ export function RegistrationSection({
</div> </div>
</div> </div>
<Switch <Switch
checked={registrationOpen} checked={optimisticOpen}
onCheckedChange={handleToggle} onCheckedChange={handleToggle}
disabled={toggling} disabled={isPending}
aria-label="Toggle open registration" aria-label="Toggle open registration"
/> />
</div> </div>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { IconWorldUpload } from "@tabler/icons-react"; import { IconWorldUpload } from "@tabler/icons-react";
import { useState } from "react"; import { useOptimistic, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card"; import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
@@ -13,23 +13,22 @@ export function UpdateCheckSection({
initialEnabled: boolean; initialEnabled: boolean;
}) { }) {
const [enabled, setEnabled] = useState(initialEnabled); const [enabled, setEnabled] = useState(initialEnabled);
const [toggling, setToggling] = useState(false); const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled);
const [isPending, startTransition] = useTransition();
async function handleToggle(checked: boolean) { function handleToggle(checked: boolean) {
const previous = enabled; startTransition(async () => {
setEnabled(checked); setOptimisticEnabled(checked);
setToggling(true); try {
try { await toggleUpdateCheck(checked);
await toggleUpdateCheck(checked); setEnabled(checked);
toast.success( toast.success(
checked ? "Update checks enabled" : "Update checks disabled", checked ? "Update checks enabled" : "Update checks disabled",
); );
} catch { } catch {
setEnabled(previous); toast.error("Failed to update setting");
toast.error("Failed to update setting"); }
} finally { });
setToggling(false);
}
} }
return ( return (
@@ -50,9 +49,9 @@ export function UpdateCheckSection({
</div> </div>
</div> </div>
<Switch <Switch
checked={enabled} checked={optimisticEnabled}
onCheckedChange={handleToggle} onCheckedChange={handleToggle}
disabled={toggling} disabled={isPending}
aria-label="Toggle automatic update checks" aria-label="Toggle automatic update checks"
/> />
</div> </div>
@@ -1,7 +1,6 @@
"use client"; "use client";
import { IconUser, IconUsers } from "@tabler/icons-react"; import { IconUser, IconUsers } from "@tabler/icons-react";
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { import {
@@ -31,7 +30,6 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
dragFree: true, dragFree: true,
containScroll: "trimSnaps", containScroll: "trimSnaps",
}} }}
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
className="-mx-4 sm:-mx-0" className="-mx-4 sm:-mx-0"
> >
<CarouselContent className="px-4 sm:px-0"> <CarouselContent className="px-4 sm:px-0">
@@ -4,6 +4,12 @@ import { IconStar, IconStarFilled } from "@tabler/icons-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useState } from "react"; import { useState } from "react";
const springTransition = {
type: "spring" as const,
stiffness: 400,
damping: 15,
};
interface StarRatingProps { interface StarRatingProps {
value: number; value: number;
onChange: (value: number) => void; onChange: (value: number) => void;
@@ -36,11 +42,7 @@ export function StarRating({ value, onChange }: StarRatingProps) {
animate={ animate={
filled && star === value ? { scale: [1, 1.25, 1] } : { scale: 1 } filled && star === value ? { scale: [1, 1.25, 1] } : { scale: 1 }
} }
transition={{ transition={springTransition}
type: "spring" as const,
stiffness: 400,
damping: 15,
}}
> >
{filled ? ( {filled ? (
<IconStarFilled className="size-4.5 text-primary" /> <IconStarFilled className="size-4.5 text-primary" />
@@ -11,7 +11,7 @@ import { format, parseISO } from "date-fns";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -50,6 +50,7 @@ export function TitleSeasons({
const seasons = useAtomValue(seasonsAtom); const seasons = useAtomValue(seasonsAtom);
const episodeWatches = useAtomValue(episodeWatchesAtom); const episodeWatches = useAtomValue(episodeWatchesAtom);
const watchedSet = useMemo(() => new Set(episodeWatches), [episodeWatches]);
const userStatus = useAtomValue(userStatusAtom); const userStatus = useAtomValue(userStatusAtom);
const watchingEp = useAtomValue(watchingEpAtom); const watchingEp = useAtomValue(watchingEpAtom);
const { const {
@@ -58,6 +59,18 @@ export function TitleSeasons({
handleUnmarkSeason, handleUnmarkSeason,
handleMarkAllWatched, handleMarkAllWatched,
} = useTitleActions(); } = useTitleActions();
const seasonProgress = useMemo(() => {
const map = new Map<string, number>();
for (const season of seasons) {
let count = 0;
for (const ep of season.episodes) {
if (watchedSet.has(ep.id)) count++;
}
map.set(season.id, count);
}
return map;
}, [seasons, watchedSet]);
const [openSeason, setOpenSeason] = useState<number | null>(null); const [openSeason, setOpenSeason] = useState<number | null>(null);
const [markAllOpen, setMarkAllOpen] = useState(false); const [markAllOpen, setMarkAllOpen] = useState(false);
@@ -110,9 +123,7 @@ export function TitleSeasons({
<div className="space-y-2"> <div className="space-y-2">
{seasons.map((season) => { {seasons.map((season) => {
const isOpen = openSeason === season.seasonNumber; const isOpen = openSeason === season.seasonNumber;
const watchedCount = season.episodes.filter((ep) => const watchedCount = seasonProgress.get(season.id) ?? 0;
episodeWatches.includes(ep.id),
).length;
const totalCount = season.episodes.length; const totalCount = season.episodes.length;
const progressPercent = const progressPercent =
totalCount > 0 ? (watchedCount / totalCount) * 100 : 0; totalCount > 0 ? (watchedCount / totalCount) * 100 : 0;
@@ -207,7 +218,7 @@ export function TitleSeasons({
className="overflow-hidden border-t border-border/50" className="overflow-hidden border-t border-border/50"
> >
{season.episodes.map((ep) => { {season.episodes.map((ep) => {
const isWatched = episodeWatches.includes(ep.id); const isWatched = watchedSet.has(ep.id);
const { stillPath } = ep; const { stillPath } = ep;
return ( return (
<div <div
+11 -12
View File
@@ -44,6 +44,15 @@ import {
} from "@/lib/atoms/command-palette"; } from "@/lib/atoms/command-palette";
import { SHORTCUT_DESCRIPTIONS } from "@/lib/constants/shortcuts"; import { SHORTCUT_DESCRIPTIONS } from "@/lib/constants/shortcuts";
const groupedShortcuts: Record<
string,
{ description: string; keys: readonly string[] }[]
> = {};
for (const entry of SHORTCUT_DESCRIPTIONS) {
if (!groupedShortcuts[entry.scope]) groupedShortcuts[entry.scope] = [];
groupedShortcuts[entry.scope].push(entry);
}
type SearchResult = ReturnType<typeof useSearch>["results"][number]; type SearchResult = ReturnType<typeof useSearch>["results"][number];
export function CommandPalette() { export function CommandPalette() {
@@ -102,7 +111,7 @@ export function CommandPalette() {
return () => { return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current); if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
}; };
}, [debouncedQuery, results, setRecentSearches]); }, [debouncedQuery, results.length, setRecentSearches]);
const handleSelect = useCallback( const handleSelect = useCallback(
(result: SearchResult) => { (result: SearchResult) => {
@@ -134,16 +143,6 @@ export function CommandPalette() {
const hasQuery = query.trim().length > 0; const hasQuery = query.trim().length > 0;
// Group shortcuts by scope for help dialog
const grouped: Record<
string,
{ description: string; keys: readonly string[] }[]
> = {};
for (const entry of SHORTCUT_DESCRIPTIONS) {
if (!grouped[entry.scope]) grouped[entry.scope] = [];
grouped[entry.scope].push(entry);
}
return ( return (
<> <>
<Dialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}> <Dialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
@@ -362,7 +361,7 @@ export function CommandPalette() {
<DialogTitle>Keyboard Shortcuts</DialogTitle> <DialogTitle>Keyboard Shortcuts</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-5 py-2"> <div className="space-y-5 py-2">
{Object.entries(grouped).map(([scope, items]) => ( {Object.entries(groupedShortcuts).map(([scope, items]) => (
<div key={scope} className="space-y-2"> <div key={scope} className="space-y-2">
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"> <h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{scope} {scope}
+4 -1
View File
@@ -4,10 +4,13 @@ import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
import useEmblaCarousel, { import useEmblaCarousel, {
type UseEmblaCarouselType, type UseEmblaCarouselType,
} from "embla-carousel-react"; } from "embla-carousel-react";
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
import * as React from "react"; import * as React from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const defaultPlugins = [WheelGesturesPlugin({ forceWheelAxis: "x" })];
type CarouselApi = UseEmblaCarouselType[1]; type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>; type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0]; type CarouselOptions = UseCarouselParameters[0];
@@ -55,7 +58,7 @@ function Carousel({
...opts, ...opts,
axis: orientation === "horizontal" ? "x" : "y", axis: orientation === "horizontal" ? "x" : "y",
}, },
plugins, plugins ?? defaultPlugins,
); );
const [canScrollPrev, setCanScrollPrev] = React.useState(false); const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false); const [canScrollNext, setCanScrollNext] = React.useState(false);
+7 -1
View File
@@ -1,3 +1,4 @@
import { useMemo } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { fetcher } from "@/lib/swr/fetcher"; import { fetcher } from "@/lib/swr/fetcher";
@@ -26,8 +27,13 @@ export function useSearch(debouncedQuery: string) {
}, },
); );
const results = useMemo(
() => data?.results?.slice(0, 8) ?? [],
[data?.results],
);
return { return {
results: data?.results?.slice(0, 8) ?? [], results,
isLoading, isLoading,
}; };
} }
+11 -5
View File
@@ -34,11 +34,17 @@ export function useTiltEffect(config: TiltConfig = {}) {
const [disabled, setDisabled] = useState(false); const [disabled, setDisabled] = useState(false);
useEffect(() => { useEffect(() => {
const mql = window.matchMedia("(prefers-reduced-motion: reduce)"); const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
setDisabled(mql.matches); const finePointer = window.matchMedia("(pointer: fine)");
const handler = (e: MediaQueryListEvent) => setDisabled(e.matches); const update = () =>
mql.addEventListener("change", handler); setDisabled(reducedMotion.matches || !finePointer.matches);
return () => mql.removeEventListener("change", handler); update();
reducedMotion.addEventListener("change", update);
finePointer.addEventListener("change", update);
return () => {
reducedMotion.removeEventListener("change", update);
finePointer.removeEventListener("change", update);
};
}, []); }, []);
// Normalized mouse position [0,1], center = 0.5 // Normalized mouse position [0,1], center = 0.5
+32 -21
View File
@@ -1,5 +1,32 @@
import { formatDistanceToNowStrict } from "date-fns"; import { formatDistanceToNowStrict } from "date-fns";
import { useEffect, useEffectEvent, useState } from "react"; import { useSyncExternalStore } from "react";
// Shared ticker — one interval regardless of how many components subscribe
const TICK_MS = 30_000;
let tick = Date.now();
const listeners = new Set<() => void>();
let timerId: ReturnType<typeof setInterval> | null = null;
function subscribe(cb: () => void) {
if (listeners.size === 0) {
timerId = setInterval(() => {
tick = Date.now();
for (const l of listeners) l();
}, TICK_MS);
}
listeners.add(cb);
return () => {
listeners.delete(cb);
if (listeners.size === 0 && timerId) {
clearInterval(timerId);
timerId = null;
}
};
}
function getSnapshot() {
return tick;
}
function toTimestamp(date: string | Date | null | undefined): number | null { function toTimestamp(date: string | Date | null | undefined): number | null {
if (!date) return null; if (!date) return null;
@@ -9,26 +36,10 @@ function toTimestamp(date: string | Date | null | undefined): number | null {
export function useTimeAgo( export function useTimeAgo(
date: string | Date | null | undefined, date: string | Date | null | undefined,
{ intervalMs = 1_000, addSuffix = true, fallback = "" } = {}, { addSuffix = true, fallback = "" } = {},
): string { ): string {
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const ts = toTimestamp(date); const ts = toTimestamp(date);
if (ts === null) return fallback;
const [text, setText] = useState(() => return formatDistanceToNowStrict(ts, { addSuffix });
ts === null ? fallback : formatDistanceToNowStrict(ts, { addSuffix }),
);
const tick = useEffectEvent(() => {
const next =
ts === null ? fallback : formatDistanceToNowStrict(ts, { addSuffix });
if (next !== text) setText(next);
});
useEffect(() => {
tick();
if (ts === null) return;
const id = setInterval(tick, intervalMs);
return () => clearInterval(id);
}, [ts, intervalMs]); // tick is NOT listed — that's the point
return text;
} }