diff --git a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx index e3c14fc6..516599d6 100644 --- a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx +++ b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, startTransition } from 'react'; import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react'; import { useNavigate, useLocation } from '@tanstack/react-router'; import { cn, isMac } from '@/lib/utils'; @@ -112,9 +112,17 @@ export function ProjectSwitcher() { // Continue with switch even if initialization fails - // the project may already be initialized } - setCurrentProject(project); - // Navigate to board view when switching projects - navigate({ to: '/board' }); + // Wrap in startTransition to let React batch the project switch and + // navigation into a single low-priority update. Without this, the two + // synchronous calls fire separate renders where currentProject points + // to the new project but per-project state (worktrees, features) is + // still stale, causing a cascade of effects and store mutations that + // can trigger React error #185 (maximum update depth exceeded). + startTransition(() => { + setCurrentProject(project); + // Navigate to board view when switching projects + navigate({ to: '/board' }); + }); }, [setCurrentProject, navigate] ); diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 10ca5dfe..e57e0201 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -478,6 +478,13 @@ export function BoardView() { // Get the branch for the currently selected worktree // Find the worktree that matches the current selection, or use main worktree + // + // IMPORTANT: Stabilize the returned object reference using a ref to prevent + // cascading re-renders during project switches. The spread `{ ...found, ... }` + // creates a new object every time, even when the underlying data is identical. + // Without stabilization, the new reference propagates to useAutoMode and other + // consumers, contributing to the re-render cascade that triggers React error #185. + const prevSelectedWorktreeRef = useRef(undefined); const selectedWorktree = useMemo((): WorktreeInfo | undefined => { let found; let usedFallback = false; @@ -495,9 +502,12 @@ export function BoardView() { usedFallback = true; } } - if (!found) return undefined; + if (!found) { + prevSelectedWorktreeRef.current = undefined; + return undefined; + } // Ensure all required WorktreeInfo fields are present - return { + const result: WorktreeInfo = { ...found, isCurrent: found.isCurrent ?? @@ -508,6 +518,21 @@ export function BoardView() { : found.isMain), hasWorktree: found.hasWorktree ?? true, }; + // Return the previous reference if the key fields haven't changed, + // preventing downstream hooks from seeing a "new" worktree on every render. + const prev = prevSelectedWorktreeRef.current; + if ( + prev && + prev.path === result.path && + prev.branch === result.branch && + prev.isMain === result.isMain && + prev.isCurrent === result.isCurrent && + prev.hasWorktree === result.hasWorktree + ) { + return prev; + } + prevSelectedWorktreeRef.current = result; + return result; }, [worktrees, currentWorktreePath]); // Auto mode hook - pass current worktree to get worktree-specific state diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts index 1e2423f8..bbd3842b 100644 --- a/apps/ui/src/hooks/use-auto-mode.ts +++ b/apps/ui/src/hooks/use-auto-mode.ts @@ -252,10 +252,19 @@ export function useAutoMode(worktree?: WorktreeInfo) { } }, [branchName, currentProject, setAutoModeRunning]); - // On mount, query backend for current auto loop status and sync UI state. + // On mount (and when refreshStatus identity changes, e.g. project switch), + // query backend for current auto loop status and sync UI state. // This handles cases where the backend is still running after a page refresh. + // + // IMPORTANT: Debounce with a short delay to prevent a synchronous cascade + // during project switches. Without this, the sequence is: + // refreshStatus() → setAutoModeRunning() → store update → re-render → + // other effects fire → more store updates → React error #185. + // The 150ms delay lets React settle the initial mount renders before we + // trigger additional store mutations from the API response. useEffect(() => { - void refreshStatus(); + const timer = setTimeout(() => void refreshStatus(), 150); + return () => clearTimeout(timer); }, [refreshStatus]); // Periodic polling fallback when WebSocket events are stale. diff --git a/apps/ui/src/store/ui-cache-store.ts b/apps/ui/src/store/ui-cache-store.ts index 123e06c2..bf8415f5 100644 --- a/apps/ui/src/store/ui-cache-store.ts +++ b/apps/ui/src/store/ui-cache-store.ts @@ -113,8 +113,25 @@ export function syncUICache(appState: { if ('collapsedNavSections' in appState) { update.cachedCollapsedNavSections = appState.collapsedNavSections; } - if ('currentWorktreeByProject' in appState) { - update.cachedCurrentWorktreeByProject = appState.currentWorktreeByProject; + if ('currentWorktreeByProject' in appState && appState.currentWorktreeByProject) { + // Sanitize on write: only persist entries where path is null (main branch). + // Non-null paths point to worktree directories on disk that may be deleted + // while the app is not running. Persisting stale paths can cause crash loops + // on restore (the board renders with an invalid selection, the error boundary + // reloads, which restores the same bad cache). This mirrors the sanitization + // in restoreFromUICache() for defense-in-depth. + const sanitized: Record = {}; + for (const [projectPath, worktree] of Object.entries(appState.currentWorktreeByProject)) { + if ( + typeof worktree === 'object' && + worktree !== null && + 'path' in worktree && + worktree.path === null + ) { + sanitized[projectPath] = worktree; + } + } + update.cachedCurrentWorktreeByProject = sanitized; } if (Object.keys(update).length > 0) {