Changes from fix/manual-crash (#795)

This commit is contained in:
gsxdsm
2026-02-21 17:32:34 -08:00
committed by GitHub
parent 28becb177b
commit dfa719079f
4 changed files with 69 additions and 10 deletions

View File

@@ -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 { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react';
import { useNavigate, useLocation } from '@tanstack/react-router'; import { useNavigate, useLocation } from '@tanstack/react-router';
import { cn, isMac } from '@/lib/utils'; import { cn, isMac } from '@/lib/utils';
@@ -112,9 +112,17 @@ export function ProjectSwitcher() {
// Continue with switch even if initialization fails - // Continue with switch even if initialization fails -
// the project may already be initialized // the project may already be initialized
} }
setCurrentProject(project); // Wrap in startTransition to let React batch the project switch and
// Navigate to board view when switching projects // navigation into a single low-priority update. Without this, the two
navigate({ to: '/board' }); // 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] [setCurrentProject, navigate]
); );

View File

@@ -478,6 +478,13 @@ export function BoardView() {
// Get the branch for the currently selected worktree // Get the branch for the currently selected worktree
// Find the worktree that matches the current selection, or use main 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<WorktreeInfo | undefined>(undefined);
const selectedWorktree = useMemo((): WorktreeInfo | undefined => { const selectedWorktree = useMemo((): WorktreeInfo | undefined => {
let found; let found;
let usedFallback = false; let usedFallback = false;
@@ -495,9 +502,12 @@ export function BoardView() {
usedFallback = true; usedFallback = true;
} }
} }
if (!found) return undefined; if (!found) {
prevSelectedWorktreeRef.current = undefined;
return undefined;
}
// Ensure all required WorktreeInfo fields are present // Ensure all required WorktreeInfo fields are present
return { const result: WorktreeInfo = {
...found, ...found,
isCurrent: isCurrent:
found.isCurrent ?? found.isCurrent ??
@@ -508,6 +518,21 @@ export function BoardView() {
: found.isMain), : found.isMain),
hasWorktree: found.hasWorktree ?? true, 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]); }, [worktrees, currentWorktreePath]);
// Auto mode hook - pass current worktree to get worktree-specific state // Auto mode hook - pass current worktree to get worktree-specific state

View File

@@ -252,10 +252,19 @@ export function useAutoMode(worktree?: WorktreeInfo) {
} }
}, [branchName, currentProject, setAutoModeRunning]); }, [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. // 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(() => { useEffect(() => {
void refreshStatus(); const timer = setTimeout(() => void refreshStatus(), 150);
return () => clearTimeout(timer);
}, [refreshStatus]); }, [refreshStatus]);
// Periodic polling fallback when WebSocket events are stale. // Periodic polling fallback when WebSocket events are stale.

View File

@@ -113,8 +113,25 @@ export function syncUICache(appState: {
if ('collapsedNavSections' in appState) { if ('collapsedNavSections' in appState) {
update.cachedCollapsedNavSections = appState.collapsedNavSections; update.cachedCollapsedNavSections = appState.collapsedNavSections;
} }
if ('currentWorktreeByProject' in appState) { if ('currentWorktreeByProject' in appState && appState.currentWorktreeByProject) {
update.cachedCurrentWorktreeByProject = 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<string, { path: string | null; branch: string }> = {};
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) { if (Object.keys(update).length > 0) {