mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
## ELI-5 Some custom nodes have a `getCustomWidgets()` function that's *supposed* to hand us a list of widgets. A few of them hand us back nothing (null/undefined) instead. We were trying to read that "nothing" like a list, which crashes with *"Cannot convert undefined or null to object"* — and because it happens while the app is still starting up, it can break the whole page. This PR just says "if there's nothing to register, skip it." ## What `registerCustomWidgets` called `Object.entries(newWidgets)` directly. When an extension's `getCustomWidgets()` resolves to `null`/`undefined` (it's typed non-null, but extensions are untrusted and routinely violate the type), this throws `TypeError: Cannot convert undefined or null to object`. The call site in `extensionService.ts` runs this inside a bare async IIFE, *outside* the `wrapWithErrorHandling` wrappers used for keybindings/settings, so the throw is unhandled and surfaces during app initialization. ## Why it matters In production this is one of the highest-volume unhandled frontend errors — ~2.6k events across **~1,160 distinct sessions/day**, all funneling through this one `Object.entries` call. Guarding the choke point silences it for every caller. ## Fix - Keep `registerCustomWidgets` typed `Record<string, ComfyWidgetConstructor>` (the correct internal contract) and early-return on nullish input. The runtime guard defends against untrusted extensions that violate the type at the boundary, without weakening the signature for legitimate callers. - Add a regression test asserting `registerCustomWidgets(null!/undefined!)` does not throw (the `!` casts simulate the boundary violation). ## Test plan - [x] `npx vitest run src/stores/widgetStore.test.ts` — 8 passing, including the new null/undefined case. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
86 lines
2.7 KiB
TypeScript
86 lines
2.7 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { setActivePinia } from 'pinia'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { ComfyWidgets } from '@/scripts/widgets'
|
|
import { useWidgetStore } from '@/stores/widgetStore'
|
|
|
|
vi.mock('@/scripts/widgets', () => ({
|
|
ComfyWidgets: {
|
|
INT: vi.fn(),
|
|
FLOAT: vi.fn(),
|
|
STRING: vi.fn(),
|
|
BOOLEAN: vi.fn(),
|
|
COMBO: vi.fn()
|
|
}
|
|
}))
|
|
|
|
vi.mock('@/schemas/nodeDefSchema', () => ({
|
|
getInputSpecType: (spec: unknown[]) => spec[0]
|
|
}))
|
|
|
|
describe('widgetStore', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
})
|
|
|
|
describe('widgets getter', () => {
|
|
it('includes custom widgets after registration', () => {
|
|
const store = useWidgetStore()
|
|
const customFn = vi.fn()
|
|
store.registerCustomWidgets({ CUSTOM_TYPE: customFn })
|
|
expect(store.widgets.get('CUSTOM_TYPE')).toBe(customFn)
|
|
})
|
|
|
|
it('core widgets take precedence over custom widgets with same key', () => {
|
|
const store = useWidgetStore()
|
|
const override = vi.fn()
|
|
store.registerCustomWidgets({ INT: override })
|
|
expect(store.widgets.get('INT')).toBe(ComfyWidgets.INT)
|
|
})
|
|
|
|
it('does not throw when an extension returns null/undefined widgets', () => {
|
|
const store = useWidgetStore()
|
|
// Regression: a misbehaving extension can resolve getCustomWidgets() to
|
|
// nullish, which must not break app init. The `!` casts deliberately
|
|
// violate the non-null parameter type to simulate that untrusted input.
|
|
expect(() => store.registerCustomWidgets(undefined!)).not.toThrow()
|
|
expect(() => store.registerCustomWidgets(null!)).not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('inputIsWidget', () => {
|
|
it('returns true for known widget type (v1 spec)', () => {
|
|
const store = useWidgetStore()
|
|
expect(store.inputIsWidget(['INT', {}] as const)).toBe(true)
|
|
})
|
|
|
|
it('returns false for unknown type (v1 spec)', () => {
|
|
const store = useWidgetStore()
|
|
expect(store.inputIsWidget(['UNKNOWN_TYPE', {}] as const)).toBe(false)
|
|
})
|
|
|
|
it('returns true for v2 spec with known type', () => {
|
|
const store = useWidgetStore()
|
|
expect(store.inputIsWidget({ type: 'STRING', name: 'test_input' })).toBe(
|
|
true
|
|
)
|
|
})
|
|
|
|
it('returns false for v2 spec with unknown type', () => {
|
|
const store = useWidgetStore()
|
|
expect(store.inputIsWidget({ type: 'LATENT', name: 'test_input' })).toBe(
|
|
false
|
|
)
|
|
})
|
|
|
|
it('returns true for custom registered type', () => {
|
|
const store = useWidgetStore()
|
|
store.registerCustomWidgets({ MY_WIDGET: vi.fn() })
|
|
expect(
|
|
store.inputIsWidget({ type: 'MY_WIDGET', name: 'test_input' })
|
|
).toBe(true)
|
|
})
|
|
})
|
|
})
|