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:
DrJKL
2026-07-07 16:40:51 -07:00
parent 75ad306a80
commit 3772615fcb
20 changed files with 168 additions and 102 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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> }

View File

@@ -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))

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -80,7 +80,6 @@ function createTestLink(
inputSlot
)
graph._addLink(link)
targetNode.inputs[inputSlot].link = linkId
return link
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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
})

View File

@@ -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()

View File

@@ -189,7 +189,6 @@ export class SubgraphInputNode
subgraph.removeFloatingLink(floatingLink)
}
input.link = null
subgraph.setDirtyCanvas(false, true)
if (!link) return

View File

@@ -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)

View File

@@ -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)

View File

@@ -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(

View File

@@ -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')

View File

@@ -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)
})
})

View File

@@ -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'] || {}

View File

@@ -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)
}
}