From 41c0934e6f8873cc101790b3ddf993e3b94971d8 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Thu, 4 Jun 2026 22:13:47 +0800 Subject: [PATCH] perf(context): prevent cascade re-renders from stream events Two fixes for the PanelContext re-render cascade: 1. The stream-session-event handler created new Set objects on every event dispatch, even when the logical content was unchanged. Since activeStreamingSessions and pendingApprovalSessionIds are in the panelContextValue useMemo dependency array, every stream event (firing rapidly during active streaming) produced a new context value and cascaded re-renders to ALL usePanel() consumers. Fix: track previous Sets in refs and only call setState when the set content actually changed (size + membership check). 2. ChatContentRow was a plain function component receiving isChatDetailRoute and isSplitActive props. Every navigation or split-state change re-rendered it and its children (SplitChatContainer, WorkspaceSidebar, PanelZone). Fix: wrap in React.memo. --- src/components/layout/AppShell.tsx | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 79c79293c..c5d9e7ad3 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import dynamic from "next/dynamic"; import { usePathname, useRouter } from "next/navigation"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -146,7 +146,7 @@ const LG_BREAKPOINT = 1024; * supported coexistence — only the behavior was wrong. */ -function ChatContentRow({ +const ChatContentRow = React.memo(function ChatContentRow({ isChatDetailRoute, isSplitActive, children, @@ -198,7 +198,7 @@ function ChatContentRow({ {isChatDetailRoute && } ); -} +}); export function AppShell({ children }: { children: React.ReactNode }) { const pathname = usePathname(); @@ -368,12 +368,25 @@ export function AppShell({ children }: { children: React.ReactNode }) { // --- Multi-session stream tracking (driven by stream-session-manager) --- const [activeStreamingSessions, setActiveStreamingSessions] = useState>(EMPTY_SET); const [pendingApprovalSessionIds, setPendingApprovalSessionIds] = useState>(EMPTY_SET); + const prevActiveRef = useRef>(EMPTY_SET); + const prevApprovalsRef = useRef>(EMPTY_SET); + + // Helper: only update state if the Set content actually changed, + // avoiding unnecessary context value changes that cascade re-renders + // to every usePanel() consumer. + function setIfChanged(prev: Set, next: Set, setter: (s: Set) => void): void { + if (prev.size !== next.size || ![...prev].every((v) => next.has(v))) { + setter(next); + } + } // Listen for global stream events from stream-session-manager useEffect(() => { const handler = () => { const activeIds = getActiveSessionIds(); - setActiveStreamingSessions(activeIds.length > 0 ? new Set(activeIds) : EMPTY_SET); + const nextActive = activeIds.length > 0 ? new Set(activeIds) : EMPTY_SET; + setIfChanged(prevActiveRef.current, nextActive, setActiveStreamingSessions); + prevActiveRef.current = nextActive; const approvals = new Set(); for (const sid of activeIds) { @@ -382,7 +395,9 @@ export function AppShell({ children }: { children: React.ReactNode }) { approvals.add(sid); } } - setPendingApprovalSessionIds(approvals.size > 0 ? approvals : EMPTY_SET); + const nextApprovals = approvals.size > 0 ? approvals : EMPTY_SET; + setIfChanged(prevApprovalsRef.current, nextApprovals, setPendingApprovalSessionIds); + prevApprovalsRef.current = nextApprovals; }; window.addEventListener('stream-session-event', handler); return () => window.removeEventListener('stream-session-event', handler);