mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 06:35:10 +00:00
## 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)
198 lines
6.0 KiB
TypeScript
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
|
|
}
|
|
}
|