mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
fix(views): prevent infinite re-render loops in sidebar and chat resize (#1322)
* 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.
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<ReturnType<typeof api.listWorkspaces>> = [];
|
||||
const EMPTY_INVITATIONS: Awaited<ReturnType<typeof api.listMyInvitations>> = [];
|
||||
const EMPTY_INBOX: Awaited<ReturnType<typeof api.listInbox>> = [];
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user