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.
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
import { ArrowLeft, ImagePlus, Lock, Send } from "lucide-react"
|
import { ArrowLeft, ImagePlus, Lock, Send } from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -16,7 +16,15 @@ import { useChatStore } from "@/store/chat"
|
|||||||
export default function ChatDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
export default function ChatDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = use(params)
|
const { id } = use(params)
|
||||||
const session = useChatStore((state) => state.sessions.find((item) => item.id === id))
|
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 sendTextMessage = useChatStore((state) => state.sendTextMessage)
|
||||||
const sendImageMessage = useChatStore((state) => state.sendImageMessage)
|
const sendImageMessage = useChatStore((state) => state.sendImageMessage)
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react"
|
import { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react"
|
||||||
import Link from "next/link"
|
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 OrderActions from "@/components/order-actions"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
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 order = useOrderStore((state) => state.orders.find((item) => item.id === id))
|
||||||
const sessions = useChatStore((state) => state.sessions)
|
const sessions = useChatStore((state) => state.sessions)
|
||||||
const ensureOrderSession = useChatStore((state) => state.ensureOrderSession)
|
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())
|
const [nowTs, setNowTs] = useState(Date.now())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { ArrowLeft, Lock, Star } from "lucide-react"
|
import { ArrowLeft, Lock, Star } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { use, useState } from "react"
|
import { use, useMemo, useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Label } from "@/components/ui/label"
|
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 order = useOrderStore((state) => state.orders.find((item) => item.id === id))
|
||||||
const userId = useAuthStore((state) => state.user?.id)
|
const userId = useAuthStore((state) => state.user?.id)
|
||||||
const submitReview = useReviewStore((state) => state.submitReview)
|
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 [rating, setRating] = useState(0)
|
||||||
const [hoverRating, setHoverRating] = useState(0)
|
const [hoverRating, setHoverRating] = useState(0)
|
||||||
const [content, setContent] = useState("")
|
const [content, setContent] = useState("")
|
||||||
|
|||||||
Reference in New Issue
Block a user