Compare commits

...

6 Commits

Author SHA1 Message Date
dante01yoon
73c354b91e Merge remote-tracking branch 'origin/main' into fix/subgraph-widget-reorder-persistence 2026-04-11 14:07:38 +09:00
dante01yoon
8e9183411e refactor: address review feedback on widget reorder PR
- Move PromotionEntryResolver.ts from lib/litegraph to core/graph/subgraph
  to fix upward layer violation (non-blocking suggestion by christian-byrne)
- Extract _orderPreservingMerge and _reorderViewsByStoreEntries from
  SubgraphNode into PromotionEntryResolver as pure functions
- Remove unused linkedPromotionEntries destructure in _getPromotedViews
- Add direct promotedWidgets() order assertion test
2026-04-11 10:18:41 +09:00
dante01yoon
70c4c38499 refactor: extract PromotionEntryResolver from SubgraphNode
Move promotion entry resolution logic (linked/fallback merging, alias
pruning, persistence decisions) into a standalone module to reduce
SubgraphNode method count and improve readability.

- Extract resolvePromotionEntries, buildLinkedReconcileEntries,
  buildDisplayNameByViewKey, makePromotionViewKey into
  PromotionEntryResolver.ts
- Remove 10 private methods (~250 lines) from SubgraphNode
- Eliminate _makePromotionEntryKey wrapper (use store export directly)
- Update caching tests to spy on extracted module function
2026-04-08 16:44:51 +09:00
dante01yoon
19a7656b82 test: add reorder preservation tests for linked, independent, and mixed widgets
- linked-only reorder (2 linked widgets, swap order)
- mixed 3-widget reorder (independent between two linked)
- new linked entry appended while preserving existing order
- stale entry removed while preserving order of remaining
2026-04-07 16:37:39 +09:00
dante01yoon
9baeb211ef fix: reorder node widgets to follow store order on pure reorder 2026-04-07 16:18:41 +09:00
dante01yoon
2f1ac34395 fix: preserve user widget order through _syncPromotions
Replace linked-first ordering in _buildPromotionPersistenceState with
an order-preserving merge that keeps existing store entries in their
current position while pruning stale entries and appending new ones.

This fixes a regression from 74a48ab2 where removing the
shouldPersistLinkedOnly guard caused _syncPromotions to always
overwrite user-reordered widget order with linked-first ordering.

Fixes Notion: Bug: Subgraph widget reorder causes UI and panel mismatch
2026-04-07 16:00:02 +09:00
4 changed files with 642 additions and 373 deletions

View File

@@ -0,0 +1,287 @@
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { makePromotionEntryKey } from '@/stores/promotionStore'
export type LinkedPromotionEntry = PromotedWidgetSource & {
inputName: string
inputKey: string
slotName: string
}
export interface ResolvedPromotionEntries {
linkedPromotionEntries: PromotedWidgetSource[]
fallbackStoredEntries: PromotedWidgetSource[]
shouldPersistLinkedOnly: boolean
}
export function resolvePromotionEntries(
storeEntries: PromotedWidgetSource[],
linkedEntries: LinkedPromotionEntry[],
connectedEntryKeys: Set<string>,
inputCount: number,
subgraph: Subgraph,
subgraphNode: SubgraphNode
): ResolvedPromotionEntries {
const linkedPromotionEntries = toPromotionEntries(linkedEntries)
const excludedEntryKeys = new Set(
linkedPromotionEntries.map((e) => makePromotionEntryKey(e))
)
for (const key of connectedEntryKeys) {
excludedEntryKeys.add(key)
}
const prePruneFallback = storeEntries.filter(
(e) => !excludedEntryKeys.has(makePromotionEntryKey(e))
)
const fallbackStoredEntries = pruneStaleFallbackAliases(
prePruneFallback,
linkedPromotionEntries,
subgraph,
subgraphNode
)
const persistLinkedOnly = shouldPersistLinkedOnly(
linkedEntries,
fallbackStoredEntries,
inputCount,
subgraph
)
return {
linkedPromotionEntries,
fallbackStoredEntries,
shouldPersistLinkedOnly: persistLinkedOnly
}
}
function toPromotionEntries(
linkedEntries: LinkedPromotionEntry[]
): PromotedWidgetSource[] {
return linkedEntries.map(
({ sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId }) => ({
sourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
})
)
}
function shouldPersistLinkedOnly(
linkedEntries: LinkedPromotionEntry[],
fallbackStoredEntries: PromotedWidgetSource[],
inputCount: number,
subgraph: Subgraph
): boolean {
if (!(inputCount > 0 && linkedEntries.length === inputCount)) return false
const linkedEntryKeys = new Set(
linkedEntries.map((e) =>
makePromotionEntryKey({
sourceNodeId: e.sourceNodeId,
sourceWidgetName: e.sourceWidgetName
})
)
)
const linkedWidgetNames = new Set(
linkedEntries.map((e) => e.sourceWidgetName)
)
const hasFallbackToKeep = fallbackStoredEntries.some((entry) => {
const sourceNode = subgraph.getNodeById(entry.sourceNodeId)
if (!sourceNode) return linkedWidgetNames.has(entry.sourceWidgetName)
const hasSourceWidget =
sourceNode.widgets?.some(
(widget) => widget.name === entry.sourceWidgetName
) === true
if (hasSourceWidget) return true
return linkedEntryKeys.has(
makePromotionEntryKey({
sourceNodeId: entry.sourceNodeId,
sourceWidgetName: entry.sourceWidgetName
})
)
})
return !hasFallbackToKeep
}
function pruneStaleFallbackAliases(
fallbackEntries: PromotedWidgetSource[],
linkedPromotionEntries: PromotedWidgetSource[],
subgraph: Subgraph,
subgraphNode: SubgraphNode
): PromotedWidgetSource[] {
if (fallbackEntries.length === 0 || linkedPromotionEntries.length === 0)
return fallbackEntries
const linkedConcreteKeys = new Set(
linkedPromotionEntries
.map((e) => resolveConcreteEntryKey(e, subgraphNode))
.filter((key): key is string => key !== undefined)
)
if (linkedConcreteKeys.size === 0) return fallbackEntries
const pruned: PromotedWidgetSource[] = []
for (const entry of fallbackEntries) {
if (!subgraph.getNodeById(entry.sourceNodeId)) continue
const concreteKey = resolveConcreteEntryKey(entry, subgraphNode)
if (concreteKey && linkedConcreteKeys.has(concreteKey)) continue
pruned.push(entry)
}
return pruned
}
function resolveConcreteEntryKey(
entry: PromotedWidgetSource,
subgraphNode: SubgraphNode
): string | undefined {
const result = resolveConcretePromotedWidget(
subgraphNode,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId
)
if (result.status !== 'resolved') return undefined
return makePromotionEntryKey({
sourceNodeId: String(result.resolved.node.id),
sourceWidgetName: result.resolved.widget.name
})
}
export function buildLinkedReconcileEntries(
linkedEntries: LinkedPromotionEntry[]
): Array<{
sourceNodeId: string
sourceWidgetName: string
viewKey: string
disambiguatingSourceNodeId?: string
slotName: string
}> {
return linkedEntries.map(
({
inputKey,
inputName,
slotName,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
}) => ({
sourceNodeId,
sourceWidgetName,
slotName,
disambiguatingSourceNodeId,
viewKey: makePromotionViewKey(
inputKey,
sourceNodeId,
sourceWidgetName,
inputName,
disambiguatingSourceNodeId
)
})
)
}
export function buildDisplayNameByViewKey(
linkedEntries: LinkedPromotionEntry[]
): Map<string, string> {
return new Map(
linkedEntries.map((entry) => [
makePromotionViewKey(
entry.inputKey,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.inputName,
entry.disambiguatingSourceNodeId
),
entry.inputName
])
)
}
export function makePromotionViewKey(
inputKey: string,
sourceNodeId: string,
sourceWidgetName: string,
inputName = '',
disambiguatingSourceNodeId?: string
): string {
return disambiguatingSourceNodeId
? JSON.stringify([
inputKey,
sourceNodeId,
sourceWidgetName,
inputName,
disambiguatingSourceNodeId
])
: JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
}
export function orderPreservingMerge(
currentEntries: PromotedWidgetSource[],
desiredEntries: PromotedWidgetSource[]
): PromotedWidgetSource[] {
const desiredByKey = new Map(
desiredEntries.map((e) => [makePromotionEntryKey(e), e])
)
const currentKeys = new Set(currentEntries.map(makePromotionEntryKey))
const preserved = currentEntries
.filter((e) => desiredByKey.has(makePromotionEntryKey(e)))
.map((e) => desiredByKey.get(makePromotionEntryKey(e))!)
const added = desiredEntries.filter(
(e) => !currentKeys.has(makePromotionEntryKey(e))
)
return [...preserved, ...added]
}
export function reorderViewsByStoreEntries(
views: PromotedWidgetView[],
storeEntries: PromotedWidgetSource[]
): PromotedWidgetView[] {
if (views.length <= 1 || storeEntries.length === 0) return views
const storeKeys = new Set(storeEntries.map(makePromotionEntryKey))
const viewKeys = new Set(views.map(makePromotionEntryKey))
if (storeKeys.size !== viewKeys.size) return views
for (const key of viewKeys) {
if (!storeKeys.has(key)) return views
}
const viewsByKey = new Map<string, PromotedWidgetView[]>()
for (const v of views) {
const key = makePromotionEntryKey(v)
const group = viewsByKey.get(key)
if (group) group.push(v)
else viewsByKey.set(key, [v])
}
const emittedKeys = new Set<string>()
const ordered: PromotedWidgetView[] = []
for (const entry of storeEntries) {
const key = makePromotionEntryKey(entry)
if (emittedKeys.has(key)) continue
const group = viewsByKey.get(key)
if (group) {
ordered.push(...group)
emittedKeys.add(key)
}
}
return ordered
}

View File

@@ -17,6 +17,7 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import * as PromotionEntryResolver from '@/core/graph/subgraph/PromotionEntryResolver'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
@@ -840,8 +841,282 @@ describe('SubgraphNode.widgets getter', () => {
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ sourceNodeId: String(linkedNodeA.id), sourceWidgetName: 'string_a' },
{ sourceNodeId: String(independentNode.id), sourceWidgetName: 'string_a' }
{
sourceNodeId: String(independentNode.id),
sourceWidgetName: 'string_a'
},
{ sourceNodeId: String(linkedNodeA.id), sourceWidgetName: 'string_a' }
])
})
test('syncPromotions preserves user-reordered store order', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'widget_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 97 })
subgraphNode.graph?.add(subgraphNode)
const linkedNode = new LGraphNode('LinkedNode')
const linkedInput = linkedNode.addInput('widget_a', '*')
linkedNode.addWidget('text', 'widget_a', 'val_a', () => {})
linkedInput.widget = { name: 'widget_a' }
subgraph.add(linkedNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
const independentNode = new LGraphNode('IndependentNode')
independentNode.addWidget('text', 'indep_widget', 'val_b', () => {})
subgraph.add(independentNode)
// Simulate user reorder: independent first, then linked
setPromotions(subgraphNode, [
[String(independentNode.id), 'indep_widget'],
[String(linkedNode.id), 'widget_a']
])
callSyncPromotions(subgraphNode)
const promotions = usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
// Order must be preserved: independent first, linked second
expect(promotions).toStrictEqual([
{
sourceNodeId: String(independentNode.id),
sourceWidgetName: 'indep_widget'
},
{ sourceNodeId: String(linkedNode.id), sourceWidgetName: 'widget_a' }
])
})
test('syncPromotions preserves reorder among linked-only widgets', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'slot_a', type: '*' },
{ name: 'slot_b', type: '*' }
]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 98 })
subgraphNode.graph?.add(subgraphNode)
const nodeA = new LGraphNode('NodeA')
const inputA = nodeA.addInput('slot_a', '*')
nodeA.addWidget('text', 'slot_a', 'a', () => {})
inputA.widget = { name: 'slot_a' }
subgraph.add(nodeA)
subgraph.inputNode.slots[0].connect(inputA, nodeA)
const nodeB = new LGraphNode('NodeB')
const inputB = nodeB.addInput('slot_b', '*')
nodeB.addWidget('text', 'slot_b', 'b', () => {})
inputB.widget = { name: 'slot_b' }
subgraph.add(nodeB)
subgraph.inputNode.slots[1].connect(inputB, nodeB)
// Initial order: A, B — then user reorders to B, A
setPromotions(subgraphNode, [
[String(nodeB.id), 'slot_b'],
[String(nodeA.id), 'slot_a']
])
callSyncPromotions(subgraphNode)
const promotions = usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ sourceNodeId: String(nodeB.id), sourceWidgetName: 'slot_b' },
{ sourceNodeId: String(nodeA.id), sourceWidgetName: 'slot_a' }
])
})
test('syncPromotions preserves reorder in mixed 3-widget scenario', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'slot_a', type: '*' },
{ name: 'slot_b', type: '*' }
]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 99 })
subgraphNode.graph?.add(subgraphNode)
const linkedA = new LGraphNode('LinkedA')
const inputA = linkedA.addInput('slot_a', '*')
linkedA.addWidget('text', 'slot_a', 'a', () => {})
inputA.widget = { name: 'slot_a' }
subgraph.add(linkedA)
subgraph.inputNode.slots[0].connect(inputA, linkedA)
const linkedB = new LGraphNode('LinkedB')
const inputB = linkedB.addInput('slot_b', '*')
linkedB.addWidget('text', 'slot_b', 'b', () => {})
inputB.widget = { name: 'slot_b' }
subgraph.add(linkedB)
subgraph.inputNode.slots[1].connect(inputB, linkedB)
const independentNode = new LGraphNode('IndependentNode')
independentNode.addWidget('text', 'indep', 'c', () => {})
subgraph.add(independentNode)
// User reorders: independent between the two linked
setPromotions(subgraphNode, [
[String(linkedA.id), 'slot_a'],
[String(independentNode.id), 'indep'],
[String(linkedB.id), 'slot_b']
])
callSyncPromotions(subgraphNode)
const promotions = usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ sourceNodeId: String(linkedA.id), sourceWidgetName: 'slot_a' },
{ sourceNodeId: String(independentNode.id), sourceWidgetName: 'indep' },
{ sourceNodeId: String(linkedB.id), sourceWidgetName: 'slot_b' }
])
})
test('syncPromotions appends new linked entry while preserving existing order', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'slot_a', type: '*' },
{ name: 'slot_b', type: '*' }
]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 100 })
subgraphNode.graph?.add(subgraphNode)
const nodeA = new LGraphNode('NodeA')
const inputA = nodeA.addInput('slot_a', '*')
nodeA.addWidget('text', 'slot_a', 'a', () => {})
inputA.widget = { name: 'slot_a' }
subgraph.add(nodeA)
subgraph.inputNode.slots[0].connect(inputA, nodeA)
const independentNode = new LGraphNode('IndependentNode')
independentNode.addWidget('text', 'indep', 'b', () => {})
subgraph.add(independentNode)
// Store has only independent + first linked (slot_b not yet connected)
setPromotions(subgraphNode, [
[String(independentNode.id), 'indep'],
[String(nodeA.id), 'slot_a']
])
// Now connect slot_b
const nodeB = new LGraphNode('NodeB')
const inputB = nodeB.addInput('slot_b', '*')
nodeB.addWidget('text', 'slot_b', 'c', () => {})
inputB.widget = { name: 'slot_b' }
subgraph.add(nodeB)
subgraph.inputNode.slots[1].connect(inputB, nodeB)
callSyncPromotions(subgraphNode)
const promotions = usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
// Existing entries keep their order, new linked entry appended
expect(promotions).toStrictEqual([
{ sourceNodeId: String(independentNode.id), sourceWidgetName: 'indep' },
{ sourceNodeId: String(nodeA.id), sourceWidgetName: 'slot_a' },
{ sourceNodeId: String(nodeB.id), sourceWidgetName: 'slot_b' }
])
})
test('syncPromotions removes stale entry while preserving order of remaining', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'slot_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 101 })
subgraphNode.graph?.add(subgraphNode)
const linkedNode = new LGraphNode('LinkedNode')
const linkedInput = linkedNode.addInput('slot_a', '*')
linkedNode.addWidget('text', 'slot_a', 'a', () => {})
linkedInput.widget = { name: 'slot_a' }
subgraph.add(linkedNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
const indepA = new LGraphNode('IndepA')
indepA.addWidget('text', 'indep_a', 'b', () => {})
subgraph.add(indepA)
const indepB = new LGraphNode('IndepB')
indepB.addWidget('text', 'indep_b', 'c', () => {})
subgraph.add(indepB)
// User has reordered: indepB, linked, indepA, stale
setPromotions(subgraphNode, [
[String(indepB.id), 'indep_b'],
[String(linkedNode.id), 'slot_a'],
[String(indepA.id), 'indep_a'],
['9999', 'gone']
])
callSyncPromotions(subgraphNode)
const promotions = usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
// Stale entry removed, remaining order preserved
expect(promotions).toStrictEqual([
{ sourceNodeId: String(indepB.id), sourceWidgetName: 'indep_b' },
{ sourceNodeId: String(linkedNode.id), sourceWidgetName: 'slot_a' },
{ sourceNodeId: String(indepA.id), sourceWidgetName: 'indep_a' }
])
})
test('promotedWidgets returns views in user-reordered store order', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'slot_a', type: '*' },
{ name: 'slot_b', type: '*' }
]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 102 })
subgraphNode.graph?.add(subgraphNode)
const linkedA = new LGraphNode('LinkedA')
const inputA = linkedA.addInput('slot_a', '*')
linkedA.addWidget('text', 'slot_a', 'a', () => {})
inputA.widget = { name: 'slot_a' }
subgraph.add(linkedA)
subgraph.inputNode.slots[0].connect(inputA, linkedA)
const linkedB = new LGraphNode('LinkedB')
const inputB = linkedB.addInput('slot_b', '*')
linkedB.addWidget('text', 'slot_b', 'b', () => {})
inputB.widget = { name: 'slot_b' }
subgraph.add(linkedB)
subgraph.inputNode.slots[1].connect(inputB, linkedB)
const independentNode = new LGraphNode('IndependentNode')
independentNode.addWidget('text', 'indep', 'c', () => {})
subgraph.add(independentNode)
// User reorders: independent between the two linked
setPromotions(subgraphNode, [
[String(linkedA.id), 'slot_a'],
[String(independentNode.id), 'indep'],
[String(linkedB.id), 'slot_b']
])
callSyncPromotions(subgraphNode)
const widgets = promotedWidgets(subgraphNode)
expect(widgets.map((w) => w.sourceWidgetName)).toStrictEqual([
'slot_a',
'indep',
'slot_b'
])
})
@@ -1452,22 +1727,10 @@ describe('widgets getter caching', () => {
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
const reconcileSpy = vi.spyOn(
fromAny<
{
_buildPromotionReconcileState: (
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
linkedEntries: Array<{
inputName: string
inputKey: string
sourceNodeId: string
sourceWidgetName: string
}>
) => unknown
},
unknown
>(subgraphNode),
'_buildPromotionReconcileState'
PromotionEntryResolver,
'resolvePromotionEntries'
)
reconcileSpy.mockClear()
void subgraphNode.widgets
void subgraphNode.widgets
@@ -1485,22 +1748,10 @@ describe('widgets getter caching', () => {
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
const reconcileSpy = vi.spyOn(
fromAny<
{
_buildPromotionReconcileState: (
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
linkedEntries: Array<{
inputName: string
inputKey: string
sourceNodeId: string
sourceWidgetName: string
}>
) => unknown
},
unknown
>(subgraphNode),
'_buildPromotionReconcileState'
PromotionEntryResolver,
'resolvePromotionEntries'
)
reconcileSpy.mockClear()
void subgraphNode.widgets
fakeCanvas.frame += 1

View File

@@ -5,11 +5,11 @@
* IO synchronization, and edge cases.
*/
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { makePromotionViewKey } from '@/core/graph/subgraph/PromotionEntryResolver'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
@@ -931,34 +931,8 @@ describe('Nested SubgraphNode duplicate input prevention', () => {
describe('SubgraphNode promotion view keys', () => {
it('distinguishes tuples that differ only by colon placement', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const nodeWithKeyBuilder = fromAny<
{
_makePromotionViewKey: (
inputKey: string,
interiorNodeId: string,
widgetName: string,
inputName?: string
) => string
},
unknown
>(subgraphNode)
const firstKey = nodeWithKeyBuilder._makePromotionViewKey(
'65',
'18',
'a:b',
'c'
)
const secondKey = nodeWithKeyBuilder._makePromotionViewKey(
'65',
'18',
'a',
'b:c'
)
const firstKey = makePromotionViewKey('65', '18', 'a:b', 'c')
const secondKey = makePromotionViewKey('65', '18', 'a', 'b:c')
expect(firstKey).not.toBe(secondKey)
})

View File

@@ -53,6 +53,15 @@ import {
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
import {
buildDisplayNameByViewKey,
buildLinkedReconcileEntries,
makePromotionViewKey,
orderPreservingMerge,
reorderViewsByStoreEntries,
resolvePromotionEntries
} from '@/core/graph/subgraph/PromotionEntryResolver'
import type { LinkedPromotionEntry } from '@/core/graph/subgraph/PromotionEntryResolver'
import { PromotedWidgetViewManager } from './PromotedWidgetViewManager'
import type { SubgraphInput } from './SubgraphInput'
import { createBitmapCache } from './svgBitmapCache'
@@ -61,12 +70,6 @@ const workflowSvg = new Image()
workflowSvg.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3E%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-width='1.3' d='M9.18613 3.09999H6.81377M9.18613 12.9H7.55288c-3.08678 0-5.35171-2.99581-4.60305-6.08843l.3054-1.26158M14.7486 2.1721l-.5931 2.45c-.132.54533-.6065.92789-1.1508.92789h-2.2993c-.77173 0-1.33797-.74895-1.1508-1.5221l.5931-2.45c.132-.54533.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.74896 1.1508 1.52211Zm-8.3033 0-.59309 2.45c-.13201.54533-.60646.92789-1.15076.92789H2.4021c-.7717 0-1.33793-.74895-1.15077-1.5221l.59309-2.45c.13201-.54533.60647-.9279 1.15077-.9279h2.29935c.77169 0 1.33792.74896 1.15076 1.52211Zm8.3033 9.8-.5931 2.45c-.132.5453-.6065.9279-1.1508.9279h-2.2993c-.77173 0-1.33797-.749-1.1508-1.5221l.5931-2.45c.132-.5453.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.7489 1.1508 1.5221Z'/%3E%3C/svg%3E %3C/svg%3E"
type LinkedPromotionEntry = PromotedWidgetSource & {
inputName: string
inputKey: string
/** The subgraph input slot's internal name (stable identity). */
slotName: string
}
// Pre-rasterize the SVG to a bitmap canvas to avoid Firefox re-processing
// the SVG's internal stylesheet on every ctx.drawImage() call per frame.
const workflowBitmapCache = createBitmapCache(workflowSvg, 32)
@@ -217,7 +220,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const seenEntryKeys = new Set<string>()
const deduplicatedEntries = linkedEntries.filter((entry) => {
const entryKey = this._makePromotionViewKey(
const entryKey = makePromotionViewKey(
entry.inputKey,
entry.sourceNodeId,
entry.sourceWidgetName,
@@ -271,10 +274,41 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const linkedEntries = this._getLinkedPromotionEntries()
const { displayNameByViewKey, reconcileEntries } =
this._buildPromotionReconcileState(entries, linkedEntries)
const connectedEntryKeys = this._getConnectedPromotionEntryKeys()
const { fallbackStoredEntries, shouldPersistLinkedOnly } =
resolvePromotionEntries(
entries,
linkedEntries,
connectedEntryKeys,
this.inputs.length,
this.subgraph,
this
)
const views = this._promotedViewManager.reconcile(
const linkedReconcileEntries = buildLinkedReconcileEntries(linkedEntries)
const fallbackReconcileEntries: Array<{
sourceNodeId: string
sourceWidgetName: string
viewKey?: string
disambiguatingSourceNodeId?: string
slotName?: string
}> = fallbackStoredEntries.map((e) =>
e.disambiguatingSourceNodeId
? {
sourceNodeId: e.sourceNodeId,
sourceWidgetName: e.sourceWidgetName,
disambiguatingSourceNodeId: e.disambiguatingSourceNodeId,
viewKey: `src:${e.sourceNodeId}:${e.sourceWidgetName}:${e.disambiguatingSourceNodeId}`
}
: e
)
const reconcileEntries = shouldPersistLinkedOnly
? linkedReconcileEntries
: [...linkedReconcileEntries, ...fallbackReconcileEntries]
const displayNameByViewKey = buildDisplayNameByViewKey(linkedEntries)
const reconciledViews = this._promotedViewManager.reconcile(
reconcileEntries,
(entry) =>
createPromotedWidgetView(
@@ -287,6 +321,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
)
const views = reorderViewsByStoreEntries(reconciledViews, entries)
this._promotedViewsCache = {
version: this._cacheVersion,
entriesRef: entries,
@@ -307,13 +343,26 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const store = usePromotionStore()
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
const linkedEntries = this._getLinkedPromotionEntries(false)
// Intentionally preserve independent store promotions when linked coverage is partial;
// tests assert that mixed linked/independent states must not collapse to linked-only.
const { mergedEntries } = this._buildPromotionPersistenceState(
const connectedEntryKeys = this._getConnectedPromotionEntryKeys()
const {
linkedPromotionEntries,
fallbackStoredEntries,
shouldPersistLinkedOnly
} = resolvePromotionEntries(
entries,
linkedEntries
linkedEntries,
connectedEntryKeys,
this.inputs.length,
this.subgraph,
this
)
const desiredEntries = shouldPersistLinkedOnly
? linkedPromotionEntries
: [...linkedPromotionEntries, ...fallbackStoredEntries]
const mergedEntries = orderPreservingMerge(entries, desiredEntries)
const hasChanged =
mergedEntries.length !== entries.length ||
mergedEntries.some(
@@ -329,221 +378,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
store.setPromotions(this.rootGraph.id, this.id, mergedEntries)
}
private _buildPromotionReconcileState(
entries: PromotedWidgetSource[],
linkedEntries: LinkedPromotionEntry[]
): {
displayNameByViewKey: Map<string, string>
reconcileEntries: Array<{
sourceNodeId: string
sourceWidgetName: string
viewKey?: string
disambiguatingSourceNodeId?: string
slotName?: string
}>
} {
const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(
entries,
linkedEntries
)
const linkedReconcileEntries =
this._buildLinkedReconcileEntries(linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(
linkedEntries,
fallbackStoredEntries
)
const fallbackReconcileEntries = fallbackStoredEntries.map((e) =>
e.disambiguatingSourceNodeId
? {
sourceNodeId: e.sourceNodeId,
sourceWidgetName: e.sourceWidgetName,
disambiguatingSourceNodeId: e.disambiguatingSourceNodeId,
viewKey: `src:${e.sourceNodeId}:${e.sourceWidgetName}:${e.disambiguatingSourceNodeId}`
}
: e
)
const reconcileEntries = shouldPersistLinkedOnly
? linkedReconcileEntries
: [...linkedReconcileEntries, ...fallbackReconcileEntries]
return {
displayNameByViewKey: this._buildDisplayNameByViewKey(linkedEntries),
reconcileEntries
}
}
private _buildPromotionPersistenceState(
entries: PromotedWidgetSource[],
linkedEntries: LinkedPromotionEntry[]
): {
mergedEntries: PromotedWidgetSource[]
} {
const { linkedPromotionEntries, fallbackStoredEntries } =
this._collectLinkedAndFallbackEntries(entries, linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(
linkedEntries,
fallbackStoredEntries
)
return {
mergedEntries: shouldPersistLinkedOnly
? linkedPromotionEntries
: [...linkedPromotionEntries, ...fallbackStoredEntries]
}
}
private _collectLinkedAndFallbackEntries(
entries: PromotedWidgetSource[],
linkedEntries: LinkedPromotionEntry[]
): {
linkedPromotionEntries: PromotedWidgetSource[]
fallbackStoredEntries: PromotedWidgetSource[]
} {
const linkedPromotionEntries = this._toPromotionEntries(linkedEntries)
const excludedEntryKeys = new Set(
linkedPromotionEntries.map((entry) =>
this._makePromotionEntryKey(
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId
)
)
)
const connectedEntryKeys = this._getConnectedPromotionEntryKeys()
for (const key of connectedEntryKeys) {
excludedEntryKeys.add(key)
}
const prePruneFallbackStoredEntries = this._getFallbackStoredEntries(
entries,
excludedEntryKeys
)
const fallbackStoredEntries = this._pruneStaleAliasFallbackEntries(
prePruneFallbackStoredEntries,
linkedPromotionEntries
)
return {
linkedPromotionEntries,
fallbackStoredEntries
}
}
private _shouldPersistLinkedOnly(
linkedEntries: LinkedPromotionEntry[],
fallbackStoredEntries: PromotedWidgetSource[]
): boolean {
if (
!(this.inputs.length > 0 && linkedEntries.length === this.inputs.length)
)
return false
const linkedEntryKeys = new Set(
linkedEntries.map((entry) =>
this._makePromotionEntryKey(entry.sourceNodeId, entry.sourceWidgetName)
)
)
const linkedWidgetNames = new Set(
linkedEntries.map((entry) => entry.sourceWidgetName)
)
const hasFallbackToKeep = fallbackStoredEntries.some((entry) => {
const sourceNode = this.subgraph.getNodeById(entry.sourceNodeId)
if (!sourceNode) return linkedWidgetNames.has(entry.sourceWidgetName)
const hasSourceWidget =
sourceNode.widgets?.some(
(widget) => widget.name === entry.sourceWidgetName
) === true
if (hasSourceWidget) return true
// If the fallback entry overlaps a linked entry, keep it
// until aliasing can be positively proven.
return linkedEntryKeys.has(
this._makePromotionEntryKey(entry.sourceNodeId, entry.sourceWidgetName)
)
})
return !hasFallbackToKeep
}
private _toPromotionEntries(
linkedEntries: LinkedPromotionEntry[]
): PromotedWidgetSource[] {
return linkedEntries.map(
({ sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId }) => ({
sourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
})
)
}
private _getFallbackStoredEntries(
entries: PromotedWidgetSource[],
excludedEntryKeys: Set<string>
): PromotedWidgetSource[] {
return entries.filter(
(entry) =>
!excludedEntryKeys.has(
this._makePromotionEntryKey(
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId
)
)
)
}
private _pruneStaleAliasFallbackEntries(
fallbackStoredEntries: PromotedWidgetSource[],
linkedPromotionEntries: PromotedWidgetSource[]
): PromotedWidgetSource[] {
if (
fallbackStoredEntries.length === 0 ||
linkedPromotionEntries.length === 0
)
return fallbackStoredEntries
const linkedConcreteKeys = new Set(
linkedPromotionEntries
.map((entry) => this._resolveConcretePromotionEntryKey(entry))
.filter((key): key is string => key !== undefined)
)
if (linkedConcreteKeys.size === 0) return fallbackStoredEntries
const prunedEntries: PromotedWidgetSource[] = []
for (const entry of fallbackStoredEntries) {
if (!this.subgraph.getNodeById(entry.sourceNodeId)) continue
const concreteKey = this._resolveConcretePromotionEntryKey(entry)
if (concreteKey && linkedConcreteKeys.has(concreteKey)) continue
prunedEntries.push(entry)
}
return prunedEntries
}
private _resolveConcretePromotionEntryKey(
entry: PromotedWidgetSource
): string | undefined {
const result = resolveConcretePromotedWidget(
this,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId
)
if (result.status !== 'resolved') return undefined
return this._makePromotionEntryKey(
String(result.resolved.node.id),
result.resolved.widget.name
)
}
private _getConnectedPromotionEntryKeys(): Set<string> {
const connectedEntryKeys = new Set<string>()
@@ -557,7 +391,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (!hasWidgetNode(widget)) continue
connectedEntryKeys.add(
this._makePromotionEntryKey(String(widget.node.id), widget.name)
makePromotionEntryKey({
sourceNodeId: String(widget.node.id),
sourceWidgetName: widget.name
})
)
}
}
@@ -565,86 +402,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return connectedEntryKeys
}
private _buildLinkedReconcileEntries(
linkedEntries: LinkedPromotionEntry[]
): Array<{
sourceNodeId: string
sourceWidgetName: string
viewKey: string
disambiguatingSourceNodeId?: string
slotName: string
}> {
return linkedEntries.map(
({
inputKey,
inputName,
slotName,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
}) => ({
sourceNodeId,
sourceWidgetName,
slotName,
disambiguatingSourceNodeId,
viewKey: this._makePromotionViewKey(
inputKey,
sourceNodeId,
sourceWidgetName,
inputName,
disambiguatingSourceNodeId
)
})
)
}
private _buildDisplayNameByViewKey(
linkedEntries: LinkedPromotionEntry[]
): Map<string, string> {
return new Map(
linkedEntries.map((entry) => [
this._makePromotionViewKey(
entry.inputKey,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.inputName,
entry.disambiguatingSourceNodeId
),
entry.inputName
])
)
}
private _makePromotionEntryKey(
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): string {
return makePromotionEntryKey({
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
})
}
private _makePromotionViewKey(
inputKey: string,
sourceNodeId: string,
sourceWidgetName: string,
inputName = '',
disambiguatingSourceNodeId?: string
): string {
return disambiguatingSourceNodeId
? JSON.stringify([
inputKey,
sourceNodeId,
sourceWidgetName,
inputName,
disambiguatingSourceNodeId
])
: JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
}
private _serializeEntries(
entries: PromotedWidgetSource[]
): (string[] | [string, string, string])[] {
@@ -1263,7 +1020,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
sourceNodeId,
subgraphInput.name
),
this._makePromotionViewKey(
makePromotionViewKey(
String(subgraphInput.id),
nodeId,
widgetName,
@@ -1480,7 +1237,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this._promotedViewManager.removeByViewKey(
view.sourceNodeId,
view.sourceWidgetName,
this._makePromotionViewKey(
makePromotionViewKey(
String(input._subgraphSlot.id),
view.sourceNodeId,
view.sourceWidgetName,