Compare commits

..

1 Commits

Author SHA1 Message Date
PabloWiedemann
714a11872f feat: add outline button variant
Add a new `outline` variant to the Button component with transparent
background, subtle border stroke, and hover state. Automatically
reflected in Storybook via the existing AllVariants story.
2026-03-21 17:35:50 -07:00
24 changed files with 123 additions and 1499 deletions

View File

@@ -1,237 +0,0 @@
{
"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

@@ -1,215 +0,0 @@
{
"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

@@ -1,207 +0,0 @@
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'
)
})
})
}
)

View File

@@ -1,215 +0,0 @@
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,33 +271,6 @@ 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) => {
@@ -587,10 +560,6 @@ 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

@@ -23,7 +23,9 @@ export const buttonVariants = cva({
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
gradient:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90',
outline:
'border border-solid border-border-subtle bg-transparent text-base-foreground hover:bg-secondary-background-hover'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -55,7 +57,8 @@ const variants = [
'link',
'base',
'overlay-white',
'gradient'
'gradient',
'outline'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = [
'sm',

View File

@@ -197,16 +197,14 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
// Create a PromotedWidgetView with identityName="value" (subgraph input
// Create a PromotedWidgetView with displayName="value" (subgraph input
// slot name) and sourceWidgetName="prompt" (interior widget name).
// PromotedWidgetView.name returns "value" (identityName), but
// safeWidgetMapper sets SafeWidgetData.name to sourceWidgetName ("prompt").
// PromotedWidgetView.name returns "value", but safeWidgetMapper sets
// SafeWidgetData.name to sourceWidgetName ("prompt").
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
'prompt',
undefined,
undefined,
'value'
)
@@ -245,45 +243,6 @@ 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,10 +92,6 @@ 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 {
@@ -356,8 +352,7 @@ function safeWidgetMapper(
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
tooltip: widget.tooltip,
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
tooltip: widget.tooltip
}
} catch (error) {
console.warn(
@@ -808,8 +803,6 @@ 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,21 +86,6 @@ 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,15 +6,13 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
import {
CanvasPointer,
LGraph,
LGraphNode,
LiteGraph,
SubgraphNode
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import type {
CanvasPointerEvent,
ExportedSubgraphInstance,
LGraphCanvas
LGraphCanvas,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -35,10 +33,6 @@ 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: () => ({})
@@ -144,20 +138,7 @@ describe(createPromotedWidgetView, () => {
expect(view.name).toBe('myWidget')
})
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', () => {
test('name uses displayName when provided', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(
subgraphNode,
@@ -165,7 +146,7 @@ describe(createPromotedWidgetView, () => {
'myWidget',
'Custom Label'
)
expect(view.name).toBe('myWidget')
expect(view.name).toBe('Custom Label')
})
test('node getter returns the subgraphNode', () => {
@@ -347,17 +328,17 @@ describe(createPromotedWidgetView, () => {
expect(linkedNode.widgets?.[0].value).toBe('updated')
})
test('label falls back to displayName when no bound slot exists', () => {
test('label falls back to displayName then widgetName', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'myWidget', 'val', () => {})
const bareId = String(innerNode.id)
// No displayName and no bound slot → undefined
// No displayName → falls back to widgetName
const view1 = createPromotedWidgetView(subgraphNode, bareId, 'myWidget')
expect(view1.label).toBeUndefined()
expect(view1.label).toBe('myWidget')
// With displayName but no bound slot → displayName
// With displayName → falls back to displayName
const view2 = createPromotedWidgetView(
subgraphNode,
bareId,
@@ -1007,7 +988,7 @@ describe('SubgraphNode.widgets getter', () => {
expect(secondNode.widgets?.[0].value).toBe('second-updated')
})
test('renaming an input updates label but preserves stable identity name', () => {
test('renaming an input updates linked promoted view display names', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: '*' }]
})
@@ -1031,10 +1012,7 @@ describe('SubgraphNode.widgets getter', () => {
const afterRename = promotedWidgets(subgraphNode)[0]
if (!afterRename) throw new Error('Expected linked promoted view')
// 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')
expect(afterRename.name).toBe('seed_renamed')
})
test('caches view objects across getter calls (stable references)', () => {
@@ -2441,122 +2419,3 @@ 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,12 +27,6 @@ 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
@@ -56,16 +50,14 @@ export function createPromotedWidgetView(
nodeId: string,
widgetName: string,
displayName?: string,
disambiguatingSourceNodeId?: string,
identityName?: string
disambiguatingSourceNodeId?: string
): IPromotedWidgetView {
return new PromotedWidgetView(
subgraphNode,
nodeId,
widgetName,
displayName,
disambiguatingSourceNodeId,
identityName
disambiguatingSourceNodeId
)
}
@@ -91,17 +83,12 @@ 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,
private readonly identityName?: string
readonly disambiguatingSourceNodeId?: string
) {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
@@ -113,7 +100,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get name(): string {
return this.identityName ?? this.sourceWidgetName
return this.displayName ?? this.sourceWidgetName
}
get y(): number {
@@ -201,73 +188,15 @@ 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
return state?.label ?? this.displayName ?? this.sourceWidgetName
}
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
}
@@ -309,27 +238,21 @@ 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
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
}
onPointerDown(

View File

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

View File

@@ -63,8 +63,6 @@ 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.
@@ -194,7 +192,6 @@ 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
})
@@ -209,7 +206,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
slotName: subgraphInput.name,
...resolved
})
}
@@ -281,8 +277,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
entry.sourceNodeId,
entry.sourceWidgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined,
entry.disambiguatingSourceNodeId,
entry.slotName
entry.disambiguatingSourceNodeId
)
)
@@ -338,7 +333,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
sourceWidgetName: string
viewKey?: string
disambiguatingSourceNodeId?: string
slotName?: string
}>
} {
const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(
@@ -568,22 +562,17 @@ 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,
@@ -791,12 +780,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (!input) throw new Error('Subgraph input not found')
input.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
if (input._widget) {
input._widget.label = newName
}
this._invalidatePromotedViewsCache()
this.graph?.trigger('node:slot-label:changed', {
nodeId: this.id,
@@ -1148,13 +1134,6 @@ 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,
@@ -1208,10 +1187,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
})
}
// 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.
// Create/retrieve the view from cache
const view = this._promotedViewManager.getOrCreate(
nodeId,
widgetName,
@@ -1220,9 +1196,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this,
nodeId,
widgetName,
undefined,
sourceNodeId,
subgraphInput.name
input.label ?? subgraphInput.name,
sourceNodeId
),
this._makePromotionViewKey(
String(subgraphInput.id),
@@ -1236,9 +1211,6 @@ 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

@@ -1,3 +1,5 @@
import { useSettingStore } from '@/platform/settings/settingStore'
import type { FeatureSurveyConfig } from './useSurveyEligibility'
/**
@@ -9,7 +11,13 @@ export const FEATURE_SURVEYS: Record<string, FeatureSurveyConfig> = {
featureId: 'node-search',
typeformId: 'goZLqjKL',
triggerThreshold: 3,
delayMs: 5000
delayMs: 5000,
isFeatureActive: () => {
const settingStore = useSettingStore()
return (
settingStore.get('Comfy.NodeSearchBoxImpl') !== 'litegraph (legacy)'
)
}
}
}

View File

@@ -181,6 +181,17 @@ describe('useSurveyEligibility', () => {
expect(isEligible.value).toBe(false)
})
it('is not eligible when isFeatureActive returns false', () => {
setFeatureUsage('test-feature', 5)
const { isEligible } = useSurveyEligibility({
...defaultConfig,
isFeatureActive: () => false
})
expect(isEligible.value).toBe(false)
})
})
describe('actions', () => {

View File

@@ -13,6 +13,7 @@ export interface FeatureSurveyConfig {
triggerThreshold?: number
delayMs?: number
enabled?: boolean
isFeatureActive?: () => boolean
}
interface SurveyState {
@@ -61,8 +62,13 @@ export function useSurveyEligibility(
const hasOptedOut = computed(() => state.value.optedOut)
const isFeatureActive = computed(
() => resolvedConfig.value.isFeatureActive?.() ?? true
)
const isEligible = computed(() => {
if (!isSurveyEnabled.value) return false
if (!isFeatureActive.value) return false
if (!isNightlyLocalhost.value) return false
if (!hasReachedThreshold.value) return false
if (hasSeenSurvey.value) return false

View File

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

View File

@@ -7,6 +7,14 @@ 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()
@@ -92,68 +100,46 @@ describe('renameWidget', () => {
expect(widget.label).toBeUndefined()
})
it('does not propagate rename to interior node widgets or inputs', () => {
const interiorWidget = makeWidget({ name: 'innerSeed', label: undefined })
it('renames promoted widget source when node is a subgraph without explicit parents', () => {
const sourceWidget = makeWidget({ name: 'innerSeed' })
const interiorInput = {
name: 'innerSeed',
label: undefined,
widget: { name: 'innerSeed' }
} as INodeInputSlot
const interiorNode = makeNode({ inputs: [interiorInput] })
// Promoted widget on SubgraphNode exterior
mockedResolve.mockReturnValue({
widget: sourceWidget,
node: interiorNode
})
const promotedWidget = makeWidget({
name: 'seed',
sourceNodeId: '5',
sourceWidgetName: 'innerSeed'
})
const subgraphInput = {
name: 'seed',
widget: { name: 'seed' }
} as INodeInputSlot
const subgraphNode = makeNode({
isSubgraph: true,
inputs: [subgraphInput]
})
const subgraphNode = makeNode({ isSubgraph: true })
const result = renameWidget(promotedWidget, subgraphNode, 'Renamed')
expect(result).toBe(true)
// External label changed
expect(sourceWidget.label).toBe('Renamed')
expect(interiorInput.label).toBe('Renamed')
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('only modifies the matching input, not other inputs', () => {
const widget = makeWidget({ name: 'seed' })
const matchingInput = {
it('does not resolve promoted widget source for non-subgraph node without parents', () => {
const promotedWidget = makeWidget({
name: 'seed',
widget: { name: 'seed' }
} as INodeInputSlot
const otherInput = {
name: 'steps',
widget: { name: 'steps' }
} as INodeInputSlot
const node = makeNode({ inputs: [matchingInput, otherInput] })
sourceNodeId: '5',
sourceWidgetName: 'innerSeed'
})
const node = makeNode({ isSubgraph: false })
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')
const result = renameWidget(promotedWidget, node, 'Renamed')
expect(result).toBe(true)
expect(widget.label).toBe('Renamed')
expect(mockedResolve).not.toHaveBeenCalled()
expect(promotedWidget.label).toBe('Renamed')
})
})

View File

@@ -1,6 +1,7 @@
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'
@@ -45,11 +46,31 @@ export function renameWidget(
widget: IBaseWidget,
node: LGraphNode,
newLabel: string,
_parents?: SubgraphNode[]
parents?: SubgraphNode[]
): boolean {
// 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.
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
}
}
const input = node.inputs?.find((inp) => inp.widget?.name === widget.name)
widget.label = newLabel || undefined
@@ -57,11 +78,6 @@ export function renameWidget(
input.label = newLabel || undefined
}
node.graph?.trigger('node:slot-label:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT
})
return true
}