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, });