mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-08 08:08:04 +00:00
refactor!: delete the input.link runtime mirror
The linkStore is now the single source for slot connectivity in both directions. The NodeInputSlot.link field and all runtime write sites are gone; a read-only prototype getter derives the id from the store and fires warnDeprecated as extension migration telemetry (no setter, so writes throw). Wire format unchanged: serialization derives inputs[].link from the store. The mirror-carried association machinery is reworked rather than papered over: fixLinkInputSlots consumes the serialized graph data (where node.configure reorders inputs in place), dynamicWidgets' group rebuilds carry slot-to-link association in a WeakMap refreshed from the store, reorderSubgraphInputs captures outer links by pre-reorder index, and link deduplication selects survivors from the store registration - repairInputLinks is deleted because the derived view cannot be inconsistent. Extension migration: read via node.isInputConnected(slot) / node.getInputLink(slot); mutate via node.connect() / node.disconnectInput().
This commit is contained in:
@@ -105,11 +105,9 @@ subgraph-definition GC unregister whole graphs
|
||||
|
||||
This design covers link topology (endpoints, type, chain terminus).
|
||||
Link visual state (`color`, path caches) and the layout store's link
|
||||
_geometry_ records are out of scope. The `output.links` mirror has since
|
||||
been deleted — the store is the single source for output-side
|
||||
connectivity (see
|
||||
[output slot connectivity](output-slot-connectivity.md) Decision 6). The
|
||||
`input.link` slot mirror remains the litegraph-native representation
|
||||
un-migrated consumers read; extracting it is the `SlotConnection`
|
||||
component work in the [ECS migration plan](ecs-migration-plan.md), not
|
||||
part of this store.
|
||||
_geometry_ records are out of scope. The `output.links` and `input.link`
|
||||
slot mirrors have since been deleted — the store is the single source
|
||||
for slot connectivity in both directions (see
|
||||
[output slot connectivity](output-slot-connectivity.md) Decision 6);
|
||||
the remaining fields are deprecated warning getters kept as extension
|
||||
migration telemetry.
|
||||
|
||||
@@ -123,10 +123,20 @@ a mirror read plus a `slotFloatingLinks` scan), widget value propagation
|
||||
(`widgetValuePropagation`), and matchType link revalidation
|
||||
(`dynamicWidgets.changeOutputType`).
|
||||
|
||||
## Decision 6: Delete the mirror (implemented)
|
||||
## Decision 6: Delete the mirrors (implemented; extended to `input.link`)
|
||||
|
||||
The runtime `output.links[]` field and all nine of its write sites are
|
||||
deleted. The store is the single source; litegraph internals read through
|
||||
deleted. The same recipe has since been applied to `input.link`: the
|
||||
field is a deprecated warning getter, litegraph and app code read through
|
||||
the slotLinks input helpers (`inputHasLink`, `inputLinkId`, `inputLink`)
|
||||
or `node.isInputConnected` / `node.getInputLink`, serialization derives
|
||||
`inputs[].link` from the store, and the mirror-carried association
|
||||
shuffles were reworked — `fixLinkInputSlots` consumes the serialized
|
||||
graph data, dynamicWidgets' group rebuilds carry slot→link association
|
||||
in a module-scoped WeakMap refreshed from the store, and link
|
||||
deduplication selects survivors from the store registration (the
|
||||
`repairInputLinks` mirror repair is gone; the derived view cannot be
|
||||
wrong). The store is the single source; litegraph internals read through
|
||||
the pure helpers in `node/slotLinks.ts` (`outputHasLinks`,
|
||||
`outputLinkIds`, `outputLinks`), and `NodeOutputSlot.isConnected`,
|
||||
`serialize`, and `configure` derive from the store. Details:
|
||||
@@ -193,7 +203,6 @@ sites, and field deletion.
|
||||
|
||||
Out of scope, each a piece of the deferred `SlotConnection` phase:
|
||||
|
||||
- Deleting the `input.link` mirror field (same recipe as Decision 6).
|
||||
- Slot entity extraction: `SlotIdentity`, `SlotVisual`, and retiring the
|
||||
`NodeInputSlot` / `NodeOutputSlot` class instances and their
|
||||
`shallowReactive` graft.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
@@ -8,6 +8,7 @@ import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { HasInitialMinSize } from '@/services/litegraphService'
|
||||
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
type DynamicInputs = ('INT' | 'STRING' | 'IMAGE' | DynamicInputs)[][]
|
||||
type TestAutogrowNode = LGraphNode & {
|
||||
comfyDynamic: { autogrow: Record<string, unknown> }
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import { inputLink } from '@/lib/litegraph/src/node/slotLinks'
|
||||
import { useLinkStore } from '@/stores/linkStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
@@ -109,6 +110,7 @@ function dynamicComboWidget(
|
||||
if (!node.widgets) throw new Error('Not Reachable')
|
||||
const newSpec = value ? options[value] : undefined
|
||||
|
||||
captureCarriedLinks(node)
|
||||
const removedInputs = remove(node.inputs, isInGroup)
|
||||
for (const widget of remove(node.widgets, isInGroup)) {
|
||||
widget.onRemove?.()
|
||||
@@ -178,13 +180,12 @@ function dynamicComboWidget(
|
||||
|
||||
for (const input of removedInputs) {
|
||||
const inputIndex = node.inputs.findIndex((inp) => inp.name === input.name)
|
||||
const link = carriedLink(node, input)
|
||||
if (inputIndex === -1) {
|
||||
if (link) node.graph?.removeLink(link.id)
|
||||
node.inputs.push(input)
|
||||
node.removeInput(node.inputs.length - 1)
|
||||
} else {
|
||||
node.inputs[inputIndex].link = input.link
|
||||
if (!input.link) continue
|
||||
const link = node.graph?.links?.[input.link]
|
||||
if (!link) continue
|
||||
link.target_slot = inputIndex
|
||||
node.onConnectionsChange?.(
|
||||
@@ -247,16 +248,39 @@ export function applyDynamicInputs(
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Slot-object → link association carried across the group rebuild shuffles.
|
||||
* The deleted `input.link` mirror used to travel with the slot object; this
|
||||
* registry replaces it for the duration of a rebuild, refreshed from the
|
||||
* link store whenever the inputs array is at rest.
|
||||
*/
|
||||
const carriedLinks = new WeakMap<INodeInputSlot, LLink>()
|
||||
|
||||
function captureCarriedLinks(node: LGraphNode) {
|
||||
const { graph } = node
|
||||
if (!graph) return
|
||||
for (const [index, input] of node.inputs.entries()) {
|
||||
const link = inputLink(graph, node.id, index)
|
||||
if (link) carriedLinks.set(input, link)
|
||||
}
|
||||
}
|
||||
|
||||
function carriedLink(node: LGraphNode, input: INodeInputSlot) {
|
||||
const link = carriedLinks.get(input)
|
||||
return link && node.graph?.getLink(link.id) ? link : undefined
|
||||
}
|
||||
|
||||
function spliceInputs(
|
||||
node: LGraphNode,
|
||||
startIndex: number,
|
||||
deleteCount = -1,
|
||||
...toAdd: INodeInputSlot[]
|
||||
): INodeInputSlot[] {
|
||||
captureCarriedLinks(node)
|
||||
if (deleteCount < 0) return node.inputs.splice(startIndex)
|
||||
const ret = node.inputs.splice(startIndex, deleteCount, ...toAdd)
|
||||
node.inputs.slice(startIndex).forEach((input, index) => {
|
||||
const link = input.link && node.graph?.links?.get(input.link)
|
||||
const link = carriedLink(node, input)
|
||||
if (link) link.target_slot = startIndex + index
|
||||
})
|
||||
return ret
|
||||
@@ -384,11 +408,12 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
|
||||
const input = node.inputs[index]
|
||||
if (!input) return
|
||||
node.inputs[index] = shallowReactive(input)
|
||||
const existingLink = node.getInputLink(index)
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
index,
|
||||
!!input.link,
|
||||
input.link ? node.graph?.links?.[input.link] : undefined,
|
||||
!!existingLink,
|
||||
existingLink ?? undefined,
|
||||
input
|
||||
)
|
||||
})
|
||||
@@ -440,7 +465,10 @@ function addAutogrowGroup(
|
||||
(inp) => inp.name === newInput.name
|
||||
)) {
|
||||
//NOTE: link.target_slot is updated on spliceInputs call
|
||||
newInput.link ??= existingInput.link
|
||||
const carried = carriedLinks.get(existingInput)
|
||||
if (carried && !carriedLinks.has(newInput)) {
|
||||
carriedLinks.set(newInput, carried)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,6 +544,13 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
return
|
||||
}
|
||||
app.canvas?.setDirty(true, true)
|
||||
// Snapshot each group slot's link before shuffling: donors are always read
|
||||
// pre-shuffle, matching the sequential copy the mirror used to perform.
|
||||
const linkByOrdinal = groupInputs.map((inp) =>
|
||||
node.graph
|
||||
? inputLink(node.graph, node.id, node.inputs.indexOf(inp))
|
||||
: undefined
|
||||
)
|
||||
//groupBy would be nice here, but may not be supported
|
||||
for (let column = 0; column < stride; column++) {
|
||||
for (
|
||||
@@ -524,9 +559,7 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
bubbleOrdinal += stride
|
||||
) {
|
||||
const curInput = groupInputs[bubbleOrdinal]
|
||||
curInput.link = groupInputs[bubbleOrdinal + stride].link
|
||||
if (!curInput.link) continue
|
||||
const link = node.graph?.links[curInput.link]
|
||||
const link = linkByOrdinal[bubbleOrdinal + stride]
|
||||
if (!link) continue
|
||||
const curIndex = node.inputs.findIndex((inp) => inp === curInput)
|
||||
if (curIndex === -1) throw new Error('missing input')
|
||||
@@ -541,7 +574,6 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
}
|
||||
const lastInput = groupInputs.at(column - stride)
|
||||
if (!lastInput) continue
|
||||
lastInput.link = null
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
node.inputs.length + column - stride,
|
||||
@@ -553,7 +585,12 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
const removalChecks = groupInputs.slice(min * stride)
|
||||
let i
|
||||
for (i = removalChecks.length - stride; i >= 0; i -= stride) {
|
||||
if (removalChecks.slice(i, i + stride).some((inp) => inp.link)) break
|
||||
if (
|
||||
removalChecks
|
||||
.slice(i, i + stride)
|
||||
.some((inp) => node.isInputConnected(node.inputs.indexOf(inp)))
|
||||
)
|
||||
break
|
||||
}
|
||||
const toRemove = removalChecks.slice(i + stride * 2)
|
||||
remove(node.inputs, (inp) => toRemove.includes(inp))
|
||||
|
||||
@@ -1004,19 +1004,21 @@ describe('_removeDuplicateLinks', () => {
|
||||
expect(graph._links.size).toBe(1)
|
||||
expect(graph._links.has(validLinkId)).toBe(true)
|
||||
expect(graph._links.has(dupLink.id)).toBe(false)
|
||||
expect(target.inputs[1].link).toBe(validLinkId)
|
||||
const store = useLinkStore()
|
||||
expect(store.getInputSlotLink(graph.rootGraph.id, target.id, 0)?.id).toBe(
|
||||
validLinkId
|
||||
)
|
||||
})
|
||||
|
||||
it('repairs input.link when it points to a removed duplicate', () => {
|
||||
it('derives input.link from the surviving registration after dedup', () => {
|
||||
const { graph, source, target } = createConnectedGraph()
|
||||
|
||||
const dupLink = injectDuplicateLink(graph, source, target)
|
||||
// Point input.link to the duplicate (simulating corrupted state)
|
||||
target.inputs[0].link = dupLink.id
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
|
||||
expect(graph._links.size).toBe(1)
|
||||
expect(graph._links.has(dupLink.id)).toBe(false)
|
||||
const survivingId = graph._links.keys().next().value!
|
||||
expect(target.inputs[0].link).toBe(survivingId)
|
||||
expect(graph._links.has(target.inputs[0].link!)).toBe(true)
|
||||
|
||||
@@ -25,7 +25,6 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
import {
|
||||
groupLinksByTuple,
|
||||
purgeOrphanedLinks,
|
||||
repairInputLinks,
|
||||
selectSurvivorLink
|
||||
} from './linkDeduplication'
|
||||
|
||||
@@ -1707,7 +1706,6 @@ export class LGraph
|
||||
const keepId = selectSurvivorLink(ids, node)
|
||||
|
||||
purgeOrphanedLinks(ids, keepId, this)
|
||||
repairInputLinks(ids, keepId, node)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,6 @@ function createTestLink(
|
||||
inputSlot
|
||||
)
|
||||
graph._addLink(link)
|
||||
targetNode.inputs[inputSlot].link = linkId
|
||||
return link
|
||||
}
|
||||
|
||||
|
||||
@@ -2983,9 +2983,7 @@ export class LGraphNode
|
||||
// add to graph links list
|
||||
graph._addLink(link)
|
||||
|
||||
// connect in input
|
||||
const targetInput = inputNode.inputs[inputIndex]
|
||||
targetInput.link = link.id
|
||||
if (targetInput.widget) {
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: inputNode.id,
|
||||
@@ -3128,8 +3126,6 @@ export class LGraphNode
|
||||
|
||||
// is the link we are searching for...
|
||||
const input = target.inputs[link_info.target_slot]
|
||||
// remove there
|
||||
input.link = null
|
||||
if (input.widget) {
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: target.id,
|
||||
@@ -3182,8 +3178,6 @@ export class LGraphNode
|
||||
|
||||
if (target) {
|
||||
const input = target.inputs[link_info.target_slot]
|
||||
// remove other side link
|
||||
input.link = null
|
||||
if (input.widget) {
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: target.id,
|
||||
@@ -3258,7 +3252,6 @@ export class LGraphNode
|
||||
|
||||
const link_id = inputLinkId(graph, this.id, slot) ?? null
|
||||
if (link_id != null) {
|
||||
this.inputs[slot].link = null
|
||||
if (input.widget) {
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: this.id,
|
||||
|
||||
@@ -360,7 +360,13 @@ export interface IWidgetLocator {
|
||||
}
|
||||
|
||||
export interface INodeInputSlot extends INodeSlot {
|
||||
link: LinkId | null
|
||||
/**
|
||||
* @deprecated Id of the link targeting this slot, derived from the link
|
||||
* store by a warning getter. Read via `node.isInputConnected(slot)` /
|
||||
* `node.getInputLink(slot)`; mutate via `node.connect()` /
|
||||
* `node.disconnectInput()`.
|
||||
*/
|
||||
readonly link?: LinkId | null
|
||||
widget?: IWidgetLocator
|
||||
widgetId?: WidgetId
|
||||
alwaysVisible?: boolean
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { registerLinkTopology } from './LLink'
|
||||
import { inputLinkId } from './node/slotLinks'
|
||||
|
||||
import type { LGraph } from './LGraph'
|
||||
import type { LGraphNode } from './LGraphNode'
|
||||
@@ -31,12 +32,11 @@ export function selectSurvivorLink(
|
||||
ids: LinkId[],
|
||||
node: LGraphNode | null
|
||||
): LinkId {
|
||||
if (!node) return ids[0]
|
||||
if (!node?.graph) return ids[0]
|
||||
|
||||
for (const input of node.inputs ?? []) {
|
||||
if (!input) continue
|
||||
const match = ids.find((id) => input.link === id)
|
||||
if (match != null) return match
|
||||
for (const [index] of (node.inputs ?? []).entries()) {
|
||||
const registered = inputLinkId(node.graph, node.id, index)
|
||||
if (registered != null && ids.includes(registered)) return registered
|
||||
}
|
||||
return ids[0]
|
||||
}
|
||||
@@ -65,21 +65,3 @@ export function purgeOrphanedLinks(
|
||||
const survivor = graph._links.get(keepId)
|
||||
if (survivor) registerLinkTopology(graph, survivor)
|
||||
}
|
||||
|
||||
/** Ensures input.link on the target node points to the surviving link. */
|
||||
export function repairInputLinks(
|
||||
ids: LinkId[],
|
||||
keepId: LinkId,
|
||||
node: LGraphNode | null
|
||||
): void {
|
||||
if (!node) return
|
||||
|
||||
const duplicateIds = new Set(ids)
|
||||
|
||||
for (const input of node.inputs ?? []) {
|
||||
if (input?.link == null || input.link === keepId) continue
|
||||
if (duplicateIds.has(input.link)) {
|
||||
input.link = keepId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
|
||||
import { inputHasLink, inputLinkId } from '@/lib/litegraph/src/node/slotLinks'
|
||||
import { warnDeprecated } from '@/lib/litegraph/src/utils/feedback'
|
||||
import type { IDrawOptions } from '@/lib/litegraph/src/node/NodeSlot'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
|
||||
@@ -17,7 +18,8 @@ import { isSubgraphInput } from '@/lib/litegraph/src/subgraph/subgraphUtils'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
|
||||
link: LinkId | null
|
||||
/** @deprecated Derived from the link store via a warning prototype getter; never written. */
|
||||
declare readonly link?: LinkId | null
|
||||
alwaysVisible?: boolean
|
||||
|
||||
get isWidgetInputSlot(): boolean {
|
||||
@@ -43,8 +45,11 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
|
||||
slot: OptionalProps<INodeInputSlot, 'boundingRect'>,
|
||||
node: LGraphNode
|
||||
) {
|
||||
super(slot, node)
|
||||
this.link = slot.link
|
||||
// Serialized inputs carry a legacy link mirror; strip it so the base
|
||||
// ctor's Object.assign cannot collide with the deprecated prototype
|
||||
// getter (assigning a getter-only property throws in strict mode).
|
||||
const { link: _legacyLink, ...rest } = slot
|
||||
super(rest, node)
|
||||
}
|
||||
|
||||
override get isConnected(): boolean {
|
||||
@@ -95,3 +100,23 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecation telemetry for extensions that still read `input.link`.
|
||||
* Returns the store-derived link id; there is deliberately no setter, so
|
||||
* writes throw in strict mode. First-party code uses the slotLinks helpers.
|
||||
*/
|
||||
Object.defineProperty(NodeInputSlot.prototype, 'link', {
|
||||
get(this: NodeInputSlot): LinkId | null {
|
||||
warnDeprecated(
|
||||
'input.link is deprecated. Read connectivity via node.isInputConnected(slot) / node.getInputLink(slot); mutate via node.connect() / node.disconnectInput().'
|
||||
)
|
||||
const { graph } = this._node
|
||||
if (!graph) return null
|
||||
return (
|
||||
inputLinkId(graph, this._node.id, this._node.inputs.indexOf(this)) ?? null
|
||||
)
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: false
|
||||
})
|
||||
|
||||
@@ -120,7 +120,6 @@ export class SubgraphInput extends SubgraphSlot {
|
||||
|
||||
// Set link ID in each slot
|
||||
this.linkIds.push(link.id)
|
||||
slot.link = link.id
|
||||
|
||||
anchorRerouteChain(subgraph, link)
|
||||
subgraph.incrementVersion()
|
||||
|
||||
@@ -189,7 +189,6 @@ export class SubgraphInputNode
|
||||
subgraph.removeFloatingLink(floatingLink)
|
||||
}
|
||||
|
||||
input.link = null
|
||||
subgraph.setDirtyCanvas(false, true)
|
||||
|
||||
if (!link) return
|
||||
|
||||
@@ -4,7 +4,11 @@ import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LLink, slotFloatingLinks } from '@/lib/litegraph/src/LLink'
|
||||
import type { ResolvedConnection } from '@/lib/litegraph/src/LLink'
|
||||
import { inputLinkId, outputLinkIds } from '@/lib/litegraph/src/node/slotLinks'
|
||||
import {
|
||||
inputLink,
|
||||
inputLinkId,
|
||||
outputLinkIds
|
||||
} from '@/lib/litegraph/src/node/slotLinks'
|
||||
import { Reroute } from '@/lib/litegraph/src/Reroute'
|
||||
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
|
||||
import { toRerouteId } from '@/types/rerouteId'
|
||||
@@ -530,6 +534,14 @@ export function reorderSubgraphInputs(
|
||||
|
||||
const oldOrder = subgraph.inputs.map((i) => i.id)
|
||||
|
||||
// Capture outer links by pre-reorder index: the store is keyed by
|
||||
// target_slot, which the physical reorder does not move.
|
||||
const outerLinks = subgraphNode.inputs.map((_input, index) =>
|
||||
subgraphNode.graph
|
||||
? inputLink(subgraphNode.graph, subgraphNode.id, index)
|
||||
: undefined
|
||||
)
|
||||
|
||||
reorderInPlace(subgraph.inputs, orderedIndices)
|
||||
reorderInPlace(subgraphNode.inputs, orderedIndices)
|
||||
subgraphNode.invalidatePromotedViews()
|
||||
@@ -541,11 +553,9 @@ export function reorderSubgraphInputs(
|
||||
link.origin_slot = slot
|
||||
}
|
||||
|
||||
function* outerLink(input: INodeInputSlot): Generator<LLink | undefined> {
|
||||
if (input.link != null) yield subgraphNode.graph?.getLink(input.link)
|
||||
}
|
||||
for (const [slot, link] of indexedLinks(subgraphNode.inputs, outerLink)) {
|
||||
link.target_slot = slot
|
||||
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
|
||||
const link = outerLinks[oldIndex]
|
||||
if (link) link.target_slot = newIndex
|
||||
}
|
||||
|
||||
const newOrder = subgraph.inputs.map((i) => i.id)
|
||||
|
||||
@@ -269,7 +269,6 @@ describe('useNodeReplacement', () => {
|
||||
// Link should be updated to point at new node's input
|
||||
expect(link.target_id).toBe(1)
|
||||
expect(link.target_slot).toBe(0)
|
||||
expect(newNode.inputs[0].link).toBe(10)
|
||||
})
|
||||
|
||||
it('should transfer output connections using output_mapping', () => {
|
||||
@@ -655,7 +654,8 @@ describe('useNodeReplacement', () => {
|
||||
|
||||
// Default mapping transfers connections and widget values by name
|
||||
expect(newNode.id).toBe(13)
|
||||
expect(newNode.inputs[0].link).toBe(4)
|
||||
expect(link.target_id).toBe(13)
|
||||
expect(link.target_slot).toBe(0)
|
||||
expect(outLink.origin_id).toBe(13)
|
||||
expect(outLink.origin_slot).toBe(0)
|
||||
expect(newNode.widgets![0].value).toBe(0.75)
|
||||
|
||||
@@ -51,8 +51,6 @@ function transferInputConnection(
|
||||
|
||||
link.target_id = newNode.id
|
||||
link.target_slot = newSlotIdx
|
||||
newNode.inputs[newSlotIdx].link = linkId
|
||||
oldNode.inputs[oldSlotIdx].link = null
|
||||
}
|
||||
|
||||
function transferOutputConnections(
|
||||
|
||||
@@ -863,7 +863,8 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
try {
|
||||
fixLinkInputSlots(this)
|
||||
const [configuredData] = args
|
||||
if (configuredData) fixLinkInputSlots(this, configuredData)
|
||||
|
||||
// Fire callbacks before the onConfigure, this is used by widget inputs to setup the config
|
||||
triggerCallbackOnAllNodes(this, 'onGraphConfigured')
|
||||
|
||||
@@ -326,7 +326,7 @@ describe('fixBadLinks', () => {
|
||||
describe('fixBadLinks ↔ linkStore integration', () => {
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
it('completes the input mirror for a registered link missing it', () => {
|
||||
it('treats a store-registered link as consistent without repairs', () => {
|
||||
const graph = new LGraph()
|
||||
const a = new LGraphNode('A')
|
||||
const b = new LGraphNode('B')
|
||||
@@ -335,20 +335,19 @@ describe('fixBadLinks ↔ linkStore integration', () => {
|
||||
graph.add(a)
|
||||
graph.add(b)
|
||||
|
||||
// Registered via the chokepoint, but the input mirror was never written.
|
||||
const orphan = new LLink(toLinkId(9), '*', a.id, 0, b.id, 0)
|
||||
graph._addLink(orphan)
|
||||
// Registered via the chokepoint; slot views derive from the store.
|
||||
const link = new LLink(toLinkId(9), '*', a.id, 0, b.id, 0)
|
||||
graph._addLink(link)
|
||||
|
||||
const store = useLinkStore()
|
||||
const graphId = graph.rootGraph.id
|
||||
expect(store.isInputSlotConnected(graphId, b.id, 0)).toBe(true)
|
||||
expect(b.inputs[0].link).toBeNull()
|
||||
expect(b.inputs[0].link).toBe(link.id)
|
||||
|
||||
const result = fixBadLinks(graph, { fix: true, silent: true })
|
||||
|
||||
expect(result).toMatchObject({ fixed: true, patched: 1, deleted: 0 })
|
||||
expect(graph._links.has(orphan.id)).toBe(true)
|
||||
expect(b.inputs[0].link).toBe(orphan.id)
|
||||
expect(result).toMatchObject({ hasBadLinks: false, deleted: 0 })
|
||||
expect(graph._links.has(link.id)).toBe(true)
|
||||
expect(store.isInputSlotConnected(graphId, b.id, 0)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import { NodeInputSlot } from '@/lib/litegraph/src/node/NodeInputSlot'
|
||||
import { NodeOutputSlot } from '@/lib/litegraph/src/node/NodeOutputSlot'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { parseNodeId } from '@/types/nodeId'
|
||||
@@ -148,8 +149,11 @@ export function fixBadLinks(
|
||||
return false
|
||||
}
|
||||
patchedNode['inputs']![slot] = linkIdToSet
|
||||
if (fix) {
|
||||
inputSlot!.link = linkIdToSet === null ? null : toLinkId(linkIdToSet)
|
||||
// Live NodeInputSlot mirrors are store-derived and need no repair;
|
||||
// only serialized plain slots carry a writable link field.
|
||||
if (fix && !(inputSlot instanceof NodeInputSlot)) {
|
||||
const writable = inputSlot as { link?: number | null }
|
||||
writable.link = linkIdToSet
|
||||
}
|
||||
} else {
|
||||
patchedNode['outputs'] = patchedNode['outputs'] || {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
|
||||
import type { ColorOption, LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import {
|
||||
LGraphCanvas,
|
||||
@@ -13,7 +14,8 @@ import {
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
ISerialisableNodeInput,
|
||||
ISerialisedGraph
|
||||
ISerialisedGraph,
|
||||
SerialisableGraph
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
@@ -234,25 +236,29 @@ export function migrateWidgetsValues<TWidgetValue>(
|
||||
*
|
||||
* @param graph - The graph to fix links for.
|
||||
*/
|
||||
export function fixLinkInputSlots(graph: LGraph) {
|
||||
// Note: We can't use forEachNode here because we need access to the graph's
|
||||
// links map at each level. Links are stored in their respective graph/subgraph.
|
||||
for (const node of graph.nodes) {
|
||||
// Fix links for the current node
|
||||
for (const [inputIndex, input] of node.inputs.entries()) {
|
||||
const linkId = input.link
|
||||
if (!linkId) continue
|
||||
export function fixLinkInputSlots(
|
||||
graph: LGraph,
|
||||
data: ISerialisedGraph | SerialisableGraph
|
||||
) {
|
||||
// The slot association lives in the serialized node data: node.configure
|
||||
// reorders each serialized inputs array to definition order in place, and
|
||||
// each entry still carries the link id it was saved with. Links are stored
|
||||
// in their respective graph/subgraph.
|
||||
for (const serialisedNode of data.nodes ?? []) {
|
||||
for (const [inputIndex, input] of (serialisedNode.inputs ?? []).entries()) {
|
||||
if (input.link == null) continue
|
||||
|
||||
const link = graph.links.get(linkId)
|
||||
const link = graph.links.get(toLinkId(input.link))
|
||||
if (!link) continue
|
||||
|
||||
link.target_slot = inputIndex
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively fix links in subgraphs
|
||||
if (node.isSubgraphNode?.() && node.subgraph) {
|
||||
fixLinkInputSlots(node.subgraph)
|
||||
}
|
||||
// Recursively fix links in subgraph definitions
|
||||
for (const subgraphData of data.definitions?.subgraphs ?? []) {
|
||||
const subgraph = graph.rootGraph.subgraphs.get(subgraphData.id)
|
||||
if (subgraph) fixLinkInputSlots(subgraph, subgraphData)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user