mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-12 00:20:15 +00:00
fix: stabilize nested subgraph promoted widget resolution (#9282)
## Summary Fix multiple issues with promoted widget resolution in nested subgraphs, ensuring correct value propagation, slot matching, and rendering for deeply nested promoted widgets. ## Changes - **What**: Stabilize nested subgraph promoted widget resolution chain - Use deep source keys for promoted widget values in Vue rendering mode - Resolve effective widget options from the source widget instead of the promoted view - Stabilize slot resolution for nested promoted widgets - Preserve combo value rendering for promoted subgraph widgets - Prevent subgraph definition deletion while other nodes still reference the same type - Clean up unused exported resolution types ## Review Focus - `resolveConcretePromotedWidget.ts` — new recursive resolution logic for deeply nested promoted widgets - `useGraphNodeManager.ts` — option extraction now uses `effectiveWidget` for promoted widgets - `SubgraphNode.ts` — unpack no longer force-deletes definitions referenced by other nodes ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9282-fix-stabilize-nested-subgraph-promoted-widget-resolution-3146d73d365081208a4fe931bb7569cf) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -634,4 +634,25 @@ describe('Subgraph Unpacking', () => {
|
||||
expect(unpackedTarget.inputs[0].link).not.toBeNull()
|
||||
expect(unpackedTarget.inputs[1].link).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps subgraph definition when unpacking one instance while another remains', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createSubgraphOnGraph(rootGraph)
|
||||
|
||||
const firstInstance = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
const secondInstance = createTestSubgraphNode(subgraph, { pos: [300, 100] })
|
||||
secondInstance.id = 2
|
||||
rootGraph.add(firstInstance)
|
||||
rootGraph.add(secondInstance)
|
||||
|
||||
rootGraph.unpackSubgraph(firstInstance)
|
||||
|
||||
expect(rootGraph.subgraphs.has(subgraph.id)).toBe(true)
|
||||
|
||||
const serialized = rootGraph.serialize()
|
||||
const definitionIds =
|
||||
serialized.definitions?.subgraphs?.map((definition) => definition.id) ??
|
||||
[]
|
||||
expect(definitionIds).toContain(subgraph.id)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1071,13 +1071,23 @@ export class LGraph
|
||||
}
|
||||
|
||||
if (node.isSubgraphNode()) {
|
||||
forEachNode(node.subgraph, (innerNode) => {
|
||||
innerNode.onRemoved?.()
|
||||
innerNode.graph?.onNodeRemoved?.(innerNode)
|
||||
if (innerNode.isSubgraphNode())
|
||||
this.rootGraph.subgraphs.delete(innerNode.subgraph.id)
|
||||
})
|
||||
this.rootGraph.subgraphs.delete(node.subgraph.id)
|
||||
const allGraphs = [this.rootGraph, ...this.rootGraph.subgraphs.values()]
|
||||
const hasRemainingReferences = allGraphs.some((graph) =>
|
||||
graph.nodes.some(
|
||||
(candidate) =>
|
||||
candidate !== node &&
|
||||
candidate.isSubgraphNode() &&
|
||||
candidate.type === node.subgraph.id
|
||||
)
|
||||
)
|
||||
|
||||
if (!hasRemainingReferences) {
|
||||
forEachNode(node.subgraph, (innerNode) => {
|
||||
innerNode.onRemoved?.()
|
||||
innerNode.graph?.onNodeRemoved?.(innerNode)
|
||||
})
|
||||
this.rootGraph.subgraphs.delete(node.subgraph.id)
|
||||
}
|
||||
}
|
||||
|
||||
// callback
|
||||
@@ -1869,6 +1879,7 @@ export class LGraph
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return { subgraph, node: subgraphNode as SubgraphNode }
|
||||
}
|
||||
|
||||
@@ -2055,7 +2066,6 @@ export class LGraph
|
||||
})
|
||||
}
|
||||
this.remove(subgraphNode)
|
||||
this.subgraphs.delete(subgraphNode.subgraph.id)
|
||||
|
||||
// Deduplicate links by (oid, oslot, tid, tslot) to prevent repeated
|
||||
// disconnect/reconnect cycles on widget inputs that can shift slot indices.
|
||||
@@ -2342,7 +2352,6 @@ export class LGraph
|
||||
const usedSubgraphs = [...this._subgraphs.values()]
|
||||
.filter((subgraph) => usedSubgraphIds.has(subgraph.id))
|
||||
.map((x) => x.asSerialisable())
|
||||
|
||||
if (usedSubgraphs.length > 0) {
|
||||
data.definitions = { subgraphs: usedSubgraphs }
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { PromotedWidgetViewManager } from '@/lib/litegraph/src/subgraph/PromotedWidgetViewManager'
|
||||
import type { SubgraphPromotionEntry } from '@/services/subgraphPseudoWidgetCache'
|
||||
|
||||
function makeView(entry: SubgraphPromotionEntry) {
|
||||
type TestPromotionEntry = {
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
viewKey?: string
|
||||
}
|
||||
|
||||
function makeView(entry: TestPromotionEntry) {
|
||||
const baseKey = `${entry.interiorNodeId}:${entry.widgetName}`
|
||||
|
||||
return {
|
||||
key: `${entry.interiorNodeId}:${entry.widgetName}`
|
||||
key: entry.viewKey ? `${baseKey}:${entry.viewKey}` : baseKey
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,4 +83,46 @@ describe('PromotedWidgetViewManager', () => {
|
||||
expect(restored[0]).toBe(first[1])
|
||||
expect(restored[1]).not.toBe(first[0])
|
||||
})
|
||||
|
||||
test('keeps distinct views for same source widget when viewKeys differ', () => {
|
||||
const manager = new PromotedWidgetViewManager<{ key: string }>()
|
||||
|
||||
const views = manager.reconcile(
|
||||
[
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
|
||||
expect(views).toHaveLength(2)
|
||||
expect(views[0]).not.toBe(views[1])
|
||||
expect(views[0].key).toBe('1:widgetA:slotA')
|
||||
expect(views[1].key).toBe('1:widgetA:slotB')
|
||||
})
|
||||
|
||||
test('removeByViewKey removes only the targeted keyed view', () => {
|
||||
const manager = new PromotedWidgetViewManager<{ key: string }>()
|
||||
|
||||
const firstPass = manager.reconcile(
|
||||
[
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
|
||||
manager.removeByViewKey('1', 'widgetA', 'slotA')
|
||||
|
||||
const secondPass = manager.reconcile(
|
||||
[
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
|
||||
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
|
||||
],
|
||||
makeView
|
||||
)
|
||||
|
||||
expect(secondPass[0]).not.toBe(firstPass[0])
|
||||
expect(secondPass[1]).toBe(firstPass[1])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
type PromotionEntry = {
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
viewKey?: string
|
||||
}
|
||||
|
||||
type CreateView<TView> = (entry: PromotionEntry) => TView
|
||||
@@ -14,20 +15,28 @@ type CreateView<TView> = (entry: PromotionEntry) => TView
|
||||
export class PromotedWidgetViewManager<TView> {
|
||||
private viewCache = new Map<string, TView>()
|
||||
private cachedViews: TView[] | null = null
|
||||
private cachedEntriesRef: readonly PromotionEntry[] | null = null
|
||||
private cachedEntryKeys: string[] | null = null
|
||||
|
||||
reconcile(
|
||||
entries: readonly PromotionEntry[],
|
||||
createView: CreateView<TView>
|
||||
): TView[] {
|
||||
if (this.cachedViews && entries === this.cachedEntriesRef)
|
||||
const entryKeys = entries.map((entry) =>
|
||||
this.makeKey(entry.interiorNodeId, entry.widgetName, entry.viewKey)
|
||||
)
|
||||
|
||||
if (this.cachedViews && this.areEntryKeysEqual(entryKeys))
|
||||
return this.cachedViews
|
||||
|
||||
const views: TView[] = []
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const key = this.makeKey(entry.interiorNodeId, entry.widgetName)
|
||||
const key = this.makeKey(
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName,
|
||||
entry.viewKey
|
||||
)
|
||||
if (seenKeys.has(key)) continue
|
||||
seenKeys.add(key)
|
||||
|
||||
@@ -47,16 +56,17 @@ export class PromotedWidgetViewManager<TView> {
|
||||
}
|
||||
|
||||
this.cachedViews = views
|
||||
this.cachedEntriesRef = entries
|
||||
this.cachedEntryKeys = entryKeys
|
||||
return views
|
||||
}
|
||||
|
||||
getOrCreate(
|
||||
interiorNodeId: string,
|
||||
widgetName: string,
|
||||
createView: () => TView
|
||||
createView: () => TView,
|
||||
viewKey?: string
|
||||
): TView {
|
||||
const key = this.makeKey(interiorNodeId, widgetName)
|
||||
const key = this.makeKey(interiorNodeId, widgetName, viewKey)
|
||||
const cached = this.viewCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
@@ -70,6 +80,15 @@ export class PromotedWidgetViewManager<TView> {
|
||||
this.invalidateMemoizedList()
|
||||
}
|
||||
|
||||
removeByViewKey(
|
||||
interiorNodeId: string,
|
||||
widgetName: string,
|
||||
viewKey: string
|
||||
): void {
|
||||
this.viewCache.delete(this.makeKey(interiorNodeId, widgetName, viewKey))
|
||||
this.invalidateMemoizedList()
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.viewCache.clear()
|
||||
this.invalidateMemoizedList()
|
||||
@@ -77,10 +96,25 @@ export class PromotedWidgetViewManager<TView> {
|
||||
|
||||
invalidateMemoizedList(): void {
|
||||
this.cachedViews = null
|
||||
this.cachedEntriesRef = null
|
||||
this.cachedEntryKeys = null
|
||||
}
|
||||
|
||||
private makeKey(interiorNodeId: string, widgetName: string): string {
|
||||
return `${interiorNodeId}:${widgetName}`
|
||||
private areEntryKeysEqual(entryKeys: string[]): boolean {
|
||||
if (!this.cachedEntryKeys) return false
|
||||
if (this.cachedEntryKeys.length !== entryKeys.length) return false
|
||||
|
||||
for (let index = 0; index < entryKeys.length; index += 1) {
|
||||
if (this.cachedEntryKeys[index] !== entryKeys[index]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private makeKey(
|
||||
interiorNodeId: string,
|
||||
widgetName: string,
|
||||
viewKey?: string
|
||||
): string {
|
||||
const baseKey = `${interiorNodeId}:${widgetName}`
|
||||
return viewKey ? `${baseKey}:${viewKey}` : baseKey
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,9 @@ export class SubgraphInput extends SubgraphSlot {
|
||||
return
|
||||
}
|
||||
|
||||
this._widget ??= inputWidget
|
||||
// Keep the widget reference in sync with the active upstream widget.
|
||||
// Stale references can appear across nested promotion rebinds.
|
||||
this._widget = inputWidget
|
||||
this.events.dispatch('input-connected', {
|
||||
input: slot,
|
||||
widget: inputWidget,
|
||||
@@ -208,6 +210,8 @@ export class SubgraphInput extends SubgraphSlot {
|
||||
override disconnect(): void {
|
||||
super.disconnect()
|
||||
|
||||
this._widget = undefined
|
||||
|
||||
this.events.dispatch('input-disconnected', { input: this })
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
isPromotedWidgetView
|
||||
} from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
@@ -48,6 +49,11 @@ 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 = {
|
||||
inputName: string
|
||||
interiorNodeId: string
|
||||
widgetName: 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)
|
||||
@@ -78,21 +84,244 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
private _promotedViewManager =
|
||||
new PromotedWidgetViewManager<PromotedWidgetView>()
|
||||
/**
|
||||
* Promotions buffered before this node is attached to a graph (`id === -1`).
|
||||
* They are flushed in `_flushPendingPromotions()` from `_setWidget()` and
|
||||
* `onAdded()`, so construction-time promotions require normal add-to-graph
|
||||
* lifecycle to persist.
|
||||
*/
|
||||
private _pendingPromotions: Array<{
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}> = []
|
||||
|
||||
// Declared as accessor via Object.defineProperty in constructor.
|
||||
// TypeScript doesn't allow overriding a property with get/set syntax,
|
||||
// so we use declare + defineProperty instead.
|
||||
declare widgets: IBaseWidget[]
|
||||
|
||||
private _resolveLinkedPromotionByInputName(
|
||||
inputName: string
|
||||
): { interiorNodeId: string; widgetName: string } | undefined {
|
||||
const resolvedTarget = resolveSubgraphInputTarget(this, inputName)
|
||||
if (!resolvedTarget) return undefined
|
||||
|
||||
return {
|
||||
interiorNodeId: resolvedTarget.nodeId,
|
||||
widgetName: resolvedTarget.widgetName
|
||||
}
|
||||
}
|
||||
|
||||
private _getLinkedPromotionEntries(): LinkedPromotionEntry[] {
|
||||
const linkedEntries: LinkedPromotionEntry[] = []
|
||||
|
||||
// TODO(pr9282): Optimization target. This path runs on widgets getter reads
|
||||
// and resolves each input link chain eagerly.
|
||||
for (const input of this.inputs) {
|
||||
const resolved = this._resolveLinkedPromotionByInputName(input.name)
|
||||
if (!resolved) continue
|
||||
|
||||
linkedEntries.push({ inputName: input.name, ...resolved })
|
||||
}
|
||||
|
||||
const seenEntryKeys = new Set<string>()
|
||||
const deduplicatedEntries = linkedEntries.filter((entry) => {
|
||||
const entryKey = this._makePromotionViewKey(
|
||||
entry.inputName,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName
|
||||
)
|
||||
if (seenEntryKeys.has(entryKey)) return false
|
||||
|
||||
seenEntryKeys.add(entryKey)
|
||||
return true
|
||||
})
|
||||
|
||||
return deduplicatedEntries
|
||||
}
|
||||
|
||||
private _getPromotedViews(): PromotedWidgetView[] {
|
||||
const store = usePromotionStore()
|
||||
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
|
||||
const linkedEntries = this._getLinkedPromotionEntries()
|
||||
const { displayNameByViewKey, reconcileEntries } =
|
||||
this._buildPromotionReconcileState(entries, linkedEntries)
|
||||
|
||||
return this._promotedViewManager.reconcile(entries, (entry) =>
|
||||
createPromotedWidgetView(this, entry.interiorNodeId, entry.widgetName)
|
||||
return this._promotedViewManager.reconcile(reconcileEntries, (entry) =>
|
||||
createPromotedWidgetView(
|
||||
this,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName,
|
||||
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private _syncPromotions(): void {
|
||||
if (this.id === -1) return
|
||||
|
||||
const store = usePromotionStore()
|
||||
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
|
||||
const linkedEntries = this._getLinkedPromotionEntries()
|
||||
const { mergedEntries, shouldPersistLinkedOnly } =
|
||||
this._buildPromotionPersistenceState(entries, linkedEntries)
|
||||
if (!shouldPersistLinkedOnly) return
|
||||
|
||||
const hasChanged =
|
||||
mergedEntries.length !== entries.length ||
|
||||
mergedEntries.some(
|
||||
(entry, index) =>
|
||||
entry.interiorNodeId !== entries[index]?.interiorNodeId ||
|
||||
entry.widgetName !== entries[index]?.widgetName
|
||||
)
|
||||
if (!hasChanged) return
|
||||
|
||||
store.setPromotions(this.rootGraph.id, this.id, mergedEntries)
|
||||
}
|
||||
|
||||
private _buildPromotionReconcileState(
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): {
|
||||
displayNameByViewKey: Map<string, string>
|
||||
reconcileEntries: Array<{
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
viewKey?: string
|
||||
}>
|
||||
} {
|
||||
const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(
|
||||
entries,
|
||||
linkedEntries
|
||||
)
|
||||
const linkedReconcileEntries =
|
||||
this._buildLinkedReconcileEntries(linkedEntries)
|
||||
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries)
|
||||
|
||||
return {
|
||||
displayNameByViewKey: this._buildDisplayNameByViewKey(linkedEntries),
|
||||
reconcileEntries: shouldPersistLinkedOnly
|
||||
? linkedReconcileEntries
|
||||
: [...linkedReconcileEntries, ...fallbackStoredEntries]
|
||||
}
|
||||
}
|
||||
|
||||
private _buildPromotionPersistenceState(
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): {
|
||||
mergedEntries: Array<{ interiorNodeId: string; widgetName: string }>
|
||||
shouldPersistLinkedOnly: boolean
|
||||
} {
|
||||
const { linkedPromotionEntries, fallbackStoredEntries } =
|
||||
this._collectLinkedAndFallbackEntries(entries, linkedEntries)
|
||||
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries)
|
||||
|
||||
return {
|
||||
mergedEntries: shouldPersistLinkedOnly
|
||||
? linkedPromotionEntries
|
||||
: [...linkedPromotionEntries, ...fallbackStoredEntries],
|
||||
shouldPersistLinkedOnly
|
||||
}
|
||||
}
|
||||
|
||||
private _collectLinkedAndFallbackEntries(
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): {
|
||||
linkedPromotionEntries: Array<{
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}>
|
||||
fallbackStoredEntries: Array<{ interiorNodeId: string; widgetName: string }>
|
||||
} {
|
||||
const linkedPromotionEntries = this._toPromotionEntries(linkedEntries)
|
||||
const fallbackStoredEntries = this._getFallbackStoredEntries(
|
||||
entries,
|
||||
linkedPromotionEntries
|
||||
)
|
||||
|
||||
return {
|
||||
linkedPromotionEntries,
|
||||
fallbackStoredEntries
|
||||
}
|
||||
}
|
||||
|
||||
private _shouldPersistLinkedOnly(
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): boolean {
|
||||
return this.inputs.length > 0 && linkedEntries.length === this.inputs.length
|
||||
}
|
||||
|
||||
private _toPromotionEntries(
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): Array<{ interiorNodeId: string; widgetName: string }> {
|
||||
return linkedEntries.map(({ interiorNodeId, widgetName }) => ({
|
||||
interiorNodeId,
|
||||
widgetName
|
||||
}))
|
||||
}
|
||||
|
||||
private _getFallbackStoredEntries(
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
linkedPromotionEntries: Array<{
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}>
|
||||
): Array<{ interiorNodeId: string; widgetName: string }> {
|
||||
const linkedKeys = new Set(
|
||||
linkedPromotionEntries.map((entry) =>
|
||||
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
|
||||
)
|
||||
)
|
||||
return entries.filter(
|
||||
(entry) =>
|
||||
!linkedKeys.has(
|
||||
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private _buildLinkedReconcileEntries(
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): Array<{ interiorNodeId: string; widgetName: string; viewKey: string }> {
|
||||
return linkedEntries.map(({ inputName, interiorNodeId, widgetName }) => ({
|
||||
interiorNodeId,
|
||||
widgetName,
|
||||
viewKey: this._makePromotionViewKey(inputName, interiorNodeId, widgetName)
|
||||
}))
|
||||
}
|
||||
|
||||
private _buildDisplayNameByViewKey(
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): Map<string, string> {
|
||||
return new Map(
|
||||
linkedEntries.map((entry) => [
|
||||
this._makePromotionViewKey(
|
||||
entry.inputName,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName
|
||||
),
|
||||
entry.inputName
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
private _makePromotionEntryKey(
|
||||
interiorNodeId: string,
|
||||
widgetName: string
|
||||
): string {
|
||||
return `${interiorNodeId}:${widgetName}`
|
||||
}
|
||||
|
||||
private _makePromotionViewKey(
|
||||
inputName: string,
|
||||
interiorNodeId: string,
|
||||
widgetName: string
|
||||
): string {
|
||||
return `${inputName}:${interiorNodeId}:${widgetName}`
|
||||
}
|
||||
|
||||
private _resolveLegacyEntry(
|
||||
widgetName: string
|
||||
): [string, string] | undefined {
|
||||
@@ -107,23 +336,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
// Fallback: find via subgraph input slot connection
|
||||
const subgraphInput = this.subgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === widgetName
|
||||
)
|
||||
if (!subgraphInput) return undefined
|
||||
const resolvedTarget = resolveSubgraphInputTarget(this, widgetName)
|
||||
if (!resolvedTarget) return undefined
|
||||
|
||||
for (const linkId of subgraphInput.linkIds) {
|
||||
const link = this.subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
const { inputNode } = link.resolve(this.subgraph)
|
||||
if (!inputNode) continue
|
||||
const targetInput = inputNode.inputs.find((inp) => inp.link === linkId)
|
||||
if (!targetInput) continue
|
||||
const w = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (w) return [String(inputNode.id), w.name]
|
||||
}
|
||||
|
||||
return undefined
|
||||
return [resolvedTarget.nodeId, resolvedTarget.widgetName]
|
||||
}
|
||||
|
||||
/** Manages lifecycle of all subgraph event listeners */
|
||||
@@ -190,6 +406,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (widget) this.ensureWidgetRemoved(widget)
|
||||
|
||||
this.removeInput(e.detail.index)
|
||||
this._syncPromotions()
|
||||
this.setDirtyCanvas(true, true)
|
||||
},
|
||||
{ signal }
|
||||
@@ -309,6 +526,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
widgetLocator,
|
||||
e.detail.node
|
||||
)
|
||||
this._syncPromotions()
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
@@ -325,6 +543,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
delete input.pos
|
||||
delete input.widget
|
||||
input._widget = undefined
|
||||
this._syncPromotions()
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
@@ -469,24 +688,68 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
this._syncPromotions()
|
||||
}
|
||||
|
||||
private _setWidget(
|
||||
subgraphInput: Readonly<SubgraphInput>,
|
||||
input: INodeInputSlot,
|
||||
_widget: Readonly<IBaseWidget>,
|
||||
interiorWidget: Readonly<IBaseWidget>,
|
||||
inputWidget: IWidgetLocator | undefined,
|
||||
interiorNode: LGraphNode
|
||||
) {
|
||||
const nodeId = String(interiorNode.id)
|
||||
const widgetName = _widget.name
|
||||
this._flushPendingPromotions()
|
||||
|
||||
// Add to promotion store
|
||||
usePromotionStore().promote(this.rootGraph.id, this.id, nodeId, widgetName)
|
||||
const nodeId = String(interiorNode.id)
|
||||
const widgetName = interiorWidget.name
|
||||
|
||||
const previousView = input._widget
|
||||
|
||||
if (
|
||||
previousView &&
|
||||
isPromotedWidgetView(previousView) &&
|
||||
(previousView.sourceNodeId !== nodeId ||
|
||||
previousView.sourceWidgetName !== widgetName)
|
||||
) {
|
||||
usePromotionStore().demote(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
previousView.sourceNodeId,
|
||||
previousView.sourceWidgetName
|
||||
)
|
||||
this._removePromotedView(previousView)
|
||||
}
|
||||
|
||||
if (this.id === -1) {
|
||||
if (
|
||||
!this._pendingPromotions.some(
|
||||
(entry) =>
|
||||
entry.interiorNodeId === nodeId && entry.widgetName === widgetName
|
||||
)
|
||||
) {
|
||||
this._pendingPromotions.push({
|
||||
interiorNodeId: nodeId,
|
||||
widgetName
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Add to promotion store
|
||||
usePromotionStore().promote(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
nodeId,
|
||||
widgetName
|
||||
)
|
||||
}
|
||||
|
||||
// Create/retrieve the view from cache
|
||||
const view = this._promotedViewManager.getOrCreate(nodeId, widgetName, () =>
|
||||
createPromotedWidgetView(this, nodeId, widgetName, subgraphInput.name)
|
||||
const view = this._promotedViewManager.getOrCreate(
|
||||
nodeId,
|
||||
widgetName,
|
||||
() =>
|
||||
createPromotedWidgetView(this, nodeId, widgetName, subgraphInput.name),
|
||||
this._makePromotionViewKey(subgraphInput.name, nodeId, widgetName)
|
||||
)
|
||||
|
||||
// NOTE: This code creates linked chains of prototypes for passing across
|
||||
@@ -505,6 +768,26 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
})
|
||||
}
|
||||
|
||||
private _flushPendingPromotions() {
|
||||
if (this.id === -1 || this._pendingPromotions.length === 0) return
|
||||
|
||||
for (const entry of this._pendingPromotions) {
|
||||
usePromotionStore().promote(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName
|
||||
)
|
||||
}
|
||||
|
||||
this._pendingPromotions = []
|
||||
}
|
||||
|
||||
override onAdded(_graph: LGraph): void {
|
||||
this._flushPendingPromotions()
|
||||
this._syncPromotions()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the subgraph slot is in the params before adding the input as normal.
|
||||
* @param name The name of the input slot.
|
||||
@@ -650,6 +933,21 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
}
|
||||
|
||||
private _removePromotedView(view: PromotedWidgetView): void {
|
||||
this._promotedViewManager.remove(view.sourceNodeId, view.sourceWidgetName)
|
||||
// Reconciled views can also be keyed by inputName-scoped view keys.
|
||||
// Remove both key shapes to avoid stale cache entries across promote/rebind flows.
|
||||
this._promotedViewManager.removeByViewKey(
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName,
|
||||
this._makePromotionViewKey(
|
||||
view.name,
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override removeWidget(widget: IBaseWidget): void {
|
||||
this.ensureWidgetRemoved(widget)
|
||||
}
|
||||
@@ -668,10 +966,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
this._promotedViewManager.remove(
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
this._removePromotedView(widget)
|
||||
}
|
||||
for (const input of this.inputs) {
|
||||
if (input._widget === widget) {
|
||||
@@ -683,6 +978,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
widget,
|
||||
subgraphNode: this
|
||||
})
|
||||
|
||||
this._syncPromotions()
|
||||
}
|
||||
|
||||
override onRemoved(): void {
|
||||
|
||||
Reference in New Issue
Block a user