Compare commits

...

1 Commits

Author SHA1 Message Date
bymyself
58d74f452b fix: clear stale widget slotMetadata when inputs change
Extract shared buildSlotMetadata() helper used by both the full reactive
rebuild and partial slot-refresh paths, ensuring slotMetadata is always
overwritten (never merged) to prevent stale linked:true states when
inputs are removed or renamed.

Add regression tests for link disconnect, input removal, input addition,
and array replacement lifecycle paths.
2026-03-15 02:10:19 -07:00
2 changed files with 111 additions and 22 deletions

View File

@@ -241,6 +241,89 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
it('clears stale slotMetadata when input no longer matches widget', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))!
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
expect(widgetData.slotMetadata?.linked).toBe(true)
node.inputs[0].name = 'other'
node.inputs[0].widget = { name: 'other' }
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
expect(widgetData.slotMetadata).toBeUndefined()
})
it('clears slotMetadata when the matching input is removed via removeInput', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))!
const getWidget = () => nodeData.widgets?.find((w) => w.name === 'prompt')
expect(getWidget()?.slotMetadata?.linked).toBe(true)
// Remove the input slot entirely (LGraphNode.removeInput disconnects first)
node.removeInput(0)
await nextTick()
// slotMetadata should be cleared because the input no longer exists
// (reactiveComputed rebuilds the widgets array with fresh objects)
expect(getWidget()?.slotMetadata).toBeUndefined()
})
it('clears slotMetadata when node.inputs is replaced with an empty array', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))!
const getWidget = () => nodeData.widgets?.find((w) => w.name === 'prompt')
expect(getWidget()?.slotMetadata?.linked).toBe(true)
// Some extensions (e.g., groupNode) replace the entire inputs array
node.inputs = []
await nextTick()
expect(getWidget()?.slotMetadata).toBeUndefined()
})
it('populates slotMetadata when a matching input is added via addInput', async () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))!
const getWidget = () => nodeData.widgets?.find((w) => w.name === 'prompt')
// No matching input yet
expect(getWidget()?.slotMetadata).toBeUndefined()
// Add a matching input
const input = node.addInput('prompt', 'STRING')
input.widget = { name: 'prompt' }
await nextTick()
// slotMetadata should now be populated (linked: false since no link)
expect(getWidget()?.slotMetadata).toBeDefined()
expect(getWidget()?.slotMetadata?.linked).toBe(false)
})
})
describe('Subgraph Promoted Pseudo Widgets', () => {

View File

@@ -43,6 +43,26 @@ export interface WidgetSlotMetadata {
linked: boolean
}
/**
* Builds a map of widget-name → slot metadata from the node's current inputs.
* Used by both the full reactive rebuild and the partial slot-refresh path
* to ensure consistent derivation of slot metadata.
*/
function buildSlotMetadata(
inputs?: INodeInputSlot[]
): Map<string, WidgetSlotMetadata> {
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
const slotInfo: WidgetSlotMetadata = {
index,
linked: input.link != null
}
if (input.name) metadata.set(input.name, slotInfo)
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
})
return metadata
}
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
@@ -411,16 +431,12 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgetsSnapshot = node.widgets ?? []
const currentSlotMetadata = buildSlotMetadata(node.inputs)
slotMetadata.clear()
node.inputs?.forEach((input, index) => {
const slotInfo = {
index,
linked: input.link != null
}
if (input.name) slotMetadata.set(input.name, slotInfo)
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
})
for (const [key, value] of currentSlotMetadata) {
slotMetadata.set(key, value)
}
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
})
@@ -473,22 +489,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (!nodeRef || !currentData) return
// Only extract slot-related data instead of full node re-extraction
const slotMetadata = new Map<string, WidgetSlotMetadata>()
const slotMetadata = buildSlotMetadata(nodeRef.inputs)
nodeRef.inputs?.forEach((input, index) => {
const slotInfo = {
index,
linked: input.link != null
}
if (input.name) slotMetadata.set(input.name, slotInfo)
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
})
// Update only widgets with new slot metadata, keeping other widget data intact
// Always overwrite — never merge — to clear stale metadata when inputs
// are removed or renamed.
for (const widget of currentData.widgets ?? []) {
const slotInfo = slotMetadata.get(widget.slotName ?? widget.name)
if (slotInfo) widget.slotMetadata = slotInfo
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
}
}