fix(projects-state): stop websocket message reprocessing loop

The websocket projects effect in useProjectsState could re-handle the same
latestMessage after local state writes triggered re-renders.

Under bursty websocket traffic, this created an update feedback cycle
that surfaced as 'Maximum update depth exceeded', often from Sidebar.

What changed:
- Added lastHandledMessageRef so each latestMessage object is handled once.
- Added an early return guard when the current message was already handled.
- Made projects updates idempotent by comparing previous and merged payloads
  before calling setProjects.

Result:
- Breaks the effect -> state update -> effect re-entry cycle.
- Reduces redundant renders during rapid projects_updated traffic while
  preserving normal project/session synchronization.
This commit is contained in:
Haileyesus
2026-04-25 21:45:30 +03:00
parent 68123dcc33
commit 360aa514f9

View File

@@ -183,6 +183,7 @@ export function useProjectsState({
const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastHandledMessageRef = useRef<AppSocketMessage | null>(null);
const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => {
try {
@@ -288,6 +289,15 @@ export function useProjectsState({
return;
}
// `latestMessage` is event-like data. This effect also depends on local state
// (`projects`, `selectedProject`, `selectedSession`) to compute derived updates.
// Without this guard, handling one websocket message can update that local
// state, retrigger the effect, and re-handle the same websocket message.
if (lastHandledMessageRef.current === latestMessage) {
return;
}
lastHandledMessageRef.current = latestMessage;
if (latestMessage.type === 'loading_progress') {
if (loadingProgressTimeoutRef.current) {
clearTimeout(loadingProgressTimeoutRef.current);
@@ -335,7 +345,9 @@ export function useProjectsState({
return;
}
setProjects(updatedProjects);
setProjects((previousProjects) =>
projectsHaveChanges(previousProjects, updatedProjects, true) ? updatedProjects : previousProjects,
);
if (!selectedProject) {
return;