fix: prune orphaned SubgraphNode inputs after configure (#9977)

_internalConfigureAfterSlots() calls _rebindInputSubgraphSlots() which
marks inputs without a matching subgraph slot by deleting _subgraphSlot,
but never removes them from the inputs array. LGraphNode.configure()
uses cloneObject which expands the array when serialized data has extra
entries. Once corrupted, serialize-load cycles are self-reinforcing.

Fix: filter out inputs with no _subgraphSlot immediately after rebinding.
This commit is contained in:
bymyself
2026-03-16 06:45:45 +00:00
parent 06f7e13957
commit 012d211966
2 changed files with 86 additions and 2 deletions

View File

@@ -9,9 +9,9 @@ import { describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { LGraph, Subgraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
@@ -618,6 +618,86 @@ describe.skip('SubgraphNode Cleanup', () => {
})
})
describe('SubgraphNode duplicate input pruning (#9977)', () => {
it('should prune inputs that have no matching subgraph slot after configure', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph({
inputs: [
{ name: 'a', type: 'STRING' },
{ name: 'b', type: 'NUMBER' }
]
})
const parentGraph = new LGraph()
const instanceData = {
id: 1 as const,
type: subgraph.id,
pos: [0, 0] as [number, number],
size: [200, 100] as [number, number],
inputs: [
{ name: 'a', type: 'STRING', link: null },
{ name: 'b', type: 'NUMBER', link: null },
{ name: 'a', type: 'STRING', link: null },
{ name: 'b', type: 'NUMBER', link: null }
],
outputs: [],
properties: {},
flags: {},
mode: 0,
order: 0
}
const node = new SubgraphNode(
parentGraph,
subgraph,
instanceData as ExportedSubgraphInstance
)
expect(node.inputs).toHaveLength(2)
expect(node.inputs.every((i) => i._subgraphSlot)).toBe(true)
})
it('should not accumulate duplicate inputs on reconfigure', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph({
inputs: [
{ name: 'a', type: 'STRING' },
{ name: 'b', type: 'NUMBER' }
]
})
const node = createTestSubgraphNode(subgraph)
expect(node.inputs).toHaveLength(2)
const serialized = node.serialize()
node.configure(serialized)
expect(node.inputs).toHaveLength(2)
const serialized2 = node.serialize()
node.configure(serialized2)
expect(node.inputs).toHaveLength(2)
})
it('should serialize with exactly the subgraph-defined inputs', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph({
inputs: [
{ name: 'x', type: 'IMAGE' },
{ name: 'y', type: 'VAE' }
]
})
const node = createTestSubgraphNode(subgraph)
const serialized = node.serialize()
expect(serialized.inputs).toHaveLength(2)
expect(serialized.inputs?.map((i) => i.name)).toEqual(['x', 'y'])
})
})
describe('SubgraphNode promotion view keys', () => {
it('distinguishes tuples that differ only by colon placement', () => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -926,6 +926,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
override _internalConfigureAfterSlots() {
this._rebindInputSubgraphSlots()
// Prune inputs that don't map to any subgraph slot definition.
// This prevents stale/duplicate serialized inputs from persisting (#9977).
this.inputs = this.inputs.filter((input) => input._subgraphSlot)
// Ensure proxyWidgets is initialized so it serializes
this.properties.proxyWidgets ??= []