feat: structured preload error logging with Sentry enrichment (#8928)

## Summary

Add structured preload error logging with Sentry context enrichment and
a user-facing toast notification when chunk loading fails (e.g. after a
deploy with new hashed filenames).

## Changes

- **`parsePreloadError` utility** (`src/utils/preloadErrorUtil.ts`):
Extracts structured info from `vite:preloadError` events — URL, file
type (JS/CSS/unknown), chunk name, and whether it looks like a hash
mismatch.
- **Sentry enrichment** (`src/App.vue`): Sets Sentry context and tags on
preload errors so they are searchable/filterable in the Sentry
dashboard.
- **User-facing toast**: Shows an actionable "please refresh" message
when a preload error occurs, across all distributions (cloud, desktop,
localhost).
- **Capture-phase resource error listener** (`src/App.vue`): Catches
CSS/script load failures that bypass `vite:preloadError` and reports
them to Sentry with the same structured context.
- **Unit tests** (`src/utils/preloadErrorUtil.test.ts`): 9 tests
covering URL parsing, chunk name extraction, hash mismatch detection,
and edge cases.

## Files Changed

| File | What |
|------|------|
| `src/App.vue` | Preload error handler + resource error listener |
| `src/locales/en/main.json` | Toast message string |
| `src/utils/preloadErrorUtil.ts` | `parsePreloadError()` utility |
| `src/utils/preloadErrorUtil.test.ts` | Unit tests |

## Review Focus

- Toast fires for all distributions (cloud/desktop/localhost) —
intentional so all users see stale chunk errors
- `parsePreloadError` is defensive — returns `unknown` for any field it
cannot parse
- Capture-phase listener filters to only `<script>` and `<link
rel="stylesheet">` elements

## References

- [Vite preload error
handling](https://vite.dev/guide/build#load-error-handling)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
This commit is contained in:
Johnpaul Chiwetelu
2026-03-13 18:29:33 +01:00
committed by GitHub
parent f1626acb61
commit fcdc08fb27
4 changed files with 236 additions and 7 deletions

View File

@@ -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()

View File

@@ -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",

View File

@@ -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')
})
})

View File

@@ -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
}
}