From 3772615fcb75ee32ea718cd9ce23938fdaf7ef36 Mon Sep 17 00:00:00 2001 From: DrJKL Date: Tue, 7 Jul 2026 16:40:51 -0700 Subject: [PATCH] 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(). --- docs/architecture/link-topology-store.md | 14 ++--- docs/architecture/output-slot-connectivity.md | 15 ++++- src/core/graph/widgets/dynamicWidgets.test.ts | 3 +- src/core/graph/widgets/dynamicWidgets.ts | 61 +++++++++++++++---- src/lib/litegraph/src/LGraph.test.ts | 10 +-- src/lib/litegraph/src/LGraph.ts | 2 - .../src/LGraphCanvas.drawConnections.test.ts | 1 - src/lib/litegraph/src/LGraphNode.ts | 7 --- src/lib/litegraph/src/interfaces.ts | 8 ++- src/lib/litegraph/src/linkDeduplication.ts | 28 ++------- src/lib/litegraph/src/node/NodeInputSlot.ts | 31 +++++++++- .../litegraph/src/subgraph/SubgraphInput.ts | 1 - .../src/subgraph/SubgraphInputNode.ts | 1 - .../litegraph/src/subgraph/subgraphUtils.ts | 22 +++++-- .../useNodeReplacement.test.ts | 4 +- .../nodeReplacement/useNodeReplacement.ts | 2 - src/scripts/app.ts | 3 +- src/utils/linkFixer.test.ts | 15 +++-- src/utils/linkFixer.ts | 8 ++- src/utils/litegraphUtil.ts | 34 ++++++----- 20 files changed, 168 insertions(+), 102 deletions(-) diff --git a/docs/architecture/link-topology-store.md b/docs/architecture/link-topology-store.md index 99c0308487..f7f20152d0 100644 --- a/docs/architecture/link-topology-store.md +++ b/docs/architecture/link-topology-store.md @@ -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. diff --git a/docs/architecture/output-slot-connectivity.md b/docs/architecture/output-slot-connectivity.md index 73f42e63b7..2187c7abe6 100644 --- a/docs/architecture/output-slot-connectivity.md +++ b/docs/architecture/output-slot-connectivity.md @@ -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. diff --git a/src/core/graph/widgets/dynamicWidgets.test.ts b/src/core/graph/widgets/dynamicWidgets.test.ts index fa77100838..137dc5d7cf 100644 --- a/src/core/graph/widgets/dynamicWidgets.test.ts +++ b/src/core/graph/widgets/dynamicWidgets.test.ts @@ -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 } diff --git a/src/core/graph/widgets/dynamicWidgets.ts b/src/core/graph/widgets/dynamicWidgets.ts index ad36d014fa..ec607b7445 100644 --- a/src/core/graph/widgets/dynamicWidgets.ts +++ b/src/core/graph/widgets/dynamicWidgets.ts @@ -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() + +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)) diff --git a/src/lib/litegraph/src/LGraph.test.ts b/src/lib/litegraph/src/LGraph.test.ts index 6da3715805..29321fb4c4 100644 --- a/src/lib/litegraph/src/LGraph.test.ts +++ b/src/lib/litegraph/src/LGraph.test.ts @@ -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) diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index 90777c5ba9..621bea2928 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -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) } } diff --git a/src/lib/litegraph/src/LGraphCanvas.drawConnections.test.ts b/src/lib/litegraph/src/LGraphCanvas.drawConnections.test.ts index 02c02a109d..e147fba9e7 100644 --- a/src/lib/litegraph/src/LGraphCanvas.drawConnections.test.ts +++ b/src/lib/litegraph/src/LGraphCanvas.drawConnections.test.ts @@ -80,7 +80,6 @@ function createTestLink( inputSlot ) graph._addLink(link) - targetNode.inputs[inputSlot].link = linkId return link } diff --git a/src/lib/litegraph/src/LGraphNode.ts b/src/lib/litegraph/src/LGraphNode.ts index 0c35df5962..4530cd4d6f 100644 --- a/src/lib/litegraph/src/LGraphNode.ts +++ b/src/lib/litegraph/src/LGraphNode.ts @@ -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, diff --git a/src/lib/litegraph/src/interfaces.ts b/src/lib/litegraph/src/interfaces.ts index e4c4776e27..6560415e90 100644 --- a/src/lib/litegraph/src/interfaces.ts +++ b/src/lib/litegraph/src/interfaces.ts @@ -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 diff --git a/src/lib/litegraph/src/linkDeduplication.ts b/src/lib/litegraph/src/linkDeduplication.ts index 4b0d7c3a67..dd70527810 100644 --- a/src/lib/litegraph/src/linkDeduplication.ts +++ b/src/lib/litegraph/src/linkDeduplication.ts @@ -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 - } - } -} diff --git a/src/lib/litegraph/src/node/NodeInputSlot.ts b/src/lib/litegraph/src/node/NodeInputSlot.ts index 1fbbabfab5..bcf904b6fa 100644 --- a/src/lib/litegraph/src/node/NodeInputSlot.ts +++ b/src/lib/litegraph/src/node/NodeInputSlot.ts @@ -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, 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 +}) diff --git a/src/lib/litegraph/src/subgraph/SubgraphInput.ts b/src/lib/litegraph/src/subgraph/SubgraphInput.ts index f02a791222..355ec91979 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphInput.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphInput.ts @@ -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() diff --git a/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts b/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts index 9ff419b1ca..f588a4b352 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts @@ -189,7 +189,6 @@ export class SubgraphInputNode subgraph.removeFloatingLink(floatingLink) } - input.link = null subgraph.setDirtyCanvas(false, true) if (!link) return diff --git a/src/lib/litegraph/src/subgraph/subgraphUtils.ts b/src/lib/litegraph/src/subgraph/subgraphUtils.ts index 9841197379..c51738cfa4 100644 --- a/src/lib/litegraph/src/subgraph/subgraphUtils.ts +++ b/src/lib/litegraph/src/subgraph/subgraphUtils.ts @@ -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 { - 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) diff --git a/src/platform/nodeReplacement/useNodeReplacement.test.ts b/src/platform/nodeReplacement/useNodeReplacement.test.ts index dfa9b2e134..785fcd4d1b 100644 --- a/src/platform/nodeReplacement/useNodeReplacement.test.ts +++ b/src/platform/nodeReplacement/useNodeReplacement.test.ts @@ -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) diff --git a/src/platform/nodeReplacement/useNodeReplacement.ts b/src/platform/nodeReplacement/useNodeReplacement.ts index 36f5956fa5..63ca20c8fe 100644 --- a/src/platform/nodeReplacement/useNodeReplacement.ts +++ b/src/platform/nodeReplacement/useNodeReplacement.ts @@ -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( diff --git a/src/scripts/app.ts b/src/scripts/app.ts index a635ee333c..067300499c 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -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') diff --git a/src/utils/linkFixer.test.ts b/src/utils/linkFixer.test.ts index a84693f304..30d9901649 100644 --- a/src/utils/linkFixer.test.ts +++ b/src/utils/linkFixer.test.ts @@ -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) }) }) diff --git a/src/utils/linkFixer.ts b/src/utils/linkFixer.ts index 477a64e39e..02e5f1e432 100644 --- a/src/utils/linkFixer.ts +++ b/src/utils/linkFixer.ts @@ -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'] || {} diff --git a/src/utils/litegraphUtil.ts b/src/utils/litegraphUtil.ts index 10d67cb0f5..4c5f959bb4 100644 --- a/src/utils/litegraphUtil.ts +++ b/src/utils/litegraphUtil.ts @@ -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( * * @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) } }