mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
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:
@@ -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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user