From 7a3052250db09cb0e4e3620755e5d06edddbc1b5 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Fri, 6 Mar 2026 16:15:08 -0500 Subject: [PATCH] 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 --- .../_components/continue-watching-list.tsx | 2 - .../_components/filterable-title-row.tsx | 2 - app/(pages)/explore/_components/title-row.tsx | 2 - .../_components/registration-section.tsx | 33 ++++++------ .../_components/update-check-section.tsx | 37 +++++++------ .../titles/[id]/_components/cast-carousel.tsx | 2 - .../titles/[id]/_components/star-rating.tsx | 12 +++-- .../titles/[id]/_components/title-seasons.tsx | 21 ++++++-- components/command-palette.tsx | 23 ++++---- components/ui/carousel.tsx | 5 +- hooks/use-search.ts | 8 ++- hooks/use-tilt-effect.ts | 16 ++++-- hooks/use-time-ago.ts | 53 +++++++++++-------- 13 files changed, 122 insertions(+), 94 deletions(-) diff --git a/app/(pages)/dashboard/_components/continue-watching-list.tsx b/app/(pages)/dashboard/_components/continue-watching-list.tsx index 41ed565..28eedac 100644 --- a/app/(pages)/dashboard/_components/continue-watching-list.tsx +++ b/app/(pages)/dashboard/_components/continue-watching-list.tsx @@ -1,6 +1,5 @@ "use client"; -import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"; import { Carousel, CarouselContent, @@ -19,7 +18,6 @@ export function ContinueWatchingList({ return ( diff --git a/app/(pages)/explore/_components/filterable-title-row.tsx b/app/(pages)/explore/_components/filterable-title-row.tsx index 51cc3d1..55e2765 100644 --- a/app/(pages)/explore/_components/filterable-title-row.tsx +++ b/app/(pages)/explore/_components/filterable-title-row.tsx @@ -1,6 +1,5 @@ "use client"; -import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"; import { createStore, Provider, useAtom, useAtomValue } from "jotai"; import { useState } from "react"; import { TitleCardSkeleton } from "@/components/skeletons"; @@ -162,7 +161,6 @@ function FilterableTitleRowInner({ dragFree: true, containScroll: "trimSnaps", }} - plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]} className="-mx-6 sm:-mx-2 carousel-tilt" > diff --git a/app/(pages)/explore/_components/title-row.tsx b/app/(pages)/explore/_components/title-row.tsx index 113096d..06002e7 100644 --- a/app/(pages)/explore/_components/title-row.tsx +++ b/app/(pages)/explore/_components/title-row.tsx @@ -1,6 +1,5 @@ "use client"; -import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"; import { TitleCard } from "@/components/title-card"; import { Carousel, @@ -44,7 +43,6 @@ export function TitleRow({ diff --git a/app/(pages)/settings/_components/registration-section.tsx b/app/(pages)/settings/_components/registration-section.tsx index 57d71cd..cfb796c 100644 --- a/app/(pages)/settings/_components/registration-section.tsx +++ b/app/(pages)/settings/_components/registration-section.tsx @@ -1,7 +1,7 @@ "use client"; import { IconDoorEnter } from "@tabler/icons-react"; -import { useState } from "react"; +import { useOptimistic, useState, useTransition } from "react"; import { toast } from "sonner"; import { CardContent, CardDescription, CardTitle } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; @@ -15,21 +15,20 @@ export function RegistrationSection({ const [registrationOpen, setRegistrationOpen] = useState( initialRegistrationOpen, ); - const [toggling, setToggling] = useState(false); + const [optimisticOpen, setOptimisticOpen] = useOptimistic(registrationOpen); + const [isPending, startTransition] = useTransition(); - async function handleToggle(checked: boolean) { - const previous = registrationOpen; - setRegistrationOpen(checked); - setToggling(true); - try { - await toggleRegistration(checked); - toast.success(checked ? "Registration opened" : "Registration closed"); - } catch { - setRegistrationOpen(previous); - toast.error("Failed to update registration setting"); - } finally { - setToggling(false); - } + function handleToggle(checked: boolean) { + startTransition(async () => { + setOptimisticOpen(checked); + try { + await toggleRegistration(checked); + setRegistrationOpen(checked); + toast.success(checked ? "Registration opened" : "Registration closed"); + } catch { + toast.error("Failed to update registration setting"); + } + }); } return ( @@ -47,9 +46,9 @@ export function RegistrationSection({ diff --git a/app/(pages)/settings/_components/update-check-section.tsx b/app/(pages)/settings/_components/update-check-section.tsx index 62269a4..e2a807d 100644 --- a/app/(pages)/settings/_components/update-check-section.tsx +++ b/app/(pages)/settings/_components/update-check-section.tsx @@ -1,7 +1,7 @@ "use client"; import { IconWorldUpload } from "@tabler/icons-react"; -import { useState } from "react"; +import { useOptimistic, useState, useTransition } from "react"; import { toast } from "sonner"; import { CardContent, CardDescription, CardTitle } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; @@ -13,23 +13,22 @@ export function UpdateCheckSection({ initialEnabled: boolean; }) { const [enabled, setEnabled] = useState(initialEnabled); - const [toggling, setToggling] = useState(false); + const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled); + const [isPending, startTransition] = useTransition(); - async function handleToggle(checked: boolean) { - const previous = enabled; - setEnabled(checked); - setToggling(true); - try { - await toggleUpdateCheck(checked); - toast.success( - checked ? "Update checks enabled" : "Update checks disabled", - ); - } catch { - setEnabled(previous); - toast.error("Failed to update setting"); - } finally { - setToggling(false); - } + function handleToggle(checked: boolean) { + startTransition(async () => { + setOptimisticEnabled(checked); + try { + await toggleUpdateCheck(checked); + setEnabled(checked); + toast.success( + checked ? "Update checks enabled" : "Update checks disabled", + ); + } catch { + toast.error("Failed to update setting"); + } + }); } return ( @@ -50,9 +49,9 @@ export function UpdateCheckSection({ diff --git a/app/(pages)/titles/[id]/_components/cast-carousel.tsx b/app/(pages)/titles/[id]/_components/cast-carousel.tsx index 791a6ae..d2c94f2 100644 --- a/app/(pages)/titles/[id]/_components/cast-carousel.tsx +++ b/app/(pages)/titles/[id]/_components/cast-carousel.tsx @@ -1,7 +1,6 @@ "use client"; import { IconUser, IconUsers } from "@tabler/icons-react"; -import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"; import Image from "next/image"; import Link from "next/link"; import { @@ -31,7 +30,6 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) { dragFree: true, containScroll: "trimSnaps", }} - plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]} className="-mx-4 sm:-mx-0" > diff --git a/app/(pages)/titles/[id]/_components/star-rating.tsx b/app/(pages)/titles/[id]/_components/star-rating.tsx index d8c64b1..ac4cf14 100644 --- a/app/(pages)/titles/[id]/_components/star-rating.tsx +++ b/app/(pages)/titles/[id]/_components/star-rating.tsx @@ -4,6 +4,12 @@ import { IconStar, IconStarFilled } from "@tabler/icons-react"; import { motion } from "motion/react"; import { useState } from "react"; +const springTransition = { + type: "spring" as const, + stiffness: 400, + damping: 15, +}; + interface StarRatingProps { value: number; onChange: (value: number) => void; @@ -36,11 +42,7 @@ export function StarRating({ value, onChange }: StarRatingProps) { animate={ filled && star === value ? { scale: [1, 1.25, 1] } : { scale: 1 } } - transition={{ - type: "spring" as const, - stiffness: 400, - damping: 15, - }} + transition={springTransition} > {filled ? ( diff --git a/app/(pages)/titles/[id]/_components/title-seasons.tsx b/app/(pages)/titles/[id]/_components/title-seasons.tsx index 37279c6..538dcf2 100644 --- a/app/(pages)/titles/[id]/_components/title-seasons.tsx +++ b/app/(pages)/titles/[id]/_components/title-seasons.tsx @@ -11,7 +11,7 @@ import { format, parseISO } from "date-fns"; import { useAtomValue, useSetAtom } from "jotai"; import { AnimatePresence, motion } from "motion/react"; import Image from "next/image"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { AlertDialog, AlertDialogAction, @@ -50,6 +50,7 @@ export function TitleSeasons({ const seasons = useAtomValue(seasonsAtom); const episodeWatches = useAtomValue(episodeWatchesAtom); + const watchedSet = useMemo(() => new Set(episodeWatches), [episodeWatches]); const userStatus = useAtomValue(userStatusAtom); const watchingEp = useAtomValue(watchingEpAtom); const { @@ -58,6 +59,18 @@ export function TitleSeasons({ handleUnmarkSeason, handleMarkAllWatched, } = useTitleActions(); + const seasonProgress = useMemo(() => { + const map = new Map(); + 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(null); const [markAllOpen, setMarkAllOpen] = useState(false); @@ -110,9 +123,7 @@ export function TitleSeasons({
{seasons.map((season) => { const isOpen = openSeason === season.seasonNumber; - const watchedCount = season.episodes.filter((ep) => - episodeWatches.includes(ep.id), - ).length; + const watchedCount = seasonProgress.get(season.id) ?? 0; const totalCount = season.episodes.length; const progressPercent = totalCount > 0 ? (watchedCount / totalCount) * 100 : 0; @@ -207,7 +218,7 @@ export function TitleSeasons({ className="overflow-hidden border-t border-border/50" > {season.episodes.map((ep) => { - const isWatched = episodeWatches.includes(ep.id); + const isWatched = watchedSet.has(ep.id); const { stillPath } = ep; return (
= {}; +for (const entry of SHORTCUT_DESCRIPTIONS) { + if (!groupedShortcuts[entry.scope]) groupedShortcuts[entry.scope] = []; + groupedShortcuts[entry.scope].push(entry); +} + type SearchResult = ReturnType["results"][number]; export function CommandPalette() { @@ -102,7 +111,7 @@ export function CommandPalette() { return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); }; - }, [debouncedQuery, results, setRecentSearches]); + }, [debouncedQuery, results.length, setRecentSearches]); const handleSelect = useCallback( (result: SearchResult) => { @@ -134,16 +143,6 @@ export function CommandPalette() { 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 ( <> @@ -362,7 +361,7 @@ export function CommandPalette() { Keyboard Shortcuts
- {Object.entries(grouped).map(([scope, items]) => ( + {Object.entries(groupedShortcuts).map(([scope, items]) => (

{scope} diff --git a/components/ui/carousel.tsx b/components/ui/carousel.tsx index 16d4652..168c152 100644 --- a/components/ui/carousel.tsx +++ b/components/ui/carousel.tsx @@ -4,10 +4,13 @@ import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; import useEmblaCarousel, { type UseEmblaCarouselType, } from "embla-carousel-react"; +import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"; import * as React from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +const defaultPlugins = [WheelGesturesPlugin({ forceWheelAxis: "x" })]; + type CarouselApi = UseEmblaCarouselType[1]; type UseCarouselParameters = Parameters; type CarouselOptions = UseCarouselParameters[0]; @@ -55,7 +58,7 @@ function Carousel({ ...opts, axis: orientation === "horizontal" ? "x" : "y", }, - plugins, + plugins ?? defaultPlugins, ); const [canScrollPrev, setCanScrollPrev] = React.useState(false); const [canScrollNext, setCanScrollNext] = React.useState(false); diff --git a/hooks/use-search.ts b/hooks/use-search.ts index 710a56c..8f907a6 100644 --- a/hooks/use-search.ts +++ b/hooks/use-search.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import useSWR from "swr"; 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 { - results: data?.results?.slice(0, 8) ?? [], + results, isLoading, }; } diff --git a/hooks/use-tilt-effect.ts b/hooks/use-tilt-effect.ts index 41fc74e..069838c 100644 --- a/hooks/use-tilt-effect.ts +++ b/hooks/use-tilt-effect.ts @@ -34,11 +34,17 @@ export function useTiltEffect(config: TiltConfig = {}) { const [disabled, setDisabled] = useState(false); useEffect(() => { - const mql = window.matchMedia("(prefers-reduced-motion: reduce)"); - setDisabled(mql.matches); - const handler = (e: MediaQueryListEvent) => setDisabled(e.matches); - mql.addEventListener("change", handler); - return () => mql.removeEventListener("change", handler); + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + const finePointer = window.matchMedia("(pointer: fine)"); + const update = () => + setDisabled(reducedMotion.matches || !finePointer.matches); + 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 diff --git a/hooks/use-time-ago.ts b/hooks/use-time-ago.ts index b5956c8..3e85145 100644 --- a/hooks/use-time-ago.ts +++ b/hooks/use-time-ago.ts @@ -1,5 +1,32 @@ 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 | 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 { if (!date) return null; @@ -9,26 +36,10 @@ function toTimestamp(date: string | Date | null | undefined): number | null { export function useTimeAgo( date: string | Date | null | undefined, - { intervalMs = 1_000, addSuffix = true, fallback = "" } = {}, + { addSuffix = true, fallback = "" } = {}, ): string { + useSyncExternalStore(subscribe, getSnapshot, getSnapshot); const ts = toTimestamp(date); - - const [text, setText] = useState(() => - 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; + if (ts === null) return fallback; + return formatDistanceToNowStrict(ts, { addSuffix }); }