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
7 changes: 4 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { handleSdkMessage } from '@/lib/sdk-message-handler'
import { createLogger } from '@/lib/client-logger'
import type { LocalSettingsPatch, ServerSettings } from '@shared/settings'
import { z } from 'zod'
import { withChunkErrorRecovery } from '@/lib/import-retry'

const log = createLogger('App')

Expand All @@ -89,9 +90,9 @@ function ShareQrCode({ url }: { url: string }) {
return <img src={svgUrl} alt="QR code for access URL" className="w-48 h-48" />
}

const HistoryView = lazy(() => import('@/components/HistoryView'))
const SettingsView = lazy(() => import('@/components/SettingsView'))
const ExtensionsView = lazy(() => import('@/components/ExtensionsView'))
const HistoryView = lazy(() => withChunkErrorRecovery(import('@/components/HistoryView')))
const SettingsView = lazy(() => withChunkErrorRecovery(import('@/components/SettingsView')))
const ExtensionsView = lazy(() => withChunkErrorRecovery(import('@/components/ExtensionsView')))

const SIDEBAR_MIN_WIDTH = 200
const SIDEBAR_MAX_WIDTH = 500
Expand Down
3 changes: 2 additions & 1 deletion src/components/markdown/LazyMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { lazy, Suspense, type ReactNode } from 'react'
import { withChunkErrorRecovery } from '@/lib/import-retry'

const MarkdownRenderer = lazy(() =>
import('./MarkdownRenderer').then((module) => ({ default: module.MarkdownRenderer }))
withChunkErrorRecovery(import('./MarkdownRenderer')).then((module) => ({ default: module.MarkdownRenderer }))
)

type LazyMarkdownProps = {
Expand Down
3 changes: 2 additions & 1 deletion src/components/panes/PaneContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { clearDraft } from '@/lib/draft-store'
import { getTerminalActions } from '@/lib/pane-action-registry'
import { buildPaneRefreshTarget } from '@/lib/pane-utils'
import { cn } from '@/lib/utils'
import { withChunkErrorRecovery } from '@/lib/import-retry'
import { getWsClient } from '@/lib/ws-client'
import { api } from '@/lib/api'
import { resolvePaneActivity } from '@/lib/pane-activity'
Expand Down Expand Up @@ -58,7 +59,7 @@ const EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID: Record<string, PaneRuntimeActivityRecor
const EMPTY_ATTENTION_BY_PANE: Record<string, boolean> = {}
const EMPTY_PENDING_CREATES: Record<string, PendingAgentCreate> = {}
const EMPTY_EXTENSION_ENTRIES: ClientExtensionEntry[] = []
const EditorPane = lazy(() => import('./EditorPane'))
const EditorPane = lazy(() => withChunkErrorRecovery(import('./EditorPane')))

interface PaneContainerProps {
tabId: string
Expand Down
8 changes: 8 additions & 0 deletions src/components/ui/error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { isChunkLoadError, shouldReload } from '@/lib/import-retry'

type ErrorBoundaryProps = {
children: ReactNode
Expand Down Expand Up @@ -30,6 +31,13 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
}

private handleReset = () => {
const err = this.state.error
if (err != null && isChunkLoadError(err)) {
if (shouldReload()) {
window.location.reload()
return
}
}
this.setState({ hasError: false, error: null })
}

Expand Down
56 changes: 56 additions & 0 deletions src/lib/import-retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const CHUNK_ERROR_RE =
/(?:failed to fetch|error loading).*dynamically imported module|importing a module script|loading chunk \d+ failed/i

export const RELOAD_KEY = 'freshell.chunk-reload'
const RELOAD_COOLDOWN_MS = 10_000

export function isChunkLoadError(err: unknown): boolean {
return err instanceof TypeError && CHUNK_ERROR_RE.test(err.message)
}

export function shouldReload(): boolean {
try {
const last = sessionStorage.getItem(RELOAD_KEY)
if (last && Date.now() - parseInt(last, 10) < RELOAD_COOLDOWN_MS) {
return false
}
sessionStorage.setItem(RELOAD_KEY, String(Date.now()))
return true
} catch {
return true
Comment on lines +19 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Prevent reload loops when sessionStorage is blocked

When sessionStorage access throws (for example, users with storage/cookies disabled), shouldReload() currently returns true unconditionally, so every chunk-load failure triggers another window.location.reload() with no working cooldown. In that environment, a stale-chunk condition can trap the app in repeated reloads and make it unusable; the fallback path should use a non-persistent in-memory breaker or return false after the first attempted reload instead of always allowing reload.

Useful? React with 👍 / 👎.

}
}

export function withChunkErrorRecovery<T>(importPromise: Promise<T>): Promise<T> {
return importPromise.catch((err: unknown) => {
if (isChunkLoadError(err)) {
if (shouldReload()) {
window.location.reload()
return new Promise<never>(() => {})
}
throw err
}
throw err
})
}

let recoveryInitialized = false

export function initChunkErrorRecovery(): void {
if (recoveryInitialized) return
recoveryInitialized = true

window.addEventListener('vite:preloadError', (event) => {
if (shouldReload()) {
event.preventDefault()
window.location.reload()
}
})

window.addEventListener('unhandledrejection', (event) => {
if (isChunkLoadError(event.reason) && shouldReload()) {
event.preventDefault()
window.location.reload()
}
})
}
2 changes: 2 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import { initializeAuthToken } from '@/lib/auth'
import { createClientLogger } from '@/lib/client-logger'
import { initClientPerfLogging } from '@/lib/perf-logger'
import { registerServiceWorker } from '@/lib/pwa'
import { initChunkErrorRecovery } from '@/lib/import-retry'

initializeAuthToken()
createClientLogger().installConsoleCapture()
initClientPerfLogging()
registerServiceWorker()
initChunkErrorRecovery()

if (import.meta.env.DEV) {
document.title = 'freshell:dev'
Expand Down
97 changes: 97 additions & 0 deletions test/unit/client/components/ui/error-boundary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,101 @@ describe('ErrorBoundary', () => {
await user.click(screen.getByRole('button', { name: 'Go to Overview' }))
expect(onNavigate).toHaveBeenCalledTimes(1)
})

describe('chunk-load error recovery', () => {
const originalReload = window.location.reload

beforeEach(() => {
Object.defineProperty(window, 'location', {
value: { ...window.location, reload: vi.fn() },
writable: true,
configurable: true,
})
sessionStorage.clear()
})

afterEach(() => {
Object.defineProperty(window, 'location', {
value: { ...window.location, reload: originalReload },
writable: true,
configurable: true,
})
})

it('reloads the page when Try Again is clicked on a Chrome chunk-load error', async () => {
const user = userEvent.setup()
const chunkError = new TypeError(
'Failed to fetch dynamically imported module: http://192.168.3.50:3001/assets/EditorPane-DAYbRo9B.js'
)
function BadImport() {
throw chunkError
}
render(
<ErrorBoundary label="Editor">
<BadImport />
</ErrorBoundary>
)
await user.click(screen.getByRole('button', { name: 'Try Again' }))
expect(window.location.reload).toHaveBeenCalled()
})

it('reloads on Firefox-style chunk error', async () => {
const user = userEvent.setup()
const err = new TypeError(
'error loading dynamically imported module: http://localhost/assets/chunk.js'
)
function BadImport() {
throw err
}
render(
<ErrorBoundary>
<BadImport />
</ErrorBoundary>
)
await user.click(screen.getByRole('button', { name: 'Try Again' }))
expect(window.location.reload).toHaveBeenCalled()
})

it('still resets state for non-chunk errors (no reload)', async () => {
const user = userEvent.setup()
let shouldThrow = true
function ToggleChild() {
if (shouldThrow) throw new Error('Regular error')
return <div>Recovered</div>
}
const { container, rerender } = render(
<ErrorBoundary>
<ToggleChild />
</ErrorBoundary>
)
expect(within(container).getByRole('alert')).toBeInTheDocument()
shouldThrow = false
await user.click(within(container).getByRole('button', { name: 'Try Again' }))
rerender(
<ErrorBoundary>
<ToggleChild />
</ErrorBoundary>
)
expect(within(container).getByText('Recovered')).toBeInTheDocument()
expect(window.location.reload).not.toHaveBeenCalled()
})

it('does not reload if circuit breaker is tripped', async () => {
sessionStorage.setItem('freshell.chunk-reload', String(Date.now()))
const user = userEvent.setup()
const err = new TypeError(
'Failed to fetch dynamically imported module: http://localhost/assets/chunk.js'
)
function BadImport() {
throw err
}
render(
<ErrorBoundary>
<BadImport />
</ErrorBoundary>
)
await user.click(screen.getByRole('button', { name: 'Try Again' }))
expect(window.location.reload).not.toHaveBeenCalled()
})
})
})
68 changes: 68 additions & 0 deletions test/unit/client/lib/chunk-error-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { initChunkErrorRecovery } from '@/lib/import-retry'

function createRejectionEvent(reason: unknown): Event {
const event = new Event('unhandledrejection', { cancelable: true })
Object.defineProperty(event, 'reason', { value: reason, writable: false })
return event
}

describe('initChunkErrorRecovery', () => {
const originalReload = window.location.reload

beforeEach(() => {
Object.defineProperty(window, 'location', {
value: { ...window.location, reload: vi.fn() },
writable: true,
configurable: true,
})
sessionStorage.clear()
})

afterEach(() => {
Object.defineProperty(window, 'location', {
value: { ...window.location, reload: originalReload },
writable: true,
configurable: true,
})
})

it('reloads on vite:preloadError event', () => {
initChunkErrorRecovery()
const event = new Event('vite:preloadError', { cancelable: true })
window.dispatchEvent(event)
expect(window.location.reload).toHaveBeenCalled()
})

it('reloads on unhandledrejection with chunk-load error', () => {
initChunkErrorRecovery()
const err = new TypeError(
'Failed to fetch dynamically imported module: http://localhost/assets/chunk-abc123.js'
)
window.dispatchEvent(createRejectionEvent(err))
expect(window.location.reload).toHaveBeenCalled()
})

it('does not reload on unhandledrejection with non-chunk error', () => {
initChunkErrorRecovery()
const err = new Error('Something else')
window.dispatchEvent(createRejectionEvent(err))
expect(window.location.reload).not.toHaveBeenCalled()
})

it('respects circuit breaker on vite:preloadError', () => {
sessionStorage.setItem('freshell.chunk-reload', String(Date.now()))
initChunkErrorRecovery()
const event = new Event('vite:preloadError', { cancelable: true })
window.dispatchEvent(event)
expect(window.location.reload).not.toHaveBeenCalled()
})

it('is idempotent — calling initChunkErrorRecovery multiple times does not double-fire', () => {
initChunkErrorRecovery()
initChunkErrorRecovery()
const event = new Event('vite:preloadError', { cancelable: true })
window.dispatchEvent(event)
expect(window.location.reload).toHaveBeenCalledTimes(1)
})
})
Loading
Loading