Files
ComfyUI_frontend/src/utils/preloadErrorUtil.ts
Johnpaul Chiwetelu fcdc08fb27 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>
2026-03-13 10:29:33 -07:00

78 lines
2.1 KiB
TypeScript

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