Changes from fix/pwa-cache-fix (#794)

This commit is contained in:
gsxdsm
2026-02-21 12:45:18 -08:00
committed by GitHub
parent f3edfbf24e
commit f785f1204b
2 changed files with 121 additions and 27 deletions

View File

@@ -10,8 +10,16 @@ interface Props {
interface State { interface State {
hasError: boolean; hasError: boolean;
error: Error | null; 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;
/** /**
* Root-level error boundary for the entire application. * Root-level error boundary for the entire application.
* *
@@ -21,14 +29,18 @@ interface State {
* Provides a user-friendly error screen with a reload button to recover. * Provides a user-friendly error screen with a reload button to recover.
* This is especially important for transient errors during initial app load * This is especially important for transient errors during initial app load
* (e.g., race conditions during auth/hydration on fresh browser sessions). * (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> { export class AppErrorBoundary extends Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(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 }; return { hasError: true, error };
} }
@@ -38,12 +50,48 @@ export class AppErrorBoundary extends Component<Props, State> {
stack: error.stack, stack: error.stack,
componentStack: errorInfo.componentStack, 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 = () => { handleReload = () => {
window.location.reload(); 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() { render() {
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
@@ -82,34 +130,60 @@ export class AppErrorBoundary extends Component<Props, State> {
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h1 className="text-xl font-semibold">Something went wrong</h1> <h1 className="text-xl font-semibold">Something went wrong</h1>
<p className="text-sm text-muted-foreground max-w-md"> <p className="text-sm text-muted-foreground max-w-md">
The application encountered an unexpected error. This is usually temporary and can be {this.state.isCrashLoop
resolved by reloading the page. ? '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> </p>
</div> </div>
<button <div className="flex items-center gap-3">
type="button" <button
onClick={this.handleReload} type="button"
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" 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" /> <svg
<path d="M3 3v5h5" /> className="h-4 w-4"
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" /> xmlns="http://www.w3.org/2000/svg"
<path d="M16 21h5v-5" /> viewBox="0 0 24 24"
</svg> fill="none"
Reload Page stroke="currentColor"
</button> 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"
>
<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 */} {/* Collapsible technical details for debugging */}
{this.state.error && ( {this.state.error && (

View File

@@ -160,11 +160,31 @@ export function restoreFromUICache(
// Restore last selected worktree per project so the board doesn't // Restore last selected worktree per project so the board doesn't
// reset to main branch after PWA memory eviction or tab discard. // 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 ( if (
cache.cachedCurrentWorktreeByProject && cache.cachedCurrentWorktreeByProject &&
Object.keys(cache.cachedCurrentWorktreeByProject).length > 0 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;
}
} }
// Restore the project context when the project object is available. // Restore the project context when the project object is available.