Compare commits

...

5 Commits

Author SHA1 Message Date
Alexander Brown
1151359364 Merge branch 'main' into glary/fix-primitive-refresh-combo-options 2026-05-18 16:35:07 -07:00
Glary-Bot
410bfc2360 test: convert primitive refresh helper to function declaration
Per repo conventions (no function expressions when a declaration works).
2026-05-11 20:25:46 +00:00
Glary-Bot
fc284fc903 test: add e2e coverage for primitive combo refresh
Adds an E2E test that loads a Primitive wired to KSampler.sampler_name,
presses 'r' (Refresh Node Definitions), and asserts the primitive's
combo options stay a non-empty array containing the connected input's
values. Reproduces the regression this PR fixes — before the fix, the
options array was clobbered with undefined.
2026-05-11 20:20:22 +00:00
Glary-Bot
89ac191cbc fix: address review feedback for refreshComboInNode
- Distinguish 'no result' (undefined) from 'empty options' ([]) so a
  legitimate empty combo list propagates to the widget instead of being
  treated as a no-op.
- Fall back to input.name when the connected target's input has no
  widget locator, mirroring _onFirstConnection.
- Add regression tests for V2 combo specs, empty option propagation,
  and the widgetless-input fallback path.
2026-05-02 02:10:37 +00:00
Glary-Bot
d0fd56bf9a fix: guard PrimitiveNode.refreshComboInNode against missing slot config
Pressing 'r' (Refresh Node Definitions) calls refreshComboInNode on every
node. For primitives, the previous override re-derived combo values from
this.outputs[0].widget[GET_CONFIG]()[0] without null checks. When that
returned undefined (stale slot widget reference after subgraph traversal
rebuilds widgets), widget.options.values was clobbered with undefined
and the dropdown went blank.

Now the override:
- Uses the freshly passed defs to resolve combo values from the connected
  node + input, matching what reloadNodeDefs does for normal combo widgets.
- Falls back to outputs[0].widget[GET_CONFIG]() when defs are unavailable
  or do not include the target type.
- Preserves existing options if both lookups fail rather than overwriting
  with undefined.

Add regression tests covering the defs-driven refresh, the slot-config
fallback, the missing-config preservation case, value reset when the
current value is no longer valid, no-op when it is still valid, and
non-combo widgets being skipped.
2026-04-29 05:00:37 +00:00
4 changed files with 431 additions and 10 deletions

View File

@@ -0,0 +1,58 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "KSampler",
"pos": [521, 41],
"size": [315, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null },
{
"name": "sampler_name",
"type": "COMBO",
"link": 1,
"widget": { "name": "sampler_name" }
}
],
"outputs": [
{ "name": "LATENT", "type": "LATENT", "links": null, "shape": 3 }
],
"properties": { "Node name for S&R": "KSampler" },
"widgets_values": [0, "randomize", 20, 8, "euler", "normal", 1]
},
{
"id": 1,
"type": "PrimitiveNode",
"pos": [15, 46],
"size": [400, 110],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "COMBO",
"type": "COMBO",
"links": [1],
"slot_index": 0,
"widget": { "name": "sampler_name" }
}
],
"properties": { "Run widget replace on values": false },
"widgets_values": ["euler", "fixed"]
}
],
"links": [[1, 1, 0, 2, 4, "COMBO"]],
"groups": [],
"config": {},
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
"version": 0.4
}

View File

@@ -59,6 +59,51 @@ test.describe('Primitive Node', { tag: ['@screenshot', '@node'] }, () => {
)
})
test('Preserves combo options on the primitive after pressing R to refresh', async ({
comfyPage
}) => {
async function getPrimitiveComboState() {
return comfyPage.page.evaluate(() => {
const primitive = window.app!.graph!.nodes.find(
(node) => node.type === 'PrimitiveNode'
)
const widget = primitive?.widgets?.[0]
const values = widget?.options?.values
return {
isArray: Array.isArray(values),
length: Array.isArray(values) ? values.length : 0,
includesEuler: Array.isArray(values)
? values.includes('euler')
: false,
value: widget?.value
}
})
}
await comfyPage.workflow.loadWorkflow(
'primitive/primitive_combo_sampler_name'
)
await expect.poll(getPrimitiveComboState).toMatchObject({
isArray: true,
includesEuler: true,
value: 'euler'
})
const before = await getPrimitiveComboState()
expect(before.length).toBeGreaterThan(0)
await comfyPage.canvas.click()
await comfyPage.page.keyboard.press('r')
await expect.poll(getPrimitiveComboState).toMatchObject({
isArray: true,
includesEuler: true,
value: 'euler'
})
const after = await getPrimitiveComboState()
expect(after.length).toBeGreaterThan(0)
})
test('Report missing nodes when connect to missing node', async ({
comfyPage
}) => {

View File

@@ -0,0 +1,271 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it, vi } from 'vitest'
import type { IWidgetLocator } from '@/lib/litegraph/src/interfaces'
import type {
INodeInputSlot,
INodeOutputSlot,
LGraph,
LGraphNode,
LLink
} from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { ComfyNodeDef, InputSpec } from '@/schemas/nodeDefSchema'
import { GET_CONFIG } from '@/services/litegraphService'
type SlotWidget = IWidgetLocator & {
[GET_CONFIG]: () => InputSpec | undefined
}
vi.mock('@/scripts/app', () => ({
app: {
canvas: { graph_mouse: [0, 0] },
registerExtension: vi.fn()
}
}))
import { PrimitiveNode } from './widgetInputs'
const TARGET_NODE_TYPE = 'KSampler'
const TARGET_INPUT_NAME = 'sampler_name'
const ORIGINAL_OPTIONS = ['euler', 'euler_ancestral', 'heun']
const FRESH_OPTIONS = ['euler', 'euler_ancestral', 'heun', 'lcm']
function createComboWidget(
value: IBaseWidget['value'],
values: (string | number)[]
): IBaseWidget {
return fromPartial<IBaseWidget>({
type: 'combo',
name: 'value',
value,
options: { values },
callback: vi.fn()
})
}
function createSlotWidget(config?: InputSpec): SlotWidget | undefined {
if (!config) return undefined
return fromPartial<SlotWidget>({
name: TARGET_INPUT_NAME,
[GET_CONFIG]: () => config
})
}
type TargetNode = Pick<LGraphNode, 'id' | 'type' | 'inputs'>
function createTargetNode(
inputWidgetName: string | undefined = TARGET_INPUT_NAME
): TargetNode {
return fromPartial<TargetNode>({
id: 7,
type: TARGET_NODE_TYPE,
inputs: [
fromPartial<INodeInputSlot>({
widget: inputWidgetName ? { name: inputWidgetName } : undefined
})
]
})
}
interface PrimitiveStub {
graph?: LGraph
outputs: INodeOutputSlot[]
widgets: IBaseWidget[]
}
function createPrimitiveStub(options: {
widget: IBaseWidget
slotWidget?: SlotWidget
targetNode?: TargetNode | null
hasLink?: boolean
}): PrimitiveStub {
const { widget, slotWidget, hasLink = true } = options
const targetNode =
options.targetNode === undefined ? createTargetNode() : options.targetNode
const link = hasLink
? fromPartial<LLink>({
target_id: targetNode?.id ?? 7,
target_slot: 0
})
: undefined
const stub = Object.create(PrimitiveNode.prototype) as PrimitiveStub
stub.graph = fromPartial<LGraph>({
links: link ? { 1: link } : {},
getNodeById: vi.fn(() => targetNode ?? null)
})
stub.outputs = [
fromPartial<INodeOutputSlot>({
links: hasLink ? [1] : [],
widget: slotWidget
})
]
stub.widgets = [widget]
return stub
}
function refreshOnStub(
stub: PrimitiveStub,
defs?: Record<string, ComfyNodeDef>
) {
;(stub as unknown as PrimitiveNode).refreshComboInNode(defs)
}
function defsWithCombo(
values: (string | number)[]
): Record<string, ComfyNodeDef> {
return {
[TARGET_NODE_TYPE]: fromPartial<ComfyNodeDef>({
input: { required: { [TARGET_INPUT_NAME]: [values, {}] } }
})
}
}
function defsWithV2Combo(
values: (string | number)[]
): Record<string, ComfyNodeDef> {
return {
[TARGET_NODE_TYPE]: fromPartial<ComfyNodeDef>({
input: {
required: { [TARGET_INPUT_NAME]: ['COMBO', { options: values }] }
}
})
}
}
describe('PrimitiveNode.refreshComboInNode', () => {
it('updates combo options from the freshly passed defs', () => {
const widget = createComboWidget('euler', ORIGINAL_OPTIONS)
const stub = createPrimitiveStub({
widget,
slotWidget: createSlotWidget([ORIGINAL_OPTIONS, {}])
})
refreshOnStub(stub, defsWithCombo(FRESH_OPTIONS))
expect(widget.options.values).toEqual(FRESH_OPTIONS)
})
it('preserves existing options when defs lookup yields nothing and slot config is missing', () => {
const widget = createComboWidget('euler', ORIGINAL_OPTIONS)
const stub = createPrimitiveStub({ widget, slotWidget: undefined })
refreshOnStub(stub, {})
expect(widget.options.values).toEqual(ORIGINAL_OPTIONS)
expect(widget.value).toBe('euler')
})
it('preserves existing options when slot widget GET_CONFIG returns undefined', () => {
const widget = createComboWidget('euler', ORIGINAL_OPTIONS)
const slotWidget = fromPartial<SlotWidget>({
name: TARGET_INPUT_NAME,
[GET_CONFIG]: () => undefined
})
const stub = createPrimitiveStub({ widget, slotWidget })
refreshOnStub(stub)
expect(widget.options.values).toEqual(ORIGINAL_OPTIONS)
})
it('falls back to slot widget config when defs do not include the target node', () => {
const widget = createComboWidget('euler', [])
const stub = createPrimitiveStub({
widget,
slotWidget: createSlotWidget([ORIGINAL_OPTIONS, {}])
})
refreshOnStub(stub, {})
expect(widget.options.values).toEqual(ORIGINAL_OPTIONS)
})
it('resets value and fires callback when current value is no longer in the new options', () => {
const widget = createComboWidget('removed_value', ORIGINAL_OPTIONS)
const stub = createPrimitiveStub({
widget,
slotWidget: createSlotWidget([ORIGINAL_OPTIONS, {}])
})
refreshOnStub(stub, defsWithCombo(FRESH_OPTIONS))
expect(widget.value).toBe('euler')
expect(widget.callback).toHaveBeenCalledWith('euler')
})
it('does not change value or fire callback when current value is still valid', () => {
const widget = createComboWidget('euler', ORIGINAL_OPTIONS)
const stub = createPrimitiveStub({
widget,
slotWidget: createSlotWidget([ORIGINAL_OPTIONS, {}])
})
refreshOnStub(stub, defsWithCombo(FRESH_OPTIONS))
expect(widget.value).toBe('euler')
expect(widget.callback).not.toHaveBeenCalled()
})
it('updates combo options from V2 combo specs in defs', () => {
const widget = createComboWidget('euler', ORIGINAL_OPTIONS)
const stub = createPrimitiveStub({
widget,
slotWidget: createSlotWidget([ORIGINAL_OPTIONS, {}])
})
refreshOnStub(stub, defsWithV2Combo(FRESH_OPTIONS))
expect(widget.options.values).toEqual(FRESH_OPTIONS)
})
it('propagates an empty option list when defs return one', () => {
const widget = createComboWidget('euler', ORIGINAL_OPTIONS)
const stub = createPrimitiveStub({
widget,
slotWidget: createSlotWidget([ORIGINAL_OPTIONS, {}])
})
refreshOnStub(stub, defsWithCombo([]))
expect(widget.options.values).toEqual([])
expect(widget.value).toBeUndefined()
})
it('falls back to input.name when the target input has no widget locator', () => {
const widget = createComboWidget('euler', [])
const targetNode = fromPartial<TargetNode>({
id: 9,
type: TARGET_NODE_TYPE,
inputs: [
fromPartial<INodeInputSlot>({
name: TARGET_INPUT_NAME
})
]
})
const stub = createPrimitiveStub({ widget, targetNode })
refreshOnStub(stub, defsWithCombo(FRESH_OPTIONS))
expect(widget.options.values).toEqual(FRESH_OPTIONS)
})
it('skips non-combo widgets', () => {
const widget = fromPartial<IBaseWidget>({
type: 'string',
name: 'value',
value: 'hello',
options: { values: ORIGINAL_OPTIONS },
callback: vi.fn()
})
const stub = createPrimitiveStub({
widget,
slotWidget: createSlotWidget([FRESH_OPTIONS, {}])
})
refreshOnStub(stub, defsWithCombo(FRESH_OPTIONS))
expect(widget.options.values).toEqual(ORIGINAL_OPTIONS)
})
})

View File

@@ -14,6 +14,10 @@ import type {
} from '@/lib/litegraph/src/types/widgets'
import { assetService } from '@/platform/assets/services/assetService'
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
import {
getComboSpecComboOptions,
isComboInputSpec
} from '@/schemas/nodeDefSchema'
import type { ComfyNodeDef, InputSpec } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import {
@@ -56,21 +60,64 @@ export class PrimitiveNode extends LGraphNode {
applyFirstWidgetValueToGraph(this, extraLinks, () => v)
}
override refreshComboInNode() {
override refreshComboInNode(defs?: Record<string, ComfyNodeDef>) {
const widget = this.widgets?.[0]
if (widget?.type === 'combo') {
// @ts-expect-error fixme ts strict error
widget.options.values = this.outputs[0].widget[GET_CONFIG]()[0]
if (widget?.type !== 'combo') return
// @ts-expect-error fixme ts strict error
if (!widget.options.values.includes(widget.value as string)) {
// @ts-expect-error fixme ts strict error
widget.value = widget.options.values[0]
widget.callback?.(widget.value)
}
const newValues = this._resolveComboValues(defs)
if (newValues === undefined) return
widget.options.values = newValues
if (!newValues.includes(widget.value as string | number)) {
widget.value = newValues[0]
widget.callback?.(widget.value)
}
}
private _resolveComboValues(
defs?: Record<string, ComfyNodeDef>
): (string | number)[] | undefined {
const fromDefs = defs ? this._comboValuesFromDefs(defs) : undefined
if (fromDefs !== undefined) return fromDefs
const slotWidget = this.outputs?.[0]?.widget
const config = (
slotWidget?.[GET_CONFIG] as (() => InputSpec) | undefined
)?.()
if (!config || !isComboInputSpec(config)) return undefined
return getComboSpecComboOptions(config)
}
private _comboValuesFromDefs(
defs: Record<string, ComfyNodeDef>
): (string | number)[] | undefined {
const link = this._getFirstOutputLink()
if (!link) return undefined
const targetNode = this.graph?.getNodeById(link.target_id)
const targetType = targetNode?.type
if (!targetType) return undefined
const targetInput = targetNode?.inputs?.[link.target_slot]
const inputName = targetInput?.widget?.name ?? targetInput?.name
if (!inputName) return undefined
const def = defs[targetType]
const inputSpec =
def?.input?.required?.[inputName] ?? def?.input?.optional?.[inputName]
if (!inputSpec || !isComboInputSpec(inputSpec)) return undefined
return getComboSpecComboOptions(inputSpec)
}
private _getFirstOutputLink(): LLink | undefined {
const linkId = this.outputs?.[0]?.links?.[0]
if (linkId == null || !this.graph) return undefined
return this.graph.links[linkId] ?? undefined
}
override onAfterGraphConfigured() {
if (this.outputs[0].links?.length && !this.widgets?.length) {
this._onFirstConnection()