perf(cluster-g): fix broken search debounce, memoize feed rendering, lazy-load MuxPlayer

- 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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-07-16 20:16:20 +02:00
1 parent 70115de483
commit 45af6b2a93
7 files changed
+64 -32

No files matched your search

+4 -10
View File
@@ -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<HTMLInputElement>) => {
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() {
<Input
placeholder={t("placeholder")}
value={query}
onChange={handleQueryChange}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
autoFocus
/>
+8 -4
View File
@@ -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({
+19 -12
View File
@@ -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<FeedMode>("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<FeedItem[]>(() => {
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 (
+8 -3
View File
@@ -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) {
</article>
);
}
export const PostCard = memo(PostCardImpl);
+5 -2
View File
@@ -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) => {
+8 -1
View File
@@ -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: () => <div className="aspect-video bg-muted animate-pulse" />,
});
interface VideoCardProps {
postId: string;
+12
View File
@@ -0,0 +1,12 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}