From 7a81d30f9ec0e012ba7143bf42329aae477db5ee Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sun, 10 May 2026 00:34:31 -0700 Subject: [PATCH 1/5] feat: add withChunkErrorRecovery utility with broad chunk-error detection and circuit breaker (cherry picked from commit 4ae106c0828b29926b8cd4845c4f02bce336089c) --- src/lib/import-retry.ts | 47 ++++++++++ test/unit/client/lib/import-retry.test.ts | 103 ++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 src/lib/import-retry.ts create mode 100644 test/unit/client/lib/import-retry.test.ts diff --git a/src/lib/import-retry.ts b/src/lib/import-retry.ts new file mode 100644 index 000000000..67e2a367e --- /dev/null +++ b/src/lib/import-retry.ts @@ -0,0 +1,47 @@ +const CHUNK_ERROR_RE = + /(?:failed to fetch|error loading).*dynamically imported module|importing a module script|loading chunk \d+ failed/i + +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) +} + +function shouldReload(): boolean { + 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 +} + +export function withChunkErrorRecovery(importPromise: Promise): Promise { + return importPromise.catch((err: unknown) => { + if (isChunkLoadError(err)) { + if (shouldReload()) { + window.location.reload() + return new Promise(() => {}) + } + throw err + } + throw err + }) +} + +export function initChunkErrorRecovery(): void { + 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() + } + }) +} diff --git a/test/unit/client/lib/import-retry.test.ts b/test/unit/client/lib/import-retry.test.ts new file mode 100644 index 000000000..e398e2a0f --- /dev/null +++ b/test/unit/client/lib/import-retry.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { withChunkErrorRecovery } from '@/lib/import-retry' + +describe('withChunkErrorRecovery', () => { + 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('resolves with the module when import succeeds', async () => { + const mod = { foo: 'bar' } + const result = await withChunkErrorRecovery(Promise.resolve(mod)) + expect(result).toBe(mod) + }) + + describe('chunk-load error detection', () => { + it('reloads on Chrome-style chunk error', async () => { + const err = new TypeError( + 'Failed to fetch dynamically imported module: http://localhost/assets/chunk-abc123.js' + ) + const p = withChunkErrorRecovery(Promise.reject(err)) + await new Promise((r) => setTimeout(r, 0)) + expect(window.location.reload).toHaveBeenCalled() + }) + + it('reloads on Firefox-style chunk error', async () => { + const err = new TypeError( + 'error loading dynamically imported module: http://localhost/assets/chunk-abc123.js' + ) + const p = withChunkErrorRecovery(Promise.reject(err)) + await new Promise((r) => setTimeout(r, 0)) + expect(window.location.reload).toHaveBeenCalled() + }) + + it('reloads on Safari-style chunk error', async () => { + const err = new TypeError('Importing a module script failed.') + const p = withChunkErrorRecovery(Promise.reject(err)) + await new Promise((r) => setTimeout(r, 0)) + expect(window.location.reload).toHaveBeenCalled() + }) + + it('reloads on Vite-style chunk failure', async () => { + const err = new TypeError('loading chunk 42 failed') + const p = withChunkErrorRecovery(Promise.reject(err)) + await new Promise((r) => setTimeout(r, 0)) + expect(window.location.reload).toHaveBeenCalled() + }) + + it('does not reload on unrelated TypeError', async () => { + const err = new TypeError('NetworkError when attempting to fetch resource.') + await expect(withChunkErrorRecovery(Promise.reject(err))).rejects.toBe(err) + expect(window.location.reload).not.toHaveBeenCalled() + }) + }) + + describe('circuit breaker', () => { + it('does not reload if a reload happened within cooldown window', async () => { + sessionStorage.setItem('freshell.chunk-reload', String(Date.now())) + const err = new TypeError( + 'Failed to fetch dynamically imported module: http://localhost/assets/chunk-abc123.js' + ) + await expect(withChunkErrorRecovery(Promise.reject(err))).rejects.toBe(err) + expect(window.location.reload).not.toHaveBeenCalled() + }) + + it('reloads if the previous reload was outside cooldown window', async () => { + sessionStorage.setItem('freshell.chunk-reload', String(Date.now() - 20_000)) + const err = new TypeError( + 'Failed to fetch dynamically imported module: http://localhost/assets/chunk-abc123.js' + ) + const p = withChunkErrorRecovery(Promise.reject(err)) + await new Promise((r) => setTimeout(r, 0)) + expect(window.location.reload).toHaveBeenCalled() + }) + }) + + it('re-throws regular Error unchanged', async () => { + const err = new Error('Something else broke') + await expect(withChunkErrorRecovery(Promise.reject(err))).rejects.toBe(err) + expect(window.location.reload).not.toHaveBeenCalled() + }) + + it('re-throws non-Error rejections unchanged', async () => { + await expect(withChunkErrorRecovery(Promise.reject('string failure'))).rejects.toBe( + 'string failure' + ) + expect(window.location.reload).not.toHaveBeenCalled() + }) +}) From c3182fe2e58cf33bcc376aede026f194e77abef6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sun, 10 May 2026 00:36:24 -0700 Subject: [PATCH 2/5] feat: add global vite:preloadError and unhandledrejection listener for chunk-error recovery (cherry picked from commit aa6e1f0da91ec78a64cdcd1c7ef5287d161a68bc) --- src/main.tsx | 2 + .../client/lib/chunk-error-recovery.test.ts | 60 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 test/unit/client/lib/chunk-error-recovery.test.ts diff --git a/src/main.tsx b/src/main.tsx index 69d7d60a7..1f1a13d04 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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' diff --git a/test/unit/client/lib/chunk-error-recovery.test.ts b/test/unit/client/lib/chunk-error-recovery.test.ts new file mode 100644 index 000000000..6b334bb44 --- /dev/null +++ b/test/unit/client/lib/chunk-error-recovery.test.ts @@ -0,0 +1,60 @@ +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() + }) +}) From b5692de52ac6035a642abb74a1401676d77ef41d Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sun, 10 May 2026 00:38:26 -0700 Subject: [PATCH 3/5] fix: wrap all 5 lazy imports with chunk-error recovery (cherry picked from commit 890dcfb6f40756082f5d110e7c97faeb3b47c3da) --- src/App.tsx | 7 ++++--- src/components/markdown/LazyMarkdown.tsx | 3 ++- src/components/panes/PaneContainer.tsx | 3 ++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 379406fc3..03564081a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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') @@ -89,9 +90,9 @@ function ShareQrCode({ url }: { url: string }) { return QR code for access URL } -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 diff --git a/src/components/markdown/LazyMarkdown.tsx b/src/components/markdown/LazyMarkdown.tsx index 990443796..c43327e3c 100644 --- a/src/components/markdown/LazyMarkdown.tsx +++ b/src/components/markdown/LazyMarkdown.tsx @@ -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 = { diff --git a/src/components/panes/PaneContainer.tsx b/src/components/panes/PaneContainer.tsx index a6c35edf2..94f57098c 100644 --- a/src/components/panes/PaneContainer.tsx +++ b/src/components/panes/PaneContainer.tsx @@ -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' @@ -58,7 +59,7 @@ const EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID: Record = {} const EMPTY_PENDING_CREATES: Record = {} const EMPTY_EXTENSION_ENTRIES: ClientExtensionEntry[] = [] -const EditorPane = lazy(() => import('./EditorPane')) +const EditorPane = lazy(() => withChunkErrorRecovery(import('./EditorPane'))) interface PaneContainerProps { tabId: string From 8e00d1b87c039aafe4b5eb19be5c6c0f2405d6db Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sun, 10 May 2026 00:40:13 -0700 Subject: [PATCH 4/5] fix: reload page on chunk-load errors in ErrorBoundary Try Again with circuit breaker (cherry picked from commit 55c9db5d42d1f5511ec75faa8edfe614ba875e87) --- src/components/ui/error-boundary.tsx | 11 +++ .../components/ui/error-boundary.test.tsx | 97 +++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/src/components/ui/error-boundary.tsx b/src/components/ui/error-boundary.tsx index e729ced89..9a2247cb7 100644 --- a/src/components/ui/error-boundary.tsx +++ b/src/components/ui/error-boundary.tsx @@ -1,4 +1,5 @@ import { Component, type ErrorInfo, type ReactNode } from 'react' +import { isChunkLoadError } from '@/lib/import-retry' type ErrorBoundaryProps = { children: ReactNode @@ -30,6 +31,16 @@ export class ErrorBoundary extends Component { + const err = this.state.error + if (err != null && isChunkLoadError(err)) { + const key = 'freshell.chunk-reload' + const last = sessionStorage.getItem(key) + if (!last || Date.now() - parseInt(last, 10) >= 10_000) { + sessionStorage.setItem(key, String(Date.now())) + window.location.reload() + return + } + } this.setState({ hasError: false, error: null }) } diff --git a/test/unit/client/components/ui/error-boundary.test.tsx b/test/unit/client/components/ui/error-boundary.test.tsx index ed507e9fe..509d41601 100644 --- a/test/unit/client/components/ui/error-boundary.test.tsx +++ b/test/unit/client/components/ui/error-boundary.test.tsx @@ -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( + + + + ) + 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( + + + + ) + 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
Recovered
+ } + const { container, rerender } = render( + + + + ) + expect(within(container).getByRole('alert')).toBeInTheDocument() + shouldThrow = false + await user.click(within(container).getByRole('button', { name: 'Try Again' })) + rerender( + + + + ) + 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( + + + + ) + await user.click(screen.getByRole('button', { name: 'Try Again' })) + expect(window.location.reload).not.toHaveBeenCalled() + }) + }) }) From d3786f38040c8a9895eb400122357d7e9b6ef61e Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sun, 10 May 2026 01:09:29 -0700 Subject: [PATCH 5/5] refactor: export shouldReload, add try/catch on sessionStorage, make initChunkErrorRecovery idempotent, add unit test coverage (cherry picked from commit fdbefe52bcfb90c7ee592ddfdb00575b7573ea8f) --- src/components/ui/error-boundary.tsx | 7 +-- src/lib/import-retry.ts | 23 +++++++--- .../client/lib/chunk-error-recovery.test.ts | 8 ++++ test/unit/client/lib/import-retry.test.ts | 43 ++++++++++++++++++- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/src/components/ui/error-boundary.tsx b/src/components/ui/error-boundary.tsx index 9a2247cb7..c1f6bb903 100644 --- a/src/components/ui/error-boundary.tsx +++ b/src/components/ui/error-boundary.tsx @@ -1,5 +1,5 @@ import { Component, type ErrorInfo, type ReactNode } from 'react' -import { isChunkLoadError } from '@/lib/import-retry' +import { isChunkLoadError, shouldReload } from '@/lib/import-retry' type ErrorBoundaryProps = { children: ReactNode @@ -33,10 +33,7 @@ export class ErrorBoundary extends Component { const err = this.state.error if (err != null && isChunkLoadError(err)) { - const key = 'freshell.chunk-reload' - const last = sessionStorage.getItem(key) - if (!last || Date.now() - parseInt(last, 10) >= 10_000) { - sessionStorage.setItem(key, String(Date.now())) + if (shouldReload()) { window.location.reload() return } diff --git a/src/lib/import-retry.ts b/src/lib/import-retry.ts index 67e2a367e..bb0083234 100644 --- a/src/lib/import-retry.ts +++ b/src/lib/import-retry.ts @@ -1,20 +1,24 @@ const CHUNK_ERROR_RE = /(?:failed to fetch|error loading).*dynamically imported module|importing a module script|loading chunk \d+ failed/i -const RELOAD_KEY = 'freshell.chunk-reload' +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) } -function shouldReload(): boolean { - const last = sessionStorage.getItem(RELOAD_KEY) - if (last && Date.now() - parseInt(last, 10) < RELOAD_COOLDOWN_MS) { - return false +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 } - sessionStorage.setItem(RELOAD_KEY, String(Date.now())) - return true } export function withChunkErrorRecovery(importPromise: Promise): Promise { @@ -30,7 +34,12 @@ export function withChunkErrorRecovery(importPromise: Promise): Promise }) } +let recoveryInitialized = false + export function initChunkErrorRecovery(): void { + if (recoveryInitialized) return + recoveryInitialized = true + window.addEventListener('vite:preloadError', (event) => { if (shouldReload()) { event.preventDefault() diff --git a/test/unit/client/lib/chunk-error-recovery.test.ts b/test/unit/client/lib/chunk-error-recovery.test.ts index 6b334bb44..42b8c040e 100644 --- a/test/unit/client/lib/chunk-error-recovery.test.ts +++ b/test/unit/client/lib/chunk-error-recovery.test.ts @@ -57,4 +57,12 @@ describe('initChunkErrorRecovery', () => { 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) + }) }) diff --git a/test/unit/client/lib/import-retry.test.ts b/test/unit/client/lib/import-retry.test.ts index e398e2a0f..d61b48e74 100644 --- a/test/unit/client/lib/import-retry.test.ts +++ b/test/unit/client/lib/import-retry.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { withChunkErrorRecovery } from '@/lib/import-retry' +import { withChunkErrorRecovery, shouldReload, isChunkLoadError } from '@/lib/import-retry' describe('withChunkErrorRecovery', () => { const originalReload = window.location.reload @@ -101,3 +101,44 @@ describe('withChunkErrorRecovery', () => { expect(window.location.reload).not.toHaveBeenCalled() }) }) + +describe('shouldReload', () => { + beforeEach(() => { + sessionStorage.clear() + }) + + it('returns true on first call', () => { + expect(shouldReload()).toBe(true) + }) + + it('returns false if called within cooldown window', () => { + sessionStorage.setItem('freshell.chunk-reload', String(Date.now())) + expect(shouldReload()).toBe(false) + }) + + it('returns true after cooldown expires', () => { + sessionStorage.setItem('freshell.chunk-reload', String(Date.now() - 20_000)) + expect(shouldReload()).toBe(true) + }) + + it('returns true when sessionStorage is unavailable', () => { + const originalGetItem = sessionStorage.getItem + sessionStorage.getItem = () => { throw new DOMException('SecurityError') } + expect(shouldReload()).toBe(true) + sessionStorage.getItem = originalGetItem + }) +}) + +describe('isChunkLoadError', () => { + it('returns false for non-TypeError errors', () => { + const err = new RangeError( + 'Failed to fetch dynamically imported module: http://localhost/assets/chunk.js' + ) + expect(isChunkLoadError(err)).toBe(false) + }) + + it('returns false for matching message in regular Error', () => { + const err = new Error('importing a module script') + expect(isChunkLoadError(err)).toBe(false) + }) +})