Compare commits

...

13 Commits

Author SHA1 Message Date
jaeone94
56e86037ed chore: re-apply zoomed-in-ctrl-shift screenshot expectation update 2026-03-24 00:27:49 +09:00
jaeone94
1cdd979932 fix: re-extract Vue node data on slot label change for immediate rename
When a promoted widget is renamed via the sidebar, the
node:slot-label:changed event now triggers a full re-extraction of
Vue node data via extractVueNodeData, ensuring promotedLabel is
recomputed immediately instead of waiting for a subgraph navigation
round-trip.
2026-03-24 00:25:42 +09:00
jaeone94
3e752b3c81 revert: restore unrelated screenshot expectations to previous version
Revert save-image-and-webm-preview, selection-toolbox-multiple-nodes-border,
and zoomed-in-ctrl-shift screenshots that were inadvertently updated by
the automated expectation update. Only promoted-link screenshots should
have changed.
2026-03-24 00:13:15 +09:00
jaeone94
49218f7493 fix: label setter fallback and replace waitForTimeout with polling
- promotedWidgetView label setter: fall back to widgetValueStore when
  no bound subgraph slot exists (fixes app mode rename for freshly
  converted subgraphs where slot binding is not yet established)
- promotedWidgetView label getter: read from widget state as fallback
- E2E: replace waitForTimeout(1000) with expect.poll on localStorage
  draft key presence per Playwright best practices
2026-03-24 00:03:46 +09:00
github-actions
ae0a86102a [automated] Update test expectations 2026-03-23 14:54:25 +00:00
jaeone94
e50b29c0e1 perf: O(1) cache validation for getBoundSubgraphSlot
Replace O(n) isSlotStillBound scan with O(1) identity check on the
owning input's _subgraphSlot reference. Full O(n) scan only runs on
cache miss (after configure replaces slot objects). Prevents per-frame
linear scan through all inputs on every label getter call at 60fps.
2026-03-23 23:30:47 +09:00
jaeone94
f73b1248b0 fix: address code review feedback for promoted widget system
- widgetUtil: emit node:slot-label:changed after rename so Vue node
  manager recomputes promotedLabel without waiting for unrelated rerender
- GraphCanvas: register onCleanup for legacy-to-Vue watcher so timers
  and layoutStore listener are cleaned up on mode flip or unmount
- promotedWidgetView: replace inputs.length cache key with identity
  check (isSlotStillBound) to survive configure() slot replacement;
  match exact widget reference (w === this) before falling back to
  source IDs with disambiguatingSourceNodeId and stable name
2026-03-23 23:06:35 +09:00
jaeone94
5bddd078d3 test: add unit and E2E tests for promoted widget identity and link position
Unit tests:
- widgetUtil: verify rename does not propagate to interior nodes
- promotedWidgetView: identityName vs label separation, fixture-based
  configure round-trip using renamedPromotedLabels fixture
- useGraphNodeManager: promotedLabel field for promoted/regular widgets

E2E tests (subgraphPromotedWidgetLabel):
- Rename non-propagation: interior KSampler widget names unchanged
- Fix existing tests to use widget.label instead of widget.name

E2E tests (subgraphPromotedSlotLinks, new):
- Vue-to-Legacy switch: link endpoints at slot position, not header
- Legacy-to-Vue switch: link endpoints converge after debounced reset
- Draft restore: promoted widgets visible after draft reload
- Screenshot assertions for all three scenarios

New fixtures:
- renamedPromotedLabels.ts: TS fixture for unit test configure()
- subgraph-promoted-linked.json: E2E fixture with external link
2026-03-23 23:01:07 +09:00
jaeone94
e785488fa7 fix: force arrange() on Vue-to-legacy switch to prevent link header fallback
When switching from Vue to legacy mode, drawConnections (background
canvas) can run before drawNode (foreground canvas) in the same frame,
leaving input.pos unset and causing links to fall back to the node
header.  Force arrange() on all nodes immediately before setDirty so
slot positions are ready for the first legacy render pass.
2026-03-23 21:26:46 +09:00
jaeone94
0c062c24c7 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
2026-03-23 21:13:27 +09:00
jaeone94
b1f141d76d fix: improve promoted widget label fix and expand E2E coverage
- Align draw() label override condition with label getter (unconditional
  when displayName is set)
- Remove fragile negative text assertion from Vue Node test
- Add round-trip persistence test (serialize -> reload -> verify labels)
- Add rename-inside-subgraph test (enter subgraph -> rename slot ->
  exit -> verify updated label on SubgraphNode)
2026-03-22 21:48:07 +09:00
jaeone94
7707dbfcd3 test: add E2E test for promoted widget renamed label display
Verify that SubgraphNode promoted widgets display user-renamed labels
(e.g. "my_seed") instead of interior widget names (e.g. "seed") in
both Vue Node and Legacy canvas rendering modes.
2026-03-22 21:20:12 +09:00
jaeone94
b6cafe7d2b fix: prefer user-renamed label over interior widget store label for promoted widgets
The PromotedWidgetView.label getter read from widgetValueStore first,
returning the interior widget's own name (e.g. "value") instead of the
user's renamed label (e.g. "prompt"). Swap priority so displayName
(from input.label) is checked first.

For legacy canvas, the projected interior BaseWidget reads its own
store label directly, so temporarily override it during draw().

For Vue Node rendering, NodeWidgets.vue fetches widgetState from the
store independently; prefer slotName (which carries the renamed label)
over widgetState.label.

Fixes promoted widget label regression introduced by PR #9896/#9928.
2026-03-22 21:06:09 +09:00
20 changed files with 1496 additions and 92 deletions

View File

@@ -0,0 +1,237 @@
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"revision": 0,
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "e5fb1765-0001-4548-801a-5aead34d879e",
"pos": [600, 300],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"label": "my_seed",
"name": "seed",
"type": "INT",
"widget": { "name": "seed" },
"link": 1
},
{
"label": "num_steps",
"name": "steps",
"type": "INT",
"widget": { "name": "steps" },
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"proxyWidgets": [
["1", "seed"],
["1", "steps"]
]
},
"widgets_values": []
},
{
"id": 3,
"type": "PrimitiveNode",
"pos": [200, 350],
"size": [210, 82],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [1]
}
],
"title": "Seed Source",
"properties": {
"Run widget replace on values": false
},
"widgets_values": [42, "fixed"]
}
],
"links": [[1, 3, 0, 2, 1, "INT"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-0001-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 6,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Renamed Labels Subgraph",
"inputNode": {
"id": -10,
"bounding": [200, 300, 120, 100]
},
"outputNode": {
"id": -20,
"bounding": [900, 400, 120, 60]
},
"inputs": [
{
"id": "slot-positive",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [1],
"pos": [220, 320]
},
{
"id": "slot-seed",
"name": "seed",
"type": "INT",
"linkIds": [3],
"label": "my_seed",
"pos": [220, 340]
},
{
"id": "slot-steps",
"name": "steps",
"type": "INT",
"linkIds": [4],
"label": "num_steps",
"pos": [220, 360]
}
],
"outputs": [
{
"id": "slot-latent-out",
"name": "LATENT",
"type": "LATENT",
"linkIds": [2],
"pos": [920, 420]
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [500, 200],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"name": "seed",
"type": "INT",
"widget": { "name": "seed" },
"link": 3
},
{
"name": "steps",
"type": "INT",
"widget": { "name": "steps" },
"link": 4
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [2]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 3,
"origin_id": -10,
"origin_slot": 1,
"target_id": 1,
"target_slot": 4,
"type": "INT"
},
{
"id": 4,
"origin_id": -10,
"origin_slot": 2,
"target_id": 1,
"target_slot": 5,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"frontendVersion": "1.41.21"
},
"version": 0.4
}

View File

@@ -0,0 +1,215 @@
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "e5fb1765-0001-4548-801a-5aead34d879e",
"pos": [400, 300],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"label": "my_seed",
"name": "seed",
"type": "INT",
"widget": { "name": "seed" },
"link": null
},
{
"label": "num_steps",
"name": "steps",
"type": "INT",
"widget": { "name": "steps" },
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"proxyWidgets": [
["1", "seed"],
["1", "steps"]
]
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-0001-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 6,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Renamed Labels Subgraph",
"inputNode": {
"id": -10,
"bounding": [200, 300, 120, 100]
},
"outputNode": {
"id": -20,
"bounding": [900, 400, 120, 60]
},
"inputs": [
{
"id": "slot-positive",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [1],
"pos": [220, 320]
},
{
"id": "slot-seed",
"name": "seed",
"type": "INT",
"linkIds": [3],
"label": "my_seed",
"pos": [220, 340]
},
{
"id": "slot-steps",
"name": "steps",
"type": "INT",
"linkIds": [4],
"label": "num_steps",
"pos": [220, 360]
}
],
"outputs": [
{
"id": "slot-latent-out",
"name": "LATENT",
"type": "LATENT",
"linkIds": [2],
"pos": [920, 420]
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [500, 200],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"name": "seed",
"type": "INT",
"widget": { "name": "seed" },
"link": 3
},
{
"name": "steps",
"type": "INT",
"widget": { "name": "steps" },
"link": 4
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [2]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 3,
"origin_id": -10,
"origin_slot": 1,
"target_id": 1,
"target_slot": 4,
"type": "INT"
},
{
"id": 4,
"origin_id": -10,
"origin_slot": 2,
"target_id": 1,
"target_slot": 5,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"frontendVersion": "1.41.21"
},
"version": 0.4
}

View File

@@ -0,0 +1,207 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
/**
* Returns the link endpoint Y position for a specific input slot on a node.
* Compares against the node's header Y to detect header-fallback.
*/
async function getLinkTargetY(
comfyPage: ComfyPage,
nodeId: string,
inputIndex: number
): Promise<{ slotY: number; headerY: number }> {
return comfyPage.page.evaluate(
([id, idx]) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found`)
const slotPos = node.getConnectionPos(true, idx)
return {
slotY: slotPos[1],
headerY: node.pos[1]
}
},
[nodeId, inputIndex] as const
)
}
const LINKED_WORKFLOW = 'subgraphs/subgraph-promoted-linked'
const SUBGRAPH_NODE_ID = '2'
// Input index 1 = "seed" slot (index 0 = "positive", non-widget)
const SEED_INPUT_INDEX = 1
test.describe(
'Subgraph promoted widget link position on mode switch',
{ tag: ['@subgraph', '@canvas', '@screenshot'] },
() => {
test.describe('Vue-to-Legacy switch', () => {
test('Link endpoints render at correct slot position, not header', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(LINKED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// Switch to legacy
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
// Link endpoint should NOT be at header position
await expect
.poll(async () => {
const { slotY, headerY } = await getLinkTargetY(
comfyPage,
SUBGRAPH_NODE_ID,
SEED_INPUT_INDEX
)
return slotY - headerY
})
.toBeGreaterThan(20)
await expect(comfyPage.canvas).toHaveScreenshot(
'promoted-link-vue-to-legacy.png'
)
})
})
test.describe('Legacy-to-Vue switch', () => {
test('Link endpoints converge to correct slot position after mode switch', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', true)
// Load subgraph workflow and wait for draft to persist
await comfyPage.workflow.loadWorkflow(LINKED_WORKFLOW)
await comfyPage.nextFrame()
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const keys = Object.keys(localStorage)
return keys.some((k) =>
k.startsWith('Comfy.Workflow.Draft.v2:')
)
}),
{ timeout: 3000 }
)
.toBe(true)
// Switch to legacy and reload — app restores draft in legacy mode
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.page.reload({ waitUntil: 'networkidle' })
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
await comfyPage.page.waitForSelector('.p-blockui-mask', {
state: 'hidden'
})
await comfyPage.nextFrame()
// Switch to Vue
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
// Wait for debounced lifecycle reset (800ms + margin)
await expect
.poll(
async () => {
const { slotY, headerY } = await getLinkTargetY(
comfyPage,
SUBGRAPH_NODE_ID,
SEED_INPUT_INDEX
)
return slotY - headerY
},
{ timeout: 5000 }
)
.toBeGreaterThan(20)
await expect(comfyPage.canvas).toHaveScreenshot(
'promoted-link-legacy-to-vue.png'
)
})
})
test.describe('Draft restore', () => {
test('Link endpoints are correct when app restores a draft workflow on startup', async ({
comfyPage
}) => {
// 1. Enable Vue mode and workflow persistence
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', true)
// 2. Load the subgraph workflow — persistence auto-saves a draft
await comfyPage.workflow.loadWorkflow(LINKED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// 3. Wait for debounced draft persistence to complete
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const keys = Object.keys(localStorage)
return keys.some((k) =>
k.startsWith('Comfy.Workflow.Draft.v2:')
)
}),
{ timeout: 3000 }
)
.toBe(true)
// 4. Reload — app restores the draft via tryLoadGraph (single configure)
await comfyPage.page.reload({ waitUntil: 'networkidle' })
// Wait for app to be ready (same checks as setup() but without navigation)
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
await comfyPage.page.waitForSelector('.p-blockui-mask', {
state: 'hidden'
})
await comfyPage.nextFrame()
// 5. Verify the draft was restored with the subgraph workflow
await expect
.poll(
async () => {
return comfyPage.page.evaluate(() => {
const graph = window.app!.canvas?.graph
if (!graph) return null
const sgNode = graph._nodes.find((n) => n.isSubgraphNode())
if (!sgNode) return null
return sgNode.widgets?.length ?? 0
})
},
{ timeout: 10000 }
)
.toBeGreaterThanOrEqual(2)
// 6. Verify promoted widget labels are visible in Vue DOM
await comfyPage.vueNodes.waitForNodes()
const sgNodeId = await comfyPage.page.evaluate(() => {
const sgNode = window.app!.canvas!.graph!._nodes.find((n) =>
n.isSubgraphNode()
)
return sgNode ? String(sgNode.id) : null
})
expect(sgNodeId).not.toBeNull()
const vueNode = comfyPage.vueNodes.getNodeLocator(sgNodeId!)
await expect(vueNode).toBeVisible()
const nodeBody = vueNode.locator(
`[data-testid="node-body-${sgNodeId}"]`
)
await expect(nodeBody).toContainText('my_seed')
await expect(comfyPage.canvas).toHaveScreenshot(
'promoted-link-draft-restore.png'
)
})
})
}
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -0,0 +1,215 @@
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
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'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
/** Read interior node widget names from the subgraph without navigating into it. */
async function getInteriorWidgetNames(
comfyPage: ComfyPage,
subgraphNodeId: string,
interiorType: string
): Promise<string[]> {
return comfyPage.page.evaluate(
([nodeId, type]) => {
const sgNode = window.app!.canvas.graph!.getNodeById(
nodeId
) as unknown as SubgraphNode
const subgraph = sgNode?.subgraph
if (!subgraph) throw new Error('No subgraph found')
const interior = (subgraph._nodes as LGraphNode[]).find(
(n) => n.type === type
)
return ((interior?.widgets ?? []) as IBaseWidget[]).map((w) => w.name)
},
[subgraphNodeId, interiorType] as const
)
}
test.describe(
'Subgraph Promoted Widget Renamed Labels',
{ tag: ['@subgraph', '@widget'] },
() => {
const WORKFLOW = 'subgraphs/subgraph-with-renamed-promoted-labels'
const SUBGRAPH_NODE_ID = '2'
test.describe('Vue Node rendering', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Promoted widgets display user-renamed labels instead of interior widget names', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const subgraphNode = comfyPage.vueNodes.getNodeLocator(SUBGRAPH_NODE_ID)
await expect(subgraphNode).toBeVisible()
const nodeBody = subgraphNode.locator(
`[data-testid="node-body-${SUBGRAPH_NODE_ID}"]`
)
await expect(nodeBody).toBeVisible()
// The promoted widgets should display the renamed labels
await expect(nodeBody).toContainText('my_seed')
await expect(nodeBody).toContainText('num_steps')
})
})
test.describe('Round-trip persistence', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Renamed labels survive serialize -> loadGraphData round-trip', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const serialized = await comfyPage.page.evaluate(() => {
return window.app!.graph!.serialize()
})
await comfyPage.page.evaluate((workflow: ComfyWorkflowJSON) => {
return window.app!.loadGraphData(workflow)
}, serialized as ComfyWorkflowJSON)
await comfyPage.nextFrame()
const widgetLabels = await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)
return (node?.widgets ?? []).map((w) => w.label)
}, SUBGRAPH_NODE_ID)
expect(widgetLabels).toContain('my_seed')
expect(widgetLabels).toContain('num_steps')
})
})
test.describe('Rename inside subgraph', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Renaming a promoted input slot inside the subgraph updates the label on the SubgraphNode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const subgraphNode =
await comfyPage.nodeOps.getNodeRefById(SUBGRAPH_NODE_ID)
await subgraphNode.navigateIntoSubgraph()
// Rename the "seed" input slot (currently labeled "my_seed") to "renamed_seed"
await comfyPage.subgraph.rightClickInputSlot('seed')
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector('.graphdialog input', {
state: 'visible'
})
await comfyPage.page.fill('.graphdialog input', 'renamed_seed')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
// Navigate back to root graph
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify the promoted widget now shows the new label
const widgetLabels = await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)
return (node?.widgets ?? []).map((w) => w.label)
}, SUBGRAPH_NODE_ID)
expect(widgetLabels).toContain('renamed_seed')
})
})
test.describe('Rename non-propagation', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Renaming a promoted widget does not change interior node widget names', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
// Read interior widget names via the subgraph object (no navigation needed)
const interiorWidgetsBefore = await getInteriorWidgetNames(
comfyPage,
SUBGRAPH_NODE_ID,
'KSampler'
)
// Rename "seed" slot from root graph
const subgraphNode =
await comfyPage.nodeOps.getNodeRefById(SUBGRAPH_NODE_ID)
await subgraphNode.navigateIntoSubgraph()
await comfyPage.subgraph.rightClickInputSlot('seed')
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector('.graphdialog input', {
state: 'visible'
})
await comfyPage.page.fill('.graphdialog input', 'totally_new_name')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
// Navigate back to root
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify interior widget names are unchanged
const interiorWidgetsAfter = await getInteriorWidgetNames(
comfyPage,
SUBGRAPH_NODE_ID,
'KSampler'
)
expect(interiorWidgetsAfter).toEqual(interiorWidgetsBefore)
})
})
test.describe('Legacy Node rendering', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
})
test('Promoted widgets display user-renamed labels on legacy canvas', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
// Verify the widget labels via the PromotedWidgetView name property
const widgetNames = await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)
return (node?.widgets ?? []).map((w) => ({
name: w.name,
label: w.label
}))
}, SUBGRAPH_NODE_ID)
// name is now the stable identity (e.g. "seed"), label is the user rename
const seedWidget = widgetNames.find((w) => w.name === 'seed')
const stepsWidget = widgetNames.find((w) => w.name === 'steps')
expect(seedWidget).toBeDefined()
expect(stepsWidget).toBeDefined()
expect(seedWidget?.label).toBe('my_seed')
expect(stepsWidget?.label).toBe('num_steps')
})
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -271,6 +271,33 @@ 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, _oldEnabled, onCleanup) => {
if (enabled && comfyApp.canvas?.graph) {
let timer: ReturnType<typeof setTimeout> | undefined
const cleanup = () => {
clearTimeout(timer)
clearTimeout(fallback)
unsub()
}
const unsub = layoutStore.onChange(() => {
clearTimeout(timer)
timer = setTimeout(() => {
cleanup()
void handleVueNodeLifecycleReset()
}, 800)
})
// Fallback: if onChange never fires (e.g. empty graph), reset after 3s
const fallback = setTimeout(() => {
cleanup()
void handleVueNodeLifecycleReset()
}, 3000)
onCleanup(cleanup)
}
})
watch(
() => canvasStore.isInSubgraph,
async (newValue, oldValue) => {
@@ -560,6 +587,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

@@ -197,14 +197,16 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
// Create a PromotedWidgetView with displayName="value" (subgraph input
// Create a PromotedWidgetView with identityName="value" (subgraph input
// slot name) and sourceWidgetName="prompt" (interior widget name).
// PromotedWidgetView.name returns "value", but safeWidgetMapper sets
// SafeWidgetData.name to sourceWidgetName ("prompt").
// PromotedWidgetView.name returns "value" (identityName), but
// safeWidgetMapper sets SafeWidgetData.name to sourceWidgetName ("prompt").
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
'prompt',
undefined,
undefined,
'value'
)
@@ -243,6 +245,45 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
it('sets promotedLabel from widget.label for promoted widget views', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: '*' }]
})
const interiorNode = new LGraphNode('interior')
interiorNode.addWidget('number', 'seed', 42, () => undefined, {})
const interiorInput = interiorNode.addInput('seed', '*')
interiorInput.widget = { name: 'seed' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 200 })
const graph = subgraphNode.graph!
graph.add(subgraphNode)
// Rename the subgraph input to simulate user rename
const subgraphInput = subgraph.inputs[0]
if (subgraphInput) subgraph.renameInput(subgraphInput, 'my_seed')
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const promotedWidget = nodeData?.widgets?.find(
(w) => w.promotedLabel !== undefined
)
expect(promotedWidget).toBeDefined()
expect(promotedWidget?.promotedLabel).toBe('my_seed')
})
it('does not set promotedLabel for regular widgets', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))
const regularWidget = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(regularWidget).toBeDefined()
expect(regularWidget?.promotedLabel).toBeUndefined()
})
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [

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(
@@ -803,6 +808,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so promotedLabel reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}

View File

@@ -86,6 +86,21 @@ function useVueNodeLifecycleIndividual() {
() => !shouldRenderVueNodes.value,
() => {
disposeNodeManagerAndSyncs()
// Force arrange() on all nodes so input.pos is computed before
// the first legacy drawConnections frame (which may run before
// drawNode on the foreground canvas).
const graph = comfyApp.canvas?.graph
if (graph) {
for (const node of graph._nodes) {
try {
if (!node.flags.collapsed) node.arrange()
} catch {
/* skip nodes not fully initialized */
}
}
}
comfyApp.canvas?.setDirty(true, true)
}
)

View File

@@ -6,13 +6,15 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
import {
CanvasPointer,
LGraph,
LGraphNode,
LiteGraph
LiteGraph,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type {
CanvasPointerEvent,
LGraphCanvas,
SubgraphNode
ExportedSubgraphInstance,
LGraphCanvas
} from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -33,6 +35,10 @@ import {
resetSubgraphFixtureState,
setupComplexPromotionFixture
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import {
renamedPromotedLabels,
SUBGRAPH_UUID
} from '@/lib/litegraph/src/__fixtures__/renamedPromotedLabels'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
@@ -138,7 +144,20 @@ describe(createPromotedWidgetView, () => {
expect(view.name).toBe('myWidget')
})
test('name uses displayName when provided', () => {
test('name uses identityName when provided', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(
subgraphNode,
'1',
'myWidget',
'Custom Label',
undefined,
'value_1'
)
expect(view.name).toBe('value_1')
})
test('name falls back to sourceWidgetName when identityName is not provided', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(
subgraphNode,
@@ -146,7 +165,7 @@ describe(createPromotedWidgetView, () => {
'myWidget',
'Custom Label'
)
expect(view.name).toBe('Custom Label')
expect(view.name).toBe('myWidget')
})
test('node getter returns the subgraphNode', () => {
@@ -328,17 +347,17 @@ describe(createPromotedWidgetView, () => {
expect(linkedNode.widgets?.[0].value).toBe('updated')
})
test('label falls back to displayName then widgetName', () => {
test('label falls back to displayName when no bound slot exists', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'myWidget', 'val', () => {})
const bareId = String(innerNode.id)
// No displayName → falls back to widgetName
// No displayName and no bound slot → undefined
const view1 = createPromotedWidgetView(subgraphNode, bareId, 'myWidget')
expect(view1.label).toBe('myWidget')
expect(view1.label).toBeUndefined()
// With displayName → falls back to displayName
// With displayName but no bound slot → displayName
const view2 = createPromotedWidgetView(
subgraphNode,
bareId,
@@ -988,7 +1007,7 @@ describe('SubgraphNode.widgets getter', () => {
expect(secondNode.widgets?.[0].value).toBe('second-updated')
})
test('renaming an input updates linked promoted view display names', () => {
test('renaming an input updates label but preserves stable identity name', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: '*' }]
})
@@ -1012,7 +1031,10 @@ describe('SubgraphNode.widgets getter', () => {
const afterRename = promotedWidgets(subgraphNode)[0]
if (!afterRename) throw new Error('Expected linked promoted view')
expect(afterRename.name).toBe('seed_renamed')
// name (identity) stays stable — used for slot matching
expect(afterRename.name).toBe('seed')
// label reflects the user-facing rename
expect(afterRename.label).toBe('seed_renamed')
})
test('caches view objects across getter calls (stable references)', () => {
@@ -2419,3 +2441,122 @@ describe('DOM widget promotion', () => {
)
})
})
describe('fixture: renamed promoted labels', () => {
class WidgetNode extends LGraphNode {
constructor() {
super('WidgetNode')
const seedInput = this.addInput('seed', 'INT')
seedInput.widget = { name: 'seed' }
const stepsInput = this.addInput('steps', 'INT')
stepsInput.widget = { name: 'steps' }
this.addOutput('OUTPUT', '*')
this.addWidget('number', 'seed', 0, () => {})
this.addWidget('number', 'steps', 20, () => {})
}
}
function registerSubgraphOnCreated(graph: LGraph) {
graph.events.addEventListener('subgraph-created', (e) => {
const { subgraph, data } = e.detail
const id = data.id
class TestSubgraphNode extends SubgraphNode {
constructor() {
super(graph, subgraph, data as unknown as ExportedSubgraphInstance)
}
}
LiteGraph.registerNodeType(id, TestSubgraphNode)
})
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
LiteGraph.registerNodeType('test/WidgetNode', WidgetNode)
})
afterEach(() => {
LiteGraph.unregisterNodeType('test/WidgetNode')
LiteGraph.unregisterNodeType(SUBGRAPH_UUID)
})
function loadFixture() {
const graph = new LGraph()
registerSubgraphOnCreated(graph)
graph.configure(structuredClone(renamedPromotedLabels))
const subgraphNode = graph._nodes.find(
(n) => n.type === SUBGRAPH_UUID
)! as SubgraphNode
return { graph, subgraphNode }
}
test('promoted widgets have correct identity names after configure', () => {
const { subgraphNode } = loadFixture()
expect(subgraphNode.isSubgraphNode()).toBe(true)
const widgets = subgraphNode.widgets
expect(widgets.length).toBeGreaterThanOrEqual(2)
const names = widgets.map((w) => w.name)
expect(names).toContain('seed')
expect(names).toContain('steps')
})
test('promoted widget labels reflect user-renamed values', () => {
const { subgraphNode } = loadFixture()
const widgets = subgraphNode.widgets
const seedWidget = widgets.find((w) => w.name === 'seed')
const stepsWidget = widgets.find((w) => w.name === 'steps')
expect(seedWidget?.label).toBe('my_seed')
expect(stepsWidget?.label).toBe('num_steps')
})
test('renaming promoted widget does not change identity name', () => {
const { subgraphNode } = loadFixture()
const seedWidget = subgraphNode.widgets.find((w) => w.name === 'seed')!
seedWidget.label = 'renamed_seed'
expect(seedWidget.name).toBe('seed')
expect(seedWidget.label).toBe('renamed_seed')
})
test('interior node widget names are not affected by exterior rename', () => {
const { subgraphNode } = loadFixture()
const seedWidget = subgraphNode.widgets.find((w) => w.name === 'seed')!
seedWidget.label = 'exterior_rename'
const interiorNode = subgraphNode.subgraph!._nodes.find(
(n) => n.type === 'test/WidgetNode'
)!
const interiorSeedWidget = interiorNode.widgets?.find(
(w) => w.name === 'seed'
)
expect(interiorSeedWidget).toBeDefined()
expect(interiorSeedWidget?.name).toBe('seed')
expect(interiorSeedWidget?.label).toBeUndefined()
})
test('renamed labels survive serialize → configure round-trip', () => {
const { graph } = loadFixture()
// Fixture already has renamed labels (my_seed, num_steps).
// Verify they survive a full serialize → configure cycle.
const serialized = graph.serialize()
const graph2 = new LGraph()
registerSubgraphOnCreated(graph2)
graph2.configure(serialized)
const subgraphNode2 = graph2._nodes.find(
(n) => n.type === SUBGRAPH_UUID
)! as SubgraphNode
const seedWidget = subgraphNode2.widgets.find((w) => w.name === 'seed')
const stepsWidget = subgraphNode2.widgets.find((w) => w.name === 'steps')
expect(seedWidget?.label).toBe('my_seed')
expect(stepsWidget?.label).toBe('num_steps')
})
})

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 and its owning input. */
private _boundSlot?: SubgraphSlotRef
private _boundSlotOwner?: { _subgraphSlot?: unknown }
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,15 +201,73 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get label(): string | undefined {
const slot = this.getBoundSubgraphSlot()
if (slot) return slot.label ?? slot.displayName ?? slot.name
const state = this.getWidgetState()
return state?.label ?? this.displayName ?? this.sourceWidgetName
return state?.label ?? this.displayName
}
set label(value: string | undefined) {
const slot = this.getBoundSubgraphSlot()
if (slot) {
slot.label = value || undefined
return
}
// Fallback: write to widget value store when no bound slot exists
const state = this.getWidgetState()
if (state) state.label = value
}
/**
* Returns the cached bound subgraph slot reference, invalidating when the
* owning input's _subgraphSlot identity changes (e.g. after configure
* replaces slot objects). O(1) on cache hit, O(n) on miss.
*/
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
// O(1) check: the owning input still holds the same slot reference
if (
this._boundSlot &&
this._boundSlotOwner &&
this._boundSlotOwner._subgraphSlot === this._boundSlot
) {
return this._boundSlot
}
return this.refreshBoundSubgraphSlot()
}
private refreshBoundSubgraphSlot(): SubgraphSlotRef | undefined {
const result = this.findBoundSubgraphSlot()
this._boundSlot = result?.slot
this._boundSlotOwner = result?.owner
return this._boundSlot
}
private findBoundSubgraphSlot():
| { slot: SubgraphSlotRef; owner: { _subgraphSlot?: unknown } }
| undefined {
for (const input of this.subgraphNode.inputs ?? []) {
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
if (!slot) continue
// Exact identity match first
if (input._widget === this) return { slot, owner: input }
// Fallback: match by source IDs and stable name
const w = input._widget
if (
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === this.sourceNodeId &&
w.sourceWidgetName === this.sourceWidgetName &&
w.disambiguatingSourceNodeId === this.disambiguatingSourceNodeId &&
w.name === this.name
) {
return { slot, owner: input }
}
}
return undefined
}
get hidden(): boolean {
return this.resolveDeepest()?.widget.hidden ?? false
}
@@ -238,21 +309,27 @@ class PromotedWidgetView implements IPromotedWidgetView {
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
projected.label = this.label
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
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
}
}
onPointerDown(

View File

@@ -0,0 +1,189 @@
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
/**
* Subgraph with two promoted widget inputs that have user-renamed labels.
*
* Structure:
* Root graph → SubgraphNode (id 2, type = subgraph UUID)
* Interior: test/WidgetNode (id 1) with widget inputs "seed" and "steps"
*
* Subgraph inputs:
* - "seed" (label: "my_seed") → linked to interior node seed
* - "steps" (label: "num_steps") → linked to interior node steps
*
* The interior node type "test/WidgetNode" must be registered before
* configure() — see the test setup.
*/
export const SUBGRAPH_UUID = 'aaaa0000-0001-4000-8000-000000000001'
export const renamedPromotedLabels: SerialisableGraph = {
id: 'aaaa0000-0000-4000-8000-000000000001',
version: 1,
revision: 0,
state: {
lastNodeId: 2,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
nodes: [
{
id: 2,
type: SUBGRAPH_UUID,
pos: [400, 300],
size: [400, 200],
flags: {},
order: 0,
mode: 0,
inputs: [
{
label: 'my_seed',
name: 'seed',
type: 'INT',
widget: { name: 'seed' },
link: null
},
{
label: 'num_steps',
name: 'steps',
type: 'INT',
widget: { name: 'steps' },
link: null
}
],
outputs: [
{
name: 'OUTPUT',
type: '*',
links: null
}
],
properties: {
proxyWidgets: [
['1', 'seed'],
['1', 'steps']
]
},
widgets_values: []
}
],
links: [],
groups: [],
definitions: {
subgraphs: [
{
id: SUBGRAPH_UUID,
version: 1,
state: {
lastGroupId: 0,
lastNodeId: 1,
lastLinkId: 4,
lastRerouteId: 0
},
revision: 0,
config: {},
name: 'Renamed Labels Subgraph',
inputNode: {
id: -10,
bounding: [200, 300, 120, 100]
},
outputNode: {
id: -20,
bounding: [900, 400, 120, 60]
},
inputs: [
{
id: 'slot-seed',
name: 'seed',
type: 'INT',
linkIds: [1],
label: 'my_seed',
pos: [220, 320]
},
{
id: 'slot-steps',
name: 'steps',
type: 'INT',
linkIds: [2],
label: 'num_steps',
pos: [220, 340]
}
],
outputs: [
{
id: 'slot-out',
name: 'OUTPUT',
type: '*',
linkIds: [3],
pos: [920, 420]
}
],
widgets: [],
nodes: [
{
id: 1,
type: 'test/WidgetNode',
pos: [500, 200],
size: [270, 200],
flags: {},
order: 0,
mode: 0,
inputs: [
{
name: 'seed',
type: 'INT',
widget: { name: 'seed' },
link: 1
},
{
name: 'steps',
type: 'INT',
widget: { name: 'steps' },
link: 2
}
],
outputs: [
{
name: 'OUTPUT',
type: '*',
links: [3]
}
],
properties: {},
widgets_values: [42, 20]
}
],
groups: [],
links: [
{
id: 1,
origin_id: -10,
origin_slot: 0,
target_id: 1,
target_slot: 0,
type: 'INT'
},
{
id: 2,
origin_id: -10,
origin_slot: 1,
target_id: 1,
target_slot: 1,
type: 'INT'
},
{
id: 3,
origin_id: 1,
origin_slot: 0,
target_id: -20,
target_slot: 0,
type: '*'
}
],
extra: {}
}
]
},
config: {},
extra: {}
} as unknown as SerialisableGraph

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: widgetState?.label,
label: widget.promotedLabel ?? widgetState?.label,
linkedUpstream,
options: widgetOptions,
spec: widget.spec

View File

@@ -7,14 +7,6 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { getWidgetDefaultValue, renameWidget } from '@/utils/widgetUtil'
vi.mock('@/core/graph/subgraph/resolvePromotedWidgetSource', () => ({
resolvePromotedWidgetSource: vi.fn()
}))
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
const mockedResolve = vi.mocked(resolvePromotedWidgetSource)
describe('getWidgetDefaultValue', () => {
it('returns undefined for undefined spec', () => {
expect(getWidgetDefaultValue(undefined)).toBeUndefined()
@@ -100,46 +92,68 @@ describe('renameWidget', () => {
expect(widget.label).toBeUndefined()
})
it('renames promoted widget source when node is a subgraph without explicit parents', () => {
const sourceWidget = makeWidget({ name: 'innerSeed' })
it('does not propagate rename to interior node widgets or inputs', () => {
const interiorWidget = makeWidget({ name: 'innerSeed', label: undefined })
const interiorInput = {
name: 'innerSeed',
label: undefined,
widget: { name: 'innerSeed' }
} as INodeInputSlot
const interiorNode = makeNode({ inputs: [interiorInput] })
mockedResolve.mockReturnValue({
widget: sourceWidget,
node: interiorNode
})
// Promoted widget on SubgraphNode exterior
const promotedWidget = makeWidget({
name: 'seed',
sourceNodeId: '5',
sourceWidgetName: 'innerSeed'
})
const subgraphNode = makeNode({ isSubgraph: true })
const subgraphInput = {
name: 'seed',
widget: { name: 'seed' }
} as INodeInputSlot
const subgraphNode = makeNode({
isSubgraph: true,
inputs: [subgraphInput]
})
const result = renameWidget(promotedWidget, subgraphNode, 'Renamed')
expect(result).toBe(true)
expect(sourceWidget.label).toBe('Renamed')
expect(interiorInput.label).toBe('Renamed')
// External label changed
expect(promotedWidget.label).toBe('Renamed')
expect(subgraphInput.label).toBe('Renamed')
// Interior widget and input remain untouched
expect(interiorWidget.label).toBeUndefined()
expect(interiorInput.label).toBeUndefined()
// Interior node was never accessed
expect(interiorNode.inputs[0].label).toBeUndefined()
})
it('does not resolve promoted widget source for non-subgraph node without parents', () => {
const promotedWidget = makeWidget({
it('only modifies the matching input, not other inputs', () => {
const widget = makeWidget({ name: 'seed' })
const matchingInput = {
name: 'seed',
sourceNodeId: '5',
sourceWidgetName: 'innerSeed'
})
const node = makeNode({ isSubgraph: false })
widget: { name: 'seed' }
} as INodeInputSlot
const otherInput = {
name: 'steps',
widget: { name: 'steps' }
} as INodeInputSlot
const node = makeNode({ inputs: [matchingInput, otherInput] })
const result = renameWidget(promotedWidget, node, 'Renamed')
renameWidget(widget, node, 'My Seed')
expect(matchingInput.label).toBe('My Seed')
expect(otherInput.label).toBeUndefined()
})
it('handles node with no inputs gracefully', () => {
const widget = makeWidget({ name: 'seed' })
const node = makeNode({ inputs: [] })
const result = renameWidget(widget, node, 'Renamed')
expect(result).toBe(true)
expect(mockedResolve).not.toHaveBeenCalled()
expect(promotedWidget.label).toBe('Renamed')
expect(widget.label).toBe('Renamed')
})
})

View File

@@ -1,7 +1,6 @@
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 { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -46,31 +45,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
@@ -78,6 +57,11 @@ export function renameWidget(
input.label = newLabel || undefined
}
node.graph?.trigger('node:slot-label:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT
})
return true
}