From b8907dda8d313cc96f0927da6dce2ea121fde43e Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Sat, 18 Apr 2026 23:24:32 +0800 Subject: [PATCH] fix(views): prevent infinite re-render loops in sidebar and chat resize (#1322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sidebar): stabilize useQuery default arrays to prevent render loop Inline `= []` defaults on `useQuery` return a new array reference on every render when `data` is undefined (query disabled or mid-load). Downstream effects/memos that depend on the value then fire every render; the pinned-items `useEffect` compounds this by calling `setLocalPinned` each time, so under sustained `data === undefined` (e.g. backend unreachable, WebSocket in reconnect loop) React trips its "Maximum update depth exceeded" guard and the sidebar becomes unusable. Use module-level empty-array constants so the default identity stays stable across renders. * fix(chat): short-circuit ResizeObserver update when bounds unchanged The resize observer always called `setRevision(r => r + 1)` from its callback, even when `clientWidth`/`clientHeight` were identical to the previous reading. Any spurious notification — sub-pixel layout jitter during mount, or an ancestor reflow triggered by an unrelated state update — then fed back into the same render cycle and could exceed React's update-depth limit. Guard the state bump by comparing against the previous bounds, and leave `setBoundsReady(true)` outside the guard since it's idempotent. --- .../views/chat/components/use-chat-resize.ts | 15 ++++++++++----- packages/views/layout/app-sidebar.tsx | 18 ++++++++++++++---- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/views/chat/components/use-chat-resize.ts b/packages/views/chat/components/use-chat-resize.ts index a5f17622e..01a53ddeb 100644 --- a/packages/views/chat/components/use-chat-resize.ts +++ b/packages/views/chat/components/use-chat-resize.ts @@ -34,11 +34,16 @@ export function useChatResize( if (!parent) return; const update = () => { - boundsRef.current = { - maxW: Math.floor(parent.clientWidth * MAX_RATIO), - maxH: Math.floor(parent.clientHeight * MAX_RATIO), - }; - setBoundsReady(true); + const maxW = Math.floor(parent.clientWidth * MAX_RATIO); + const maxH = Math.floor(parent.clientHeight * MAX_RATIO); + setBoundsReady(true); // idempotent once true + // Only trigger a re-render if the bounds actually changed. Without this + // guard, any spurious ResizeObserver notification (including sub-pixel + // layout jitter during mount) schedules a setState that feeds back into + // the observer, producing "Maximum update depth exceeded". + const prev = boundsRef.current; + if (prev.maxW === maxW && prev.maxH === maxH) return; + boundsRef.current = { maxW, maxH }; setRevision((r) => r + 1); }; diff --git a/packages/views/layout/app-sidebar.tsx b/packages/views/layout/app-sidebar.tsx index a24766030..16fc90b04 100644 --- a/packages/views/layout/app-sidebar.tsx +++ b/packages/views/layout/app-sidebar.tsx @@ -78,6 +78,16 @@ import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations"; import type { PinnedItem } from "@multica/core/types"; import { useLogout } from "../auth"; +// Stable empty arrays for query defaults. Using an inline `= []` default on +// `useQuery` creates a new array reference on every render when `data` is +// undefined (e.g. query disabled or loading) — which in turn breaks any +// `useEffect`/`useMemo` that depends on the value, and can trigger infinite +// re-render loops when the effect itself calls `setState`. +const EMPTY_PINS: PinnedItem[] = []; +const EMPTY_WORKSPACES: Awaited> = []; +const EMPTY_INVITATIONS: Awaited> = []; +const EMPTY_INBOX: Awaited> = []; + // Nav items reference WorkspacePaths method names so they can be resolved // against the current workspace slug at render time (see AppSidebar body). // Only parameterless paths are valid nav destinations. @@ -202,11 +212,11 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle } const logout = useLogout(); const workspace = useCurrentWorkspace(); const p = useWorkspacePaths(); - const { data: workspaces = [] } = useQuery(workspaceListOptions()); - const { data: myInvitations = [] } = useQuery(myInvitationListOptions()); + const { data: workspaces = EMPTY_WORKSPACES } = useQuery(workspaceListOptions()); + const { data: myInvitations = EMPTY_INVITATIONS } = useQuery(myInvitationListOptions()); const wsId = workspace?.id; - const { data: inboxItems = [] } = useQuery({ + const { data: inboxItems = EMPTY_INBOX } = useQuery({ queryKey: wsId ? inboxKeys.list(wsId) : ["inbox", "disabled"], queryFn: () => api.listInbox(), enabled: !!wsId, @@ -216,7 +226,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle } [inboxItems], ); const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId); - const { data: pinnedItems = [] } = useQuery({ + const { data: pinnedItems = EMPTY_PINS } = useQuery({ ...pinListOptions(wsId ?? "", userId ?? ""), enabled: !!wsId && !!userId, });