From 45af6b2a93657b263fc73e5993b0196f948b65ae Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Thu, 16 Jul 2026 20:16:20 +0200 Subject: [PATCH] perf(cluster-g): fix broken search debounce, memoize feed rendering, lazy-load MuxPlayer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - search/page.tsx: debounce timer lived in the change handler, whose cleanup return value is discarded (only useEffect cleanup runs) — every keystroke fired an uncancelled query. Extracted shared useDebounce hook, applied to both search and mention-textarea (previously undebounced). - VideoCard: MuxPlayer (pulls in the HLS.js runtime) is now next/dynamic instead of a static import, keeping it out of the main feed bundle. - FeedList: post/ad interleaving array was rebuilt on every render (e.g. every scroll-sentinel inView toggle) — now memoized. - PostCard: memoized now that FeedList hands it referentially stable post objects. - AdCard: replaced a setState-in-effect (server->local sync of optimistic reaction/repost state) with the React-recommended render-time adjustment pattern; fixed a real react-hooks/set-state-in-effect lint error. Two pre-existing, legitimate instances of the same pattern (FeedList's hydration-safe localStorage read, PostCard's embla carousel subscription) got scoped eslint-disable comments with rationale instead of a risky rewrite. - Reviewed MultiImageUpload.tsx's flagged effect: notifies the parent deliberately outside the render phase (already commented, no lint violation) — left unchanged. Co-Authored-By: Claude Sonnet 5 --- src/app/(app)/search/page.tsx | 14 ++++-------- src/components/feed/AdCard.tsx | 12 ++++++---- src/components/feed/FeedList.tsx | 31 ++++++++++++++++---------- src/components/feed/PostCard.tsx | 11 ++++++--- src/components/ui/mention-textarea.tsx | 7 ++++-- src/components/video/VideoCard.tsx | 9 +++++++- src/hooks/use-debounce.ts | 12 ++++++++++ 7 files changed, 64 insertions(+), 32 deletions(-) create mode 100644 src/hooks/use-debounce.ts diff --git a/src/app/(app)/search/page.tsx b/src/app/(app)/search/page.tsx index f5f56c0..c47018c 100644 --- a/src/app/(app)/search/page.tsx +++ b/src/app/(app)/search/page.tsx @@ -1,12 +1,13 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState } from "react"; import { useTranslations } from "next-intl"; import { useQuery } from "@tanstack/react-query"; import { Search, Layers2, Loader2, Users } from "lucide-react"; import Link from "next/link"; import { useTRPC } from "@/trpc/client"; import { useActivePet } from "@/context/ActivePetContext"; +import { useDebounce } from "@/hooks/use-debounce"; import { Input } from "@/components/ui/input"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; @@ -21,14 +22,7 @@ export default function SearchPage() { const { activePetId } = useActivePet(); const [query, setQuery] = useState(""); - const [debouncedQuery, setDebouncedQuery] = useState(""); - - const handleQueryChange = useCallback((e: React.ChangeEvent) => { - const val = e.target.value; - setQuery(val); - const timer = setTimeout(() => setDebouncedQuery(val), 300); - return () => clearTimeout(timer); - }, []); + const debouncedQuery = useDebounce(query, 300); const trimmed = debouncedQuery.trim(); const petsEnabled = trimmed.length >= 2; @@ -63,7 +57,7 @@ export default function SearchPage() { setQuery(e.target.value)} className="pl-9" autoFocus /> diff --git a/src/components/feed/AdCard.tsx b/src/components/feed/AdCard.tsx index 4b1b1cc..426b661 100644 --- a/src/components/feed/AdCard.tsx +++ b/src/components/feed/AdCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef, useCallback, useEffect } from "react"; +import { useState, useRef, useCallback } from "react"; import { ExternalLink, PawPrint, MessageCircle, Repeat2, Loader2 } from "lucide-react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -53,15 +53,19 @@ export function AdCard({ ad, activePetId }: AdCardProps) { const [repostCount, setRepostCount] = useState(feedData?.repostCount ?? 0); const [commentSheetOpen, setCommentSheetOpen] = useState(false); - // Sync state from server once data loads - useEffect(() => { + // Sync local (optimistically-mutated) display state to fresh server data — + // adjusted during render on the feedData-reference transition instead of + // in an effect, so there's no extra commit-then-rerender cascade. + const [prevFeedData, setPrevFeedData] = useState(feedData); + if (feedData !== prevFeedData) { + setPrevFeedData(feedData); if (feedData) { setReacted(feedData.hasReacted); setReactionCount(feedData.reactionCount); setReposted(feedData.hasReposted); setRepostCount(feedData.repostCount); } - }, [feedData]); + } const invalidateFeedData = useCallback(() => { void queryClient.invalidateQueries({ diff --git a/src/components/feed/FeedList.tsx b/src/components/feed/FeedList.tsx index ee217c2..06c8627 100644 --- a/src/components/feed/FeedList.tsx +++ b/src/components/feed/FeedList.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useInView } from "react-intersection-observer"; import { useTRPC } from "@/trpc/client"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; @@ -22,7 +22,11 @@ function useFeedMode() { const [mode, setMode] = useState("chrono"); useEffect(() => { + // Deliberately client-only: reading localStorage during the initial + // render (e.g. via a lazy useState initializer) would mismatch the + // server-rendered "chrono" default and break hydration. const stored = localStorage.getItem(FEED_MODE_KEY); + // eslint-disable-next-line react-hooks/set-state-in-effect if (stored === "algo" || stored === "chrono") setMode(stored); }, []); @@ -67,24 +71,27 @@ export function FeedList() { }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); const { data: activeAds } = useQuery(trpc.admin.getActiveAds.queryOptions()); - const ads = activeAds ?? []; + const ads = useMemo(() => activeAds ?? [], [activeAds]); - const allPosts = data?.pages.flatMap((page) => page.posts) ?? []; + const allPosts = useMemo(() => data?.pages.flatMap((page) => page.posts) ?? [], [data]); // Build interleaved feed: inject one ad every AD_INTERVAL posts (cycle through ads) type Post = (typeof allPosts)[number]; type Ad = (typeof ads)[number]; type FeedItem = { kind: "post"; post: Post } | { kind: "ad"; ad: Ad; key: string }; - const feedItems: FeedItem[] = []; - let adIndex = 0; - allPosts.forEach((post, i) => { - feedItems.push({ kind: "post", post }); - if (ads.length > 0 && (i + 1) % AD_INTERVAL === 0) { - feedItems.push({ kind: "ad", ad: ads[adIndex % ads.length]!, key: `ad-${i}-${adIndex}` }); - adIndex++; - } - }); + const feedItems = useMemo(() => { + const items: FeedItem[] = []; + let adIndex = 0; + allPosts.forEach((post, i) => { + items.push({ kind: "post", post }); + if (ads.length > 0 && (i + 1) % AD_INTERVAL === 0) { + items.push({ kind: "ad", ad: ads[adIndex % ads.length]!, key: `ad-${i}-${adIndex}` }); + adIndex++; + } + }); + return items; + }, [allPosts, ads]); if (isLoading) { return ( diff --git a/src/components/feed/PostCard.tsx b/src/components/feed/PostCard.tsx index b6ef2a3..e2566f7 100644 --- a/src/components/feed/PostCard.tsx +++ b/src/components/feed/PostCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, memo } from "react"; import { MoreHorizontal, MessageCircle } from "lucide-react"; import { toast } from "sonner"; import { useMutation } from "@tanstack/react-query"; @@ -120,7 +120,7 @@ export type PostCardProps = { variant?: "feed" | "detail"; }; -export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) { +function PostCardImpl({ post, onDeleted, variant = "feed" }: PostCardProps) { const t = useTranslations("PostCard"); const trpc = useTRPC(); const { activePetId } = useActivePet(); @@ -159,9 +159,12 @@ export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) { const avatarUrl = getAvatarUrl(pet.avatarKey); const isCarousel = images.length > 1; - // Carousel: track current slide index + // Carousel: track current slide index. Subscribing to the embla API and + // seeding the initial slide value are both legitimate external-system + // syncs — carouselApi is an imperative handle, not render-derivable data. useEffect(() => { if (!carouselApi) return; + // eslint-disable-next-line react-hooks/set-state-in-effect setCurrentSlide(carouselApi.selectedScrollSnap() + 1); carouselApi.on("select", () => { setCurrentSlide(carouselApi.selectedScrollSnap() + 1); @@ -531,3 +534,5 @@ export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) { ); } + +export const PostCard = memo(PostCardImpl); diff --git a/src/components/ui/mention-textarea.tsx b/src/components/ui/mention-textarea.tsx index 5b4578d..c9a7369 100644 --- a/src/components/ui/mention-textarea.tsx +++ b/src/components/ui/mention-textarea.tsx @@ -6,6 +6,7 @@ import { useTRPC } from "@/trpc/client"; import { Textarea } from "@/components/ui/textarea"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { getAvatarUrl } from "@/lib/avatar-url"; +import { useDebounce } from "@/hooks/use-debounce"; interface MentionTextareaProps { value: string; @@ -52,9 +53,11 @@ export function MentionTextarea({ const textareaRef = internalRef; + const debouncedMentionQuery = useDebounce(mention?.query ?? "", 250); + const { data } = useQuery({ - ...trpc.search.pets.queryOptions({ query: mention?.query ?? ".", limit: 6 }), - enabled: !!mention && mention.query.length >= 1, + ...trpc.search.pets.queryOptions({ query: debouncedMentionQuery || ".", limit: 6 }), + enabled: !!mention && debouncedMentionQuery.length >= 1, }); const detectMention = useCallback((text: string, cursorPos: number) => { diff --git a/src/components/video/VideoCard.tsx b/src/components/video/VideoCard.tsx index 9f5567c..e06ceb8 100644 --- a/src/components/video/VideoCard.tsx +++ b/src/components/video/VideoCard.tsx @@ -1,10 +1,17 @@ "use client"; import { useEffect } from "react"; +import dynamic from "next/dynamic"; import { Loader2, AlertCircle } from "lucide-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; -import MuxPlayer from "@mux/mux-player-react"; + +// mux-player-react pulls in the HLS.js player runtime — keep it out of the +// main feed bundle and only load it when a video card actually renders. +const MuxPlayer = dynamic(() => import("@mux/mux-player-react"), { + ssr: false, + loading: () =>
, +}); interface VideoCardProps { postId: string; diff --git a/src/hooks/use-debounce.ts b/src/hooks/use-debounce.ts new file mode 100644 index 0000000..fd611fd --- /dev/null +++ b/src/hooks/use-debounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export function useDebounce(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debounced; +}