diff --git a/src/App.vue b/src/App.vue index 9ec886dd76..d3db93bdcf 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,15 +9,19 @@ import { captureException } from '@sentry/vue' import BlockUI from 'primevue/blockui' import { computed, onMounted, watch } from 'vue' +import { useI18n } from 'vue-i18n' + import GlobalDialog from '@/components/dialog/GlobalDialog.vue' import config from '@/config' +import { isDesktop } from '@/platform/distribution/types' +import { useToastStore } from '@/platform/updates/common/toastStore' +import { app } from '@/scripts/app' import { useWorkspaceStore } from '@/stores/workspaceStore' +import { electronAPI } from '@/utils/envUtil' +import { parsePreloadError } from '@/utils/preloadErrorUtil' import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection' -import { electronAPI } from '@/utils/envUtil' -import { isDesktop } from '@/platform/distribution/types' -import { app } from '@/scripts/app' - +const { t } = useI18n() const workspaceStore = useWorkspaceStore() app.extensionManager = useWorkspaceStore() @@ -45,6 +49,19 @@ const showContextMenu = (event: MouseEvent) => { } } +function handleResourceError(url: string, tagName: string) { + console.error('[resource:loadError]', { url, tagName }) + + if (__DISTRIBUTION__ === 'cloud') { + captureException(new Error(`Resource load failed: ${url}`), { + tags: { + error_type: 'resource_load_error', + tag_name: tagName + } + }) + } +} + onMounted(() => { window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version @@ -56,15 +73,56 @@ onMounted(() => { // See: https://vite.dev/guide/build#load-error-handling window.addEventListener('vite:preloadError', (event) => { event.preventDefault() + const info = parsePreloadError(event.payload) + console.error('[vite:preloadError]', { + url: info.url, + fileType: info.fileType, + chunkName: info.chunkName, + message: info.message + }) if (__DISTRIBUTION__ === 'cloud') { captureException(event.payload, { - tags: { error_type: 'vite_preload_error' } + tags: { + error_type: 'vite_preload_error', + file_type: info.fileType, + chunk_name: info.chunkName ?? undefined + }, + contexts: { + preload: { + url: info.url, + fileType: info.fileType, + chunkName: info.chunkName + } + } }) - } else { - console.error('[vite:preloadError]', event.payload) } + useToastStore().add({ + severity: 'error', + summary: t('g.preloadErrorTitle'), + detail: t('g.preloadError'), + life: 10000 + }) }) + // Capture resource load failures (CSS, scripts) in non-localhost distributions + if (__DISTRIBUTION__ !== 'localhost') { + window.addEventListener( + 'error', + (event) => { + const target = event.target + if (target instanceof HTMLScriptElement) { + handleResourceError(target.src, 'script') + } else if ( + target instanceof HTMLLinkElement && + target.rel === 'stylesheet' + ) { + handleResourceError(target.href, 'link') + } + }, + true + ) + } + // Initialize conflict detection in background // This runs async and doesn't block UI setup void conflictDetection.initializeConflictDetection() diff --git a/src/locales/en/main.json b/src/locales/en/main.json index ce18b2ce1c..761a746bed 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -326,6 +326,8 @@ "nightly": "NIGHTLY", "profile": "Profile", "noItems": "No items", + "preloadError": "A required resource failed to load. Please reload the page.", + "preloadErrorTitle": "Loading Error", "recents": "Recents", "partner": "Partner", "collapseAll": "Collapse all", diff --git a/src/utils/preloadErrorUtil.test.ts b/src/utils/preloadErrorUtil.test.ts new file mode 100644 index 0000000000..1527ea0a46 --- /dev/null +++ b/src/utils/preloadErrorUtil.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' + +import { parsePreloadError } from './preloadErrorUtil' + +describe('parsePreloadError', () => { + it('parses CSS preload error', () => { + const error = new Error( + 'Unable to preload CSS for /assets/vendor-vue-core-abc123.css' + ) + const result = parsePreloadError(error) + + expect(result.url).toBe('/assets/vendor-vue-core-abc123.css') + expect(result.fileType).toBe('css') + expect(result.chunkName).toBe('vendor-vue-core') + expect(result.message).toBe(error.message) + }) + + it('parses dynamically imported module error', () => { + const error = new Error( + 'Failed to fetch dynamically imported module: https://example.com/assets/vendor-three-def456.js' + ) + const result = parsePreloadError(error) + + expect(result.url).toBe('https://example.com/assets/vendor-three-def456.js') + expect(result.fileType).toBe('js') + expect(result.chunkName).toBe('vendor-three') + }) + + it('extracts URL from generic error message', () => { + const error = new Error( + 'Something went wrong loading https://cdn.example.com/assets/app-9f8e7d.js' + ) + const result = parsePreloadError(error) + + expect(result.url).toBe('https://cdn.example.com/assets/app-9f8e7d.js') + expect(result.fileType).toBe('js') + expect(result.chunkName).toBe('app') + }) + + it('returns null url when no URL found', () => { + const error = new Error('Something failed') + const result = parsePreloadError(error) + + expect(result.url).toBeNull() + expect(result.fileType).toBe('unknown') + expect(result.chunkName).toBeNull() + }) + + it('detects font file types', () => { + const error = new Error( + 'Unable to preload CSS for /assets/inter-abc123.woff2' + ) + const result = parsePreloadError(error) + + expect(result.fileType).toBe('font') + }) + + it('detects image file types', () => { + const error = new Error('Unable to preload CSS for /assets/logo-abc123.png') + const result = parsePreloadError(error) + + expect(result.fileType).toBe('image') + }) + + it('handles mjs extension', () => { + const error = new Error( + 'Failed to fetch dynamically imported module: /assets/chunk-abc123.mjs' + ) + const result = parsePreloadError(error) + + expect(result.fileType).toBe('js') + }) + + it('handles URLs with query parameters', () => { + const error = new Error( + 'Unable to preload CSS for /assets/style-abc123.css?v=2' + ) + const result = parsePreloadError(error) + + expect(result.url).toBe('/assets/style-abc123.css?v=2') + expect(result.fileType).toBe('css') + }) + + it('extracts chunk name from filename without hash', () => { + const error = new Error( + 'Failed to fetch dynamically imported module: /assets/index.js' + ) + const result = parsePreloadError(error) + + expect(result.chunkName).toBe('index') + }) +}) diff --git a/src/utils/preloadErrorUtil.ts b/src/utils/preloadErrorUtil.ts new file mode 100644 index 0000000000..a5e0332814 --- /dev/null +++ b/src/utils/preloadErrorUtil.ts @@ -0,0 +1,77 @@ +type PreloadFileType = 'js' | 'css' | 'font' | 'image' | 'unknown' + +interface PreloadErrorInfo { + url: string | null + fileType: PreloadFileType + chunkName: string | null + message: string +} + +const CSS_PRELOAD_RE = /Unable to preload CSS for (.+)/ +const JS_DYNAMIC_IMPORT_RE = + /Failed to fetch dynamically imported module:\s*(.+)/ +const URL_FALLBACK_RE = /https?:\/\/[^\s"')]+/ + +const FONT_EXTENSIONS = new Set(['woff', 'woff2', 'ttf', 'otf', 'eot']) +const IMAGE_EXTENSIONS = new Set([ + 'png', + 'jpg', + 'jpeg', + 'gif', + 'svg', + 'webp', + 'avif', + 'ico' +]) + +function extractUrl(message: string): string | null { + const cssMatch = message.match(CSS_PRELOAD_RE) + if (cssMatch) return cssMatch[1].trim() + + const jsMatch = message.match(JS_DYNAMIC_IMPORT_RE) + if (jsMatch) return jsMatch[1].trim() + + const fallbackMatch = message.match(URL_FALLBACK_RE) + if (fallbackMatch) return fallbackMatch[0] + + return null +} + +function detectFileType(url: string): PreloadFileType { + const pathname = new URL(url, 'https://cloud.comfy.org').pathname + const ext = pathname.split('.').pop()?.toLowerCase() + if (!ext) return 'unknown' + + // Strip query params from extension + const cleanExt = ext.split('?')[0] + + if (cleanExt === 'js' || cleanExt === 'mjs') return 'js' + if (cleanExt === 'css') return 'css' + if (FONT_EXTENSIONS.has(cleanExt)) return 'font' + if (IMAGE_EXTENSIONS.has(cleanExt)) return 'image' + return 'unknown' +} + +function extractChunkName(url: string): string | null { + const pathname = new URL(url, 'https://cloud.comfy.org').pathname + const filename = pathname.split('/').pop() + if (!filename) return null + + // Strip extension + const nameWithoutExt = filename.replace(/\.[^.]+$/, '') + // Strip hash suffix (e.g. "vendor-vue-core-abc123" -> "vendor-vue-core") + const withoutHash = nameWithoutExt.replace(/-[a-f0-9]{6,}$/, '') + return withoutHash || null +} + +export function parsePreloadError(error: Error): PreloadErrorInfo { + const message = error.message || String(error) + const url = extractUrl(message) + + return { + url, + fileType: url ? detectFileType(url) : 'unknown', + chunkName: url ? extractChunkName(url) : null, + message + } +}