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:
1 parent
70115de483
commit
45af6b2a93
7 files changed
+64
-32
No files matched your search
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user