fix: stabilize promoted widget identity and fix link position on mode switch

Promoted widget rename propagation:
- Separate identity (name) from display (label) in PromotedWidgetView
  using a stable identityName (subgraphInput.name, e.g. "value_1")
- label getter/setter now reads/writes the bound subgraph slot directly
  via cached getBoundSubgraphSlot(), not widget state
- drawWidget uses this.label with try/finally for safe projected label
- SubgraphNode passes slotName through the reconcile pipeline as
  identityName; rename handler only changes input.label and _widget.label,
  preserving input.widget.name for matching
- renameWidget() no longer propagates to interior node widgets/inputs
- Vue label source changed from widget.slotName to widget.promotedLabel

Link position on draft restore:
- Call handleVueNodeLifecycleReset after initializeWorkflow so the node
  manager initializes against the fully-configured graph

Link position on legacy-to-Vue mode switch:
- Debounce layoutStore.onChange (800ms) to wait for all ResizeObserver
  measurement cycles to settle before resetting, with 3s fallback
This commit is contained in:
jaeone94
2026-03-23 21:13:27 +09:00
parent b1f141d76d
commit 0c062c24c7
7 changed files with 154 additions and 58 deletions

View File

@@ -271,6 +271,32 @@ const handleVueNodeLifecycleReset = async () => {
watch(() => canvasStore.currentGraph, handleVueNodeLifecycleReset)
// Force a full lifecycle reset when switching from legacy to Vue mode.
// Multiple ResizeObservers fire sequentially, so debounce onChange to
// wait until all measurement cycles have settled before resetting.
watch(shouldRenderVueNodes, (enabled) => {
if (enabled && comfyApp.canvas?.graph) {
let timer: ReturnType<typeof setTimeout>
const cleanup = () => {
clearTimeout(timer)
clearTimeout(fallback)
unsub()
}
const unsub = layoutStore.onChange(() => {
clearTimeout(timer)
timer = setTimeout(() => {
cleanup()
handleVueNodeLifecycleReset()
}, 800)
})
// Fallback: if onChange never fires (e.g. empty graph), reset after 3s
const fallback = setTimeout(() => {
cleanup()
handleVueNodeLifecycleReset()
}, 3000)
}
})
watch(
() => canvasStore.isInSubgraph,
async (newValue, oldValue) => {
@@ -560,6 +586,10 @@ onMounted(async () => {
// Restore saved workflow and workflow tabs state
await workflowPersistence.initializeWorkflow()
// Re-initialize Vue node lifecycle after draft restore so the node manager
// is created against the fully-configured graph (not the empty/partial state
// that existed when setupEmptyGraphListener first fired).
await handleVueNodeLifecycleReset()
await workflowPersistence.restoreWorkflowTabsState()
const sharedWorkflowLoadStatus =

View File

@@ -92,6 +92,10 @@ export interface SafeWidgetData {
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
/** For promoted widgets, the display label from the subgraph input slot. */
promotedLabel?: string
}
export interface VueNodeData {
@@ -352,7 +356,8 @@ function safeWidgetMapper(
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
tooltip: widget.tooltip
tooltip: widget.tooltip,
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
}
} catch (error) {
console.warn(

View File

@@ -27,6 +27,12 @@ import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidget
export type { PromotedWidgetView } from './promotedWidgetTypes'
export { isPromotedWidgetView } from './promotedWidgetTypes'
interface SubgraphSlotRef {
name: string
label?: string
displayName?: string
}
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
if (value === undefined) return true
if (typeof value === 'string') return true
@@ -50,14 +56,16 @@ export function createPromotedWidgetView(
nodeId: string,
widgetName: string,
displayName?: string,
disambiguatingSourceNodeId?: string
disambiguatingSourceNodeId?: string,
identityName?: string
): IPromotedWidgetView {
return new PromotedWidgetView(
subgraphNode,
nodeId,
widgetName,
displayName,
disambiguatingSourceNodeId
disambiguatingSourceNodeId,
identityName
)
}
@@ -83,12 +91,17 @@ class PromotedWidgetView implements IPromotedWidgetView {
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
private cachedDeepestFrame = -1
/** Cached reference to the bound subgraph slot, set at construction. */
private _boundSlot?: SubgraphSlotRef
private _boundSlotVersion = -1
constructor(
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string,
readonly disambiguatingSourceNodeId?: string
readonly disambiguatingSourceNodeId?: string,
private readonly identityName?: string
) {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
@@ -100,7 +113,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get name(): string {
return this.displayName ?? this.sourceWidgetName
return this.identityName ?? this.sourceWidgetName
}
get y(): number {
@@ -188,13 +201,51 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get label(): string | undefined {
const state = this.getWidgetState()
return this.displayName ?? state?.label ?? this.sourceWidgetName
const slot = this.getBoundSubgraphSlot()
if (slot) return slot.label ?? slot.displayName ?? slot.name
return this.displayName
}
set label(value: string | undefined) {
const state = this.getWidgetState()
if (state) state.label = value
const slot = this.getBoundSubgraphSlot()
if (!slot) return
slot.label = value || undefined
}
/**
* Returns the cached bound subgraph slot reference, refreshing only when
* the subgraph node's input list has changed (length mismatch).
*
* Note: Using length as the cache key works because the returned reference
* is the same mutable slot object. When slot properties (label, name) change,
* the caller reads fresh values from that reference. The cache only needs
* to invalidate when slots are added or removed, which changes length.
*/
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
const version = this.subgraphNode.inputs?.length ?? 0
if (this._boundSlotVersion === version) return this._boundSlot
this._boundSlot = this.findBoundSubgraphSlot()
this._boundSlotVersion = version
return this._boundSlot
}
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
for (const input of this.subgraphNode.inputs ?? []) {
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
if (!slot) continue
const w = input._widget
if (
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === this.sourceNodeId &&
w.sourceWidgetName === this.sourceWidgetName
) {
return slot
}
}
return undefined
}
get hidden(): boolean {
@@ -237,27 +288,28 @@ class PromotedWidgetView implements IPromotedWidgetView {
const originalY = projected.y
const originalComputedHeight = projected.computedHeight
const originalComputedDisabled = projected.computedDisabled
const originalLabel = projected.label
projected.y = this.y
projected.computedHeight = this.computedHeight
projected.computedDisabled = this.computedDisabled
projected.value = this.value
if (this.displayName) {
projected.label = this.displayName
projected.label = this.label
try {
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
} finally {
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
projected.label = originalLabel
}
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
projected.label = originalLabel
}
onPointerDown(

View File

@@ -1,6 +1,9 @@
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
type ViewManagerEntry = PromotedWidgetSource & { viewKey?: string }
type ViewManagerEntry = PromotedWidgetSource & {
viewKey?: string
slotName?: string
}
type CreateView<TView> = (entry: ViewManagerEntry) => TView

View File

@@ -63,6 +63,8 @@ workflowSvg.src =
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.
@@ -192,6 +194,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
slotName: subgraphInput.name,
sourceNodeId: boundWidget.sourceNodeId,
sourceWidgetName: boundWidget.sourceWidgetName
})
@@ -206,6 +209,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
slotName: subgraphInput.name,
...resolved
})
}
@@ -277,7 +281,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
entry.sourceNodeId,
entry.sourceWidgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined,
entry.disambiguatingSourceNodeId
entry.disambiguatingSourceNodeId,
entry.slotName
)
)
@@ -333,6 +338,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
sourceWidgetName: string
viewKey?: string
disambiguatingSourceNodeId?: string
slotName?: string
}>
} {
const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(
@@ -562,17 +568,22 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
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,
@@ -780,9 +791,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (!input) throw new Error('Subgraph input not found')
input.label = newName
if (input._widget) {
input._widget.label = newName
}
// Do NOT change input.widget.name — it is the stable internal
// identifier used by onGraphConfigured (widgetInputs.ts) to match
// inputs to widgets. Changing it to the display label would cause
// collisions when two promoted inputs share the same label.
// Display is handled via input.label and _widget.label.
if (input._widget) input._widget.label = newName
this._invalidatePromotedViewsCache()
this.graph?.trigger('node:slot-label:changed', {
nodeId: this.id,
@@ -1134,6 +1148,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
/**
* Binds a promoted widget view to a subgraph input slot.
*
* Creates or retrieves a {@link PromotedWidgetView}, registers it in the
* promotion store, sets up the prototype chain for multi-level subgraph
* nesting, and dispatches the `widget-promoted` event.
*/
private _setWidget(
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,
@@ -1187,7 +1208,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
})
}
// Create/retrieve the view from cache
// Create/retrieve the view from cache.
// The cache key uses `input.name` (the slot's internal name) rather
// than `subgraphInput.name` because nested subgraphs may remap
// the internal name independently of the interior node.
const view = this._promotedViewManager.getOrCreate(
nodeId,
widgetName,
@@ -1196,8 +1220,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this,
nodeId,
widgetName,
input.label ?? subgraphInput.name,
sourceNodeId
undefined,
sourceNodeId,
subgraphInput.name
),
this._makePromotionViewKey(
String(subgraphInput.id),
@@ -1211,6 +1236,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// NOTE: This code creates linked chains of prototypes for passing across
// multiple levels of subgraphs. As part of this, it intentionally avoids
// creating new objects. Have care when making changes.
// Use subgraphInput.name as the stable identity — unique per subgraph
// slot, immune to label renames. Matches PromotedWidgetView.name.
// Display is handled via widget.label / PromotedWidgetView.label.
input.widget ??= { name: subgraphInput.name }
input.widget.name = subgraphInput.name
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)

View File

@@ -412,7 +412,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
borderStyle,
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widget.slotName ?? widgetState?.label,
label: widget.promotedLabel ?? widgetState?.label,
linkedUpstream,
options: widgetOptions,
spec: widget.spec

View File

@@ -1,5 +1,3 @@
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -46,31 +44,11 @@ export function renameWidget(
widget: IBaseWidget,
node: LGraphNode,
newLabel: string,
parents?: SubgraphNode[]
_parents?: SubgraphNode[]
): boolean {
if (
isPromotedWidgetView(widget) &&
(parents?.length || node.isSubgraphNode())
) {
const sourceWidget = resolvePromotedWidgetSource(node, widget)
if (!sourceWidget) {
console.error('Could not resolve source widget for promoted widget')
return false
}
const originalWidget = sourceWidget.widget
const interiorNode = sourceWidget.node
originalWidget.label = newLabel || undefined
const interiorInput = interiorNode.inputs?.find(
(inp) => inp.widget?.name === originalWidget.name
)
if (interiorInput) {
interiorInput.label = newLabel || undefined
}
}
// For promoted widgets, only rename the external-facing label.
// Do NOT propagate to interior node widgets/inputs — those are
// implementation details that should remain unchanged.
const input = node.inputs?.find((inp) => inp.widget?.name === widget.name)
widget.label = newLabel || undefined