Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions src/extensions/core/customCombo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'

function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) {
if (!this.outputs[0].links?.length || !this.graph) return

const links = [
...this.outputs[0].links.map((l) => this.graph!.links[l]),
...extraLinks
]
let v = this.widgets?.[0].value
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add defensive check for widgets array.

The code assumes this.widgets[0] exists without validation. While the node definition likely ensures this, adding a defensive check improves robustness.

-  let v = this.widgets?.[0].value
+  const widget = this.widgets?.[0]
+  if (!widget) return
+  let v = widget.value
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let v = this.widgets?.[0].value
const widget = this.widgets?.[0]
if (!widget) return
let v = widget.value
🤖 Prompt for AI Agents
In src/extensions/core/customCombo.ts around line 14, the code accesses
this.widgets[0].value without validating this.widgets or this.widgets[0]; add a
defensive check to ensure this.widgets is an array and has at least one element
before reading .value (e.g., guard with if (!Array.isArray(this.widgets) ||
this.widgets.length === 0) return or set v = undefined), and use the safe v
afterwards so you don't access .value on undefined.

// For each output link copy our value over the original widget value
for (const linkInfo of links) {
const node = this.graph?.getNodeById(linkInfo.target_id)
const input = node?.inputs[linkInfo.target_slot]
if (!input) {
console.warn('Unable to resolve node or input for link', linkInfo)
continue
}

const widgetName = input.widget?.name
if (!widgetName) {
console.warn('Invalid widget or widget name', input.widget)
continue
}

const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget) {
console.warn(`Unable to find widget "${widgetName}" on node [${node.id}]`)
continue
}

widget.value = v
widget.callback?.(
widget.value,
app.canvas,
node,
app.canvas.graph_mouse,
{} as CanvasPointerEvent
)
}
}
Comment on lines +9 to +47
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Extract shared logic to reduce duplication.

This function duplicates significant logic from widgetInputs.ts (lines 42-86). The core link-traversal and widget-update pattern is identical.

Consider extracting the shared logic into a common utility function that both implementations can use, perhaps accepting a value-transformer function to handle the text-replacement difference.

Example approach:

// In a shared utility file
function propagateWidgetValue(
  node: LGraphNode,
  outputIndex: number,
  value: any,
  extraLinks: LLink[] = []
) {
  if (!node.outputs[outputIndex].links?.length || !node.graph) return
  
  const links = [
    ...node.outputs[outputIndex].links.map((l) => node.graph!.links[l]),
    ...extraLinks
  ]
  
  for (const linkInfo of links) {
    const targetNode = node.graph?.getNodeById(linkInfo.target_id)
    const input = targetNode?.inputs[linkInfo.target_slot]
    // ... rest of logic
  }
}
🤖 Prompt for AI Agents
In src/extensions/core/customCombo.ts around lines 7 to 45 the applyToGraph
implementation duplicates the link-traversal and widget-update logic found in
widgetInputs.ts; extract that shared behavior into a common utility (e.g.,
propagateWidgetValue) placed in a shared utils file, which accepts the node
(this), output index (0), the value to propagate, optional extraLinks, and an
optional value-transformer callback; move the common code that resolves graph
links, finds target node/input/widget, sets widget.value and invokes
widget.callback into that utility, keep all existing null-checks and warning
messages, and replace applyToGraph with a call to the new utility (passing a
transformer that returns v unchanged) and update widgetInputs.ts to call the
same utility with its own transformer for text replacement so both
implementations reuse the single function and preserve typings and behavior.


function onNodeCreated(this: LGraphNode) {
this.applyToGraph = useChainCallback(this.applyToGraph, applyToGraph)

const comboWidget = this.widgets![0]
Object.defineProperty(comboWidget.options, 'values', {
get: () => {
return this.widgets!.filter(
(w) => w.name.startsWith('option') && w.value
).map((w) => w.value)
}
})
const options = comboWidget.options as { values: string[] }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate widget existence and type.

The code uses non-null assertions (this.widgets![0]) assuming the combo widget exists and is first. If the node definition changes or widgets aren't initialized, this will throw at runtime.

Add validation:

+  if (!this.widgets || this.widgets.length === 0) {
+    console.error('CustomCombo node missing expected widgets')
+    return
+  }
  const comboWidget = this.widgets![0]
+  // Optionally validate it's actually a combo widget
+  if (comboWidget.type !== 'combo') {
+    console.error('Expected first widget to be combo type')
+    return
+  }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/extensions/core/customCombo.ts around lines 50 to 58, the code assumes
this.widgets and this.widgets[0] exist and are the combo widget; replace the
non-null assertions with explicit validation: check that this.widgets is an
array and has at least one element, find the combo widget by name/type (e.g.,
first widget whose name === 'combo' or matches expected signature) rather than
assuming index 0, verify the found widget has an options object before defining
the 'values' property, and if validation fails either throw a clear error or
return a safe fallback (empty options/values) so the code never dereferences
undefined.


function updateCombo() {
if (app.configuringGraph) return
const { values } = options
if (values.includes(`${comboWidget.value}`)) return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Clarify string coercion intent.

The comparison uses string template coercion ${comboWidget.value} rather than direct comparison. This might be intentional to handle type flexibility, but it's unclear.

If intentional, consider adding a comment:

-    if (values.includes(`${comboWidget.value}`)) return
+    // Coerce to string to handle mixed types
+    if (values.includes(`${comboWidget.value}`)) return

Or use explicit conversion for clarity:

-    if (values.includes(`${comboWidget.value}`)) return
+    if (values.includes(String(comboWidget.value))) return
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (values.includes(`${comboWidget.value}`)) return
if (values.includes(String(comboWidget.value))) return
🤖 Prompt for AI Agents
In src/extensions/core/customCombo.ts around line 63, the check uses template
string coercion if (values.includes(`${comboWidget.value}`)) which is unclear;
replace the template string with an explicit conversion (e.g.,
String(comboWidget.value)) so intent is obvious, or if the template coercion was
intentional add a one-line comment explaining why coercion via string is
required (type(s) expected and why) so future readers understand the comparison.

comboWidget.value = values[0] ?? ''
}
comboWidget.callback = useChainCallback(comboWidget.callback, () =>
this.applyToGraph!()
)

function addOption(node: LGraphNode) {
if (!node.widgets) return
const widgetPostfix =
node.widgets
.findLast((w) => w.name.startsWith('option'))
?.name?.slice(6) || '-1'
const newCount = parseInt(widgetPostfix) + 1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Specify radix in parseInt.

The parseInt call should include a radix parameter to avoid potential parsing issues.

-    const newCount = parseInt(widgetPostfix) + 1
+    const newCount = parseInt(widgetPostfix, 10) + 1
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const newCount = parseInt(widgetPostfix) + 1
const newCount = parseInt(widgetPostfix, 10) + 1
🤖 Prompt for AI Agents
In src/extensions/core/customCombo.ts around line 76, the parseInt call lacks a
radix; update the call to include the radix (e.g., parseInt(widgetPostfix, 10))
so the string is parsed as a base-10 integer, and if desired add a quick NaN
check after parsing to handle unexpected input.

node.addWidget('string', `option${newCount}`, '', () => {})
const widget = node.widgets.at(-1)
if (!widget) return

let value = ''
Object.defineProperty(widget, 'value', {
get() {
return value
},
set(v) {
value = v
updateCombo()
if (!node.widgets) return
const lastWidget = node.widgets.at(-1)
if (lastWidget === this) {
if (v) addOption(node)
return
}
if (v || node.widgets.at(-2) !== this || lastWidget?.value) return
node.widgets.pop()
node.computeSize(node.size)
}
})
Comment on lines 81 to 99
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider simplifying the custom value setter logic.

The custom getter/setter has complex side effects including:

  • Calling updateCombo() on every value change
  • Auto-adding new widgets when the last one is filled
  • Auto-removing widgets with complex conditions
  • Mutating the node.widgets array during property access

This complexity makes the behavior harder to reason about and test. Consider:

  1. Extracting the auto-add/remove logic into separate, well-named functions
  2. Adding comments explaining the removal condition (line 95)
  3. Ensuring widget array mutations are safe if iteration is happening elsewhere

Example structure:

function shouldAutoAddOption(widget, value, node) {
  return node.widgets.at(-1) === widget && value
}

function shouldAutoRemoveOption(widget, value, node) {
  const lastWidget = node.widgets.at(-1)
  const secondToLast = node.widgets.at(-2)
  // Remove this widget if:
  // - It has no value
  // - AND it's second-to-last
  // - AND the last widget also has no value
  return !value && secondToLast === widget && !lastWidget?.value
}

set(v) {
  value = v
  updateCombo()
  if (!node.widgets) return
  
  if (shouldAutoAddOption(this, v, node)) {
    addOption(node)
  } else if (shouldAutoRemoveOption(this, v, node)) {
    node.widgets.pop()
    node.computeSize(node.size)
  }
}
🤖 Prompt for AI Agents
In src/extensions/core/customCombo.ts around lines 82 to 99, the custom setter
for widget.value mixes multiple side-effects (updateCombo, auto-add,
auto-remove) and mutates node.widgets inline, making behavior hard to reason
about and unsafe during iteration; refactor by extracting the auto-add and
auto-remove checks into two well-named helper functions (e.g.,
shouldAutoAddOption(widget, value, node) and shouldAutoRemoveOption(widget,
value, node)), replace the inline conditionals with calls to those helpers, add
a concise comment explaining the removal condition (why we remove the
second-to-last empty widget when the last is also empty), and make the mutation
of node.widgets safe (use the widget index to remove via splice or defer
mutation to a safe tick/event instead of mutating during property access).

}
addOption(this)
}

app.registerExtension({
name: 'Comfy.CustomCombo',
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== 'CustomCombo') return
nodeType.prototype.onNodeCreated = useChainCallback(
nodeType.prototype.onNodeCreated,
onNodeCreated
)
}
})
1 change: 1 addition & 0 deletions src/extensions/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { isCloud } from '@/platform/distribution/types'

import './clipspace'
import './contextMenuFilter'
import './customCombo'
import './dynamicPrompts'
import './editAttention'
import './electronAdapter'
Expand Down
16 changes: 16 additions & 0 deletions src/stores/nodeDefStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,22 @@ export const SYSTEM_NODE_DEFS: Record<string, ComfyNodeDefV1> = {
python_module: 'nodes',
description:
'Node that add notes to your project. Reformats text as markdown.'
},
CustomCombo: {
name: 'CustomCombo',
display_name: 'Custom Combo',
category: 'utils',
input: {
required: { choice: [[], {}], option0: ['STRING', {}] },
optional: {}
},
output: ['string'],
output_name: ['output'],
output_is_list: [],
output_node: false,
python_module: 'nodes',
experimental: true,
description: 'Outputs the chosen string from a user provided list.'
}
}

Expand Down