Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 100 additions & 26 deletions apps/ui/src/components/ui/app-error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,16 @@ interface Props {
interface State {
hasError: boolean;
error: Error | null;
isCrashLoop: boolean;
}

/** Key used to track recent crash timestamps for crash loop detection */
const CRASH_TIMESTAMPS_KEY = 'automaker-crash-timestamps';
/** Number of crashes within the time window that constitutes a crash loop */
const CRASH_LOOP_THRESHOLD = 3;
/** Time window in ms for crash loop detection (30 seconds) */
const CRASH_LOOP_WINDOW_MS = 30_000;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid using a magic string for the UI cache key ('automaker-ui-cache'), it's good practice to define it as a constant here, alongside the other constants. This key is used in componentDidCatch and handleClearCacheAndReload.

After adding this constant, you can replace the hardcoded string in both places.

const CRASH_LOOP_WINDOW_MS = 30_000;

/** Key for the UI cache in localStorage, used for clearing on crash loops. */
const UI_CACHE_KEY = 'automaker-ui-cache';


/**
* Root-level error boundary for the entire application.
*
Expand All @@ -21,14 +29,18 @@ interface State {
* Provides a user-friendly error screen with a reload button to recover.
* This is especially important for transient errors during initial app load
* (e.g., race conditions during auth/hydration on fresh browser sessions).
*
* Includes crash loop detection: if the app crashes 3+ times within 30 seconds,
* the UI cache is automatically cleared to break loops caused by stale cached
* worktree paths or other corrupt persisted state.
*/
export class AppErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
this.state = { hasError: false, error: null, isCrashLoop: false };
}

static getDerivedStateFromError(error: Error): State {
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error };
}

Expand All @@ -38,12 +50,48 @@ export class AppErrorBoundary extends Component<Props, State> {
stack: error.stack,
componentStack: errorInfo.componentStack,
});

// Track crash timestamps to detect crash loops.
// If the app crashes multiple times in quick succession, it's likely due to
// stale cached data (e.g., worktree paths that no longer exist on disk).
try {
const now = Date.now();
const raw = sessionStorage.getItem(CRASH_TIMESTAMPS_KEY);
const timestamps: number[] = raw ? JSON.parse(raw) : [];
timestamps.push(now);
// Keep only timestamps within the detection window
const recent = timestamps.filter((t) => now - t < CRASH_LOOP_WINDOW_MS);
sessionStorage.setItem(CRASH_TIMESTAMPS_KEY, JSON.stringify(recent));

if (recent.length >= CRASH_LOOP_THRESHOLD) {
logger.error(
`Crash loop detected (${recent.length} crashes in ${CRASH_LOOP_WINDOW_MS}ms) — clearing UI cache`
);
// Auto-clear the UI cache to break the loop
localStorage.removeItem('automaker-ui-cache');
sessionStorage.removeItem(CRASH_TIMESTAMPS_KEY);
this.setState({ isCrashLoop: true });
}
} catch {
// Storage may be unavailable — ignore
}
}

handleReload = () => {
window.location.reload();
};

handleClearCacheAndReload = () => {
// Clear the UI cache store that persists worktree selections and other UI state.
// This breaks crash loops caused by stale worktree paths that no longer exist on disk.
try {
localStorage.removeItem('automaker-ui-cache');
} catch {
// localStorage may be unavailable in some contexts
}
window.location.reload();
};

render() {
if (this.state.hasError) {
return (
Expand Down Expand Up @@ -82,34 +130,60 @@ export class AppErrorBoundary extends Component<Props, State> {
<div className="text-center space-y-2">
<h1 className="text-xl font-semibold">Something went wrong</h1>
<p className="text-sm text-muted-foreground max-w-md">
The application encountered an unexpected error. This is usually temporary and can be
resolved by reloading the page.
{this.state.isCrashLoop
? 'The application crashed repeatedly, likely due to stale cached data. The cache has been cleared automatically. Reload to continue.'
: 'The application encountered an unexpected error. This is usually temporary and can be resolved by reloading the page.'}
</p>
</div>

<button
type="button"
onClick={this.handleReload}
className="inline-flex items-center gap-2 rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground shadow-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg
className="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
<div className="flex items-center gap-3">
<button
type="button"
onClick={this.handleReload}
className="inline-flex items-center gap-2 rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground shadow-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg
className="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
Reload Page
</button>

<button
type="button"
onClick={this.handleClearCacheAndReload}
className="inline-flex items-center gap-2 rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
Reload Page
</button>
<svg
className="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
Clear Cache &amp; Reload
</button>
</div>

{/* Collapsible technical details for debugging */}
{this.state.error && (
Expand Down
22 changes: 21 additions & 1 deletion apps/ui/src/store/ui-cache-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,31 @@ export function restoreFromUICache(

// Restore last selected worktree per project so the board doesn't
// reset to main branch after PWA memory eviction or tab discard.
//
// IMPORTANT: Only restore entries where path is null (main branch selection).
// Non-null paths point to worktree directories on disk that may have been
// deleted while the PWA was evicted. Restoring a stale worktree path causes
// the board to render with an invalid selection, and if the server can't
// validate it fast enough, the app enters an unrecoverable crash loop
// (the error boundary reloads, which restores the same bad cache).
// Main branch (path=null) is always valid and safe to restore.
if (
cache.cachedCurrentWorktreeByProject &&
Object.keys(cache.cachedCurrentWorktreeByProject).length > 0
) {
stateUpdate.currentWorktreeByProject = cache.cachedCurrentWorktreeByProject;
const sanitized: Record<string, { path: string | null; branch: string }> = {};
for (const [projectPath, worktree] of Object.entries(cache.cachedCurrentWorktreeByProject)) {
if (worktree.path === null) {
// Main branch selection — always safe to restore
sanitized[projectPath] = worktree;
}
// Non-null paths are dropped; the app will re-discover actual worktrees
// from the server and the validation effect in use-worktrees will handle
// resetting to main if the cached worktree no longer exists.
}
if (Object.keys(sanitized).length > 0) {
stateUpdate.currentWorktreeByProject = sanitized;
}
Comment on lines +175 to +187
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This logic for sanitizing the worktree cache can be written more concisely using functional programming constructs like filter and Object.fromEntries. This improves readability by making the intent of filtering and creating a new object more explicit.

    // Main branch selection — always safe to restore
    const sanitizedEntries = Object.entries(cache.cachedCurrentWorktreeByProject).filter(
      ([, worktree]) => worktree.path === null
    );

    if (sanitizedEntries.length > 0) {
      stateUpdate.currentWorktreeByProject = Object.fromEntries(sanitizedEntries);
    }
    // Non-null paths are dropped; the app will re-discover actual worktrees
    // from the server and the validation effect in use-worktrees will handle
    // resetting to main if the cached worktree no longer exists.

}

// Restore the project context when the project object is available.
Expand Down