Files
ComfyUI_frontend/src/extensions/core/load3d/LoaderManager.ts
Christian Byrne 341fef46a9 refactor: replace unsafe as Error assertions with type guards (#11845)
## Summary

Replaces all 7 production `as Error` type assertions with proper
`instanceof Error` narrowing or a new `toError()` helper, and adds an
ESLint rule to prevent new ones. First slice of #11429 (the `as Error`
category — 9 total occurrences, 7 production + 2 in a test file left
untouched).

## Changes

- **What**:
- New `src/utils/errorUtil.ts` exporting `toError(value: unknown):
Error` and `getErrorMessage(value: unknown): string | undefined`.
`toError` returns the value unchanged if already an `Error`, otherwise
wraps it (handles strings, `undefined`, JSON-serializable objects, and
circular refs via `String()` fallback).
  - Refactored 7 production call sites:
- `src/services/gateway/registrySearchGateway.ts` — `toError(error)` for
`lastError` assignment in fallback loop
- `src/platform/cloud/onboarding/auth.ts` (×2) — `toError(error)` for
`captureApiError` Sentry calls
-
`src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioRecorder.ts`
— `toError(err)` before forwarding to `options.onError`
- `src/extensions/core/load3d/LoaderManager.ts` — replaced `error as
Error & { response?: ... }` cast inside `isNotFoundError` with
`'response' in error` + nested narrowing
- `apps/desktop-ui/src/stores/maintenanceTaskStore.ts` — inline `error
instanceof Error ? error.message : String(error)`
- `apps/desktop-ui/src/components/maintenance/TaskListPanel.vue` —
inline `error instanceof Error ? error.message : undefined`
- New ESLint rule (`no-restricted-syntax` block named
`comfy/no-unsafe-error-assertion`) banning `TSAsExpression
TSTypeReference[typeName.name='Error']` in `src/**` and `apps/*/src/**`,
with test files (`*.test.ts`, `*.spec.ts`) excluded.
  - 12 unit tests for the new helpers in `src/utils/errorUtil.test.ts`.
- **Breaking**: none
- **Dependencies**: none

## Review Focus

- The lint rule is scoped to non-test source files. Test files retain
freedom to use `as Error` for fixture construction; only 2 occurrences
exist (in `teamWorkspaceStore.test.ts` and `errorDialog.spec.ts`) and
they're intentional.
- `toError` is duplicated as inline `instanceof` narrowing in
`apps/desktop-ui/` rather than imported, since the desktop-ui workspace
doesn't share `@/utils/` with the main app and adding a path mapping for
one helper felt heavier than two inline guards.
- Remaining `as`-on-DOM categories (HTMLElement ×133, HTMLInputElement
×55, HTMLCanvasElement ×36, KeyboardEvent ×7, Element ×3, MouseEvent ×2,
Event ×2) are intentionally left for follow-up PRs to keep this one
reviewable.

Refs #11429

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11845-refactor-replace-unsafe-as-Error-assertions-with-type-guards-3546d73d36508137a015c4f9e8708f23)
by [Unito](https://www.unito.io)
2026-05-04 11:40:28 -07:00

198 lines
6.0 KiB
TypeScript

import type * as THREE from 'three'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { MeshModelAdapter } from './MeshModelAdapter'
import { createAdapterRef } from './ModelAdapter'
import type { AdapterRef, ModelAdapter, ModelLoadContext } from './ModelAdapter'
import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
import { SplatModelAdapter } from './SplatModelAdapter'
import type {
EventManagerInterface,
LoadModelOptions,
LoaderManagerInterface,
ModelManagerInterface
} from './interfaces'
/**
* three.js's HttpError attaches the failed `Response` to the thrown Error.
* fetchModelData throws a plain Error whose message embeds the status code.
* Detect both forms so we can keep the toast for parse / network failures
* but stay silent on 404 when the caller opted in.
*/
function isNotFoundError(error: unknown): boolean {
if (!(error instanceof Error)) return false
if (
'response' in error &&
typeof error.response === 'object' &&
error.response !== null &&
'status' in error.response &&
error.response.status === 404
) {
return true
}
return /\b404\b/.test(error.message)
}
/**
* Default adapter set: mesh + pointCloud + splat. Each adapter declares the
* file extensions it owns; LoaderManager picks one by extension.
*/
function defaultAdapters(): ModelAdapter[] {
return [
new MeshModelAdapter(),
new PointCloudModelAdapter(),
new SplatModelAdapter()
]
}
export class LoaderManager implements LoaderManagerInterface {
private readonly modelManager: ModelManagerInterface
private readonly eventManager: EventManagerInterface
private readonly adapters: ModelAdapter[]
private readonly adapterRef: AdapterRef
private currentLoadId: number = 0
constructor(
modelManager: ModelManagerInterface,
eventManager: EventManagerInterface,
adapters?: readonly ModelAdapter[],
adapterRef?: AdapterRef
) {
this.modelManager = modelManager
this.eventManager = eventManager
this.adapters = adapters ? [...adapters] : defaultAdapters()
this.adapterRef = adapterRef ?? createAdapterRef()
}
getCurrentAdapter(): ModelAdapter | null {
return this.adapterRef.current
}
init(): void {}
dispose(): void {}
async loadModel(
url: string,
originalFileName?: string,
options?: LoadModelOptions
): Promise<void> {
const loadId = ++this.currentLoadId
try {
this.eventManager.emitEvent('modelLoadingStart', null)
this.modelManager.clearModel()
this.adapterRef.current = null
this.modelManager.originalURL = url
let fileExtension: string | undefined
if (originalFileName) {
fileExtension = originalFileName.split('.').pop()?.toLowerCase()
this.modelManager.originalFileName =
originalFileName.split('/').pop()?.split('.')[0] || 'model'
} else {
const filename = new URLSearchParams(url.split('?')[1]).get('filename')
fileExtension = filename?.split('.').pop()?.toLowerCase()
this.modelManager.originalFileName = filename
? filename.split('.')[0] || 'model'
: 'model'
}
if (!fileExtension) {
useToastStore().addAlert(t('toastMessages.couldNotDetermineFileType'))
return
}
const result = await this.loadModelInternal(url, fileExtension)
if (loadId !== this.currentLoadId) {
// A newer loadModel has superseded us — do not publish our adapter
// and do not setup the model. Whichever load is current owns the
// shared state.
return
}
if (result) {
// Publish only after the staleness check so a slow older load
// can't clobber adapterRef.current that a newer load already
// wrote (or cleared).
this.adapterRef.current = result.adapter
await this.modelManager.setupModel(result.model)
}
this.eventManager.emitEvent('modelLoadingEnd', null)
} catch (error) {
if (loadId === this.currentLoadId) {
this.eventManager.emitEvent('modelLoadingEnd', null)
console.error('Error loading model:', error)
if (!(options?.silentOnNotFound && isNotFoundError(error))) {
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
}
}
}
}
private pickAdapter(extension: string): ModelAdapter | null {
const match = this.adapters.find((adapter) =>
adapter.extensions.includes(extension)
)
if (!match) return null
// PLY may be routed through the splat adapter when the PLYEngine setting
// is sparkjs. Only honor the routing when both adapters are registered.
if (match.kind === 'pointCloud' && getPLYEngine() === 'sparkjs') {
const splat = this.adapters.find((adapter) => adapter.kind === 'splat')
if (splat) return splat
}
return match
}
private createLoadContext(): ModelLoadContext {
const mm = this.modelManager
return {
setOriginalModel: (model) => mm.setOriginalModel(model),
registerOriginalMaterial: (mesh, material) =>
mm.originalMaterials.set(mesh, material),
get standardMaterial() {
return mm.standardMaterial
},
get materialMode() {
return mm.materialMode
}
}
}
private async loadModelInternal(
url: string,
fileExtension: string
): Promise<{ model: THREE.Object3D; adapter: ModelAdapter } | null> {
const params = new URLSearchParams(url.split('?')[1])
const filename = params.get('filename')
if (!filename) {
console.error('Missing filename in URL:', url)
return null
}
const loadRootFolder = params.get('type') === 'output' ? 'output' : 'input'
const subfolder = params.get('subfolder') ?? ''
const path =
'api/view?type=' +
loadRootFolder +
'&subfolder=' +
encodeURIComponent(subfolder) +
'&filename='
const adapter = this.pickAdapter(fileExtension)
if (!adapter) return null
const model = await adapter.load(this.createLoadContext(), path, filename)
return model ? { model, adapter } : null
}
}