diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 651c4924..66f6881b 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -19,11 +19,17 @@ import { PrivacyModeProvider } from './contexts/privacy-mode-context'; import { SyncProvider } from './contexts/sync-context'; import { initializeTheme } from './hooks/use-appearance'; import { initializeChartColorScheme } from './hooks/use-chart-color-scheme'; +import { installChunkLoadRecovery } from './lib/chunk-load-recovery'; import { initializePostHog } from './lib/posthog'; -import { isPostMessageDataCloneNoise } from './lib/sentry'; +import { + isChunkLoadErrorEvent, + isPostMessageDataCloneNoise, +} from './lib/sentry'; import type { SharedData } from './types'; import { setTranslations } from './utils/i18n'; +installChunkLoadRecovery(); + Sentry.init({ dsn: import.meta.env.SENTRY_LARAVEL_DSN, environment: import.meta.env.MODE, @@ -31,7 +37,10 @@ Sentry.init({ tracesSampleRate: 0, sendDefaultPii: true, beforeSend(event) { - if (isPostMessageDataCloneNoise(event)) { + if ( + isChunkLoadErrorEvent(event) || + isPostMessageDataCloneNoise(event) + ) { return null; } diff --git a/resources/js/lib/chunk-load-recovery.test.ts b/resources/js/lib/chunk-load-recovery.test.ts new file mode 100644 index 00000000..7dc73896 --- /dev/null +++ b/resources/js/lib/chunk-load-recovery.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + isChunkLoadError, + reloadOnChunkLoadError, +} from './chunk-load-recovery'; + +describe('isChunkLoadError', () => { + it('detects Vite dynamic import fetch failures', () => { + expect( + isChunkLoadError( + new TypeError( + 'Failed to fetch dynamically imported module: https://whisper.money/build/assets/accounts-BO3xxENF.js', + ), + ), + ).toBe(true); + }); + + it('ignores unrelated errors', () => { + expect(isChunkLoadError(new Error('Validation failed'))).toBe(false); + }); +}); + +describe('reloadOnChunkLoadError', () => { + it('reloads once per asset signature', () => { + const reload = vi.fn(); + const storage = window.sessionStorage; + storage.clear(); + + const reason = new TypeError( + 'Failed to fetch dynamically imported module: https://whisper.money/build/assets/accounts-BO3xxENF.js', + ); + + expect( + reloadOnChunkLoadError(reason, { + assetSignature: 'app-old.js', + reload, + storage, + }), + ).toBe(true); + expect( + reloadOnChunkLoadError(reason, { + assetSignature: 'app-old.js', + reload, + storage, + }), + ).toBe(false); + expect(reload).toHaveBeenCalledOnce(); + }); + + it('does not reload for unrelated errors', () => { + const reload = vi.fn(); + + expect( + reloadOnChunkLoadError(new Error('Validation failed'), { + assetSignature: 'app-old.js', + reload, + storage: window.sessionStorage, + }), + ).toBe(false); + expect(reload).not.toHaveBeenCalled(); + }); +}); diff --git a/resources/js/lib/chunk-load-recovery.ts b/resources/js/lib/chunk-load-recovery.ts new file mode 100644 index 00000000..e3e16773 --- /dev/null +++ b/resources/js/lib/chunk-load-recovery.ts @@ -0,0 +1,122 @@ +const CHUNK_LOAD_RELOAD_STORAGE_KEY = + 'whisper-money:chunk-load-reload-asset-signature'; + +const CHUNK_LOAD_ERROR_PATTERNS = [ + /Failed to fetch dynamically imported module/i, + /error loading dynamically imported module/i, + /Importing a module script failed/i, + /Load failed for module script/i, + /ChunkLoadError/i, + /Loading chunk \d+ failed/i, +]; + +let inMemoryReloadedAssetSignature: string | null = null; + +interface ChunkLoadRecoveryOptions { + assetSignature?: string; + reload?: () => void; + storage?: Storage; +} + +export function getCurrentAssetSignature(): string { + const buildAssetScripts = Array.from(document.scripts) + .map((script) => script.src) + .filter((src) => src.includes('/build/assets/')) + .sort(); + + return buildAssetScripts.join('|') || window.location.href; +} + +export function isChunkLoadError(value: unknown): boolean { + const message = getErrorMessage(value); + + return CHUNK_LOAD_ERROR_PATTERNS.some((pattern) => pattern.test(message)); +} + +export function reloadOnChunkLoadError( + value: unknown, + options: ChunkLoadRecoveryOptions = {}, +): boolean { + if (!isChunkLoadError(value)) { + return false; + } + + const assetSignature = options.assetSignature ?? getCurrentAssetSignature(); + + if (hasAlreadyReloadedForAssetSignature(assetSignature, options.storage)) { + return false; + } + + markReloadedForAssetSignature(assetSignature, options.storage); + (options.reload ?? (() => window.location.reload()))(); + + return true; +} + +export function installChunkLoadRecovery(): void { + window.addEventListener('unhandledrejection', (event) => { + if ( + reloadOnChunkLoadError(event.reason, { + storage: window.sessionStorage, + }) + ) { + event.preventDefault(); + } + }); + + window.addEventListener('error', (event) => { + if ( + reloadOnChunkLoadError(event.error ?? event.message, { + storage: window.sessionStorage, + }) + ) { + event.preventDefault(); + } + }); +} + +function getErrorMessage(value: unknown): string { + if (value instanceof Error) { + return `${value.name}: ${value.message}`; + } + + if (typeof value === 'string') { + return value; + } + + if (typeof value === 'object' && value !== null && 'message' in value) { + return String(value.message); + } + + return ''; +} + +function hasAlreadyReloadedForAssetSignature( + assetSignature: string, + storage?: Storage, +): boolean { + if (inMemoryReloadedAssetSignature === assetSignature) { + return true; + } + + try { + return ( + storage?.getItem(CHUNK_LOAD_RELOAD_STORAGE_KEY) === assetSignature + ); + } catch { + return false; + } +} + +function markReloadedForAssetSignature( + assetSignature: string, + storage?: Storage, +): void { + inMemoryReloadedAssetSignature = assetSignature; + + try { + storage?.setItem(CHUNK_LOAD_RELOAD_STORAGE_KEY, assetSignature); + } catch { + return; + } +} diff --git a/resources/js/lib/sentry.test.ts b/resources/js/lib/sentry.test.ts index b8816542..504a4e26 100644 --- a/resources/js/lib/sentry.test.ts +++ b/resources/js/lib/sentry.test.ts @@ -1,6 +1,38 @@ import type { Event } from '@sentry/react'; import { describe, expect, it } from 'vitest'; -import { isPostMessageDataCloneNoise } from './sentry'; +import { isChunkLoadErrorEvent, isPostMessageDataCloneNoise } from './sentry'; + +describe('isChunkLoadErrorEvent', () => { + it('drops recoverable Vite dynamic import failures', () => { + const event: Event = { + exception: { + values: [ + { + type: 'TypeError', + value: 'Failed to fetch dynamically imported module: https://whisper.money/build/assets/accounts-BO3xxENF.js', + }, + ], + }, + }; + + expect(isChunkLoadErrorEvent(event)).toBe(true); + }); + + it('keeps unrelated TypeError events', () => { + const event: Event = { + exception: { + values: [ + { + type: 'TypeError', + value: 'Cannot read properties of undefined', + }, + ], + }, + }; + + expect(isChunkLoadErrorEvent(event)).toBe(false); + }); +}); describe('isPostMessageDataCloneNoise', () => { it('drops browser postMessage DataCloneError noise', () => { diff --git a/resources/js/lib/sentry.ts b/resources/js/lib/sentry.ts index d4f69f80..54a90845 100644 --- a/resources/js/lib/sentry.ts +++ b/resources/js/lib/sentry.ts @@ -1,8 +1,19 @@ import type { Event } from '@sentry/react'; +import { isChunkLoadError } from './chunk-load-recovery'; const CLONE_ERROR_MESSAGE_PATTERN = /object (can not|could not|couldn't|can't) be cloned/i; +export function isChunkLoadErrorEvent(event: Event): boolean { + return ( + event.exception?.values?.some((exception) => + isChunkLoadError( + `${exception.type ?? ''}: ${exception.value ?? ''}`, + ), + ) ?? false + ); +} + export function isPostMessageDataCloneNoise(event: Event): boolean { return ( event.exception?.values?.some((exception) => {