From acb04a02e7261611f2b33161d61f643ecabf0958 Mon Sep 17 00:00:00 2001 From: zetaloop Date: Sun, 22 Feb 2026 09:05:04 +0800 Subject: [PATCH] fix(order): stabilize zustand selector snapshots Move filter-based derivations out of Zustand selectors in order/chat/review detail pages so snapshots stay stable under useSyncExternalStore checks. Add evidence-backed comments referencing React useSyncExternalStore guidance and Zustand issues #1936/#3155 to document the regression trigger. --- app/(order)/chat/[id]/page.tsx | 12 ++++++++++-- app/(order)/order/[id]/page.tsx | 9 +++++++-- app/(order)/review/[id]/page.tsx | 9 +++++++-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/app/(order)/chat/[id]/page.tsx b/app/(order)/chat/[id]/page.tsx index b2578cf..3837a06 100644 --- a/app/(order)/chat/[id]/page.tsx +++ b/app/(order)/chat/[id]/page.tsx @@ -3,7 +3,7 @@ import { ArrowLeft, ImagePlus, Lock, Send } from "lucide-react" import Image from "next/image" import Link from "next/link" -import { use, useRef, useState } from "react" +import { use, useMemo, useRef, useState } from "react" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -16,7 +16,15 @@ import { useChatStore } from "@/store/chat" export default function ChatDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params) const session = useChatStore((state) => state.sessions.find((item) => item.id === id)) - const messages = useChatStore((state) => state.messages.filter((item) => item.sessionId === id)) + const allMessages = useChatStore((state) => state.messages) + // Filter logic runs here via useMemo rather than inside the Zustand selector. + // useSyncExternalStore requires a stable snapshot reference on each render. + // Inline filter in a selector creates a new array per call and can trigger + // infinite re-render loops in Zustand v5 (pmndrs/zustand#1936). + const messages = useMemo( + () => allMessages.filter((item) => item.sessionId === id), + [allMessages, id], + ) const sendTextMessage = useChatStore((state) => state.sendTextMessage) const sendImageMessage = useChatStore((state) => state.sendImageMessage) const [input, setInput] = useState("") diff --git a/app/(order)/order/[id]/page.tsx b/app/(order)/order/[id]/page.tsx index b4845bd..779c2e3 100644 --- a/app/(order)/order/[id]/page.tsx +++ b/app/(order)/order/[id]/page.tsx @@ -2,7 +2,7 @@ import { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react" import Link from "next/link" -import { use, useEffect, useState } from "react" +import { use, useEffect, useMemo, useState } from "react" import OrderActions from "@/components/order-actions" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -37,7 +37,12 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri const order = useOrderStore((state) => state.orders.find((item) => item.id === id)) const sessions = useChatStore((state) => state.sessions) const ensureOrderSession = useChatStore((state) => state.ensureOrderSession) - const reviews = useReviewStore((state) => state.reviews.filter((item) => item.orderId === id)) + const allReviews = useReviewStore((state) => state.reviews) + // Filtering is deferred to useMemo after reading the raw store array. + // Zustand v5 compares selector outputs by reference stability. + // Returning a fresh filtered array from the selector can re-trigger updates + // and loop under useSyncExternalStore (pmndrs/zustand#1936, #3155). + const reviews = useMemo(() => allReviews.filter((item) => item.orderId === id), [allReviews, id]) const [nowTs, setNowTs] = useState(Date.now()) useEffect(() => { diff --git a/app/(order)/review/[id]/page.tsx b/app/(order)/review/[id]/page.tsx index f7552ed..1e4db0c 100644 --- a/app/(order)/review/[id]/page.tsx +++ b/app/(order)/review/[id]/page.tsx @@ -2,7 +2,7 @@ import { ArrowLeft, Lock, Star } from "lucide-react" import Link from "next/link" -import { use, useState } from "react" +import { use, useMemo, useState } from "react" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Label } from "@/components/ui/label" @@ -16,7 +16,12 @@ export default function ReviewPage({ params }: { params: Promise<{ id: string }> const order = useOrderStore((state) => state.orders.find((item) => item.id === id)) const userId = useAuthStore((state) => state.user?.id) const submitReview = useReviewStore((state) => state.submitReview) - const reviews = useReviewStore((state) => state.reviews.filter((item) => item.orderId === id)) + const allReviews = useReviewStore((state) => state.reviews) + // The selector returns the raw store array and useMemo derives the subset. + // This keeps useSyncExternalStore snapshots stable across render checks. + // Inline filter inside the selector creates a new array reference each call + // and can cause infinite re-render loops in Zustand v5 (pmndrs/zustand#3155). + const reviews = useMemo(() => allReviews.filter((item) => item.orderId === id), [allReviews, id]) const [rating, setRating] = useState(0) const [hoverRating, setHoverRating] = useState(0) const [content, setContent] = useState("")