mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
Changes from fix/pwa-cache-fix (#794)
This commit is contained in:
@@ -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 & Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Collapsible technical details for debugging */}
|
{/* Collapsible technical details for debugging */}
|
||||||
{this.state.error && (
|
{this.state.error && (
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user