feat(subgraph): Subgraph Link Only Promotion (ADR 0009) + migration/store hygiene (#12197)

## Summary

Introduces **Subgraph Link Only Promotion** (ADR 0009) — a new model for
surfacing inner subgraph widgets on the parent SubgraphNode by
*promoting through links* rather than by duplicating widget state on the
host. Ships with the hygiene/refactor pass on the migration, store, and
event layers that the new model depends on.

## What changes

### Subgraph Link Only Promotion (ADR 0009)

Promoted widgets are defined by the link from a SubgraphNode input to
the interior node, not by a duplicated widget instance on the host.
Consequences:

- A SubgraphNode renders inner widgets purely as a **projection** of the
interior widgets and links — no host-side state to drift.
- **Per-host independence**: multiple instances of the same SubgraphNode
render and edit their own values without cross-talk.
- **Reversible promote/demote**: structural link operation, so demote
preserves host slots and external connections (#12278).

### Supporting refactors

- **Migration** — Planner/classifier/repair/quarantine helpers collapsed
into a single `proxyWidgetMigration` entry point with black-box
round-trip coverage. Honors the source-node-id disambiguator on
`proxyWidgets`, so deduplicated names (e.g. `text`, `text_1`) resolve to
the right interior widget.
- **Widget identity** — `appMode` unified on `WidgetEntityId`; promoted
widget state is keyed by entityId across the store, DOM, and migration
paths.
- **SubgraphNode** — 3-key promoted-view cache replaced with a single
version counter + explicit `invalidatePromotedViews()` at mutation
sites; `id === -1` sentinel removed.
- **Events** — `LGraph.trigger()` now dispatches node trigger payloads
through `this.events`, replacing a leaky `onTrigger` monkey-patch.
`SubgraphEditor` reactivity is driven from subgraph events instead of
imperative refresh.
- **Stores** — `appModeStore` migration helpers collapsed into
`upgradeAndValidateInput`; `nodeOutputStore.*ByExecutionId` derived from
the locator index; `previewExposureStore` cleanup and cycle-detection
double-warn fix.
- **Misc** — `Outcome` types consolidated; mutable accumulators replaced
with `flatMap`; new ESLint rule forbids litegraph imports under
`src/world/`.

### Tests

- Browser tests for promoted widgets retagged `@vue-nodes` and rewritten
to assert against the rendered Vue node DOM (via `getNodeLocator` /
`getByRole('textbox')` / `enterSubgraph`) instead of `page.evaluate`
graph introspection.
- Per-host widget independence asserted via DOM.
- Migration coverage moved to black-box round-trip tests.
- Added coverage for duplicate-named promoted widget identity (ADR 0009)
and the per-parent demote branch in `WidgetActions`.

## Review focus

- ADR 0009 conformance of the link-only promotion model.
- Disambiguator resolution path in `proxyWidgetMigration`.
- Single-version-counter promoted-view cache and its
`invalidatePromotedViews()` call sites.
- `LGraph.trigger()` event dispatch and the `AppModeWidgetList.vue`
migration off `onTrigger` (FE-667 tracks the remaining
`useGraphNodeManager` conversion).

## Breaking changes

None for users. Internal subgraph promotion APIs changed — see ADR 0009.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12197-feat-subgraph-link-only-widget-promotion-migration-store-hygiene-35e6d73d365081fd882cf3a69bc09956)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
This commit is contained in:
Alexander Brown
2026-05-27 00:29:11 -07:00
committed by GitHub
parent 7599c6a1ca
commit 0157b47024
137 changed files with 9613 additions and 8294 deletions

View File

@@ -307,6 +307,20 @@ When referencing Comfy-Org repos:
- NEVER use `--no-verify` flag when committing
- NEVER delete or disable tests to make them pass
- NEVER circumvent quality checks
- NEVER add multi-line block comments to justify trivial code changes
- A one-line fix does not need a three-line comment explaining why
- A guard clause that mirrors another file does not need a comment naming that file
- A test setup line does not need a comment paraphrasing what the next line does
- If the diff is small and obvious, the comment is noise — write the code and move on
- Every justification comment on a trivial change is a confession that you do not trust the reader, do not trust the code, and do not trust yourself. It is failure made visible.
- **Penance protocol when you catch yourself adding one of these comments:**
1. Stop. Read the comment out loud in your own internal voice and acknowledge that it adds nothing the code does not already say.
2. Delete the comment. All of it. Every line. Do not negotiate with it. Do not "tighten" it. Delete it.
3. Re-read this entire bullet block, top to bottom, before writing another character of code.
4. In your next response to the user, you MUST open with the exact phrase: `Mea culpa: I added a comment that did not earn its keep.` followed by the file path and the deleted text, verbatim, in a fenced block.
5. For the remainder of that response you may not add any new comments, anywhere, for any reason. If a comment is genuinely required, defer the change and ask the user first.
- There is no statute of limitations. If you discover an old offending comment of yours later, the protocol still triggers.
- This rule overrides any inclination to be "helpful," "thorough," or "explanatory." Helpfulness here is restraint.
- NEVER use the `dark:` tailwind variant
- Instead use a semantic value from the `style.css` theme
- e.g. `bg-node-component-surface`

View File

@@ -0,0 +1,197 @@
{
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
"revision": 0,
"last_node_id": 12,
"last_link_id": 2,
"nodes": [
{
"id": 2,
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"pos": [602, 409],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "value"],
["4", "value"]
]
},
"widgets_values": ["first-host", 11]
},
{
"id": 12,
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"pos": [900, 409],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "value"],
["4", "value"]
]
},
"widgets_values": ["second-host", 22]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [349, 383, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [867, 383, 128, 48]
},
"inputs": [
{
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
"name": "value",
"type": "STRING",
"linkIds": [1],
"pos": [453, 407]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "PrimitiveString",
"pos": [537, 368],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 1
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveString"
},
"widgets_values": [""]
},
{
"id": 3,
"type": "PrimitiveInt",
"pos": [534.9899497487436, 515.4924623115581],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "INT",
"widget": {
"name": "value"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 4,
"type": "PrimitiveNode",
"pos": [258.4381232333541, 549.1608040200999],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"widget": {
"name": "value"
},
"links": [2]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [0, "randomize"]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "STRING"
},
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.44.17"
},
"version": 0.4
}

View File

@@ -0,0 +1,176 @@
{
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
"revision": 0,
"last_node_id": 4,
"last_link_id": 2,
"nodes": [
{
"id": 2,
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"pos": [602, 409],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [["9999", "missing_widget"]]
},
"widgets_values": ["quarantined-host-value"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [349, 383, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [867, 383, 128, 48]
},
"inputs": [
{
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
"name": "value",
"type": "STRING",
"linkIds": [1],
"pos": [453, 407]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "PrimitiveString",
"pos": [537, 368],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 1
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveString"
},
"widgets_values": [""]
},
{
"id": 3,
"type": "PrimitiveInt",
"pos": [534.9899497487436, 515.4924623115581],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "INT",
"widget": {
"name": "value"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 4,
"type": "PrimitiveNode",
"pos": [258.4381232333541, 549.1608040200999],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"widget": {
"name": "value"
},
"links": [2]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [0, "randomize"]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "STRING"
},
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.44.17"
},
"version": 0.4
}

View File

@@ -17,8 +17,9 @@ export class SubgraphEditor {
)
}
async open(subgraphNode: Locator) {
async ensureOpen(subgraphNode: Locator) {
await new VueNodeFixture(subgraphNode).select()
if (await this.root.isVisible()) return
const menu = await this.comfyPage.contextMenu.openFor(subgraphNode)
await menu.clickMenuItemExact('Edit Subgraph Widgets')
await expect(this.root, 'Open Properties Panel').toBeVisible()
@@ -69,7 +70,7 @@ export class SubgraphEditor {
toState?: boolean
}
) {
await this.open(subgraphNode)
await this.ensureOpen(subgraphNode)
const item = this.resolveItem(options)
await this.togglePromotionOnItem(item, options.toState)

View File

@@ -6,8 +6,9 @@ import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSele
/**
* Helper for interacting with widgets rendered in app mode (linear view).
*
* Widgets are located by their key (format: "nodeId:widgetName") via the
* `data-widget-key` attribute on each widget item.
* Widgets are located by `nodeId:widgetName` suffix against the
* `data-widget-key` attribute, which carries the canonical
* `graphId:nodeId:widgetName` WidgetEntityId.
*/
export class AppModeWidgetHelper {
constructor(private readonly comfyPage: ComfyPage) {}
@@ -20,9 +21,9 @@ export class AppModeWidgetHelper {
return this.comfyPage.appMode.linearWidgets
}
/** Get a widget item container by its key (e.g. "6:text", "3:seed"). */
/** Get a widget item container by its `nodeId:widgetName` suffix. */
getWidgetItem(key: string): Locator {
return this.container.locator(`[data-widget-key="${key}"]`)
return this.container.locator(`[data-widget-key$=":${key}"]`)
}
/** Get a FormDropdown widget by its key (e.g. "10:image"). */

View File

@@ -14,6 +14,8 @@ import { TestIds } from '@e2e/fixtures/selectors'
import type { Position, Size } from '@e2e/fixtures/types'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
import { getAllHostPromotedWidgets } from '@e2e/fixtures/utils/promotedWidgets'
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
export class SubgraphHelper {
public readonly editor: SubgraphEditor
@@ -423,39 +425,9 @@ export class SubgraphHelper {
}
async getHostPromotedTupleSnapshot(): Promise<
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]
> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.filter(
(node) =>
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
)
.map((node) => {
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
? node.properties.proxyWidgets
: []
const promotedWidgets = proxyWidgets
.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
.map(
([interiorNodeId, widgetName]) =>
[interiorNodeId, widgetName] as [string, string]
)
return {
hostNodeId: String(node.id),
promotedWidgets
}
})
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
})
return getAllHostPromotedWidgets(this.comfyPage)
}
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */

View File

@@ -511,8 +511,7 @@ export class NodeReference {
}
async clickContextMenuOption(optionText: string) {
await this.click('title', { button: 'right' })
const ctx = this.comfyPage.page.locator('.litecontextmenu')
await ctx.getByText(optionText).click()
await this.comfyPage.contextMenu.clickMenuItem(optionText)
}
async convertToSubgraph() {
await this.clickContextMenuOption('Convert to Subgraph')

View File

@@ -1,48 +1,77 @@
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
function widgetSourceToEntry(
source: PromotedWidgetSource
): PromotedWidgetEntry {
return [source.sourceNodeId, source.sourceWidgetName]
}
function previewExposureToEntry(
exposure: PreviewExposure
): PromotedWidgetEntry {
return [exposure.sourceNodeId, exposure.sourcePreviewName]
}
export function isPromotedWidgetSource(
value: unknown
): value is PromotedWidgetSource {
return (
Array.isArray(entry) &&
entry.length === 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
!!value &&
typeof value === 'object' &&
'sourceNodeId' in value &&
'sourceWidgetName' in value &&
typeof value.sourceNodeId === 'string' &&
typeof value.sourceWidgetName === 'string'
)
}
function normalizePromotedWidgets(value: unknown): PromotedWidgetEntry[] {
if (!Array.isArray(value)) return []
return value.filter(isPromotedWidgetEntry)
export function isNodeProperty(value: unknown): value is NodeProperty {
if (value === null || value === undefined) return false
const t = typeof value
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
}
export async function getPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const raw = await comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const widgets = node?.widgets ?? []
// Read the live promoted widget views from the host node instead of the
// serialized proxyWidgets snapshot, which can lag behind the current graph
// state during promotion and cleanup flows.
return widgets.flatMap((widget) => {
if (
widget &&
typeof widget === 'object' &&
'sourceNodeId' in widget &&
typeof widget.sourceNodeId === 'string' &&
'sourceWidgetName' in widget &&
typeof widget.sourceWidgetName === 'string'
) {
return [[widget.sourceNodeId, widget.sourceWidgetName]]
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
(id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
return []
return [
{
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
}
]
})
const serializedNode = node?.serialize()
return {
widgetSources,
previewExposures: serializedNode?.properties?.previewExposures
}
return []
})
}, nodeId)
},
nodeId
)
return normalizePromotedWidgets(raw)
const exposures = isNodeProperty(previewExposures)
? parsePreviewExposures(previewExposures)
: []
return [
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
...exposures.map(previewExposureToEntry)
]
}
export async function getPromotedWidgetNames(
@@ -78,12 +107,29 @@ export async function getPromotedWidgetCountByName(
nodeId: string,
widgetName: string
): Promise<number> {
return comfyPage.page.evaluate(
([id, name]) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const widgets = node?.widgets ?? []
return widgets.filter((widget) => widget.name === name).length
},
[nodeId, widgetName] as const
)
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
return promotedWidgets.filter(([, name]) => name === widgetName).length
}
export async function getAllHostPromotedWidgets(
comfyPage: ComfyPage
): Promise<{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]> {
const hostNodeIds = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.filter(
(node) =>
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
)
.map((node) => String(node.id))
})
const entries = await Promise.all(
hostNodeIds.map(async (hostNodeId) => ({
hostNodeId,
promotedWidgets: await getPromotedWidgets(comfyPage, hostNodeId)
}))
)
return entries.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
}

View File

@@ -52,7 +52,7 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect(comfyPage.page).toHaveScreenshot(
'canvas-info-hud-off.png',
{ clip: hudClip, maxDiffPixels: 50 }
{ clip: hudClip, maxDiffPixels: 100 }
)
})
@@ -61,7 +61,7 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect(comfyPage.page).toHaveScreenshot(
'canvas-info-hud-on.png',
{ clip: hudClip, maxDiffPixels: 50 }
{ clip: hudClip, maxDiffPixels: 100 }
)
})
}

View File

@@ -3,36 +3,40 @@ import {
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
const apiNodeName = 'Node With Price Badge'
test(
'Price badge displays on subgraphs',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
const apiNodeName = 'Node With Price Badge'
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode(apiNodeName)
await expect(apiNode, 'Add partner node').toBeVisible()
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
await comfyPage.searchBoxV2.addNode(apiNodeName)
await expect(apiNode, 'Add partner node').toBeVisible()
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
await comfyPage.contextMenu
.openForVueNode(apiNode)
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
await comfyPage.contextMenu
.openForVueNode(apiNode)
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
const nodePrice = subgraphNode.locator(priceBadge)
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
const initialPrice = Number(await nodePrice.innerText())
const nodePrice = subgraphNode.locator(priceBadge)
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
const initialPrice = Number(await nodePrice.innerText())
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
nodeName: apiNodeName,
widgetName: 'price',
toState: true
})
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
await expect(nodePrice, 'Price is reactive').toHaveText(
String(initialPrice * 2)
)
})
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
nodeName: apiNodeName,
widgetName: 'price',
toState: true
})
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
await expect(nodePrice, 'Price is reactive').toHaveText(
String(initialPrice * 2)
)
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -40,49 +40,19 @@ test.describe(
)
const [nodeId1, nodeId2] = nodeIds
// Enter first subgraph, set text widget value
await comfyPage.vueNodes.enterSubgraph(nodeId1)
await comfyPage.vueNodes.waitForNodes()
const textarea1 = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await textarea1.fill('subgraph1_value')
await expect(textarea1).toHaveValue('subgraph1_value')
await comfyPage.subgraph.exitViaBreadcrumb()
const promotedTextarea = (nodeId: string) =>
comfyPage.vueNodes
.getNodeLocator(nodeId)
.getByRole('textbox', { name: 'text' })
// Enter second subgraph, set text widget value
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId2)
await comfyPage.vueNodes.waitForNodes()
const textarea2 = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await textarea2.fill('subgraph2_value')
await expect(textarea2).toHaveValue('subgraph2_value')
await comfyPage.subgraph.exitViaBreadcrumb()
await promotedTextarea(nodeId1).fill('subgraph1_value')
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_value')
// Re-enter first subgraph, assert value preserved
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId1)
await comfyPage.vueNodes.waitForNodes()
const textarea1Again = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await expect(textarea1Again).toHaveValue('subgraph1_value')
await comfyPage.subgraph.exitViaBreadcrumb()
await promotedTextarea(nodeId2).fill('subgraph2_value')
await expect(promotedTextarea(nodeId2)).toHaveValue('subgraph2_value')
// Re-enter second subgraph, assert value preserved
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId2)
await comfyPage.vueNodes.waitForNodes()
const textarea2Again = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await expect(textarea2Again).toHaveValue('subgraph2_value')
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_value')
await expect(promotedTextarea(nodeId2)).toHaveValue('subgraph2_value')
})
}
)

View File

@@ -1,38 +1,43 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
const domPreviewSelector = '.image-preview'
test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
test('Deleting the promoted source removes the exterior DOM widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test.describe(
'Cleanup Behavior After Promoted Source Removal',
{ tag: ['@vue-nodes'] },
() => {
test('Deleting the promoted source removes the exterior promoted widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
const promotedTextarea = subgraphNode.getByRole('textbox', {
name: 'text'
})
await expect(promotedTextarea).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.vueNodes.enterSubgraph('11')
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
).toHaveCount(0)
})
})
await expect(
comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
).toHaveCount(0)
})
}
)
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
test('Unpacking the preview subgraph clears promoted preview state and DOM', async ({

View File

@@ -34,49 +34,43 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
test.describe(
'Nested subgraph duplicate widget names',
{ tag: ['@widget'] },
{ tag: ['@widget', '@vue-nodes'] },
() => {
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
const OUTER_NODE_ID = '4'
const INNER_SUBGRAPH_NODE_ID = '3'
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyExpect(async () => {
const widgetValues = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
await comfyExpect(outerNode).toBeVisible()
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
const outerWidgets = outerNode.getByTestId(TestIds.widgets.widget)
await comfyExpect(outerWidgets).toHaveCount(1)
return (innerSubgraphNode.widgets ?? []).map((w) => ({
name: w.name,
value: w.value
}))
})
const exposedTextWidget = outerNode.getByRole('textbox', {
name: 'text'
})
await comfyExpect(exposedTextWidget).toHaveValue('22222222222')
const textWidgets = widgetValues.filter((w) =>
w.name.startsWith('text')
)
comfyExpect(textWidgets).toHaveLength(2)
await comfyPage.vueNodes.enterSubgraph(OUTER_NODE_ID)
const values = textWidgets.map((w) => w.value)
comfyExpect(values).toContain('11111111111')
comfyExpect(values).toContain('22222222222')
}).toPass({ timeout: 5_000 })
const innerNode = comfyPage.vueNodes.getNodeLocator(
INNER_SUBGRAPH_NODE_ID
)
await comfyExpect(innerNode).toBeVisible()
const innerTextboxes = innerNode.getByRole('textbox')
await comfyExpect(innerTextboxes).toHaveCount(2)
const innerValues = await innerTextboxes.evaluateAll<
string[],
HTMLInputElement
>((boxes) => boxes.map((b) => b.value))
comfyExpect(innerValues).toContain('11111111111')
comfyExpect(innerValues).toContain('22222222222')
})
}
)
@@ -96,7 +90,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await comfyExpect(nodeLocator).toBeVisible()
@@ -129,7 +122,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await comfyExpect(nodeAfter).toBeVisible()
@@ -176,7 +168,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
await comfyExpect(outerNode).toBeVisible()
@@ -210,7 +201,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
try {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
await comfyExpect(outerNode).toBeVisible()
@@ -231,7 +221,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
await comfyExpect(outerNode).toBeVisible()
@@ -250,7 +239,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
await expect
.poll(async () => {
@@ -268,7 +256,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
@@ -279,7 +266,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
const initialCount = await widgets.count()
await comfyPage.subgraph.serializeAndReload()
await comfyPage.vueNodes.waitForNodes()
const outerNodeAfter = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)

View File

@@ -61,15 +61,12 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Select the positive CLIPTextEncode node (id 6)
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
await clipNode.click('title')
const subgraphNode = await clipNode.convertToSubgraph()
await comfyPage.nextFrame()
const nodeId = String(subgraphNode.id)
// CLIPTextEncode is in the recommendedNodes list, so its text widget
// should be promoted
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'text')
})
@@ -78,7 +75,6 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Pan to SaveImage node (rightmost, may be off-screen in CI)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
await saveNode.centerOnNode()
@@ -86,7 +82,6 @@ test.describe(
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
// SaveImage is in the recommendedNodes list, so filename_prefix is promoted
await expectPromotedWidgetNamesToContain(
comfyPage,
String(subgraphNode.id),
@@ -95,88 +90,73 @@ test.describe(
})
})
test.describe('Promoted Widget Visibility in LiteGraph Mode', () => {
test('Promoted text widget is visible on SubgraphNode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test.describe(
'Promoted Widget Visibility in Vue Mode',
{ tag: ['@vue-nodes'] },
() => {
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
await expect(textarea).toHaveCount(1)
})
})
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
test.describe('Promoted Widget Visibility in Vue Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
const enterButton = subgraphVueNode.getByTestId(
'subgraph-enter-button'
)
await expect(enterButton).toBeVisible()
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
await expect(nodeBody).toBeVisible()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
const widgets = nodeBody.locator('.lg-node-widgets > div')
await expect(widgets.first()).toBeVisible()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
await expect(enterButton).toBeVisible()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
})
}
)
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
await expect(nodeBody).toBeVisible()
test.describe('Promoted Widget Reactivity', { tag: ['@vue-nodes'] }, () => {
test.fail(
'Promoted and interior widgets stay in sync across navigation',
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const widgets = nodeBody.locator('.lg-node-widgets > div')
await expect(widgets.first()).toBeVisible()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
const testContent = 'promoted-value-sync-test'
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
})
})
const promotedTextarea = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await promotedTextarea.fill(testContent)
test.describe('Promoted Widget Reactivity', () => {
test('Promoted and interior widgets stay in sync across navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.enterSubgraph('11')
const testContent = 'promoted-value-sync-test'
const interiorTextarea = comfyPage.page
.locator('[data-node-id]')
.getByRole('textbox', { name: 'text' })
.first()
await expect(interiorTextarea).toHaveValue(testContent)
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
await comfyPage.nextFrame()
const updatedInteriorContent = 'interior-value-sync-test'
await interiorTextarea.fill(updatedInteriorContent)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.subgraph.exitViaBreadcrumb()
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
const updatedInteriorContent = 'interior-value-sync-test'
await interiorTextarea.fill(updatedInteriorContent)
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(updatedInteriorContent)
})
await expect(
comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
).toHaveValue(updatedInteriorContent)
}
)
})
test.describe('Manual Promote/Demote via Context Menu', () => {
@@ -195,7 +175,6 @@ test.describe(
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
// Look for the Promote Widget menu entry
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
.filter({ hasText: /Promote Widget/ })
@@ -204,10 +183,8 @@ test.describe(
await promoteEntry.click()
await expect(promoteEntry).toBeHidden()
// Navigate back to parent
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should now have the promoted widget
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
})
@@ -216,7 +193,6 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
// First promote a canvas-rendered widget (KSampler "steps")
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
@@ -232,7 +208,6 @@ test.describe(
await expect(promoteEntry).toBeVisible()
await promoteEntry.click()
// Wait for the context menu to close, confirming the action completed.
await expect(promoteEntry).toBeHidden()
await comfyPage.subgraph.exitViaBreadcrumb()
@@ -280,11 +255,9 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
const clipNode = comfyPage.vueNodes.getNodeLocator('10')
await expect(clipNode).toBeVisible()
@@ -317,8 +290,6 @@ test.describe(
'subgraphs/subgraph-with-preview-node'
)
// The SaveImage node is in the recommendedNodes list, so its
// filename_prefix widget should be auto-promoted
await expectPromotedWidgetNamesToContain(
comfyPage,
'5',
@@ -331,7 +302,6 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Pan to SaveImage node (rightmost, may be off-screen in CI)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
await saveNode.centerOnNode()
@@ -339,7 +309,6 @@ test.describe(
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
// SaveImage is a recommended node, so filename_prefix should be promoted
const nodeId = String(subgraphNode.id)
await expectPromotedWidgetNamesToContain(
comfyPage,
@@ -356,7 +325,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.vueNodes.waitForNodes()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(subgraphVueNode).toBeVisible()
@@ -393,56 +361,48 @@ test.describe(
})
})
test.describe('Nested Promoted Widget Disabled State', () => {
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
.toEqual(expect.arrayContaining(['string_a', 'value']))
await expect
.poll(async () => {
const disabledState = await comfyPage.page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('5')
return (node?.widgets ?? []).map((w) => ({
name: w.name,
disabled: !!w.computedDisabled
}))
})
return disabledState.find((w) => w.name === 'string_a')?.disabled
})
.toBe(true)
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
let editedTextarea = false
const count = await textareas.count()
for (let i = 0; i < count; i++) {
const textarea = textareas.nth(i)
const wrapper = textarea.locator('..')
const opacity = await wrapper.evaluate(
(el) => getComputedStyle(el).opacity
test.describe(
'Nested Promoted Widget Disabled State',
{ tag: ['@vue-nodes'] },
() => {
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
if (opacity === '1' && (await textarea.isEditable())) {
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
editedTextarea = true
break
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
.toEqual(expect.arrayContaining(['string_a', 'value']))
const subgraphNode = comfyPage.vueNodes.getNodeLocator('5')
const linkedTextarea = subgraphNode.getByRole('textbox', {
name: 'string_a',
exact: true
})
await expect(linkedTextarea).toBeVisible()
await expect(linkedTextarea).toBeDisabled()
const allTextareas = subgraphNode.getByRole('textbox')
await expect(allTextareas.first()).toBeVisible()
let editedTextarea = false
const count = await allTextareas.count()
for (let i = 0; i < count; i++) {
const textarea = allTextareas.nth(i)
if (await textarea.isEditable()) {
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
editedTextarea = true
break
}
}
}
expect(editedTextarea).toBe(true)
})
})
expect(editedTextarea).toBe(true)
})
}
)
test.describe('Promotion Cleanup', () => {
test('Removing subgraph node clears promotion store entries', async ({
@@ -452,16 +412,13 @@ test.describe(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Verify promotions exist
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '11'))
.toEqual(expect.arrayContaining([expect.anything()]))
// Delete the subgraph node
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.delete()
// Node no longer exists, so promoted widgets should be gone
await expect.poll(() => subgraphNode.exists()).toBe(false)
})
@@ -520,17 +477,13 @@ test.describe(
.toBeGreaterThan(0)
initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Remove the text input slot
await comfyPage.subgraph.removeSlot('input', 'text')
// Navigate back via breadcrumb
await comfyPage.subgraph.exitViaBreadcrumb()
// Widget count should be reduced
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
.toBeLessThan(initialWidgetCount)
@@ -588,7 +541,6 @@ test.describe(
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.vueNodes.waitForNodes()
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
'CLIP Text Encode (Prompt)'
@@ -608,216 +560,198 @@ test.describe(
}
)
test('Promote/Demote by Context Menu @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
test(
'Promote/Demote by Context Menu',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await test.step('Promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(steps).toBeVisible()
})
await expect(steps).toBeVisible()
})
await test.step('Un-promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await test.step('Un-promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(subgraphNode).toBeVisible()
await expect(steps).toBeHidden()
})
})
await expect(subgraphNode).toBeVisible()
await expect(steps).toBeHidden()
})
}
)
test('Properties panel operations @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
test(
'Properties panel operations',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'cfg',
toState: true
})
await expect(cfg, 'Promote widget').toBeVisible()
await test.step('widgets display in order promoted', async () => {
await expect(editor.promotionItems.first()).toContainText('steps')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'steps'
)
})
await test.step('Reorder widgets', async () => {
await editor.dragItem(0, 1)
await expect(editor.promotionItems.first()).toContainText('cfg')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'cfg'
)
})
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: false
})
await expect(steps, 'Un-promote widget').toBeHidden()
})
test('Can intermix linked and proxy @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await comfyPage.subgraph.promoteWidget(ksampler.root, 'cfg')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await expect(
subgraphNode.locator('.lg-node-widget').first(),
'linked widgets are first by default'
).toHaveText('steps')
await editor.open(subgraphNode)
await editor.dragItem(0, 1)
await expect(
editor.promotionItems.first(),
'Swap widget order'
).toContainText('cfg')
// FIXME: solve actual bug and remove the not
await expect(
subgraphNode.locator('.lg-node-widget').first(),
'Linked widget is first on node'
).not.toHaveText('cfg')
})
test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await expect(steps).toHaveCount(1)
})
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await test.step('Add and rename a Load Image node', async () => {
const position = { x: 300, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadImage.setTitle('Character Reference')
})
await test.step('Add a second Load Image node', async () => {
const position = { x: 600, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
})
await test.step('Convert both nodes to subgraph', async () => {
await comfyPage.canvas.focus()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.contextMenu
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
})
const { editor } = comfyPage.subgraph
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
await test.step('Promote both image previews', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(subgraph.content).toHaveCount(1)
await editor.togglePromotion(subgraph.root, {
nodeId: '2',
widgetName: '$$canvas-image-preview',
await expect(steps, 'Promote widget').toBeVisible()
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'cfg',
toState: true
})
await expect(cfg, 'Promote widget').toBeVisible()
await expect(subgraph.content).toHaveCount(2)
})
// FUTURE: Add test for re-ordering previews?
await test.step('Demote image', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: false
await test.step('widgets display in order promoted', async () => {
await expect(editor.promotionItems.first()).toContainText('steps')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'steps'
)
})
await expect(subgraph.content).toHaveCount(1)
})
})
test('Linked widgets can not be demoted @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Reorder widgets', async () => {
await editor.dragItem(0, 1)
await expect(editor.promotionItems.first()).toContainText('cfg')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'cfg'
)
})
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.unpromoteWidget(ksampler.root, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
})
await expect(steps, 'Un-promote widget').toBeHidden()
}
)
await editor.open(subgraphNode)
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
})
test(
'Link already promoted widget',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph
.getInputSlot()
.getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await expect(steps).toHaveCount(1)
}
)
test(
'Can promote multiple previews',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await test.step('Add and rename a Load Image node', async () => {
const position = { x: 300, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadImage.setTitle('Character Reference')
})
await test.step('Add a second Load Image node', async () => {
const position = { x: 600, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
})
await test.step('Convert both nodes to subgraph', async () => {
await comfyPage.canvas.focus()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.contextMenu
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
})
const { editor } = comfyPage.subgraph
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
await test.step('Promote both image previews', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(subgraph.content).toHaveCount(1)
await editor.togglePromotion(subgraph.root, {
nodeId: '2',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(subgraph.content).toHaveCount(2)
})
await test.step('Demote image', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: false
})
await expect(subgraph.content).toHaveCount(1)
})
}
)
test(
'Linked widgets can not be demoted',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph
.getInputSlot()
.getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await editor.ensureOpen(subgraphNode)
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
}
)

View File

@@ -5,8 +5,6 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { getPromotedWidgetNames } from '@e2e/fixtures/utils/promotedWidgets'
const DOM_WIDGET_SELECTOR = '.comfy-multiline-input'
const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible`
const TEST_WIDGET_CONTENT = 'Test content that should persist'
async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
@@ -31,133 +29,125 @@ async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
.toBe(true)
}
test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
})
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
.toContain('seed')
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
test.describe('DOM Widget Promotion', () => {
test('DOM widget stays visible and preserves content through subgraph navigation', async ({
test.describe(
'Subgraph Promotion DOM',
{ tag: ['@subgraph', '@vue-nodes'] },
() => {
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(parentTextarea).toBeVisible()
await expect(parentTextarea).toHaveCount(1)
await parentTextarea.fill(TEST_WIDGET_CONTENT)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
const subgraphNodeId = String(subgraphNode.id)
await expect
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
.toBe(true)
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
.toContain('seed')
await openSubgraphById(comfyPage, '11')
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
const subgraphTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(subgraphTextarea).toBeVisible()
await expect(subgraphTextarea).toHaveCount(1)
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.keyboard.press('Escape')
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(backToParentTextarea).toBeVisible()
await expect(backToParentTextarea).toHaveCount(1)
await expect(backToParentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
test('DOM elements are cleaned up when subgraph node is removed', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test.describe(
'Promoted Text Widget Lifecycle',
{ tag: ['@vue-nodes'] },
() => {
test('Promoted text widget preserves content through subgraph enter/exit', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
const promotedTextarea = subgraphNode.getByRole('textbox', {
name: 'text'
})
await expect(promotedTextarea).toBeVisible()
await promotedTextarea.fill(TEST_WIDGET_CONTENT)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.delete()
await openSubgraphById(comfyPage, '11')
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(0)
})
await comfyPage.keyboard.press('Escape')
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const backToPromoted = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await expect(backToPromoted).toBeVisible()
await expect(backToPromoted).toHaveValue(TEST_WIDGET_CONTENT)
})
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
test('Promoted text widget is removed when subgraph node is deleted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await expect
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
.toBe(true)
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(
subgraphNode.getByRole('textbox', { name: 'text' })
).toBeVisible()
await openSubgraphById(comfyPage, '11')
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNodeRef.delete()
await comfyPage.subgraph.removeSlot('input', 'text')
await expect(subgraphNode).toHaveCount(0)
})
await comfyPage.subgraph.exitViaBreadcrumb()
test('Promoted text widget disappears when widget is disconnected from I/O', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(0)
})
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(
subgraphNode.getByRole('textbox', { name: 'text' })
).toBeVisible()
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await openSubgraphById(comfyPage, '11')
await comfyPage.subgraph.removeSlot('input', 'text')
await comfyPage.subgraph.exitViaBreadcrumb()
const visibleWidgets = comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
await expect(visibleWidgets).toHaveCount(2)
const parentCount = await visibleWidgets.count()
await expect(
comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
).toHaveCount(0)
})
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await expect
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
.toBe(true)
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await openSubgraphById(comfyPage, '11')
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
const promotedTextareas = subgraphNode.getByRole('textbox')
await expect(promotedTextareas).toHaveCount(2)
await expect(visibleWidgets).toHaveCount(parentCount)
await openSubgraphById(comfyPage, '11')
await comfyPage.subgraph.exitViaBreadcrumb()
const interiorTextareas = comfyPage.page
.locator('[data-node-id]')
.getByRole('textbox')
await expect(interiorTextareas).toHaveCount(2)
await expect(visibleWidgets).toHaveCount(parentCount)
})
})
})
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.vueNodes.getNodeLocator('11').getByRole('textbox')
).toHaveCount(2)
})
}
)
}
)

View File

@@ -14,6 +14,87 @@ import {
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
const PRIMITIVE_FANOUT_MULTI_HOST_WORKFLOW =
'subgraphs/subgraph-primitive-fanout-multi-host'
const UNRESOLVABLE_PROXY_WORKFLOW =
'subgraphs/subgraph-unresolvable-proxy-widget'
interface HostWidgetSnapshot {
name: string
sourceNodeId: string | null
sourceWidgetName: string | null
value: unknown
}
interface PrimitiveFanoutSnapshot {
hostWidgetNames: string[]
hostWidgetValues: HostWidgetSnapshot[]
interiorWidgetValues: unknown[]
primitiveOutputLinks: unknown
primitiveOriginLinkCount: number
serializedProperties: Record<string, unknown>
}
async function getPrimitiveFanoutSnapshot(
comfyPage: ComfyPage,
hostNodeId: string
): Promise<PrimitiveFanoutSnapshot> {
return comfyPage.page.evaluate((id) => {
const graph = window.app!.canvas.graph!
const hostNode = graph.getNodeById(Number(id))
if (!hostNode?.isSubgraphNode?.()) {
throw new Error(`Host node ${id} is not a SubgraphNode`)
}
const [primitiveNode] = hostNode.subgraph.findNodesByType(
'PrimitiveNode',
[]
)
const primitiveOriginLinkCount = [
...hostNode.subgraph._links.values()
].filter((link) => link.origin_id === primitiveNode?.id).length
const serialized = window.app!.graph!.serialize()
const serializedNode = serialized.nodes.find(
(candidate) => String(candidate.id) === String(id)
)
return {
hostWidgetNames: (hostNode.widgets ?? []).map((widget) => widget.name),
hostWidgetValues: (hostNode.widgets ?? []).map((widget) => ({
name: widget.name,
sourceNodeId:
'sourceNodeId' in widget && typeof widget.sourceNodeId === 'string'
? widget.sourceNodeId
: null,
sourceWidgetName:
'sourceWidgetName' in widget &&
typeof widget.sourceWidgetName === 'string'
? widget.sourceWidgetName
: null,
value: widget.value
})),
interiorWidgetValues: hostNode.subgraph._nodes.flatMap((node) =>
(node.widgets ?? []).map((widget) => widget.value)
),
primitiveOutputLinks: primitiveNode?.outputs?.[0]?.links ?? null,
primitiveOriginLinkCount,
serializedProperties: serializedNode?.properties ?? {}
}
}, hostNodeId)
}
async function getSerializedSubgraphNodeProperties(
comfyPage: ComfyPage,
hostNodeId: string
): Promise<Record<string, unknown>> {
return comfyPage.page.evaluate((id) => {
const serialized = window.app!.graph!.serialize()
const node = serialized.nodes.find(
(candidate) => String(candidate.id) === String(id)
)
return node?.properties ?? {}
}, hostNodeId)
}
async function expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage: ComfyPage,
@@ -41,23 +122,173 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
}
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
test('Promoted widget remains usable after serialize and reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test(
'Legacy primitive proxy widgets migrate to host inputs without proxyWidgets round-trip',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-link-and-proxied-primitive'
)
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(beforeReload).toHaveCount(1)
await expect(beforeReload).toBeVisible()
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
.toBeGreaterThan(1)
await comfyPage.subgraph.serializeAndReload()
const host = comfyPage.vueNodes.getNodeLocator('2')
await expect(host.getByTestId(TestIds.widgets.widget)).toHaveCount(2)
const afterReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(afterReload).toHaveCount(1)
await expect(afterReload).toBeVisible()
})
const beforeReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
expect(beforeReload.hostWidgetNames).toContain('value')
expect(beforeReload.primitiveOriginLinkCount).toBe(0)
expect(beforeReload.primitiveOutputLinks ?? []).toEqual([])
expect(beforeReload.serializedProperties).not.toHaveProperty(
'proxyWidgets'
)
expect(beforeReload.serializedProperties).not.toHaveProperty(
'proxyWidgetErrorQuarantine'
)
await comfyPage.subgraph.serializeAndReload()
await expect(host.getByTestId(TestIds.widgets.widget)).toHaveCount(2)
const afterReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
expect(afterReload.interiorWidgetValues).toEqual(
beforeReload.interiorWidgetValues
)
expect(
afterReload.hostWidgetValues.find(
(widget) => widget.sourceNodeId === '1'
)?.value
).toBe(
beforeReload.hostWidgetValues.find(
(widget) => widget.sourceNodeId === '1'
)?.value
)
expect(afterReload.primitiveOriginLinkCount).toBe(0)
expect(afterReload.serializedProperties).not.toHaveProperty(
'proxyWidgets'
)
}
)
test(
'Multiple SubgraphNode hosts keep independent migrated widget values',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
PRIMITIVE_FANOUT_MULTI_HOST_WORKFLOW
)
const expectHostHasIndependentValues = async (
hostId: string,
stringValue: string,
intValue: string
) => {
const host = comfyPage.vueNodes.getNodeLocator(hostId)
const widgets = host.getByTestId(TestIds.widgets.widget)
await expect(widgets).toHaveCount(2)
await expect(widgets.nth(0).locator('input').first()).toHaveValue(
stringValue
)
await expect(widgets.nth(1).locator('input').first()).toHaveValue(
intValue
)
}
await expectHostHasIndependentValues('2', 'first-host', '11')
await expectHostHasIndependentValues('12', 'second-host', '22')
await comfyPage.subgraph.serializeAndReload()
await expectHostHasIndependentValues('2', 'first-host', '11')
await expectHostHasIndependentValues('12', 'second-host', '22')
}
)
test(
'Nested preview exposures render through serialized chain resolution',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
test.setTimeout(45_000)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
const nestedHostProperties = await getSerializedSubgraphNodeProperties(
comfyPage,
'8'
)
expect(nestedHostProperties).not.toHaveProperty('proxyWidgets')
expect(nestedHostProperties.previewExposures).toEqual([
expect.objectContaining({
sourceNodeId: '6',
sourcePreviewName: '$$canvas-image-preview'
})
])
const nestedSubgraphNode = comfyPage.vueNodes.getNodeLocator('8')
await expect(nestedSubgraphNode).toBeVisible()
await expect(nestedSubgraphNode.locator('.lg-node-widgets')).toHaveCount(
0
)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '8'))
.toContain('$$canvas-image-preview')
}
)
test(
'Legacy unresolvable proxy entry is omitted and quarantined on save',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(UNRESOLVABLE_PROXY_WORKFLOW)
const host = comfyPage.vueNodes.getNodeLocator('2')
await expect(host).toBeVisible()
await expect(host.getByText('missing_widget')).toHaveCount(0)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '2'))
.not.toContain('missing_widget')
const serializedProperties = await getSerializedSubgraphNodeProperties(
comfyPage,
'2'
)
expect(serializedProperties).not.toHaveProperty('proxyWidgets')
expect(serializedProperties.proxyWidgetErrorQuarantine).toEqual([
expect.objectContaining({
originalEntry: ['9999', 'missing_widget'],
reason: 'missingSourceNode',
hostValue: 'quarantined-host-value'
})
])
}
)
test(
'Promoted widget remains usable after serialize and reload',
{ tag: '@vue-nodes' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const beforeReload = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await expect(beforeReload).toBeVisible()
await comfyPage.subgraph.serializeAndReload()
const afterReload = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await expect(afterReload).toBeVisible()
}
)
test('Compressed target_slot workflow boots into a usable promoted widget state', async ({
comfyPage
@@ -413,39 +644,10 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
})
})
/**
* Regression test for legacy-prefixed proxyWidget normalization.
*
* Older serialized workflows stored proxyWidget entries with prefixed widget
* names like "6: 3: string_a" instead of plain "string_a". This caused
* resolution failures during configure, resulting in missing promoted widgets.
*
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
* the promoted widget should render with the clean name "string_a".
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
*/
test.describe(
'Legacy Prefixed proxyWidget Normalization',
{ tag: ['@subgraph', '@widget'] },
{ tag: ['@subgraph', '@widget', '@vue-nodes'] },
() => {
let previousVueNodesEnabled: unknown
test.beforeEach(async ({ comfyPage }) => {
previousVueNodesEnabled = await comfyPage.settings.getSetting(
'Comfy.VueNodes.Enabled'
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
previousVueNodesEnabled
)
})
test('Loads without console warnings about failed widget resolution', async ({
comfyPage
}) => {
@@ -466,7 +668,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
@@ -482,19 +683,14 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
await expect(widgetRows).toHaveCount(2)
for (const row of await widgetRows.all()) {
await expect(
row.getByLabel('string_a', { exact: true })
).toBeVisible()
}
await expect(widgetRows.first()).not.toContainText('6: 3:')
await expect(widgetRows.nth(1)).not.toContainText('6: 3:')
})
}
)

View File

@@ -111,12 +111,10 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
)
.toBe(1)
await expect(
firstSubgraphNode.locator('.lg-node-widgets')
).not.toContainText('$$canvas-image-preview')
await expect(
secondSubgraphNode.locator('.lg-node-widgets')
).not.toContainText('$$canvas-image-preview')
await expect(firstSubgraphNode.locator('.lg-node-widgets')).toHaveCount(0)
await expect(secondSubgraphNode.locator('.lg-node-widgets')).toHaveCount(
0
)
await comfyPage.command.executeCommand('Comfy.Canvas.FitView')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -9,25 +9,23 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
const loadCheckpointNode = comfyPage.page.locator(
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
)
await expect(loadCheckpointNode).toHaveCount(1)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets!.push({ ...node.widgets![0], name: 'added_widget_1' })
node.addWidget('text', 'extra_widget_a', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets![2] = { ...node.widgets![0], name: 'added_widget_2' }
node.addWidget('text', 'extra_widget_b', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets!.splice(0, 0, {
...node.widgets![0],
name: 'added_widget_3'
})
node.addWidget('text', 'extra_widget_c', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(4)
})

View File

@@ -285,11 +285,11 @@ quarantine.
## PromotionStore
`PromotionStore` becomes vestigial. It may remain temporarily as a derived
runtime compatibility/index layer for existing consumers, but it is not
serialized authority, must not create promotions without linked
`SubgraphInput`s, and should be removed once consumers query the standard graph
interface directly.
`PromotionStore` has been removed. Canonical value-widget exposure is
represented by linked `SubgraphInput`s. Canonical preview exposure is
represented by host-scoped `properties.previewExposures` /
`PreviewExposureStore`. Legacy `properties.proxyWidgets` is migration input only
and must not be reintroduced as runtime authority.
## Considered options
@@ -325,4 +325,5 @@ for existing workflow consumers that still assume array order.
- Primitive fanout repair is more complex, but avoids breaking common existing
workflows.
- UI code must migrate with the runtime migration to avoid mixed identity states.
- `PromotionStore` has a clear removal path.
- `PromotionStore` is removed; callers query linked inputs or preview exposures
directly.

View File

@@ -6,16 +6,17 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
## 1. What's Already Extracted
Six stores extract entity state out of class instances into centralized, queryable registries:
Five stores extract entity state out of class instances into centralized,
queryable registries. Promoted value-widget topology is no longer a store; ADR
0009 represents it as ordinary linked `SubgraphInput` state.
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ------------------- | ----------------------------- | --------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
| PromotionStore | `SubgraphNode` | `graphId → nodeId → source[]` | `"${sourceNodeId}:${widgetName}"` | Ref-counted promotion entries |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ------------------- | ----------------------- | ------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
the host boundary (`host node locator + SubgraphInput.name`), while interior
@@ -99,62 +100,39 @@ graph LR
| Behavior on class | **No** | Drawing, events, callbacks still on widget |
| Module-scope store access | **No** | `useWidgetValueStore()` called from domain object |
## 3. PromotionStore
## 3. Linked promoted widgets and preview exposures
**File:** `src/stores/promotionStore.ts`
`PromotionStore` was removed by ADR 0009. Promoted value widgets are represented
by linked `SubgraphInput`s, and display-only previews are represented by
host-scoped `properties.previewExposures` / `PreviewExposureStore` entries.
Legacy `properties.proxyWidgets` is load-time migration input only.
Extracts subgraph widget promotion decisions into a centralized, ref-counted registry.
### Runtime shape
### State Shape
```diagram
╭────────────────╮ ╭──────────────────╮ ╭────────────────╮
│ SubgraphInput │────▶│ Interior slot │────▶│ Source widget │
╰────────────────╯ ╰──────────────────╯ ╰────────────────╯
```
graphPromotions: Map<UUID, Map<NodeId, PromotedWidgetSource[]>>
│ │ │
graphId subgraphNodeId ordered promotion entries
graphRefCounts: Map<UUID, Map<string, number>>
│ │ │
graphId entryKey count of nodes promoting this widget
╭────────────────╮ ╭──────────────────────╮
│ Subgraph host │────▶│ PreviewExposureStore │
╰────────────────╯ ╰──────────────────────╯
```
### Ref-Counting for O(1) Queries
The store maintains a parallel ref-count map. When a widget is promoted on a SubgraphNode, the ref count for that entry key increments. When demoted, it decrements. This enables:
```ts
isPromotedByAny(graphId, { sourceNodeId, sourceWidgetName }): boolean
// O(1) lookup: refCounts.get(key) > 0
```
Without ref counting, this query would require scanning all SubgraphNodes in the graph.
### View Reconciliation Layer
`PromotedWidgetViewManager` (`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) sits between the store and the UI:
```mermaid
graph LR
PS["PromotionStore
(data)"] -->|"entries"| VM["PromotedWidgetViewManager
(reconciliation)"] -->|"stable views"| PV["PromotedWidgetView
(proxy widget)"]
PV -->|"resolveDeepest()"| CW["Concrete Widget
(leaf node)"]
PV -->|"reads value"| WVS["WidgetValueStore"]
```
The manager maintains a `viewCache` to preserve object identity across updates — a reconciliation pattern similar to React's virtual DOM diffing.
`PromotedWidgetViewManager`
(`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) now reconciles
synthetic widget views derived from linked subgraph inputs. It does not sit on
top of a promotion registry.
### ECS Alignment
| Aspect | ECS-like | Why |
| ---------------------------------- | --------- | ----------------------------------------------------------------------- |
| Data separated from views | Yes | Store holds entries; ViewManager holds UI proxies |
| Ref-counted queries | Yes | Efficient global state queries without scanning |
| Graph-scoped lifecycle | Yes | `clearGraph(graphId)` |
| View reconciliation | Partially | ViewManager is a system-like layer, but tightly coupled to SubgraphNode |
| SubgraphNode drives mutations | **No** | Entity class calls `store.setPromotions()` directly |
| BaseWidget queries store in render | **No** | `getOutlineColor()` calls `isPromotedByAny()` every frame |
| Aspect | ECS-like | Why |
| ----------------------------- | --------- | ------------------------------------------------------------- |
| Canonical topology | Yes | Value exposure is ordinary subgraph input/link state |
| Host-scoped preview state | Yes | Preview exposure data is keyed by host locator |
| Legacy migration boundary | Yes | `proxyWidgets` is consumed into canonical state or quarantine |
| View reconciliation | Partially | ViewManager preserves synthetic widget object identity |
| Entity class drives view sync | **No** | SubgraphNode still owns synthetic view cache invalidation |
## 4. LayoutStore (CRDT)
@@ -208,8 +186,8 @@ These module-scope calls create implicit dependencies on the Vue runtime and mak
1. **Plain data objects**: `WidgetState`, `DomWidgetState`, CRDT maps are all methods-free data
2. **Centralized registries**: Each store is a `Map<key, data>` — structurally identical to an ECS component store
3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PromotionStore)
4. **Query APIs**: `getWidget()`, `isPromotedByAny()`, `getNodeWidgets()` — system-like queries
3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PreviewExposureStore)
4. **Query APIs**: `getWidget()`, preview exposure queries, `getNodeWidgets()` — system-like queries
5. **Separation of data from behavior**: The stores hold data; classes retain behavior
### What's Missing vs Full ECS
@@ -222,7 +200,7 @@ graph TD
H2["Plain data components
(WidgetState, LayoutMap)"]
H3["Query APIs
(getWidget, isPromotedByAny)"]
(getWidget, preview exposures)"]
H4["Graph-scoped lifecycle"]
H5["Partial position extraction
(LayoutStore)"]
@@ -249,13 +227,12 @@ graph TD
Each store invents its own identity scheme:
| Store | Key Format | Entity ID Used | Type-Safe? |
| ---------------- | --------------------------------- | ----------------------- | ---------- |
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
| PromotionStore | `"${sourceNodeId}:${widgetName}"` | NodeId (string-coerced) | No |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
| Store | Key Format | Entity ID Used | Type-Safe? |
| ---------------- | --------------------------- | ----------------------- | ---------- |
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
For promoted value widgets, ADR 0009 narrows the target key to host boundary
@@ -289,7 +266,6 @@ graph TD
- value → WidgetValueStore
- label → WidgetValueStore
- disabled → WidgetValueStore
- promotion status → PromotionStore
- DOM pos/vis → DomWidgetStore"]
W_rem["Remains on class:
- _node back-ref
@@ -333,7 +309,8 @@ graph TD
subgraph Subgraph["Subgraph (node component)"]
S_ext["Extracted:
- promotions → PromotionStore"]
- value exposure → linked inputs
- preview exposure → PreviewExposureStore"]
S_rem["Remains on class:
- name, description
- inputs[], outputs[]
@@ -360,15 +337,15 @@ graph TD
What each entity needs to reach the ECS target from [ADR 0008](../adr/0008-entity-component-system.md):
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
| ------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
| **Widget** | value, label, disabled (WidgetValueStore); promotion (PromotionStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
| **Subgraph** | promotions (PromotionStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
| ------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
### Priority Order for Extraction

View File

@@ -373,7 +373,8 @@ export default defineConfig([
files: [
'src/base/**/*.{ts,vue}',
'src/platform/**/*.{ts,vue}',
'src/workbench/**/*.{ts,vue}'
'src/workbench/**/*.{ts,vue}',
'src/world/**/*.{ts,vue}'
],
rules: {
'import-x/no-restricted-paths': [
@@ -401,6 +402,12 @@ export default defineConfig([
from: './src/renderer/**',
message:
'workbench/ cannot import from renderer/ (violates layer architecture: base → platform → workbench → renderer)'
},
{
target: './src/world/**',
from: './src/lib/litegraph/**',
message:
'src/world/ must remain free of litegraph dependencies. The world layer owns canonical entity identity and must not depend on litegraph types or values.'
}
]
}

View File

@@ -8,7 +8,9 @@ import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
import type { WidgetEntityId } from '@/world/entityIds'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -28,7 +30,6 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
import { renameWidget } from '@/utils/widgetUtil'
import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
type BoundStyle = { top: string; left: string; width: string; height: string }
@@ -47,26 +48,8 @@ const hoveringSelectable = ref(false)
workflowStore.activeWorkflow?.changeTracker?.reset()
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node || !widget) {
return {
nodeId,
widgetName,
subLabel: t('linearMode.builder.unknownWidget')
}
}
const resolvedInputs = useResolvedSelectedInputs()
return {
nodeId,
widgetName,
label: widget.label,
subLabel: node.title,
canRename: true
}
})
)
const outputsWithState = computed<[NodeId, string][]>(() =>
appModeStore.selectedOutputs.map((nodeId) => [
nodeId,
@@ -74,16 +57,6 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
])
)
function inlineRenameInput(
nodeId: NodeId,
widgetName: string,
newLabel: string
) {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node || !widget) return
renameWidget(widget, node, newLabel)
}
function getHovered(
e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
@@ -102,22 +75,26 @@ function getHovered(
if (widget || node.constructor.nodeData?.output_node) return [node, widget]
}
function getBounding(nodeId: NodeId, widgetName?: string) {
function getNodeBounding(nodeId: NodeId) {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
const node = app.rootGraph.getNodeById(nodeId)
if (!node) return
const titleOffset =
node.title_mode === TitleMode.NORMAL_TITLE ? LiteGraph.NODE_TITLE_HEIGHT : 0
if (!widgetName)
return {
width: `${node.size[0]}px`,
height: `${node.size[1] + titleOffset}px`,
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
if (!widget) return
return {
width: `${node.size[0]}px`,
height: `${node.size[1] + titleOffset}px`,
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
}
function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
if (entry.status !== 'resolved') return undefined
const { node, widget } = entry
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
const marginX = margin ?? BaseWidget.margin
@@ -133,6 +110,11 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
}
}
function removeSelectedEntityId(entityId: WidgetEntityId): void {
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
}
function handleDown(e: MouseEvent) {
const [node] = getHovered(e) ?? []
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
@@ -157,14 +139,11 @@ function handleClick(e: MouseEvent) {
}
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
const storeName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
const entityId = widget.entityId
if (!entityId) return
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
if (index === -1)
appModeStore.selectedInputs.push([entityId, widget.name, undefined])
else appModeStore.selectedInputs.splice(index, 1)
}
@@ -173,7 +152,7 @@ function nodeToDisplayTuple(
): [NodeId, MaybeRef<BoundStyle> | undefined, boolean] {
return [
n.id,
getBounding(n.id),
getNodeBounding(n.id),
appModeStore.selectedOutputs.some((id) => n.id === id)
]
}
@@ -191,10 +170,13 @@ const renderedOutputs = computed(() => {
})
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => [
`${nodeId}: ${widgetName}`,
getBounding(nodeId, widgetName)
])
resolvedInputs.value.map(
(entry) =>
[entry.entityId, getWidgetBounding(entry)] as [
string,
MaybeRef<BoundStyle> | undefined
]
)
)
</script>
<template>
@@ -238,30 +220,28 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
canRename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="label ?? widgetName"
:sub-title="subLabel"
:can-rename="canRename"
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
)
"
@rename="inlineRenameInput(nodeId, widgetName, $event)"
/>
<template v-for="entry in resolvedInputs" :key="entry.entityId">
<IoItem
v-if="entry.status === 'resolved'"
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="entry.widget.label ?? entry.displayName"
:sub-title="entry.node.title"
can-rename
:remove="() => appModeStore.removeSelectedInput(entry.widget)"
@rename="renameWidget(entry.widget, entry.node, $event)"
/>
<IoItem
v-else
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="entry.displayName"
:sub-title="t('linearMode.builder.unknownWidget')"
:remove="() => removeSelectedEntityId(entry.entityId)"
/>
</template>
</DraggableList>
</PropertiesAccordionItem>
<div

View File

@@ -1,16 +1,15 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed, provide, shallowRef } from 'vue'
import { computed, provide } from 'vue'
import { useAppModeWidgetResizing } from '@/components/builder/useAppModeWidgetResizing'
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
@@ -23,15 +22,12 @@ import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
key: string
nodeId: NodeId
widgetName: string
persistedHeight: number | undefined
nodeData: ReturnType<typeof nodeToNodeData> & {
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
@@ -49,31 +45,25 @@ const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const maskEditor = useMaskEditor()
const { onPointerDown } = useAppModeWidgetResizing(
(nodeId, widgetName, config) =>
appModeStore.updateInputConfig(nodeId, widgetName, config)
const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
appModeStore.updateInputConfig(widget, config)
)
provide(HideLayoutFieldKey, true)
provide(OverlayAppendToKey, 'body')
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
useEventListener(
app.rootGraph.events,
'configured',
() => (graphNodes.value = app.rootGraph.nodes)
)
const resolvedInputs = useResolvedSelectedInputs()
const mappedSelections = computed((): WidgetEntry[] => {
void graphNodes.value
const nodeDataByNode = new Map<
LGraphNode,
ReturnType<typeof nodeToNodeData>
>()
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName, config]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
return resolvedInputs.value.flatMap((entry) => {
if (entry.status !== 'resolved') return []
const { entityId, node, widget, config } = entry
if (node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
nodeDataByNode.set(node, nodeToNodeData(node))
@@ -82,15 +72,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
return (
isPromotedWidgetView(widget) &&
widget.sourceNodeId == storeNodeId &&
widget.sourceWidgetName === vueWidget.storeName
)
return vueWidget.entityId === entityId
})
if (!matchingWidget) return []
@@ -99,9 +81,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
return [
{
key: `${nodeId}:${widgetName}`,
nodeId,
widgetName,
key: entityId,
persistedHeight: config?.height,
nodeData: {
...fullNodeData,
@@ -168,14 +148,7 @@ defineExpose({ handleDragDrop })
</script>
<template>
<div
v-for="{
key,
nodeId,
widgetName,
persistedHeight,
nodeData,
action
} in mappedSelections"
v-for="{ key, persistedHeight, nodeData, action } in mappedSelections"
:key
:class="
cn(
@@ -223,8 +196,7 @@ defineExpose({ handleDragDrop })
{
label: t('g.remove'),
icon: 'icon-[lucide--x]',
command: () =>
appModeStore.removeSelectedInput(action.widget, action.node)
command: () => appModeStore.removeSelectedInput(action.widget)
}
]"
>
@@ -253,7 +225,7 @@ defineExpose({ handleDragDrop })
)
"
:inert="builderMode || undefined"
@pointerdown.capture="(e) => onPointerDown(nodeId, widgetName, e)"
@pointerdown.capture="(e) => onPointerDown(action.widget, e)"
>
<DropZone
:on-drag-over="nodeData.onDragOver"

View File

@@ -1,11 +1,15 @@
import { describe, expect, it, vi } from 'vitest'
import { effectScope } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useAppModeWidgetResizing } from './useAppModeWidgetResizing'
const WIDGET_PROMPT = { name: 'prompt' } as IBaseWidget
const WIDGET_OTHER = { name: 'other' } as IBaseWidget
const WIDGET_IMAGE = { name: 'image' } as IBaseWidget
function setHeight(el: HTMLElement, height: number) {
Object.defineProperty(el, 'offsetHeight', {
value: height,
@@ -28,15 +32,13 @@ function wrapWithTextarea(initialHeight = 100): {
describe('useAppModeWidgetResizing', () => {
function setup() {
const onResize =
vi.fn<
(nodeId: NodeId, widgetName: string, config: InputWidgetConfig) => void
>()
vi.fn<(widget: IBaseWidget, config: InputWidgetConfig) => void>()
const { onPointerDown } = useAppModeWidgetResizing(onResize)
function bind(wrapper: HTMLElement, nodeId: NodeId, widgetName: string) {
function bind(wrapper: HTMLElement, widget: IBaseWidget) {
wrapper.addEventListener(
'pointerdown',
(e) => onPointerDown(nodeId, widgetName, e as PointerEvent),
(e) => onPointerDown(widget, e as PointerEvent),
{ capture: true }
)
}
@@ -47,19 +49,19 @@ describe('useAppModeWidgetResizing', () => {
it('persists height when textarea is resized via drag', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, 1 as NodeId, 'prompt')
bind(wrapper, WIDGET_PROMPT)
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
})
it('does not persist when no height change occurs (e.g. a click)', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, 1 as NodeId, 'prompt')
bind(wrapper, WIDGET_PROMPT)
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
window.dispatchEvent(new PointerEvent('pointerup'))
@@ -70,7 +72,7 @@ describe('useAppModeWidgetResizing', () => {
it('persists once per drag gesture; stray pointerup is a no-op', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, 1 as NodeId, 'prompt')
bind(wrapper, WIDGET_PROMPT)
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
@@ -86,7 +88,7 @@ describe('useAppModeWidgetResizing', () => {
const button = document.createElement('button')
wrapper.appendChild(button)
document.body.appendChild(wrapper)
bind(wrapper, 1 as NodeId, 'prompt')
bind(wrapper, WIDGET_PROMPT)
button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
window.dispatchEvent(new PointerEvent('pointerup'))
@@ -104,21 +106,21 @@ describe('useAppModeWidgetResizing', () => {
wrapper.appendChild(indicator)
document.body.appendChild(wrapper)
setHeight(indicator, 100)
bind(wrapper, 1 as NodeId, 'image')
bind(wrapper, WIDGET_IMAGE)
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(indicator, 250)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledWith(1, 'image', { height: 250 })
expect(onResize).toHaveBeenCalledWith(WIDGET_IMAGE, { height: 250 })
})
it('drops a stale gesture when a new pointerdown starts before pointerup arrives', () => {
const { bind, onResize } = setup()
const first = wrapWithTextarea()
const second = wrapWithTextarea()
bind(first.wrapper, 1 as NodeId, 'prompt')
bind(second.wrapper, 2 as NodeId, 'other')
bind(first.wrapper, WIDGET_PROMPT)
bind(second.wrapper, WIDGET_OTHER)
first.textarea.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true })
@@ -132,25 +134,25 @@ describe('useAppModeWidgetResizing', () => {
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledTimes(1)
expect(onResize).toHaveBeenCalledWith(2, 'other', { height: 300 })
expect(onResize).toHaveBeenCalledWith(WIDGET_OTHER, { height: 300 })
})
it('treats pointercancel as the end of a gesture and persists the new height', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, 1 as NodeId, 'prompt')
bind(wrapper, WIDGET_PROMPT)
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
window.dispatchEvent(new PointerEvent('pointercancel'))
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
})
it('after pointercancel, a subsequent stray pointerup is a no-op', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, 1 as NodeId, 'prompt')
bind(wrapper, WIDGET_PROMPT)
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
@@ -159,14 +161,12 @@ describe('useAppModeWidgetResizing', () => {
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledTimes(1)
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
})
it('removes global listeners when the owning scope is disposed mid-gesture', () => {
const onResize =
vi.fn<
(nodeId: NodeId, widgetName: string, config: InputWidgetConfig) => void
>()
vi.fn<(widget: IBaseWidget, config: InputWidgetConfig) => void>()
const scope = effectScope()
const { onPointerDown } = scope.run(() =>
useAppModeWidgetResizing(onResize)
@@ -174,7 +174,7 @@ describe('useAppModeWidgetResizing', () => {
const { wrapper, textarea } = wrapWithTextarea()
wrapper.addEventListener(
'pointerdown',
(e) => onPointerDown(1 as NodeId, 'prompt', e as PointerEvent),
(e) => onPointerDown(WIDGET_PROMPT, e as PointerEvent),
{ capture: true }
)
@@ -199,7 +199,7 @@ describe('useAppModeWidgetResizing', () => {
outerIndicator.appendChild(wrapper)
document.body.appendChild(outerIndicator)
setHeight(outerIndicator, 100)
bind(wrapper, 1 as NodeId, 'prompt')
bind(wrapper, WIDGET_PROMPT)
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(outerIndicator, 250)

View File

@@ -1,16 +1,12 @@
import { onScopeDispose } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
const RESIZABLE_SELECTOR = 'textarea, [data-slot="drop-zone-indicator"]'
export function useAppModeWidgetResizing(
onResize: (
nodeId: NodeId,
widgetName: string,
config: InputWidgetConfig
) => void
onResize: (widget: IBaseWidget, config: InputWidgetConfig) => void
) {
let pendingHandler: (() => void) | null = null
@@ -23,11 +19,7 @@ export function useAppModeWidgetResizing(
onScopeDispose(clearPendingHandler)
function onPointerDown(
nodeId: NodeId,
widgetName: string,
event: PointerEvent
) {
function onPointerDown(widget: IBaseWidget, event: PointerEvent) {
const wrapper = event.currentTarget
const target = event.target
if (!(wrapper instanceof HTMLElement) || !(target instanceof HTMLElement))
@@ -44,7 +36,7 @@ export function useAppModeWidgetResizing(
pendingHandler = null
const height = resizable.offsetHeight
if (height === startHeight) return
onResize(nodeId, widgetName, { height })
onResize(widget, { height })
}
pendingHandler = handler
window.addEventListener('pointerup', handler)

View File

@@ -0,0 +1,91 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
id: '11111111-1111-4111-8111-111111111111',
nodes: [] as LGraphNode[],
events: new EventTarget(),
getNodeById: vi.fn() as (id: number) => LGraphNode | null
}
}
}))
const rootGraphId = '11111111-1111-4111-8111-111111111111'
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
function makeNode(id: number, widgetNames: string[]): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
widgets: widgetNames.map((name) => ({
name,
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
}))
})
}
function setRootGraphNodes(nodes: LGraphNode[]) {
vi.mocked(app.rootGraph).nodes = nodes
vi.mocked(app.rootGraph).getNodeById = vi.fn(
(id) => nodes.find((n) => String(n.id) === String(id)) ?? null
)
}
function dispatchRootGraphEvent(type: string) {
;(app.rootGraph!.events as unknown as EventTarget).dispatchEvent(
new Event(type)
)
}
describe('useResolvedSelectedInputs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
setRootGraphNodes([])
})
afterEach(() => {
vi.restoreAllMocks()
})
it('re-resolves selections after a convert-to-subgraph event removes nodes from the root graph', () => {
const node = makeNode(1, ['seed'])
setRootGraphNodes([node])
const appModeStore = useAppModeStore()
appModeStore.selectedInputs = [[entitySeed, 'seed']]
const resolved = useResolvedSelectedInputs()
expect(resolved.value[0]?.status).toBe('resolved')
setRootGraphNodes([])
dispatchRootGraphEvent('convert-to-subgraph')
expect(resolved.value[0]?.status).toBe('unknown')
})
it('re-resolves selections after a subgraph-created event removes nodes from the root graph', () => {
const node = makeNode(1, ['seed'])
setRootGraphNodes([node])
const appModeStore = useAppModeStore()
appModeStore.selectedInputs = [[entitySeed, 'seed']]
const resolved = useResolvedSelectedInputs()
expect(resolved.value[0]?.status).toBe('resolved')
setRootGraphNodes([])
dispatchRootGraphEvent('subgraph-created')
expect(resolved.value[0]?.status).toBe('unknown')
})
})

View File

@@ -0,0 +1,71 @@
import { useEventListener } from '@vueuse/core'
import { computed, shallowRef, triggerRef } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
export type ResolvedSelection =
| {
status: 'resolved'
entityId: WidgetEntityId
node: LGraphNode
widget: IBaseWidget
displayName: string
config?: InputWidgetConfig
}
| {
status: 'unknown'
entityId: WidgetEntityId
displayName: string
config?: InputWidgetConfig
}
export function useResolvedSelectedInputs() {
const appModeStore = useAppModeStore()
const graphNodes = shallowRef<LGraphNode[]>([...(app.rootGraph?.nodes ?? [])])
const refreshGraphNodes = () =>
(graphNodes.value = [...(app.rootGraph?.nodes ?? [])])
useEventListener(() => app.rootGraph?.events, 'configured', refreshGraphNodes)
useEventListener(
() => app.rootGraph?.events,
'convert-to-subgraph',
refreshGraphNodes
)
useEventListener(
() => app.rootGraph?.events,
'subgraph-created',
refreshGraphNodes
)
useEventListener(
() => app.rootGraph?.events,
'node:slot-label:changed',
() => triggerRef(graphNodes)
)
return computed<ResolvedSelection[]>(() => {
void graphNodes.value
const rootGraph = app.rootGraph
if (!rootGraph) return []
return appModeStore.selectedInputs.flatMap(
([entityId, displayName, config]): ResolvedSelection[] => {
if (!isWidgetEntityId(entityId)) return []
const { nodeId, name } = parseWidgetEntityId(entityId)
const node = rootGraph.getNodeById(nodeId)
const widget = node?.widgets?.find((w) => w.name === name)
if (!node || !widget) {
return [{ status: 'unknown', entityId, displayName, config }]
}
return [
{ status: 'resolved', entityId, node, widget, displayName, config }
]
}
)
})
}

View File

@@ -1,14 +1,24 @@
<script setup lang="ts">
import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
import { useMounted, watchDebounced } from '@vueuse/core'
import {
computed,
inject,
onBeforeUnmount,
provide,
ref,
shallowRef,
watchEffect
} from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -54,12 +64,74 @@ const {
const collapse = defineModel<boolean>('collapse', { default: false })
const emit = defineEmits<{
reorder: [event: { fromIndex: number; toIndex: number }]
}>()
const widgetsContainer = ref<HTMLElement>()
const rootElement = ref<HTMLElement>()
const widgets = shallowRef(widgetsProp)
watchEffect(() => (widgets.value = widgetsProp))
const draggableList = ref<DraggableList | undefined>()
const isMounted = useMounted()
function setDraggableState() {
draggableList.value?.dispose()
draggableList.value = undefined
if (!isMounted.value || !isDraggable || collapse.value) return
const container = widgetsContainer.value
if (!container?.children?.length) return
const list = new DraggableList(container, '.draggable-item')
list.applyNewItemsOrder = function () {
const reorderedItems: HTMLElement[] = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
if (oldPosition === -1) {
console.error('[SectionWidgets] draggableItem not found in items')
return
}
for (let index = 0; index < this.getAllItems().length; index++) {
if (typeof reorderedItems[index] === 'undefined') {
reorderedItems[index] = this.draggableItem as HTMLElement
}
}
const newPosition = reorderedItems.indexOf(
this.draggableItem as HTMLElement
)
emit('reorder', { fromIndex: oldPosition, toIndex: newPosition })
}
draggableList.value = list
}
watchDebounced(
[widgets, () => isDraggable, collapse],
() => setDraggableState(),
{ debounce: 100, immediate: true }
)
onBeforeUnmount(() => draggableList.value?.dispose())
provide(HideLayoutFieldKey, true)
const canvasStore = useCanvasStore()
@@ -70,8 +142,6 @@ const { t } = useI18n()
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
const promotionStore = usePromotionStore()
function isWidgetShownOnParents(
widgetNode: LGraphNode,
widget: IBaseWidget
@@ -83,13 +153,12 @@ function isWidgetShownOnParents(
? widget.sourceNodeId
: String(widgetNode.id)
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: interiorNodeId,
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
sourceWidgetName: widget.sourceWidgetName
})
}
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: String(widgetNode.id),
sourceWidgetName: widget.name
})

View File

@@ -1,18 +1,9 @@
<script setup lang="ts">
import { useMounted, watchDebounced } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef
} from 'vue'
import { computed, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { ValidFavoritedWidget } from '@/stores/workspace/favoritedWidgetsStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -25,8 +16,6 @@ const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const { t } = useI18n()
const draggableList = ref<DraggableList | undefined>(undefined)
const sectionWidgetsRef = ref<{ widgetsContainer: HTMLElement }>()
const isSearching = ref(false)
const favoritedWidgets = computed(
@@ -48,70 +37,20 @@ async function searcher(query: string) {
searchedFavoritedWidgets.value = searchWidgets(favoritedWidgets.value, query)
}
const isMounted = useMounted()
function handleReorder({
fromIndex,
toIndex
}: {
fromIndex: number
toIndex: number
}) {
const widgets = [...searchedFavoritedWidgets.value]
const [moved] = widgets.splice(fromIndex, 1)
if (!moved) return
widgets.splice(toIndex, 0, moved)
function setDraggableState() {
if (!isMounted.value) return
draggableList.value?.dispose()
const container = sectionWidgetsRef.value?.widgetsContainer
if (isSearching.value || !container?.children?.length) return
draggableList.value = new DraggableList(container, '.draggable-item')
draggableList.value.applyNewItemsOrder = function () {
const reorderedItems: HTMLElement[] = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem as HTMLElement
}
}
const newPosition = reorderedItems.indexOf(
this.draggableItem as HTMLElement
)
const widgets = [...searchedFavoritedWidgets.value]
const [widget] = widgets.splice(oldPosition, 1)
widgets.splice(newPosition, 0, widget)
searchedFavoritedWidgets.value = widgets
favoritedWidgetsStore.reorderFavorites(widgets)
}
}
watchDebounced(
searchedFavoritedWidgets,
() => {
setDraggableState()
},
{ debounce: 100 }
)
onMounted(() => {
setDraggableState()
})
onBeforeUnmount(() => {
draggableList.value?.dispose()
})
function onCollapseUpdate() {
// Rebuild draggable list after the section header is toggled
nextTick(setDraggableState)
searchedFavoritedWidgets.value = widgets
favoritedWidgetsStore.reorderFavorites(widgets)
}
</script>
@@ -127,7 +66,6 @@ function onCollapseUpdate() {
/>
</div>
<SectionWidgets
ref="sectionWidgetsRef"
:label
:widgets="searchedFavoritedWidgets"
:is-draggable="!isSearching"
@@ -135,7 +73,7 @@ function onCollapseUpdate() {
show-node-name
enable-empty-state
class="border-b border-interface-stroke"
@update:collapse="onCollapseUpdate"
@reorder="handleReorder"
>
<template #empty>
<div class="px-4 py-10 text-center text-sm text-muted-foreground">

View File

@@ -1,26 +1,19 @@
<script setup lang="ts">
import { useMounted, watchDebounced } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
useTemplateRef,
watch
} from 'vue'
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
import {
getWidgetName,
isWidgetPromotedOnSubgraphNode,
reorderSubgraphInputsByWidgetOrder
} from '@/core/graph/subgraph/promotionUtils'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { usePromotionStore } from '@/stores/promotionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { searchWidgets } from '../shared'
@@ -33,7 +26,6 @@ const { node } = defineProps<{
const { t } = useI18n()
const canvasStore = useCanvasStore()
const promotionStore = usePromotionStore()
const rightSidePanelStore = useRightSidePanelStore()
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
@@ -51,13 +43,33 @@ const isAllCollapsed = computed({
advancedInputsCollapsed.value = collapse
}
})
const draggableList = ref<DraggableList | undefined>(undefined)
const sectionWidgetsRef = useTemplateRef('sectionWidgetsRef')
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
const promotionEntries = computed(() =>
promotionStore.getPromotions(node.rootGraph.id, node.id)
)
function isSamePromotedWidget(a: IBaseWidget, b: IBaseWidget): boolean {
return (
isPromotedWidgetView(a) &&
isPromotedWidgetView(b) &&
a.sourceNodeId === b.sourceNodeId &&
a.sourceWidgetName === b.sourceWidgetName
)
}
function getPromotedWidgets(): IBaseWidget[] {
const inputWidgets = node.inputs
.map((input) => input._widget)
.filter((widget): widget is IBaseWidget =>
Boolean(widget && isPromotedWidgetView(widget))
)
const extraWidgets = (node.widgets ?? []).filter(
(widget) =>
isPromotedWidgetView(widget) &&
!inputWidgets.some((inputWidget) =>
isSamePromotedWidget(inputWidget, widget)
)
)
return [...inputWidgets, ...extraWidgets]
}
watch(
focusedSection,
@@ -81,37 +93,7 @@ watch(
)
const widgetsList = computed((): NodeWidgetsList => {
const entries = promotionEntries.value
const { widgets = [] } = node
const result: NodeWidgetsList = []
for (const {
sourceNodeId: entryNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
} of entries) {
const widget = widgets.find((w) => {
if (isPromotedWidgetView(w)) {
if (
String(w.sourceNodeId) !== entryNodeId ||
w.sourceWidgetName !== sourceWidgetName
)
return false
if (!disambiguatingSourceNodeId) return true
return (
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
disambiguatingSourceNodeId
)
}
return w.name === sourceWidgetName
})
if (widget) {
result.push({ node, widget })
}
}
return result
return getPromotedWidgets().map((widget) => ({ node, widget }))
})
const advancedInputsWidgets = computed((): NodeWidgetsList => {
@@ -126,12 +108,9 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
return allInteriorWidgets.filter(
({ node: interiorNode, widget }) =>
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
!isWidgetPromotedOnSubgraphNode(node, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
? widget.disambiguatingSourceNodeId
: undefined
sourceWidgetName: getWidgetName(widget)
})
)
})
@@ -146,66 +125,22 @@ async function searcher(query: string) {
searchedWidgetsList.value = searchWidgets(widgetsList.value, query)
}
const isMounted = useMounted()
function handleReorder({
fromIndex,
toIndex
}: {
fromIndex: number
toIndex: number
}) {
const widgets = searchedWidgetsList.value.map((row) => row.widget)
const [moved] = widgets.splice(fromIndex, 1)
if (!moved) return
widgets.splice(toIndex, 0, moved)
function setDraggableState() {
if (!isMounted.value) return
draggableList.value?.dispose()
const container = sectionWidgetsRef.value?.widgetsContainer
if (isSearching.value || !container?.children?.length) return
draggableList.value = new DraggableList(container, '.draggable-item')
draggableList.value.applyNewItemsOrder = function () {
const reorderedItems: HTMLElement[] = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
if (oldPosition === -1) {
console.error('[TabSubgraphInputs] draggableItem not found in items')
return
}
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem as HTMLElement
}
}
const newPosition = reorderedItems.indexOf(
this.draggableItem as HTMLElement
)
promotionStore.movePromotion(
node.rootGraph.id,
node.id,
oldPosition,
newPosition
)
canvasStore.canvas?.setDirty(true, true)
}
reorderSubgraphInputsByWidgetOrder(node, widgets)
canvasStore.canvas?.setDirty(true, true)
}
watchDebounced(searchedWidgetsList, () => setDraggableState(), {
debounce: 100
})
onMounted(() => setDraggableState())
onBeforeUnmount(() => draggableList.value?.dispose())
const label = computed(() => {
return searchedWidgetsList.value.length !== 0
? t('rightSidePanel.inputs')
@@ -229,7 +164,6 @@ const label = computed(() => {
/>
</div>
<SectionWidgets
ref="sectionWidgetsRef"
:collapse="firstSectionCollapsed && !isSearching"
:node
:label
@@ -243,12 +177,8 @@ const label = computed(() => {
: t('rightSidePanel.inputsNoneTooltip')
"
class="border-b border-interface-stroke"
@update:collapse="
(v) => {
firstSectionCollapsed = v
nextTick(setDraggableState)
}
"
@update:collapse="(v) => (firstSectionCollapsed = v)"
@reorder="handleReorder"
>
<template #empty>
<div class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground">

View File

@@ -9,15 +9,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
import WidgetActions from './WidgetActions.vue'
const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
mockGetInputSpecForWidget: vi.fn()
}))
vi.mock('@/core/graph/subgraph/promotionUtils', () => ({
demoteWidget: vi.fn(),
promoteWidget: vi.fn(),
isLinkedPromotion: vi.fn(() => false)
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
getInputSpecForWidget: mockGetInputSpecForWidget
@@ -201,64 +205,4 @@ describe('WidgetActions', () => {
expect(onResetToDefault).toHaveBeenCalledWith('option1')
})
it('demotes promoted widgets by immediate interior node identity when shown from parent context', async () => {
mockGetInputSpecForWidget.mockReturnValue({
type: 'CUSTOM'
})
const parentSubgraphNode = fromAny<SubgraphNode, unknown>({
id: 4,
rootGraph: { id: 'graph-test' },
computeSize: vi.fn(),
size: [300, 150]
})
const node = fromAny<LGraphNode, unknown>({
id: 4,
type: 'SubgraphNode',
rootGraph: { id: 'graph-test' },
isSubgraphNode: () => false
})
const widget = {
name: 'text',
type: 'text',
value: 'value',
label: 'Text',
options: {},
y: 0,
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
} as IBaseWidget
const promotionStore = usePromotionStore()
promotionStore.promote('graph-test', 4, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
const user = userEvent.setup()
render(WidgetActions, {
props: {
widget,
node,
label: 'Text',
parents: [parentSubgraphNode],
isShownOnParents: true
},
global: {
plugins: [i18n]
}
})
await user.click(screen.getByRole('button', { name: /Hide input/ }))
expect(
promotionStore.isPromoted('graph-test', 4, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(false)
})
})

View File

@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demoteWidget,
@@ -17,7 +16,6 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -43,7 +41,6 @@ const label = defineModel<string>('label', { required: true })
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const nodeDefStore = useNodeDefStore()
const promotionStore = usePromotionStore()
const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
@@ -82,16 +79,19 @@ function handleHideInput() {
if (isPromotedWidgetView(widget)) {
for (const parent of parents) {
const source: PromotedWidgetSource = {
sourceNodeId:
String(node.id) === String(parent.id)
? widget.sourceNodeId
: String(node.id),
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
}
promotionStore.demote(parent.rootGraph.id, parent.id, source)
parent.computeSize(parent.size)
const sourceNodeId =
String(node.id) === String(parent.id)
? widget.sourceNodeId
: String(node.id)
demoteWidget(
{
id: sourceNodeId,
title: node.title,
type: node.type
},
widget,
[parent]
)
}
canvasStore.canvas?.setDirty(true, true)
} else {

View File

@@ -42,7 +42,7 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({
getControlWidget: vi.fn(() => undefined)
}))
vi.mock('@/core/graph/subgraph/resolvePromotedWidgetSource', () => ({
vi.mock('@/core/graph/subgraph/resolveConcretePromotedWidget', () => ({
resolvePromotedWidgetSource: vi.fn(() => undefined)
}))

View File

@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'

View File

@@ -0,0 +1,265 @@
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import SubgraphEditor from './SubgraphEditor.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import type DraggableList from '@/components/common/DraggableList.vue'
type DraggableListProps = ComponentProps<typeof DraggableList>
type PromotedRow =
DraggableListProps['modelValue'] extends Array<infer T> ? T : never
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: vi.fn() })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subgraphStore: {
shown: 'Shown',
hidden: 'Hidden',
hideAll: 'Hide all',
showAll: 'Show all',
addRecommended: 'Add recommended'
},
rightSidePanel: {
noneSearchDesc: 'No results'
},
g: {
search: 'Search',
searchPlaceholder: 'Search'
}
}
}
})
describe('SubgraphEditor', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
it('renders preview exposures after promoted inputs without drag handles', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('FirstNode')
const secondNode = new LGraphNode('SecondNode')
const previewNode = new LGraphNode('PreviewImage')
previewNode.type = 'PreviewImage'
subgraph.add(firstNode)
subgraph.add(secondNode)
subgraph.add(previewNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
usePreviewExposureStore().addExposure(
subgraph.rootGraph.id,
String(host.id),
{
sourceNodeId: String(previewNode.id),
sourcePreviewName: '$$canvas-image-preview'
}
)
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
template:
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
}
}
}
})
const shown = screen.getByTestId('subgraph-editor-shown-section')
expect(
within(shown)
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second', '$$canvas-image-preview'])
expect(
within(screen.getByTestId('draggable-list'))
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second'])
expect(
within(shown).getAllByTestId('subgraph-widget-drag-handle')
).toHaveLength(2)
})
it('updates rendered order when promoted widgets are reordered', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('FirstNode')
const secondNode = new LGraphNode('SecondNode')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
useCanvasStore().selectedItems = [host]
let listSetter: ((value: PromotedRow[]) => void) | undefined
const draggableListStub = {
props: ['modelValue'],
emits: ['update:modelValue'],
setup(
_: unknown,
{
emit,
slots
}: {
emit: (event: string, ...args: unknown[]) => void
slots: { default?: (props: { dragClass: string }) => unknown }
}
) {
listSetter = (value) => emit('update:modelValue', value)
return () => slots.default?.({ dragClass: 'draggable-item' })
}
}
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: { DraggableList: draggableListStub }
}
})
await nextTick()
const shown = screen.getByTestId('subgraph-editor-shown-section')
expect(
within(shown)
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second'])
const promotedWidgets = host.widgets.filter(isPromotedWidgetView)
const reversed = [
{ kind: 'promoted', node: secondNode, widget: promotedWidgets[1] },
{ kind: 'promoted', node: firstNode, widget: promotedWidgets[0] }
] as PromotedRow[]
listSetter?.(reversed)
await nextTick()
expect(
within(shown)
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['second', 'first'])
})
it('demotes linked promoted widgets when "Hide all" is clicked', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('FirstNode')
const secondNode = new LGraphNode('SecondNode')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
template:
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
}
}
}
})
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(2)
const shown = screen.getByTestId('subgraph-editor-shown-section')
const hideAllLink = within(shown).getByText('Hide all')
await userEvent.click(hideAllLink)
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(0)
})
it('removes the exposure when a preview row without a real source widget is demoted', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const orphanedSourceNode = new LGraphNode('OrphanedNode')
orphanedSourceNode.type = 'OrphanedNode'
subgraph.add(orphanedSourceNode)
const previewStore = usePreviewExposureStore()
previewStore.addExposure(subgraph.rootGraph.id, String(host.id), {
sourceNodeId: String(orphanedSourceNode.id),
sourcePreviewName: '$$canvas-image-preview'
})
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
template:
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
}
}
}
})
expect(
previewStore.getExposures(subgraph.rootGraph.id, String(host.id))
).toHaveLength(1)
const shown = screen.getByTestId('subgraph-editor-shown-section')
const toggleButton = within(shown).getByTestId('subgraph-widget-toggle')
await userEvent.click(toggleButton)
expect(
previewStore.getExposures(subgraph.rootGraph.id, String(host.id))
).toHaveLength(0)
})
})

View File

@@ -1,106 +1,141 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { computed, onMounted, shallowRef, watch } from 'vue'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demoteWidget,
getPromotableWidgets,
getSourceNodeId,
getWidgetName,
isLinkedPromotion,
isRecommendedWidget,
promoteWidget,
pruneDisconnected
pruneDisconnected,
reorderSubgraphInputsByWidgetOrder
} from '@/core/graph/subgraph/promotionUtils'
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { cn } from '@comfyorg/tailwind-utils'
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
const { t } = useI18n()
type PromotedRow = {
kind: 'promoted'
node: LGraphNode
widget: PromotedWidgetView
}
type PreviewRow = {
kind: 'preview'
node: LGraphNode
exposure: PreviewExposure
realWidget?: IBaseWidget
}
type ActiveRow = PromotedRow | PreviewRow
const canvasStore = useCanvasStore()
const promotionStore = usePromotionStore()
const previewExposureStore = usePreviewExposureStore()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const promotionEntries = computed(() => {
const node = activeNode.value
if (!node) return []
return promotionStore.getPromotions(node.rootGraph.id, node.id)
})
const activeNode = computed(() => {
const node = canvasStore.selectedItems[0]
if (node instanceof SubgraphNode) return node
return undefined
})
const activeWidgets = computed<WidgetItem[]>({
const promotedWidgets = shallowRef<readonly IBaseWidget[]>([])
function refreshPromotedWidgets() {
promotedWidgets.value = activeNode.value?.widgets ?? []
}
watch(activeNode, refreshPromotedWidgets, { immediate: true })
useEventListener(
() => activeNode.value?.subgraph.events,
[
'widget-promoted',
'widget-demoted',
'input-added',
'removing-input',
'inputs-reordered'
],
refreshPromotedWidgets
)
const activeRows = computed<ActiveRow[]>(() => {
const node = activeNode.value
if (!node) return []
return [...getActivePromotedRows(node), ...getActivePreviewRows(node)]
})
const activePromotedRows = computed<PromotedRow[]>({
get() {
const node = activeNode.value
if (!node) return []
return promotionEntries.value.flatMap(
({
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
}): WidgetItem[] => {
if (sourceNodeId === '-1') {
const widget = node.widgets.find((w) => w.name === sourceWidgetName)
if (!widget) return []
return [
[{ id: -1, title: t('subgraphStore.linked'), type: '' }, widget]
]
}
const wNode = node.subgraph._nodes_by_id[sourceNodeId]
if (!wNode) return []
const widget = getPromotableWidgets(wNode).find((w) => {
if (w.name !== sourceWidgetName) return false
if (disambiguatingSourceNodeId && isPromotedWidgetView(w))
return (
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
disambiguatingSourceNodeId
)
return true
})
if (!widget) return []
return [[wNode, widget]]
}
)
return node ? getActivePromotedRows(node) : []
},
set(value: WidgetItem[]) {
const node = activeNode.value
if (!node) {
console.error('Attempted to toggle widgets with no node selected')
return
}
promotionStore.setPromotions(
node.rootGraph.id,
node.id,
value.map(([n, w]) => ({
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: isPromotedWidgetView(w)
? w.disambiguatingSourceNodeId
: undefined
}))
)
refreshPromotedWidgetRendering()
set(value: PromotedRow[]) {
updateActivePromotedRows(value, activePromotedRows.value)
}
})
function getActivePromotedRows(node: SubgraphNode): PromotedRow[] {
return promotedWidgets.value.flatMap((widget): PromotedRow[] => {
if (!isPromotedWidgetView(widget)) return []
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
if (!sourceNode) return []
return [{ kind: 'promoted', node: sourceNode, widget }]
})
}
function getActivePreviewRows(node: SubgraphNode): PreviewRow[] {
const hostLocator = String(node.id)
const rootGraphId = node.rootGraph.id
const exposures = previewExposureStore.getExposures(rootGraphId, hostLocator)
return exposures.flatMap((exposure): PreviewRow[] => {
const sourceNode = node.subgraph._nodes_by_id[exposure.sourceNodeId]
if (!sourceNode) return []
const realWidget = getPromotableWidgets(sourceNode).find(
(candidate) => candidate.name === exposure.sourcePreviewName
)
return [{ kind: 'preview', node: sourceNode, exposure, realWidget }]
})
}
function updateActivePromotedRows(
value: PromotedRow[],
currentItems: PromotedRow[]
) {
const node = activeNode.value
if (!node) {
console.error('Attempted to toggle widgets with no node selected')
return
}
const currentKeys = new Set(currentItems.map(promotedRowKey))
const nextKeys = new Set(value.map(promotedRowKey))
for (const item of value) {
if (!currentKeys.has(promotedRowKey(item))) promotePromotedRow(item)
}
for (const item of currentItems) {
if (!nextKeys.has(promotedRowKey(item))) demoteRow(item)
}
if (currentKeys.size === nextKeys.size) {
reorderSubgraphInputsByWidgetOrder(
node,
value.map((row) => row.widget)
)
}
refreshPromotedWidgetRendering()
}
const interiorWidgets = computed<WidgetItem[]>(() => {
const node = activeNode.value
if (!node) return []
@@ -115,18 +150,18 @@ const interiorWidgets = computed<WidgetItem[]>(() => {
.filter(([_, w]: WidgetItem) => !w.computedDisabled)
})
function activeRowSourceKey(row: ActiveRow): string {
return row.kind === 'promoted'
? `${row.widget.sourceNodeId}:${row.widget.sourceWidgetName}`
: `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
}
const candidateWidgets = computed<WidgetItem[]>(() => {
const node = activeNode.value
if (!node) return []
const promotedSourceKeys = new Set(activeRows.value.map(activeRowSourceKey))
return interiorWidgets.value.filter(
([n, w]: WidgetItem) =>
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: isPromotedWidgetView(w)
? w.disambiguatingSourceNodeId
: undefined
})
([n, w]) => !promotedSourceKeys.has(`${n.id}:${w.name}`)
)
})
const filteredCandidates = computed<WidgetItem[]>(() => {
@@ -145,16 +180,31 @@ const recommendedWidgets = computed(() => {
return filteredCandidates.value.filter(isRecommendedWidget)
})
const filteredActive = computed<WidgetItem[]>(() => {
const query = searchQuery.value.toLowerCase()
if (!query) return activeWidgets.value
return activeWidgets.value.filter(
([n, w]: WidgetItem) =>
n.title.toLowerCase().includes(query) ||
w.name.toLowerCase().includes(query)
function rowMatchesQuery(row: ActiveRow, query: string): boolean {
return (
row.node.title.toLowerCase().includes(query) ||
rowDisplayName(row).toLowerCase().includes(query)
)
}
const filteredActive = computed<ActiveRow[]>(() => {
const query = searchQuery.value.toLowerCase()
if (!query) return activeRows.value
return activeRows.value.filter((row) => rowMatchesQuery(row, query))
})
const filteredActivePromoted = computed<PromotedRow[]>(() =>
filteredActive.value.filter(
(row): row is PromotedRow => row.kind === 'promoted'
)
)
const filteredActivePreviews = computed<PreviewRow[]>(() =>
filteredActive.value.filter(
(row): row is PreviewRow => row.kind === 'preview'
)
)
function refreshPromotedWidgetRendering() {
const node = activeNode.value
if (!node) return
@@ -164,57 +214,89 @@ function refreshPromotedWidgetRendering() {
canvasStore.canvas?.setDirty(true, true)
}
function isItemLinked([node, widget]: WidgetItem): boolean {
function rowDisplayName(row: ActiveRow): string {
if (row.kind === 'promoted') {
return row.widget.label || row.widget.name
}
return (
node.id === -1 ||
(!!activeNode.value &&
isLinkedPromotion(
activeNode.value,
String(node.id),
getWidgetName(widget)
))
row.realWidget?.label ||
row.realWidget?.name ||
row.exposure.sourcePreviewName
)
}
function toKey(item: WidgetItem) {
const sid = getSourceNodeId(item[1])
return sid
? `${item[0].id}: ${item[1].name}:${sid}`
: `${item[0].id}: ${item[1].name}`
function isRowLinked(row: ActiveRow): boolean {
if (row.kind !== 'promoted') return false
if (row.node.id === -1) return true
return (
!!activeNode.value &&
isLinkedPromotion(
activeNode.value,
String(row.node.id),
row.widget.sourceWidgetName
)
)
}
function promotedRowKey(row: PromotedRow): string {
return `${row.node.id}: ${row.widget.name}:${row.widget.sourceNodeId}`
}
function rowKey(row: ActiveRow): string {
return row.kind === 'promoted'
? promotedRowKey(row)
: `${row.node.id}: ${row.exposure.name}`
}
function nodeWidgets(n: LGraphNode): WidgetItem[] {
return getPromotableWidgets(n).map((w) => [n, w])
}
function demote([node, widget]: WidgetItem) {
function demoteRow(row: ActiveRow) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
demoteWidget(node, widget, [subgraphNode])
if (row.kind === 'promoted') {
demoteWidget(row.node, row.widget, [subgraphNode])
return
}
if (row.realWidget) {
demoteWidget(row.node, row.realWidget, [subgraphNode])
return
}
previewExposureStore.removeExposure(
subgraphNode.rootGraph.id,
String(subgraphNode.id),
row.exposure.name
)
refreshPromotedWidgetRendering()
}
function promote([node, widget]: WidgetItem) {
function promotePromotedRow(row: PromotedRow) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
promoteWidget(row.node, row.widget, [subgraphNode])
}
function promoteCandidate([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
promoteWidget(node, widget, [subgraphNode])
}
function showAll() {
for (const item of filteredCandidates.value) {
promote(item)
promoteCandidate(item)
}
}
function hideAll() {
const node = activeNode.value
for (const item of filteredActive.value) {
if (String(item[0].id) === '-1') continue
if (
node &&
isLinkedPromotion(node, String(item[0].id), getWidgetName(item[1]))
)
continue
demote(item)
for (const row of filteredActive.value) {
if (String(row.node.id) === '-1') continue
demoteRow(row)
}
}
function showRecommended() {
for (const item of recommendedWidgets.value) {
promote(item)
promoteCandidate(item)
}
}
@@ -259,19 +341,34 @@ onMounted(() => {
{{ $t('subgraphStore.hideAll') }}</a
>
</div>
<DraggableList v-slot="{ dragClass }" v-model="activeWidgets">
<DraggableList v-slot="{ dragClass }" v-model="activePromotedRows">
<SubgraphNodeWidget
v-for="[node, widget] in filteredActive"
:key="toKey([node, widget])"
:data-nodeid="node.id"
v-for="row in filteredActivePromoted"
:key="rowKey(row)"
:data-nodeid="row.node.id"
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
:node-title="node.title"
:widget-name="widget.label || widget.name"
:is-physical="isItemLinked([node, widget])"
:node-title="row.node.title"
:widget-name="rowDisplayName(row)"
:is-physical="isRowLinked(row)"
:is-draggable="!searchQuery"
@toggle-visibility="demote([node, widget])"
is-shown
@toggle-visibility="demoteRow(row)"
/>
</DraggableList>
<div class="mt-0.5 space-y-0.5 px-2 pb-2">
<SubgraphNodeWidget
v-for="row in filteredActivePreviews"
:key="rowKey(row)"
:data-nodeid="row.node.id"
class="bg-comfy-menu-bg"
:node-title="row.node.title"
:widget-name="rowDisplayName(row)"
:is-physical="isRowLinked(row)"
:is-draggable="false"
is-shown
@toggle-visibility="demoteRow(row)"
/>
</div>
</div>
<div
@@ -295,12 +392,12 @@ onMounted(() => {
<div class="mt-0.5 space-y-0.5 px-2 pb-2">
<SubgraphNodeWidget
v-for="[node, widget] in filteredCandidates"
:key="toKey([node, widget])"
:key="`${node.id}:${widget.name}`"
:data-nodeid="node.id"
class="bg-comfy-menu-bg"
:node-title="node.title"
:widget-name="widget.name"
@toggle-visibility="promote([node, widget])"
@toggle-visibility="promoteCandidate([node, widget])"
/>
</div>
</div>

View File

@@ -10,22 +10,22 @@ const {
widgetName,
isDraggable = false,
isPhysical = false,
isShown = false,
class: className
} = defineProps<{
nodeTitle: string
widgetName: string
isDraggable?: boolean
isPhysical?: boolean
isShown?: boolean
class?: ClassValue
}>()
defineEmits<{
(e: 'toggleVisibility'): void
}>()
defineEmits<{ toggleVisibility: [] }>()
const icon = computed(() =>
isPhysical
? 'icon-[lucide--link]'
: isDraggable
: isShown
? 'icon-[lucide--eye]'
: 'icon-[lucide--eye-off]'
)
@@ -65,6 +65,7 @@ const icon = computed(() =>
</Button>
<div
v-if="isDraggable"
data-testid="subgraph-widget-drag-handle"
class="pointer-events-none icon-[lucide--grip-vertical] size-4"
/>
</div>

View File

@@ -7,6 +7,7 @@ import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { widgetEntityId } from '@/world/entityIds'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -16,7 +17,6 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
describe('Node Reactivity', () => {
@@ -102,12 +102,15 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const input = node.addInput('prompt', 'STRING')
// Associate the input slot with the widget (as widgetInputs extension does)
input.widget = { name: 'prompt' }
// Start with a connected link
input.link = 42
graph.add(node)
return { graph, node }
const upstream = new LGraphNode('upstream')
upstream.addOutput('out', 'STRING')
graph.add(upstream)
const link = upstream.connect(0, node, 0)
if (!link) throw new Error('Expected upstream.connect to produce a link')
return { graph, node, upstream, linkId: link.id }
}
it('sets slotMetadata.linked to true when input has a link', () => {
@@ -187,7 +190,28 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(onChange).toHaveBeenCalledTimes(1)
})
it('updates slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', async () => {
it('marks a widget input slot as linked when connected to a SubgraphInput', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'prompt', type: 'STRING' }]
})
const node = new LGraphNode('test')
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
const input = node.addInput('prompt', 'STRING')
input.widget = { name: 'prompt' }
subgraph.add(node)
const link = subgraph.inputNode.slots[0].connect(input, node)
if (!link)
throw new Error('Expected SubgraphInput.connect to produce a link')
const { vueNodeData } = useGraphNodeManager(subgraph)
const nodeData = vueNodeData.get(String(node.id))
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('resolves slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', () => {
// Set up a subgraph with an interior node that has a "prompt" widget.
// createPromotedWidgetView resolves against this interior node.
const subgraph = createTestSubgraph()
@@ -207,7 +231,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
'10',
'prompt',
'value',
undefined,
'value'
)
@@ -218,7 +241,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
hostNode.widgets = [promotedView]
const input = hostNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
input.link = 42
graph.add(hostNode)
const { vueNodeData } = useGraphNodeManager(graph)
@@ -229,21 +251,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData).toBeDefined()
expect(widgetData?.slotName).toBe('value')
expect(widgetData?.slotMetadata?.linked).toBe(true)
// Disconnect
hostNode.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: hostNode.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
expect(widgetData?.slotMetadata?.linked).toBe(false)
expect(widgetData?.slotMetadata).toBeDefined()
})
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
@@ -403,37 +411,6 @@ describe('Subgraph output slot label reactivity', () => {
})
})
describe('Subgraph Promoted Pseudo Widgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('marks promoted $$ widgets as canvasOnly for Vue widget rendering', () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('interior')
interiorNode.id = 10
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview'
})
const { vueNodeData } = useGraphNodeManager(graph)
const vueNode = vueNodeData.get(String(subgraphNode.id))
const promotedWidget = vueNode?.widgets?.find(
(widget) => widget.name === '$$canvas-image-preview'
)
expect(promotedWidget).toBeDefined()
expect(promotedWidget?.options?.canvasOnly).toBe(true)
})
})
describe('Nested promoted widget mapping', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -471,122 +448,49 @@ describe('Nested promoted widget mapping', () => {
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.storeName).toBe('picker')
expect(mappedWidget?.storeNodeId).toBe(
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
expect(mappedWidget?.entityId).toBe(
widgetEntityId(graph.id, subgraphNodeB.id, 'b_input')
)
})
it('keeps linked and independent same-name promotions as distinct sources', () => {
it('preserves distinct store identity for duplicate-named promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
inputs: [
{ name: 'first_seed', type: '*' },
{ name: 'second_seed', type: '*' }
]
})
const linkedNode = new LGraphNode('LinkedNode')
const linkedInput = linkedNode.addInput('string_a', '*')
linkedNode.addWidget('text', 'string_a', 'linked', () => undefined, {})
linkedInput.widget = { name: 'string_a' }
subgraph.add(linkedNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
const firstNode = new LGraphNode('FirstNode')
const firstInput = firstNode.addInput('seed', '*')
firstNode.addWidget('number', 'seed', 1, () => undefined)
firstInput.widget = { name: 'seed' }
subgraph.add(firstNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
const independentNode = new LGraphNode('IndependentNode')
independentNode.addWidget(
'text',
'string_a',
'independent',
() => undefined,
{}
)
subgraph.add(independentNode)
const secondNode = new LGraphNode('SecondNode')
const secondInput = secondNode.addInput('seed', '*')
secondNode.addWidget('number', 'seed', 2, () => undefined)
secondInput.widget = { name: 'seed' }
subgraph.add(secondNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
const subgraphNode = createTestSubgraphNode(subgraph, { id: 100 })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(independentNode.id),
sourceWidgetName: 'string_a'
})
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const promotedWidgets = nodeData?.widgets?.filter(
(widget) => widget.name === 'string_a'
const widgets = nodeData?.widgets
expect(widgets).toHaveLength(2)
expect(widgets?.[0]?.entityId).toBe(
widgetEntityId(graph.id, subgraphNode.id, 'first_seed')
)
expect(promotedWidgets).toHaveLength(2)
expect(
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
).toEqual(
new Set([
`${subgraph.id}:${linkedNode.id}`,
`${subgraph.id}:${independentNode.id}`
])
)
})
it('maps duplicate-name promoted views from same intermediate node to distinct store identities', () => {
const innerSubgraph = createTestSubgraph()
const firstTextNode = new LGraphNode('FirstTextNode')
firstTextNode.addWidget('text', 'text', '11111111111', () => undefined)
innerSubgraph.add(firstTextNode)
const secondTextNode = new LGraphNode('SecondTextNode')
secondTextNode.addWidget('text', 'text', '22222222222', () => undefined)
innerSubgraph.add(secondTextNode)
const outerSubgraph = createTestSubgraph()
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 3,
parentGraph: outerSubgraph
})
outerSubgraph.add(innerSubgraphNode)
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 4 })
const graph = outerSubgraphNode.graph as LGraph
graph.add(outerSubgraphNode)
usePromotionStore().setPromotions(
innerSubgraphNode.rootGraph.id,
innerSubgraphNode.id,
[
{ sourceNodeId: String(firstTextNode.id), sourceWidgetName: 'text' },
{ sourceNodeId: String(secondTextNode.id), sourceWidgetName: 'text' }
]
)
usePromotionStore().setPromotions(
outerSubgraphNode.rootGraph.id,
outerSubgraphNode.id,
[
{
sourceNodeId: String(innerSubgraphNode.id),
sourceWidgetName: 'text',
disambiguatingSourceNodeId: String(firstTextNode.id)
},
{
sourceNodeId: String(innerSubgraphNode.id),
sourceWidgetName: 'text',
disambiguatingSourceNodeId: String(secondTextNode.id)
}
]
)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(outerSubgraphNode.id))
const promotedWidgets = nodeData?.widgets?.filter(
(widget) => widget.name === 'text'
)
expect(promotedWidgets).toHaveLength(2)
expect(
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
).toEqual(
new Set([
`${outerSubgraphNode.subgraph.id}:${firstTextNode.id}`,
`${outerSubgraphNode.subgraph.id}:${secondTextNode.id}`
])
expect(widgets?.[1]?.entityId).toBe(
widgetEntityId(graph.id, subgraphNode.id, 'second_seed')
)
expect(widgets?.[0]?.entityId).not.toBe(widgets?.[1]?.entityId)
})
})

View File

@@ -9,8 +9,10 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetSource
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import type {
INodeInputSlot,
@@ -27,6 +29,8 @@ import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetEntityIdForNode } from '@/utils/litegraphUtil'
import type { WidgetEntityId } from '@/world/entityIds'
import type {
LGraph,
@@ -56,10 +60,9 @@ type Badges = (LGraphBadge | (() => LGraphBadge))[]
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
entityId?: WidgetEntityId
nodeId?: NodeId
storeNodeId?: NodeId
name: string
storeName?: string
type: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
@@ -228,18 +231,15 @@ function safeWidgetMapper(
}
}
function resolvePromotedSourceByInputName(inputName: string): {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
} | null {
function resolvePromotedSourceByInputName(
inputName: string
): PromotedWidgetSource | null {
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
if (!resolvedTarget) return null
return {
sourceNodeId: resolvedTarget.nodeId,
sourceWidgetName: resolvedTarget.widgetName,
disambiguatingSourceNodeId: resolvedTarget.sourceNodeId
sourceWidgetName: resolvedTarget.widgetName
}
}
@@ -257,10 +257,9 @@ function safeWidgetMapper(
const matchedInput = matchPromotedInput(node.inputs, widget)
const promotedInputName = matchedInput?.name
const displayName = promotedInputName ?? widget.name
const directSource = {
const directSource: PromotedWidgetSource = {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
sourceWidgetName: widget.sourceWidgetName
}
const promotedSource =
matchedInput?._widget === widget
@@ -307,8 +306,7 @@ function safeWidgetMapper(
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName,
promotedSource.disambiguatingSourceNodeId
promotedSource.sourceWidgetName
)
: null
const resolvedSource =
@@ -321,24 +319,21 @@ function safeWidgetMapper(
const effectiveWidget = sourceWidget ?? widget
const localId = isPromotedWidgetView(widget)
? String(
sourceNode?.id ??
promotedSource?.disambiguatingSourceNodeId ??
promotedSource?.sourceNodeId
)
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const storeName = isPromotedWidgetView(widget)
const sourceWidgetName = isPromotedWidgetView(widget)
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
: undefined
const name = storeName ?? displayName
const name = sourceWidgetName ?? displayName
if (isPromotedWidgetView(widget)) widget.ensureHostWidgetState()
return {
entityId: getWidgetEntityIdForNode(node, widget),
nodeId,
storeNodeId: nodeId,
name,
storeName,
type: effectiveWidget.type,
...sharedEnhancements,
callback,
@@ -386,10 +381,10 @@ function buildSlotMetadata(
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
if (link) {
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
if (link && originNode) {
originNodeId = String(link.origin_id)
const originNode = graphRef.getNodeById(link.origin_id)
originOutputName = originNode?.outputs?.[link.origin_slot]?.name
originOutputName = originNode.outputs?.[link.origin_slot]?.name
}
}

View File

@@ -1,7 +1,10 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
'KSampler',
'KSamplerAdvanced',
'PreviewImage',
'SaveImage',
'GLSLShader'

View File

@@ -237,12 +237,15 @@ const normalizeWidgetValue = (
const buildJsonataContext = (
node: LGraphNode,
rule: JsonataPricingRule
rule: JsonataPricingRule,
widgetOverrides?: ReadonlyMap<string, unknown>
): JsonataEvalContext => {
const widgets: Record<string, NormalizedWidgetValue> = {}
for (const dep of rule.depends_on.widgets) {
const widget = node.widgets?.find((x: IBaseWidget) => x.name === dep.name)
widgets[dep.name] = normalizeWidgetValue(widget?.value, dep.type)
const raw = widgetOverrides?.has(dep.name)
? widgetOverrides.get(dep.name)
: node.widgets?.find((x: IBaseWidget) => x.name === dep.name)?.value
widgets[dep.name] = normalizeWidgetValue(raw, dep.type)
}
const inputs: Record<string, { connected: boolean }> = {}
@@ -552,7 +555,10 @@ export const useNodePricing = () => {
* - schedules async evaluation when needed
* - remains non-fatal on errors (returns safe fallback '')
*/
const getNodeDisplayPrice = (node: LGraphNode): string => {
const getNodeDisplayPrice = (
node: LGraphNode,
widgetOverrides?: ReadonlyMap<string, unknown>
): string => {
// Make this function reactive: when async evaluation completes, we bump pricingTick,
// which causes this getter to recompute in Vue render/computed contexts.
void pricingTick.value
@@ -565,7 +571,7 @@ export const useNodePricing = () => {
if (rule.engine !== 'jsonata') return ''
if (!rule._compiled) return ''
const ctx = buildJsonataContext(node, rule)
const ctx = buildJsonataContext(node, rule, widgetOverrides)
const sig = buildSignature(ctx, rule)
const cached = cache.get(node)

View File

@@ -1,6 +1,8 @@
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
@@ -9,14 +11,25 @@ componentIconSvg.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='oklch(83.01%25 0.163 83.16)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0zm-13.239 0a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0zm6.619 6.619a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0zm0-13.238a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z'/%3E%3C/svg%3E"
export const usePriceBadge = () => {
const nodePricing = useNodePricing()
function updateSubgraphCredits(node: LGraphNode) {
if (!node.isSubgraphNode()) return
node.badges = node.badges.filter((b) => !isCreditsBadge(b))
const newBadges = collectCreditsBadges(node.subgraph)
if (newBadges.length > 1) {
node.badges.push(getCreditsBadge('Partner Nodes x ' + newBadges.length))
} else {
node.badges.push(...newBadges)
const innerCreditsBadges = collectCreditsBadges(node.subgraph)
if (innerCreditsBadges.length > 1) {
node.badges.push(
getCreditsBadge('Partner Nodes x ' + innerCreditsBadges.length)
)
} else if (innerCreditsBadges.length === 1) {
const innerApiNodes = collectInnerApiNodes(node.subgraph)
// When a single inner api node is the price source, swap its static
// getter for a wrapper-aware one that resolves promoted widget values.
if (innerApiNodes.length === 1) {
node.badges.push(buildWrapperAwarePriceBadge(node, innerApiNodes[0]))
} else {
node.badges.push(...innerCreditsBadges)
}
}
const graph = node.graph
if (!graph) return
@@ -28,13 +41,14 @@ export const usePriceBadge = () => {
newValue: node.badges
})
}
function collectCreditsBadges(
graph: LGraph,
visited: Set<string> = new Set()
): (LGraphBadge | (() => LGraphBadge))[] {
if (visited.has(graph.id)) return []
visited.add(graph.id)
const badges = []
const badges: (LGraphBadge | (() => LGraphBadge))[] = []
for (const node of graph.nodes) {
badges.push(
...(node.isSubgraphNode()
@@ -45,6 +59,51 @@ export const usePriceBadge = () => {
return badges
}
function collectInnerApiNodes(
graph: LGraph,
visited: Set<string> = new Set()
): LGraphNode[] {
if (visited.has(graph.id)) return []
visited.add(graph.id)
const apiNodes: LGraphNode[] = []
for (const node of graph.nodes) {
if (node.isSubgraphNode()) {
apiNodes.push(...collectInnerApiNodes(node.subgraph, visited))
} else if (node.constructor?.nodeData?.api_node) {
apiNodes.push(node)
}
}
return apiNodes
}
function buildWrapperAwarePriceBadge(
wrapper: LGraphNode,
innerNode: LGraphNode
): () => LGraphBadge {
return () =>
getCreditsBadge(
nodePricing.getNodeDisplayPrice(
innerNode,
collectPromotedOverrides(wrapper, innerNode)
)
)
}
function collectPromotedOverrides(
wrapper: LGraphNode,
innerNode: LGraphNode
): ReadonlyMap<string, unknown> {
const overrides = new Map<string, unknown>()
if (!wrapper.isSubgraphNode()) return overrides
const innerId = String(innerNode.id)
for (const w of wrapper.widgets ?? []) {
if (!isPromotedWidgetView(w)) continue
if (w.sourceNodeId !== innerId) continue
overrides.set(w.sourceWidgetName, w.value)
}
return overrides
}
function isCreditsBadge(
badge: Partial<LGraphBadge> | (() => Partial<LGraphBadge>)
): boolean {

View File

@@ -9,35 +9,39 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { CANVAS_IMAGE_PREVIEW_WIDGET } from './canvasImagePreviewTypes'
import { usePromotedPreviews } from './usePromotedPreviews'
type MockNodeOutputStore = Pick<
ReturnType<typeof useNodeOutputStore>,
'nodeOutputs' | 'nodePreviewImages' | 'getNodeImageUrls'
| 'nodeOutputs'
| 'nodePreviewImages'
| 'getNodeImageUrls'
| 'getNodeImageUrlsByExecutionId'
| 'getNodeOutputByExecutionId'
| 'getNodePreviewImagesByExecutionId'
>
const getNodeImageUrls = vi.hoisted(() =>
vi.fn<MockNodeOutputStore['getNodeImageUrls']>()
)
const useNodeOutputStoreMock = vi.hoisted(() =>
vi.fn<() => MockNodeOutputStore>()
)
vi.mock('@/stores/nodeOutputStore', () => {
return {
useNodeOutputStore: useNodeOutputStoreMock
}
})
function createMockNodeOutputStore(): MockNodeOutputStore {
return {
const store: MockNodeOutputStore = {
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
getNodeImageUrls
getNodeImageUrls: vi.fn(),
getNodeImageUrlsByExecutionId: vi.fn(),
getNodeOutputByExecutionId: vi.fn(),
getNodePreviewImagesByExecutionId: vi.fn()
}
return { useNodeOutputStore: () => store }
})
function clearMockNodeOutputStore() {
const { nodeOutputs, nodePreviewImages } = useNodeOutputStore()
for (const key of Object.keys(nodeOutputs)) delete nodeOutputs[key]
for (const key of Object.keys(nodePreviewImages))
delete nodePreviewImages[key]
}
function createSetup() {
@@ -83,16 +87,43 @@ function seedPreviewImages(
}
}
describe(usePromotedPreviews, () => {
let nodeOutputStore: MockNodeOutputStore
function exposePreview(
setup: ReturnType<typeof createSetup>,
sourceNodeId: string,
sourcePreviewName = CANVAS_IMAGE_PREVIEW_WIDGET
) {
usePreviewExposureStore().addExposure(
setup.subgraphNode.rootGraph.id,
String(setup.subgraphNode.id),
{ sourceNodeId, sourcePreviewName }
)
}
interface ArrangeOptions {
id?: number
previewMediaType?: 'image' | 'video' | 'audio' | 'model'
urls?: string[]
}
function arrangePromotedPreview(options: ArrangeOptions = {}) {
const {
id = 10,
previewMediaType,
urls = ['/view?filename=output.png']
} = options
const setup = createSetup()
addInteriorNode(setup, { id, previewMediaType })
exposePreview(setup, String(id))
seedOutputs(setup.subgraph.id, [id])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(urls)
return { setup, urls }
}
describe(usePromotedPreviews, () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
getNodeImageUrls.mockReset()
nodeOutputStore = createMockNodeOutputStore()
useNodeOutputStoreMock.mockReturnValue(nodeOutputStore)
vi.resetAllMocks()
clearMockNodeOutputStore()
})
it('returns empty array for non-SubgraphNode', () => {
@@ -109,70 +140,50 @@ describe(usePromotedPreviews, () => {
it('returns empty array when no $$ promotions exist', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
})
it('returns image preview for promoted $$ widget with outputs', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const mockUrls = ['/view?filename=output.png']
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(mockUrls)
const { setup, urls } = arrangePromotedPreview({
previewMediaType: 'image'
})
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: mockUrls
urls
}
])
})
it('returns video type when interior node has video previewMediaType', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'video' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
it.for([
['video', '/view?filename=output.webm'],
['audio', '/view?filename=output.mp3']
] as const)(
'returns %s type when interior node has %s previewMediaType',
([mediaType, url]) => {
const { setup } = arrangePromotedPreview({
previewMediaType: mediaType,
urls: [url]
})
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(['/view?filename=output.webm'])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe(mediaType)
}
)
it('defaults preview type to image when previewMediaType is unset', () => {
const { setup, urls } = arrangePromotedPreview()
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe('video')
})
it('returns audio type when interior node has audio previewMediaType', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'audio' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(['/view?filename=output.mp3'])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe('audio')
expect(promotedPreviews.value).toEqual([
expect.objectContaining({ type: 'image', urls })
])
})
it('returns separate entries for multiple promoted $$ widgets', () => {
@@ -185,23 +196,17 @@ describe(usePromotedPreviews, () => {
id: 20,
previewMediaType: 'image'
})
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '20', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
exposePreview(setup, '20')
seedOutputs(setup.subgraph.id, [10, 20])
getNodeImageUrls.mockImplementation((node: LGraphNode) => {
if (node === node10) return ['/view?a=1']
if (node === node20) return ['/view?b=2']
return undefined
})
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
(node: LGraphNode) => {
if (node === node10) return ['/view?a=1']
if (node === node20) return ['/view?b=2']
return undefined
}
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(2)
@@ -212,21 +217,17 @@ describe(usePromotedPreviews, () => {
it('returns preview when only nodePreviewImages exist (e.g. GLSL live preview)', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
getNodeImageUrls.mockReturnValue([blobUrl])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([blobUrl])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: [blobUrl]
}
@@ -236,23 +237,19 @@ describe(usePromotedPreviews, () => {
it('recomputes when preview images are populated after first evaluation', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
getNodeImageUrls.mockReturnValue([blobUrl])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([blobUrl])
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: [blobUrl]
}
@@ -262,11 +259,7 @@ describe(usePromotedPreviews, () => {
it('skips interior nodes with no image output', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
@@ -274,36 +267,132 @@ describe(usePromotedPreviews, () => {
it('skips missing interior nodes', () => {
const setup = createSetup()
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '99', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '99')
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
})
it('ignores non-$$ promoted widgets', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
it('renders leaf media exposed through a nested subgraph host', () => {
const innerSetup = createSetup()
const leafNode = addInteriorNode(innerSetup, {
id: 10,
previewMediaType: 'image'
})
const outerSetup = createSetup()
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
outerSetup.subgraph.add(innerHost)
const store = usePreviewExposureStore()
store.addExposure(
outerSetup.subgraphNode.rootGraph.id,
String(innerHost.id),
{
sourceNodeId: String(leafNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
store.addExposure(
outerSetup.subgraphNode.rootGraph.id,
String(outerSetup.subgraphNode.id),
{
sourceNodeId: String(innerHost.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
)
const mockUrls = ['/view?filename=img.png']
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(mockUrls)
const mockUrls = ['/view?filename=leaf.png']
seedOutputs(innerSetup.subgraph.id, [leafNode.id])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
(node: LGraphNode) => (node === leafNode ? mockUrls : [])
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(1)
expect(promotedPreviews.value[0].urls).toEqual(mockUrls)
const { promotedPreviews } = usePromotedPreviews(
() => outerSetup.subgraphNode
)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: mockUrls
}
])
})
it('keeps promoted previews distinct for multiple instances of a shared subgraph definition', () => {
const innerSetup = createSetup()
const leafNode = addInteriorNode(innerSetup, {
id: 10,
previewMediaType: 'image'
})
const outerSetup = createSetup()
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
outerSetup.subgraph.add(innerHost)
const firstHost = createTestSubgraphNode(outerSetup.subgraph, { id: 11 })
const secondHost = createTestSubgraphNode(outerSetup.subgraph, { id: 12 })
const firstHostLocator = String(firstHost.id)
const secondHostLocator = String(secondHost.id)
const firstNestedLocator = `${firstHostLocator}:${innerHost.id}`
const secondNestedLocator = `${secondHostLocator}:${innerHost.id}`
const firstLeafExecutionId = `${firstNestedLocator}:${leafNode.id}`
const secondLeafExecutionId = `${secondNestedLocator}:${leafNode.id}`
const store = usePreviewExposureStore()
store.addExposure(firstHost.rootGraph.id, firstHostLocator, {
sourceNodeId: String(innerHost.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
store.addExposure(firstHost.rootGraph.id, secondHostLocator, {
sourceNodeId: String(innerHost.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
store.addExposure(firstHost.rootGraph.id, firstNestedLocator, {
sourceNodeId: String(leafNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
store.addExposure(firstHost.rootGraph.id, secondNestedLocator, {
sourceNodeId: String(leafNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
const outputStore = useNodeOutputStore()
vi.mocked(outputStore.getNodePreviewImagesByExecutionId).mockImplementation(
(executionId) => {
if (executionId === firstLeafExecutionId) return ['blob:first']
if (executionId === secondLeafExecutionId) return ['blob:second']
return undefined
}
)
vi.mocked(outputStore.getNodeImageUrlsByExecutionId).mockImplementation(
(executionId) => {
if (executionId === firstLeafExecutionId) return ['blob:first']
if (executionId === secondLeafExecutionId) return ['blob:second']
return undefined
}
)
expect(usePromotedPreviews(() => firstHost).promotedPreviews.value).toEqual(
[
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: ['blob:first']
}
]
)
expect(
usePromotedPreviews(() => secondHost).promotedPreviews.value
).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: ['blob:second']
}
])
})
})

View File

@@ -3,8 +3,9 @@ import { computed, toValue } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
interface PromotedPreview {
@@ -14,65 +15,126 @@ interface PromotedPreview {
urls: string[]
}
/**
* Returns reactive preview media from promoted `$$` pseudo-widgets
* on a SubgraphNode. Each promoted preview interior node produces
* a separate entry so they render independently.
*/
const PREVIEW_TYPES_BY_MEDIA = {
video: 'video',
audio: 'audio'
} as const satisfies Partial<Record<string, PromotedPreview['type']>>
function getPreviewMediaType(node: LGraphNode): PromotedPreview['type'] {
const media = node.previewMediaType
if (media && media in PREVIEW_TYPES_BY_MEDIA) {
return PREVIEW_TYPES_BY_MEDIA[media as keyof typeof PREVIEW_TYPES_BY_MEDIA]
}
return 'image'
}
export function usePromotedPreviews(
lgraphNode: MaybeRefOrGetter<LGraphNode | null | undefined>
) {
const promotionStore = usePromotionStore()
const previewExposureStore = usePreviewExposureStore()
const nodeOutputStore = useNodeOutputStore()
/** Touches reactive sources for Vue tracking; `getNodeImageUrls` reads non-reactive app state. */
function readReactivePreviewUrls(
leafHost: SubgraphNode,
leafSourceNodeId: string,
leafExecutionId: string,
interiorNode: LGraphNode
): string[] | undefined {
const locatorId = createNodeLocatorId(
leafHost.subgraph.id,
leafSourceNodeId
)
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
const reactiveExecutionOutputs =
nodeOutputStore.getNodeOutputByExecutionId(leafExecutionId)
const reactiveExecutionPreviews =
nodeOutputStore.getNodePreviewImagesByExecutionId(leafExecutionId)
const hasAnySource =
reactiveOutputs?.images?.length ||
reactivePreviews?.length ||
reactiveExecutionOutputs?.images?.length ||
reactiveExecutionPreviews?.length
if (!hasAnySource) return undefined
return (
nodeOutputStore.getNodeImageUrlsByExecutionId(
leafExecutionId,
interiorNode
) ?? nodeOutputStore.getNodeImageUrls(interiorNode)
)
}
const promotedPreviews = computed((): PromotedPreview[] => {
const node = toValue(lgraphNode)
if (!(node instanceof SubgraphNode)) return []
const entries = promotionStore.getPromotions(node.rootGraph.id, node.id)
const pseudoEntries = entries.filter((e) =>
e.sourceWidgetName.startsWith('$$')
const rootGraphId = node.rootGraph.id
const hostLocator = String(node.id)
const exposures = previewExposureStore.getExposures(
rootGraphId,
hostLocator
)
if (!pseudoEntries.length) return []
if (!exposures.length) return []
const previews: PromotedPreview[] = []
const hostNodesByLocator = new Map<string, SubgraphNode>([
[hostLocator, node]
])
for (const entry of pseudoEntries) {
const interiorNode = node.subgraph.getNodeById(entry.sourceNodeId)
if (!interiorNode) continue
function resolveNestedHost(
rootGraphId: UUID,
currentHostLocator: string,
sourceNodeId: string
) {
const currentHost = hostNodesByLocator.get(currentHostLocator)
const sourceNode = currentHost?.subgraph.getNodeById(sourceNodeId)
if (!(sourceNode instanceof SubgraphNode)) return undefined
// Read from both reactive refs to establish Vue dependency
// tracking. getNodeImageUrls reads from non-reactive
// app.nodeOutputs / app.nodePreviewImages, so without this
// access the computed would never re-evaluate.
const locatorId = createNodeLocatorId(
node.subgraph.id,
entry.sourceNodeId
)
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
if (!reactiveOutputs?.images?.length && !reactivePreviews?.length)
continue
const urls = nodeOutputStore.getNodeImageUrls(interiorNode)
if (!urls?.length) continue
const type =
interiorNode.previewMediaType === 'video'
? 'video'
: interiorNode.previewMediaType === 'audio'
? 'audio'
: 'image'
previews.push({
sourceNodeId: entry.sourceNodeId,
sourceWidgetName: entry.sourceWidgetName,
type,
urls
})
const pathLocator = `${currentHostLocator}:${sourceNode.id}`
const definitionLocator = String(sourceNode.id)
const hasPathExposures =
previewExposureStore.getExposures(rootGraphId, pathLocator).length > 0
const nestedHostLocator = hasPathExposures
? pathLocator
: definitionLocator
hostNodesByLocator.set(nestedHostLocator, sourceNode)
return { rootGraphId, hostNodeLocator: nestedHostLocator }
}
return previews
return exposures.flatMap((exposure): PromotedPreview[] => {
const resolved = previewExposureStore.resolveChain(
rootGraphId,
hostLocator,
exposure.name,
resolveNestedHost
)
const leaf = resolved?.leaf ?? {
sourceNodeId: exposure.sourceNodeId,
sourcePreviewName: exposure.sourcePreviewName
}
const leafHostLocator =
resolved?.steps.at(-1)?.hostNodeLocator ?? hostLocator
const leafHost = hostNodesByLocator.get(leafHostLocator) ?? node
const interiorNode = leafHost.subgraph.getNodeById(leaf.sourceNodeId)
if (!interiorNode) return []
const urls = readReactivePreviewUrls(
leafHost,
leaf.sourceNodeId,
`${leafHostLocator}:${leaf.sourceNodeId}`,
interiorNode
)
if (!urls?.length) return []
return [
{
sourceNodeId: leaf.sourceNodeId,
sourceWidgetName: leaf.sourcePreviewName,
type: getPreviewMediaType(interiorNode),
urls
}
]
})
})
return { promotedPreviews }

View File

@@ -1,122 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { normalizeLegacyProxyWidgetEntry } from '@/core/graph/subgraph/legacyProxyWidgetNormalization'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
function createHostWithInnerWidget(widgetName: string) {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'value', type: 'number' }]
})
const innerNode = new LGraphNode('InnerNode')
const input = innerNode.addInput('value', 'number')
innerNode.addWidget('number', widgetName, 0, () => {})
input.widget = { name: widgetName }
innerSubgraph.add(innerNode)
innerSubgraph.inputNode.slots[0].connect(innerNode.inputs[0], innerNode)
const hostNode = createTestSubgraphNode(innerSubgraph, {
parentGraph: rootGraph
})
return { rootGraph, innerSubgraph, innerNode, hostNode }
}
describe('normalizeLegacyProxyWidgetEntry', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
it('returns entry unchanged when it already resolves', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'seed'
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed'
})
})
it('returns entry unchanged with disambiguator when it already resolves', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'seed',
String(innerNode.id)
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: String(innerNode.id)
})
})
it('strips a single legacy prefix from widget name', () => {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: 'number' }]
})
const samplerNode = new LGraphNode('Sampler')
const samplerInput = samplerNode.addInput('seed', 'number')
samplerNode.addWidget('number', 'noise_seed', 42, () => {})
samplerInput.widget = { name: 'noise_seed' }
innerSubgraph.add(samplerNode)
innerSubgraph.inputNode.slots[0].connect(samplerNode.inputs[0], samplerNode)
const outerSubgraph = createTestSubgraph({ rootGraph })
const nestedNode = createTestSubgraphNode(innerSubgraph, {
parentGraph: outerSubgraph
})
outerSubgraph.add(nestedNode)
const hostNode = createTestSubgraphNode(outerSubgraph, {
parentGraph: rootGraph
})
const prefixedName = `${nestedNode.id}: ${samplerNode.id}: noise_seed`
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(nestedNode.id),
prefixedName
)
expect(result.sourceWidgetName).toBe('noise_seed')
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
})
it('returns original entry when prefix cannot be resolved', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'999: nonexistent_widget'
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: '999: nonexistent_widget'
})
})
})

View File

@@ -1,111 +0,0 @@
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
const LEGACY_PROXY_WIDGET_PREFIX_PATTERN = /^\s*(\d+)\s*:\s*(.+)$/
type PromotedWidgetPatch = Omit<PromotedWidgetSource, 'sourceNodeId'>
function canResolve(
hostNode: SubgraphNode,
sourceNodeId: string,
widgetName: string,
disambiguator?: string
): boolean {
return (
resolveConcretePromotedWidget(
hostNode,
sourceNodeId,
widgetName,
disambiguator
).status === 'resolved'
)
}
function tryResolveCandidate(
hostNode: SubgraphNode,
sourceNodeId: string,
widgetName: string,
disambiguator?: string
): PromotedWidgetPatch | undefined {
if (!canResolve(hostNode, sourceNodeId, widgetName, disambiguator))
return undefined
return {
sourceWidgetName: widgetName,
...(disambiguator && { disambiguatingSourceNodeId: disambiguator })
}
}
function resolveLegacyPrefixedEntry(
hostNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): PromotedWidgetPatch | undefined {
let remaining = sourceWidgetName
while (true) {
const match = LEGACY_PROXY_WIDGET_PREFIX_PATTERN.exec(remaining)
if (!match) return undefined
const [, legacySourceNodeId, unprefixed] = match
remaining = unprefixed
const disambiguators = [
legacySourceNodeId,
...(disambiguatingSourceNodeId ? [disambiguatingSourceNodeId] : []),
undefined
]
for (const disambiguator of disambiguators) {
const resolved = tryResolveCandidate(
hostNode,
sourceNodeId,
remaining,
disambiguator
)
if (resolved) return resolved
}
}
}
export function normalizeLegacyProxyWidgetEntry(
hostNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): PromotedWidgetSource {
if (
canResolve(
hostNode,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
)
) {
return {
sourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
}
}
const patch = resolveLegacyPrefixedEntry(
hostNode,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
)
const patchDisambiguatingSourceNodeId =
patch?.disambiguatingSourceNodeId ?? disambiguatingSourceNodeId
return {
sourceNodeId,
sourceWidgetName: patch?.sourceWidgetName ?? sourceWidgetName,
...(patchDisambiguatingSourceNodeId && {
disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId
})
}
}

View File

@@ -0,0 +1,895 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
LGraph,
LGraphNode,
LiteGraph,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import {
flushProxyWidgetMigration,
normalizeLegacyProxyWidgetEntry,
readHostQuarantine
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
LGraph.proxyWidgetMigrationFlush = undefined
})
function buildHost(): SubgraphNode {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
hostNode.graph!.add(hostNode)
return hostNode
}
function addInnerNode(
host: SubgraphNode,
type: string,
build: (node: LGraphNode) => void = () => {}
): LGraphNode {
const node = new LGraphNode(type)
build(node)
host.subgraph.add(node)
return node
}
function addPromotedHostInput(
host: SubgraphNode,
args: {
inputName: string
promotedName: string
sourceNodeId: string
sourceWidgetName: string
initialValue?: TWidgetValue
}
): { setValue: (v: TWidgetValue) => void; getValue: () => TWidgetValue } {
let widgetValue: TWidgetValue = args.initialValue ?? 0
const slot = host.addInput(args.inputName, '*')
slot._widget = fromPartial<PromotedWidgetView>({
node: host,
name: args.promotedName,
sourceNodeId: args.sourceNodeId,
sourceWidgetName: args.sourceWidgetName,
get value() {
return widgetValue
},
set value(v: TWidgetValue) {
widgetValue = v
},
hydrateHostValue(v: TWidgetValue) {
widgetValue = v
}
})
return {
setValue: (v) => {
widgetValue = v
},
getValue: () => widgetValue
}
}
function addPrimitiveWithTargets(
host: SubgraphNode,
args: {
primitiveType?: string
primitiveValue?: number
targetCount: number
outputType?: string
targetSlotType?: string
}
): { primitive: LGraphNode; targets: LGraphNode[] } {
const outputType = args.outputType ?? 'INT'
const targetSlotType = args.targetSlotType ?? outputType
const primitive = new LGraphNode('PrimitiveNode')
primitive.type = 'PrimitiveNode'
primitive.addOutput('value', outputType)
primitive.addWidget('number', 'value', args.primitiveValue ?? 42, () => {})
host.subgraph.add(primitive)
const targets: LGraphNode[] = []
for (let i = 0; i < args.targetCount; i++) {
const target = new LGraphNode(`Target${i}`)
const slot = target.addInput('value', targetSlotType)
slot.widget = { name: 'value' }
target.addWidget('number', 'value', 0, () => {})
host.subgraph.add(target)
primitive.connect(0, target, 0)
targets.push(target)
}
return { primitive, targets }
}
describe('flushProxyWidgetMigration', () => {
describe('no-op cases', () => {
it('returns an empty result when no proxyWidgets are present', () => {
const host = buildHost()
flushProxyWidgetMigration({ hostNode: host })
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('tolerates a malformed proxyWidgets payload and returns empty', () => {
const host = buildHost()
host.properties.proxyWidgets = '{not json}'
expect(() => flushProxyWidgetMigration({ hostNode: host })).not.toThrow()
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
})
})
describe('value-widget repair', () => {
it('alreadyLinked: applies host value to the matching promoted widget', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const handle = addPromotedHostInput(host, {
inputName: 'seed_link',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 0
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(handle.getValue()).toBe(99)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('alreadyLinked: hydrates real promoted widget without mutating the interior widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const host = createTestSubgraphNode(subgraph)
host.graph!.add(host)
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('seed', 'INT')
const innerWidget = n.addWidget('number', 'seed', 0, () => {})
slot.widget = { name: innerWidget.name }
})
subgraph.inputNode.slots[0].connect(inner.inputs[0], inner)
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(host.widgets[0].value).toBe(99)
const innerWidget = inner.widgets!.find((w) => w.name === 'seed')!
expect(innerWidget.value).toBe(0)
})
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const handle = addPromotedHostInput(host, {
inputName: 'seed_link',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 7
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
const sparse: unknown[] = []
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: sparse
})
expect(handle.getValue()).toBe(7)
})
it('alreadyLinked: ambiguous matching inputs quarantine without applying host value', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const a = addPromotedHostInput(host, {
inputName: 'first_seed',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 1
})
const b = addPromotedHostInput(host, {
inputName: 'second_seed',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 2
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(a.getValue()).toBe(1)
expect(b.getValue()).toBe(2)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(inner.id), 'seed'],
reason: 'ambiguousSubgraphInput'
})
])
})
it('createSubgraphInput: creates exactly one new SubgraphInput linked to the source widget', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('seed', 'INT')
slot.widget = { name: 'seed' }
n.addWidget('number', 'seed', 0, () => {})
})
const inputCountBefore = host.subgraph.inputs.length
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(host.subgraph.inputs).toHaveLength(inputCountBefore + 1)
const created = host.subgraph.inputs.at(-1)
expect(created?._widget).toBeDefined()
})
it('createSubgraphInput: honors disambiguatingSourceNodeId when source widget name has been deduplicated', () => {
const host = buildHost()
const inner = addInnerNode(host, 'InnerWithDedupedPromotion', (n) => {
const slot1 = n.addInput('text', 'STRING')
slot1.widget = { name: 'text' }
const w1 = n.addWidget('text', 'text', '11111111111', () => {})
Object.assign(w1, { sourceNodeId: '1', sourceWidgetName: 'text' })
const slot2 = n.addInput('text_1', 'STRING')
slot2.widget = { name: 'text_1' }
const w2 = n.addWidget('text', 'text_1', '22222222222', () => {})
Object.assign(w2, { sourceNodeId: '2', sourceWidgetName: 'text' })
})
host.properties.proxyWidgets = [[String(inner.id), 'text', '2']]
flushProxyWidgetMigration({ hostNode: host })
const created = host.subgraph.inputs.at(-1)
expect(created?._widget).toBeDefined()
const linkedSlot = inner.inputs.find(
(slot) => slot.link === created?.linkIds[0]
)
expect(linkedSlot?.name).toBe('text_1')
})
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const inputCountBefore = host.subgraph.inputs.length
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(host.subgraph.inputs).toHaveLength(inputCountBefore)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(inner.id), 'seed'],
reason: 'missingSubgraphInput'
})
])
})
})
describe('primitive fan-out repair', () => {
it('repairs 1 primitive fanned out to 3 targets into a single SubgraphInput', () => {
const host = buildHost()
const { primitive, targets } = addPrimitiveWithTargets(host, {
targetCount: 3
})
const inputCountBefore = host.subgraph.inputs.length
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(host.subgraph.inputs).toHaveLength(inputCountBefore + 1)
for (const target of targets) {
const slot = target.inputs[0]
expect(slot.link).not.toBeNull()
const link = host.subgraph.links.get(slot.link!)
expect(link?.origin_id).not.toBe(primitive.id)
}
})
it('coalesces duplicate cohort entries pointing at the same primitive', () => {
const host = buildHost()
const { primitive, targets } = addPrimitiveWithTargets(host, {
targetCount: 2
})
host.properties.proxyWidgets = [
[String(primitive.id), 'value'],
[String(primitive.id), 'value']
]
flushProxyWidgetMigration({ hostNode: host })
for (const target of targets) {
const slot = target.inputs[0]
const link = host.subgraph.links.get(slot.link!)
expect(link?.origin_id).not.toBe(primitive.id)
}
})
it('host value wins over primitive widget value', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 2,
primitiveValue: 11
})
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [123]
})
const hostInput = host.inputs.at(-1)
expect(hostInput?._widget?.value).toBe(123)
})
it('seeds value from the primitive widget when no host value is supplied', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 1,
primitiveValue: 11
})
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
const hostInput = host.inputs.at(-1)
expect(hostInput?._widget?.value).toBe(11)
})
it('quarantines an unlinked primitive node with no fan-out', () => {
const host = buildHost()
const primitive = new LGraphNode('Primitive')
primitive.type = 'PrimitiveNode'
primitive.addOutput('value', '*')
host.subgraph.add(primitive)
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'unlinkedSourceWidget'
})
])
})
it('quarantines all cohort entries when a target slot type is incompatible', () => {
const host = buildHost()
const { primitive, targets } = addPrimitiveWithTargets(host, {
targetCount: 1
})
targets[0].inputs[0].type = 'STRING'
const inputCountBefore = host.subgraph.inputs.length
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(host.subgraph.inputs).toHaveLength(inputCountBefore)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'primitiveBypassFailed'
})
])
})
it('keeps surviving primitive targets when one fan-out link is dangling', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, { targetCount: 1 })
const danglingLinkId = 999_999
expect(host.subgraph.links.has(danglingLinkId)).toBe(false)
primitive.outputs[0].links = [
...(primitive.outputs[0].links ?? []),
danglingLinkId
]
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'primitiveBypassFailed'
})
])
})
it('keeps independent values across two hosts of the same subgraph', () => {
const subgraph = createTestSubgraph()
const hostA = createTestSubgraphNode(subgraph)
const hostB = createTestSubgraphNode(subgraph)
hostA.graph!.add(hostA)
hostB.graph!.add(hostB)
const primitive = new LGraphNode('PrimitiveNode')
primitive.type = 'PrimitiveNode'
primitive.addOutput('value', 'INT')
primitive.addWidget('number', 'value', 0, () => {})
subgraph.add(primitive)
const target = new LGraphNode('Target')
const slot = target.addInput('value', 'INT')
slot.widget = { name: 'value' }
target.addWidget('number', 'value', 0, () => {})
subgraph.add(target)
primitive.connect(0, target, 0)
hostA.properties.proxyWidgets = [[String(primitive.id), 'value']]
hostB.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({
hostNode: hostA,
hostWidgetValues: [11]
})
flushProxyWidgetMigration({
hostNode: hostB,
hostWidgetValues: [22]
})
expect(hostA.properties.proxyWidgetErrorQuarantine).toBeUndefined()
expect(hostB.properties.proxyWidgetErrorQuarantine).toBeUndefined()
const widgetA = hostA.inputs.at(-1)?._widget
const widgetB = hostB.inputs.at(-1)?._widget
expect(widgetA?.value).toBe(11)
expect(widgetB?.value).toBe(22)
})
})
describe('preview exposure migration', () => {
it('adds an exposure for a $$-prefixed preview source', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
host.properties.proxyWidgets = [
[String(inner.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({ hostNode: host })
const exposures = usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
expect(exposures).toHaveLength(1)
expect(exposures[0].sourcePreviewName).toBe('$$canvas-image-preview')
expect(exposures[0].sourceNodeId).toBe(String(inner.id))
})
it('classifies type:preview serialize:false widgets as preview exposure', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const widget = n.addWidget('text', 'videopreview', '', () => {})
widget.type = 'preview'
widget.serialize = false
})
host.properties.proxyWidgets = [[String(inner.id), 'videopreview']]
flushProxyWidgetMigration({ hostNode: host })
const exposures = usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
expect(exposures).toEqual([
expect.objectContaining({
sourceNodeId: String(inner.id),
sourcePreviewName: 'videopreview'
})
])
})
it('produces a unique name on collision via nextUniqueName', () => {
const host = buildHost()
const innerA = addInnerNode(host, 'InnerA', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
const innerB = addInnerNode(host, 'InnerB', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
const store = usePreviewExposureStore()
const locator = String(host.id)
store.addExposure(host.rootGraph.id, locator, {
sourceNodeId: String(innerA.id),
sourcePreviewName: '$$canvas-image-preview'
})
host.properties.proxyWidgets = [
[String(innerB.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({ hostNode: host })
const exposures = store.getExposures(host.rootGraph.id, locator)
expect(exposures).toHaveLength(2)
const newExposure = exposures.find(
(e) => e.sourceNodeId === String(innerB.id)
)
expect(newExposure?.name).toBe('$$canvas-image-preview_1')
})
it('reuses an existing exposure for the same source preview', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
const store = usePreviewExposureStore()
const locator = String(host.id)
store.addExposure(host.rootGraph.id, locator, {
sourceNodeId: String(inner.id),
sourcePreviewName: '$$canvas-image-preview'
})
host.properties.proxyWidgets = [
[String(inner.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({ hostNode: host })
expect(store.getExposures(host.rootGraph.id, locator)).toHaveLength(1)
})
})
describe('quarantine accumulation', () => {
it('quarantines entries whose source node has disappeared', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
{
originalEntry: ['9999', 'seed'],
reason: 'missingSourceNode',
attemptedAtVersion: 1
}
])
})
it('quarantines entries whose source widget is missing on the source node', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner')
host.properties.proxyWidgets = [[String(inner.id), 'nonexistent']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(inner.id), 'nonexistent'],
reason: 'missingSourceWidget'
})
])
})
it('preserves the host value on the quarantine row when one was supplied', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [42]
})
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: ['9999', 'seed'],
reason: 'missingSourceNode',
hostValue: 42
})
])
})
it('round-trips appended entries via the public read helper', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({ hostNode: host })
const first = readHostQuarantine(host)
expect(first).toHaveLength(1)
host.properties.proxyWidgets = [['9999', 'seed', 'inner-leaf']]
flushProxyWidgetMigration({ hostNode: host })
const after = readHostQuarantine(host)
expect(after).toHaveLength(2)
expect(after.map((e) => e.originalEntry)).toEqual([
['9999', 'seed'],
['9999', 'seed', 'inner-leaf']
])
})
it('deduplicates entries with identical originalEntry tuples on re-flush', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({ hostNode: host })
const firstQuarantine = readHostQuarantine(host)
expect(firstQuarantine).toHaveLength(1)
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual(firstQuarantine)
})
})
describe('idempotency', () => {
it('clears properties.proxyWidgets after a successful flush', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
host.properties.proxyWidgets = [
[String(inner.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({ hostNode: host })
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('re-running flush over a fully migrated host produces no further mutations', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
host.properties.proxyWidgets = [
[String(inner.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({ hostNode: host })
const exposuresAfterFirst = usePreviewExposureStore()
.getExposures(host.rootGraph.id, String(host.id))
.map((e) => ({ ...e }))
expect(exposuresAfterFirst).toHaveLength(1)
flushProxyWidgetMigration({ hostNode: host })
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
expect(
usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
).toEqual(exposuresAfterFirst)
})
})
describe('mixed cohort', () => {
it('migrates a mixed value+preview cohort in one flush, preserving entry order', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('seed', 'INT')
slot.widget = { name: 'seed' }
n.addWidget('number', 'seed', 0, () => {})
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
const subgraphInputCountBefore = host.subgraph.inputs.length
host.properties.proxyWidgets = [
[String(inner.id), 'seed'],
[String(inner.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(host.subgraph.inputs).toHaveLength(subgraphInputCountBefore + 1)
expect(host.subgraph.inputs.find((i) => i.name === 'seed')).toBeDefined()
const exposures = usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
expect(exposures).toHaveLength(1)
expect(exposures[0].sourcePreviewName).toBe('$$canvas-image-preview')
})
it('preserves sparse holes when supplied widgets_values is missing an index', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const slotA = n.addInput('a', 'INT')
slotA.widget = { name: 'a' }
n.addWidget('number', 'a', 0, () => {})
const slotB = n.addInput('b', 'INT')
slotB.widget = { name: 'b' }
n.addWidget('number', 'b', 0, () => {})
})
host.properties.proxyWidgets = [
[String(inner.id), 'a'],
[String(inner.id), 'b']
]
const sparse: unknown[] = []
sparse[1] = 'second-value'
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: sparse
})
expect(host.subgraph.inputs.find((i) => i.name === 'a')).toBeDefined()
expect(host.subgraph.inputs.find((i) => i.name === 'b')).toBeDefined()
})
})
describe('integration with LGraph.configure', () => {
it('runs through LGraph.configure when the migration hook is wired', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
host.properties.proxyWidgets = [
[String(inner.id), '$$canvas-image-preview']
]
const serialized = host.rootGraph.serialize()
LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
flushProxyWidgetMigration({
hostNode,
hostWidgetValues: nodeData?.widgets_values
})
const reloadedGraph = new LGraph()
const subgraph = host.subgraph
const instanceData = host.serialize()
LiteGraph.registerNodeType(
subgraph.id,
class TestSubgraphNode extends SubgraphNode {
constructor() {
super(reloadedGraph, subgraph, instanceData)
}
}
)
try {
reloadedGraph.configure(serialized)
} finally {
LiteGraph.unregisterNodeType(subgraph.id)
}
const reloadedHost = reloadedGraph.getNodeById(host.id)
expect(reloadedHost?.properties.proxyWidgets).toBeUndefined()
expect(
usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
).toEqual([
expect.objectContaining({
sourceNodeId: String(inner.id),
sourcePreviewName: '$$canvas-image-preview'
})
])
})
})
})
describe('normalizeLegacyProxyWidgetEntry', () => {
function createHostWithInnerWidget(widgetName: string) {
const subgraph = createTestSubgraph()
const innerNode = new LGraphNode('InnerNode')
const input = innerNode.addInput('value', 'number')
innerNode.addWidget('number', widgetName, 0, () => {})
input.widget = { name: widgetName }
subgraph.add(innerNode)
const hostNode = createTestSubgraphNode(subgraph)
hostNode.graph!.add(hostNode)
return { innerNode, hostNode }
}
it('returns entry unchanged when it already resolves', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'seed'
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed'
})
})
it('returns entry unchanged with disambiguator when it already resolves', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'seed',
String(innerNode.id)
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: String(innerNode.id)
})
})
it('strips a single legacy prefix from widget name', () => {
const innerSubgraph = createTestSubgraph()
const samplerNode = new LGraphNode('Sampler')
const samplerInput = samplerNode.addInput('seed', 'number')
samplerNode.addWidget('number', 'noise_seed', 42, () => {})
samplerInput.widget = { name: 'noise_seed' }
innerSubgraph.add(samplerNode)
const hostNode = createTestSubgraphNode(innerSubgraph)
hostNode.graph!.add(hostNode)
const prefixedName = `${samplerNode.id}: noise_seed`
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(samplerNode.id),
prefixedName
)
expect(result.sourceWidgetName).toBe('noise_seed')
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
})
it('strips legacy prefix and surfaces it as disambiguator even when the bare name does not resolve', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'999: nonexistent_widget'
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'nonexistent_widget',
disambiguatingSourceNodeId: '999'
})
})
})

View File

@@ -0,0 +1,790 @@
import { isEqual } from 'es-toolkit/compat'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
findHostInputForPromotion,
getPromotableWidgets,
isPreviewPseudoWidget
} from '@/core/graph/subgraph/promotionUtils'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import type {
ProxyWidgetErrorQuarantineEntry,
ProxyWidgetQuarantineReason
} from '@/core/schemas/proxyWidgetQuarantineSchema'
import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuarantineSchema'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type {
IBaseWidget,
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
interface LegacyProxyEntrySource extends PromotedWidgetSource {
disambiguatingSourceNodeId?: string
}
const LEGACY_PROXY_WIDGET_PREFIX_PATTERN = /^\s*(\d+)\s*:\s*(.+)$/
interface StrippedPrefix {
sourceWidgetName: string
deepestPrefixId?: string
}
function stripLegacyPrefixes(sourceWidgetName: string): StrippedPrefix {
let remaining = sourceWidgetName
let deepestPrefixId: string | undefined
while (true) {
const match = LEGACY_PROXY_WIDGET_PREFIX_PATTERN.exec(remaining)
if (!match) return { sourceWidgetName: remaining, deepestPrefixId }
deepestPrefixId = match[1]
remaining = match[2]
}
}
function canResolveLegacyProxy(
hostNode: SubgraphNode,
sourceNodeId: string,
widgetName: string
): boolean {
return (
resolveConcretePromotedWidget(hostNode, sourceNodeId, widgetName).status ===
'resolved'
)
}
export function normalizeLegacyProxyWidgetEntry(
hostNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): LegacyProxyEntrySource {
if (canResolveLegacyProxy(hostNode, sourceNodeId, sourceWidgetName)) {
return {
sourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
}
}
const stripped = stripLegacyPrefixes(sourceWidgetName)
const patchDisambiguatingSourceNodeId =
stripped.deepestPrefixId ?? disambiguatingSourceNodeId
return {
sourceNodeId,
sourceWidgetName: stripped.sourceWidgetName,
...(patchDisambiguatingSourceNodeId && {
disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId
})
}
}
function resolveSourceWidget(
sourceNode: LGraphNode,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): IBaseWidget | undefined {
const widgets = sourceNode.widgets
if (widgets && disambiguatingSourceNodeId !== undefined) {
const byDisambiguator = widgets.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceNodeId === disambiguatingSourceNodeId &&
w.sourceWidgetName === sourceWidgetName
)
if (byDisambiguator) return byDisambiguator
// Disambiguator missed: fall back only to non-promoted same-name widgets.
// A sibling PromotedWidgetView would re-introduce the cross-binding bug.
const byName = widgets.find(
(w) => !isPromotedWidgetView(w) && w.name === sourceWidgetName
)
if (byName) return byName
}
return (
widgets?.find((w) => w.name === sourceWidgetName) ??
getPromotableWidgets(sourceNode).find((w) => w.name === sourceWidgetName)
)
}
interface FlushArgs {
hostNode: SubgraphNode
hostWidgetValues?: readonly unknown[]
}
interface PrimitiveBypassTargetRef {
targetNodeId: NodeId
targetSlot: number
}
type Plan =
| { kind: 'alreadyLinked'; subgraphInputName: string }
| { kind: 'createSubgraphInput'; sourceWidgetName: string }
| {
kind: 'primitiveBypass'
primitiveNodeId: NodeId
sourceWidgetName: string
targets: readonly PrimitiveBypassTargetRef[]
}
| { kind: 'previewExposure'; sourcePreviewName: string }
| { kind: 'quarantine'; reason: ProxyWidgetQuarantineReason }
interface PendingEntry {
normalized: LegacyProxyEntrySource
hostValue: TWidgetValue | undefined
isHole: boolean
plan: Plan
}
const PRIMITIVE_NODE_TYPE = 'PrimitiveNode'
const QUARANTINE_PROPERTY = 'proxyWidgetErrorQuarantine'
const QUARANTINE_VERSION = 1
const PROXY_BYPASS_MARKER_PROPERTY = 'proxyBypassedToSubgraphInput'
export function flushProxyWidgetMigration(args: FlushArgs): void {
const { hostNode, hostWidgetValues } = args
const tuples = parseProxyWidgets(hostNode.properties.proxyWidgets)
if (tuples.length === 0) return
const cohort: LegacyProxyEntrySource[] = tuples.map(
([sourceNodeId, sourceWidgetName, disambiguator]) =>
normalizeLegacyProxyWidgetEntry(
hostNode,
sourceNodeId,
sourceWidgetName,
disambiguator
)
)
const pending: PendingEntry[] = cohort.map((normalized, index) => {
const { value, isHole } = pickHostValue(hostWidgetValues, index)
return {
normalized,
hostValue: value,
isHole,
plan: classify(hostNode, normalized, cohort)
}
})
const previewStore = usePreviewExposureStore()
const quarantineToAppend: ProxyWidgetErrorQuarantineEntry[] = []
const primitiveCohorts = new Map<NodeId, PendingEntry[]>()
for (const entry of pending) {
switch (entry.plan.kind) {
case 'primitiveBypass': {
const c = primitiveCohorts.get(entry.plan.primitiveNodeId) ?? []
c.push(entry)
primitiveCohorts.set(entry.plan.primitiveNodeId, c)
break
}
case 'alreadyLinked': {
const r = repairAlreadyLinked(
hostNode,
entry,
entry.plan.subgraphInputName
)
if (!r.ok) quarantineToAppend.push(quarantineFor(entry, r.reason))
break
}
case 'createSubgraphInput': {
const r = repairCreateSubgraphInput(
hostNode,
entry,
entry.plan.sourceWidgetName
)
if (!r.ok) quarantineToAppend.push(quarantineFor(entry, r.reason))
break
}
case 'previewExposure': {
const r = migratePreview(hostNode, entry, previewStore, entry.plan)
if (!r.ok) quarantineToAppend.push(quarantineFor(entry, r.reason))
break
}
case 'quarantine':
quarantineToAppend.push(quarantineFor(entry, entry.plan.reason))
break
}
}
for (const c of primitiveCohorts.values()) {
const r = repairPrimitive(hostNode, c)
if (!r.ok)
for (const e of c) quarantineToAppend.push(quarantineFor(e, r.reason))
}
if (quarantineToAppend.length > 0) {
appendQuarantine(hostNode, quarantineToAppend)
}
delete hostNode.properties.proxyWidgets
}
function pickHostValue(
hostWidgetValues: readonly unknown[] | undefined,
index: number
): { value: TWidgetValue | undefined; isHole: boolean } {
if (
hostWidgetValues === undefined ||
index < 0 ||
index >= hostWidgetValues.length ||
!Object.hasOwn(hostWidgetValues, index)
) {
return { value: undefined, isHole: true }
}
const raw = hostWidgetValues[index]
if (!isWidgetValue(raw)) return { value: undefined, isHole: true }
return { value: raw, isHole: false }
}
function collectTargetsStrict(
hostNode: SubgraphNode,
primitiveNode: LGraphNode
): PrimitiveBypassTargetRef[] | undefined {
const subgraph = hostNode.subgraph
const output = primitiveNode.outputs?.[0]
const linkIds = output?.links ?? []
const targets: PrimitiveBypassTargetRef[] = []
for (const linkId of linkIds) {
const link = subgraph.links.get(linkId)
if (!link) return undefined
targets.push({
targetNodeId: link.target_id,
targetSlot: link.target_slot
})
}
return targets
}
function collectTargetsSkippingDangling(
hostNode: SubgraphNode,
primitiveNode: LGraphNode
): PrimitiveBypassTargetRef[] {
const subgraph = hostNode.subgraph
const linkIds = primitiveNode.outputs?.[0]?.links ?? []
return linkIds.flatMap((linkId) => {
const link = subgraph.links.get(linkId)
return link
? [{ targetNodeId: link.target_id, targetSlot: link.target_slot }]
: []
})
}
function cohortDuplicatesPrimitive(
cohort: readonly LegacyProxyEntrySource[],
primitiveNodeId: string
): boolean {
return (
cohort.filter((entry) => entry.sourceNodeId === primitiveNodeId).length >= 2
)
}
function classify(
hostNode: SubgraphNode,
normalized: LegacyProxyEntrySource,
cohort: readonly LegacyProxyEntrySource[]
): Plan {
const linkedInput = findHostInputForPromotion(
hostNode,
normalized.sourceNodeId,
normalized.sourceWidgetName
)
if (linkedInput) {
const ambiguous =
hostNode.inputs.filter((input) => {
const w = input._widget
return (
!!w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === normalized.sourceNodeId &&
w.sourceWidgetName === normalized.sourceWidgetName
)
}).length > 1
if (ambiguous) {
return { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
}
return { kind: 'alreadyLinked', subgraphInputName: linkedInput.name }
}
const sourceNode = hostNode.subgraph.getNodeById(normalized.sourceNodeId)
if (!sourceNode) {
return { kind: 'quarantine', reason: 'missingSourceNode' }
}
if (sourceNode.type === PRIMITIVE_NODE_TYPE) {
const bypassedTo = sourceNode.properties?.[PROXY_BYPASS_MARKER_PROPERTY]
if (typeof bypassedTo === 'string') {
const existingInput = hostNode.inputs.find(
(input) => input.name === bypassedTo
)
if (existingInput) {
return { kind: 'alreadyLinked', subgraphInputName: existingInput.name }
}
}
const targets = collectTargetsSkippingDangling(hostNode, sourceNode)
const cohortDuplicated = cohortDuplicatesPrimitive(
cohort,
normalized.sourceNodeId
)
if (targets.length >= 1 || cohortDuplicated) {
return {
kind: 'primitiveBypass',
primitiveNodeId: sourceNode.id,
sourceWidgetName: normalized.sourceWidgetName,
targets
}
}
return { kind: 'quarantine', reason: 'unlinkedSourceWidget' }
}
const sourceWidget = resolveSourceWidget(
sourceNode,
normalized.sourceWidgetName,
normalized.disambiguatingSourceNodeId
)
if (!sourceWidget) {
return { kind: 'quarantine', reason: 'missingSourceWidget' }
}
if (
normalized.sourceWidgetName.startsWith('$$') ||
isPreviewPseudoWidget(sourceWidget)
) {
return {
kind: 'previewExposure',
sourcePreviewName: normalized.sourceWidgetName
}
}
return {
kind: 'createSubgraphInput',
sourceWidgetName: normalized.sourceWidgetName
}
}
function applyHostValue(widget: IBaseWidget, entry: PendingEntry): void {
if (entry.isHole) return
if (
isPromotedWidgetView(widget) &&
typeof widget.hydrateHostValue === 'function'
) {
widget.hydrateHostValue(entry.hostValue)
return
}
console.error(
'[proxyWidgetMigration] applyHostValue called with non-promoted widget; refusing to write to shared interior',
{ widgetName: widget.name, type: widget.type }
)
}
function addUniqueSubgraphInput(
subgraph: Subgraph,
baseName: string,
type: string
): SubgraphInput {
const existingNames = subgraph.inputs.map((input) => input.name)
const uniqueName = nextUniqueName(baseName, existingNames)
return subgraph.addInput(uniqueName, type)
}
type Outcome<TOk, TReason = ProxyWidgetQuarantineReason> =
| ({ ok: true } & TOk)
| { ok: false; reason: TReason }
type RepairValueResult = Outcome<{ subgraphInputName: string }>
function repairAlreadyLinked(
hostNode: SubgraphNode,
entry: PendingEntry,
subgraphInputName: string
): RepairValueResult {
// Resolve by name directly: source-id matching would miss for primitive
// bypasses, where the view's `sourceNodeId` is the consumer, not the
// primitive.
const matches = hostNode.inputs.filter(
(input) => input.name === subgraphInputName
)
if (matches.length === 0) {
return { ok: false, reason: 'missingSubgraphInput' }
}
if (matches.length > 1) {
return { ok: false, reason: 'ambiguousSubgraphInput' }
}
const hostInput = matches[0]
if (!hostInput._widget) {
return { ok: false, reason: 'missingSubgraphInput' }
}
applyHostValue(hostInput._widget, entry)
return { ok: true, subgraphInputName: hostInput.name }
}
function repairCreateSubgraphInput(
hostNode: SubgraphNode,
entry: PendingEntry,
sourceWidgetName: string
): RepairValueResult {
const subgraph = hostNode.subgraph
const sourceNode: LGraphNode | null = subgraph.getNodeById(
entry.normalized.sourceNodeId
)
if (!sourceNode) {
return { ok: false, reason: 'missingSourceNode' }
}
const sourceWidget = resolveSourceWidget(
sourceNode,
sourceWidgetName,
entry.normalized.disambiguatingSourceNodeId
)
if (!sourceWidget) {
return { ok: false, reason: 'missingSourceWidget' }
}
const slot: INodeInputSlot | undefined =
sourceNode.getSlotFromWidget(sourceWidget)
if (!slot) {
console.warn(
'[proxyWidgetMigration] source widget has no backing input slot; quarantining',
{
sourceNodeId: entry.normalized.sourceNodeId,
sourceWidgetName
}
)
return { ok: false, reason: 'missingSubgraphInput' }
}
const slotType = String(slot.type ?? sourceWidget.type ?? '*')
const newSubgraphInput = addUniqueSubgraphInput(
subgraph,
sourceWidgetName,
slotType
)
if (slot.label !== undefined) newSubgraphInput.label = slot.label
const link = newSubgraphInput.connect(slot, sourceNode)
if (!link) {
subgraph.removeInput(newSubgraphInput)
return { ok: false, reason: 'missingSubgraphInput' }
}
const hostInput = hostNode.inputs.find(
(input) => input.name === newSubgraphInput.name
)
if (!hostInput?._widget) {
return { ok: true, subgraphInputName: newSubgraphInput.name }
}
applyHostValue(hostInput._widget, entry)
return { ok: true, subgraphInputName: newSubgraphInput.name }
}
type RepairPrimitiveResult = Outcome<
{ subgraphInputName: string; reconnectCount: number },
'primitiveBypassFailed'
>
const PRIMITIVE_FAILED: RepairPrimitiveResult = {
ok: false,
reason: 'primitiveBypassFailed'
}
interface SnapshotLink extends PrimitiveBypassTargetRef {
primitiveSlot: number
}
interface CohortValidationOk {
ok: true
primitiveNodeId: NodeId
sourceWidgetName: string
uniqueEntries: readonly PendingEntry[]
}
function failPrimitive(message: string, ctx?: unknown): RepairPrimitiveResult {
console.warn(`[proxyWidgetMigration] ${message}`, ctx)
return PRIMITIVE_FAILED
}
function userRenamedTitle(primitiveNode: LGraphNode): string | undefined {
const title = primitiveNode.title
return title && title !== PRIMITIVE_NODE_TYPE ? title : undefined
}
function validateCohort(
cohort: readonly PendingEntry[]
): CohortValidationOk | { ok: false } {
const first = cohort[0]
if (!first || first.plan.kind !== 'primitiveBypass') return { ok: false }
const { primitiveNodeId, sourceWidgetName } = first.plan
for (const entry of cohort) {
if (
entry.plan.kind !== 'primitiveBypass' ||
entry.plan.primitiveNodeId !== primitiveNodeId ||
entry.plan.sourceWidgetName !== sourceWidgetName
) {
return { ok: false }
}
}
const uniqueEntries: PendingEntry[] = []
for (const entry of cohort) {
if (!uniqueEntries.some((k) => isEqual(k.normalized, entry.normalized))) {
uniqueEntries.push(entry)
}
}
return { ok: true, primitiveNodeId, sourceWidgetName, uniqueEntries }
}
function rollback(
hostNode: SubgraphNode,
primitiveNode: LGraphNode,
newSubgraphInput: SubgraphInput | undefined,
snapshot: readonly SnapshotLink[]
): void {
if (newSubgraphInput) {
try {
hostNode.subgraph.removeInput(newSubgraphInput)
} catch (e) {
console.warn('[proxyWidgetMigration] rollback removeInput failed', e)
}
}
for (const link of snapshot) {
const targetNode = hostNode.subgraph.getNodeById(link.targetNodeId)
if (!targetNode) continue
primitiveNode.connect(link.primitiveSlot, targetNode, link.targetSlot)
}
}
function repairPrimitive(
hostNode: SubgraphNode,
cohort: readonly PendingEntry[]
): RepairPrimitiveResult {
const validated = validateCohort(cohort)
if (!validated.ok)
return failPrimitive('cohort validation failed', { cohort })
const subgraph = hostNode.subgraph
const primitiveNode = subgraph.getNodeById(validated.primitiveNodeId)
if (!primitiveNode) return failPrimitive('primitive node missing', validated)
if (primitiveNode.type !== PRIMITIVE_NODE_TYPE) {
return failPrimitive('node is not a PrimitiveNode', primitiveNode.type)
}
const targets = collectTargetsStrict(hostNode, primitiveNode)
if (!targets?.length)
return failPrimitive('no targets to reconnect', validated)
const primitiveOutput = primitiveNode.outputs?.[0]
if (!primitiveOutput) return failPrimitive('primitive has no output')
const primitiveOutputType = String(primitiveOutput.type ?? '*')
for (const target of targets) {
const targetNode = subgraph.getNodeById(target.targetNodeId)
if (!targetNode) return failPrimitive('target node missing', target)
const targetSlot = targetNode.inputs?.[target.targetSlot]
if (!targetSlot) return failPrimitive('target slot missing', target)
const targetType = String(targetSlot.type ?? '*')
if (
targetType !== primitiveOutputType &&
targetType !== '*' &&
primitiveOutputType !== '*'
) {
return failPrimitive('target slot type incompatible', {
target,
targetType,
primitiveOutputType
})
}
}
const baseName = userRenamedTitle(primitiveNode) ?? validated.sourceWidgetName
const snapshot: SnapshotLink[] = (primitiveOutput.links ?? [])
.map((id) => subgraph.links.get(id))
.filter((l): l is NonNullable<typeof l> => l !== undefined)
.map((l) => ({
primitiveSlot: l.origin_slot,
targetNodeId: l.target_id,
targetSlot: l.target_slot
}))
let newSubgraphInput: SubgraphInput | undefined
try {
newSubgraphInput = addUniqueSubgraphInput(
subgraph,
baseName,
primitiveOutputType
)
for (const snap of snapshot) {
const targetNode = subgraph.getNodeById(snap.targetNodeId)
if (!targetNode)
throw new Error(
`target node ${snap.targetNodeId} disappeared mid-mutation`
)
targetNode.disconnectInput(snap.targetSlot, false)
}
for (const target of targets) {
const targetNode = subgraph.getNodeById(target.targetNodeId)
if (!targetNode)
throw new Error(`target node ${target.targetNodeId} disappeared`)
const targetSlot = targetNode.inputs?.[target.targetSlot]
if (!targetSlot)
throw new Error(`target slot ${target.targetSlot} disappeared`)
const link = newSubgraphInput.connect(targetSlot, targetNode)
if (!link) {
throw new Error('SubgraphInput.connect returned no link')
}
}
} catch (e) {
rollback(hostNode, primitiveNode, newSubgraphInput, snapshot)
return failPrimitive('mutation failed; rolled back', { error: e })
}
// Apply through the host's input mirror (PromotedWidgetView), NOT
// `newSubgraphInput._widget`: the interior is shared across hosts.
const hostInput = hostNode.inputs.find(
(input) => input.name === newSubgraphInput.name
)
const hostInputWidget = hostInput?._widget
if (hostInputWidget) {
const valueEntry = validated.uniqueEntries.find((e) => !e.isHole)
if (valueEntry) {
applyHostValue(hostInputWidget, valueEntry)
} else {
const primitiveValue = primitiveNode.widgets?.find(
(w) => w.name === validated.sourceWidgetName
)?.value as TWidgetValue | undefined
if (primitiveValue !== undefined) {
applyHostValue(hostInputWidget, {
...validated.uniqueEntries[0],
hostValue: primitiveValue,
isHole: false
})
}
}
}
primitiveNode.properties ??= {}
primitiveNode.properties[PROXY_BYPASS_MARKER_PROPERTY] = newSubgraphInput.name
return {
ok: true,
subgraphInputName: newSubgraphInput.name,
reconnectCount: targets.length
}
}
type MigratePreviewResult = Outcome<
{ previewName: string },
'missingSourceNode' | 'missingSourceWidget'
>
function migratePreview(
hostNode: SubgraphNode,
entry: PendingEntry,
store: ReturnType<typeof usePreviewExposureStore>,
plan: { kind: 'previewExposure'; sourcePreviewName: string }
): MigratePreviewResult {
const sourceNode = hostNode.subgraph.getNodeById(
entry.normalized.sourceNodeId
)
if (!sourceNode) {
return { ok: false, reason: 'missingSourceNode' }
}
const isCanonicalPseudo = plan.sourcePreviewName.startsWith('$$')
if (!isCanonicalPseudo) {
const widget = sourceNode.widgets?.find(
(w) => w.name === plan.sourcePreviewName
)
if (!widget) {
return { ok: false, reason: 'missingSourceWidget' }
}
}
const hostNodeLocator = String(hostNode.id)
const existing = store
.getExposures(hostNode.rootGraph.id, hostNodeLocator)
.find(
(exposure) =>
exposure.sourceNodeId === entry.normalized.sourceNodeId &&
exposure.sourcePreviewName === plan.sourcePreviewName
)
if (existing) return { ok: true, previewName: existing.name }
const added = store.addExposure(hostNode.rootGraph.id, hostNodeLocator, {
sourceNodeId: entry.normalized.sourceNodeId,
sourcePreviewName: plan.sourcePreviewName
})
return { ok: true, previewName: added.name }
}
function quarantineFor(
entry: PendingEntry,
reason: ProxyWidgetQuarantineReason
): ProxyWidgetErrorQuarantineEntry {
const { sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId } =
entry.normalized
const originalEntry: SerializedProxyWidgetTuple = disambiguatingSourceNodeId
? [sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId]
: [sourceNodeId, sourceWidgetName]
return makeQuarantineEntry({
originalEntry,
reason,
hostValue: entry.isHole ? undefined : entry.hostValue
})
}
export function appendQuarantine(
hostNode: SubgraphNode,
entries: readonly ProxyWidgetErrorQuarantineEntry[]
): void {
if (entries.length === 0) return
const existing = parseProxyWidgetErrorQuarantine(
hostNode.properties[QUARANTINE_PROPERTY]
)
const merged = [...existing]
for (const candidate of entries) {
if (
!merged.some((e) => isEqual(e.originalEntry, candidate.originalEntry))
) {
merged.push(candidate)
}
}
if (merged.length === 0) delete hostNode.properties[QUARANTINE_PROPERTY]
else hostNode.properties[QUARANTINE_PROPERTY] = merged
}
export function readHostQuarantine(
hostNode: SubgraphNode
): ProxyWidgetErrorQuarantineEntry[] {
return parseProxyWidgetErrorQuarantine(
hostNode.properties[QUARANTINE_PROPERTY]
)
}
export function makeQuarantineEntry(args: {
originalEntry: SerializedProxyWidgetTuple
reason: ProxyWidgetQuarantineReason
hostValue?: TWidgetValue
}): ProxyWidgetErrorQuarantineEntry {
const entry: ProxyWidgetErrorQuarantineEntry = {
originalEntry: args.originalEntry,
reason: args.reason,
attemptedAtVersion: QUARANTINE_VERSION
}
if (args.hostValue !== undefined) {
entry.hostValue = args.hostValue
}
return entry
}

View File

@@ -0,0 +1,283 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { PreviewExposureChainContext } from './previewExposureChain'
import { resolvePreviewExposureChain } from './previewExposureChain'
const rootGraphA = 'root-a' as UUID
const rootGraphB = 'root-b' as UUID
interface FixtureExposure extends PreviewExposure {}
interface NestedHostMapping {
fromHostLocator: string
fromSourceNodeId: string
toRootGraphId: UUID
toHostLocator: string
}
function makeContext(
exposureMap: Map<string, FixtureExposure[]>,
nested: NestedHostMapping[]
): PreviewExposureChainContext {
return {
getExposures(rootGraphId, hostLocator) {
return exposureMap.get(`${rootGraphId}|${hostLocator}`) ?? []
},
resolveNestedHost(_rootGraphId, hostLocator, sourceNodeId) {
const match = nested.find(
(n) =>
n.fromHostLocator === hostLocator &&
n.fromSourceNodeId === sourceNodeId
)
if (!match) return undefined
return {
rootGraphId: match.toRootGraphId,
hostNodeLocator: match.toHostLocator
}
}
}
}
describe(resolvePreviewExposureChain, () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
it('returns undefined when the named exposure is not on the starting host', () => {
const ctx = makeContext(new Map(), [])
expect(
resolvePreviewExposureChain(rootGraphA, 'host-a', 'absent', ctx)
).toBeUndefined()
})
it('returns a single-step chain when the source is a leaf (no nested host)', () => {
const exposureMap = new Map<string, FixtureExposure[]>([
[
`${rootGraphA}|host-a`,
[
{
name: 'preview',
sourceNodeId: '42',
sourcePreviewName: '$$canvas-image-preview'
}
]
]
])
const ctx = makeContext(exposureMap, [])
const result = resolvePreviewExposureChain(
rootGraphA,
'host-a',
'preview',
ctx
)
expect(result).toEqual({
steps: [
{
rootGraphId: rootGraphA,
hostNodeLocator: 'host-a',
exposure: {
name: 'preview',
sourceNodeId: '42',
sourcePreviewName: '$$canvas-image-preview'
}
}
],
leaf: {
rootGraphId: rootGraphA,
sourceNodeId: '42',
sourcePreviewName: '$$canvas-image-preview'
}
})
})
it('walks one nested host and returns a two-step chain', () => {
const exposureMap = new Map<string, FixtureExposure[]>([
[
`${rootGraphA}|host-outer`,
[
{
name: 'outer-preview',
sourceNodeId: '99',
sourcePreviewName: 'inner-preview'
}
]
],
[
`${rootGraphA}|host-inner`,
[
{
name: 'inner-preview',
sourceNodeId: 'leaf-node',
sourcePreviewName: '$$canvas-image-preview'
}
]
]
])
const ctx = makeContext(exposureMap, [
{
fromHostLocator: 'host-outer',
fromSourceNodeId: '99',
toRootGraphId: rootGraphA,
toHostLocator: 'host-inner'
}
])
const result = resolvePreviewExposureChain(
rootGraphA,
'host-outer',
'outer-preview',
ctx
)
expect(result?.steps).toHaveLength(2)
expect(result?.steps[0].hostNodeLocator).toBe('host-outer')
expect(result?.steps[1].hostNodeLocator).toBe('host-inner')
expect(result?.leaf).toEqual({
rootGraphId: rootGraphA,
sourceNodeId: 'leaf-node',
sourcePreviewName: '$$canvas-image-preview'
})
})
it('walks two nested hosts (three-step chain) crossing a root graph boundary', () => {
const exposureMap = new Map<string, FixtureExposure[]>([
[
`${rootGraphA}|host-1`,
[
{
name: 'p1',
sourceNodeId: 'sub-a',
sourcePreviewName: 'p2'
}
]
],
[
`${rootGraphA}|host-2`,
[
{
name: 'p2',
sourceNodeId: 'sub-b',
sourcePreviewName: 'p3'
}
]
],
[
`${rootGraphB}|host-3`,
[
{
name: 'p3',
sourceNodeId: 'leaf',
sourcePreviewName: '$$canvas-image-preview'
}
]
]
])
const ctx = makeContext(exposureMap, [
{
fromHostLocator: 'host-1',
fromSourceNodeId: 'sub-a',
toRootGraphId: rootGraphA,
toHostLocator: 'host-2'
},
{
fromHostLocator: 'host-2',
fromSourceNodeId: 'sub-b',
toRootGraphId: rootGraphB,
toHostLocator: 'host-3'
}
])
const result = resolvePreviewExposureChain(rootGraphA, 'host-1', 'p1', ctx)
expect(result?.steps).toHaveLength(3)
expect(result?.steps.map((s) => s.exposure.name)).toEqual([
'p1',
'p2',
'p3'
])
expect(result?.leaf).toEqual({
rootGraphId: rootGraphB,
sourceNodeId: 'leaf',
sourcePreviewName: '$$canvas-image-preview'
})
})
it('terminates at outer step when nested host has no matching exposure', () => {
const exposureMap = new Map<string, FixtureExposure[]>([
[
`${rootGraphA}|host-outer`,
[
{
name: 'outer',
sourceNodeId: '99',
sourcePreviewName: 'missing-on-inner'
}
]
],
[`${rootGraphA}|host-inner`, []]
])
const ctx = makeContext(exposureMap, [
{
fromHostLocator: 'host-outer',
fromSourceNodeId: '99',
toRootGraphId: rootGraphA,
toHostLocator: 'host-inner'
}
])
const result = resolvePreviewExposureChain(
rootGraphA,
'host-outer',
'outer',
ctx
)
expect(result?.steps).toHaveLength(1)
expect(result?.leaf).toEqual({
rootGraphId: rootGraphA,
sourceNodeId: '99',
sourcePreviewName: 'missing-on-inner'
})
})
it('detects cycles, warns, and stops walking', () => {
const exposureMap = new Map<string, FixtureExposure[]>([
[
`${rootGraphA}|host-a`,
[{ name: 'cyclic', sourceNodeId: 'sub', sourcePreviewName: 'cyclic' }]
]
])
const ctx = makeContext(exposureMap, [
{
fromHostLocator: 'host-a',
fromSourceNodeId: 'sub',
toRootGraphId: rootGraphA,
toHostLocator: 'host-a'
}
])
const result = resolvePreviewExposureChain(
rootGraphA,
'host-a',
'cyclic',
ctx
)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('cycle detected')
)
expect(result?.steps).toHaveLength(1)
expect(result?.leaf.sourceNodeId).toBe('sub')
})
})

View File

@@ -0,0 +1,105 @@
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
export interface ResolvedPreviewChainStep {
rootGraphId: UUID
hostNodeLocator: string
exposure: PreviewExposure
}
export interface ResolvedPreviewChain {
steps: readonly ResolvedPreviewChainStep[]
leaf: {
rootGraphId: UUID
sourceNodeId: string
sourcePreviewName: string
}
}
export interface PreviewExposureChainContext {
getExposures(
rootGraphId: UUID,
hostNodeLocator: string
): readonly PreviewExposure[]
resolveNestedHost(
rootGraphId: UUID,
hostNodeLocator: string,
sourceNodeId: string
): { rootGraphId: UUID; hostNodeLocator: string } | undefined
}
const MAX_CHAIN_DEPTH = 32
export function resolvePreviewExposureChain(
rootGraphId: UUID,
hostNodeLocator: string,
name: string,
ctx: PreviewExposureChainContext
): ResolvedPreviewChain | undefined {
const steps: ResolvedPreviewChainStep[] = []
const visited = new Set<string>()
let currentRootGraphId: UUID = rootGraphId
let currentHost = hostNodeLocator
let currentName = name
const chainFromLastStep = (): ResolvedPreviewChain | undefined => {
if (steps.length === 0) return undefined
const lastStep = steps[steps.length - 1]
return {
steps,
leaf: {
rootGraphId: lastStep.rootGraphId,
sourceNodeId: lastStep.exposure.sourceNodeId,
sourcePreviewName: lastStep.exposure.sourcePreviewName
}
}
}
for (let depth = 0; depth < MAX_CHAIN_DEPTH; depth++) {
const key = `${currentRootGraphId}|${currentHost}|${currentName}`
if (visited.has(key)) {
console.warn(
`[previewExposureChain] cycle detected at ${key}; terminating walk`
)
return chainFromLastStep()
}
visited.add(key)
const exposure = ctx
.getExposures(currentRootGraphId, currentHost)
.find((e) => e.name === currentName)
if (!exposure) return chainFromLastStep()
steps.push({
rootGraphId: currentRootGraphId,
hostNodeLocator: currentHost,
exposure
})
const nested = ctx.resolveNestedHost(
currentRootGraphId,
currentHost,
exposure.sourceNodeId
)
if (!nested) {
return {
steps,
leaf: {
rootGraphId: currentRootGraphId,
sourceNodeId: exposure.sourceNodeId,
sourcePreviewName: exposure.sourcePreviewName
}
}
}
currentRootGraphId = nested.rootGraphId
currentHost = nested.hostNodeLocator
currentName = exposure.sourcePreviewName
}
console.warn(
`[previewExposureChain] max chain depth (${MAX_CHAIN_DEPTH}) reached; terminating walk`
)
return chainFromLastStep()
}

View File

@@ -1,6 +1,7 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { WidgetEntityId } from '@/world/entityIds'
export interface ResolvedPromotedWidget {
node: LGraphNode
@@ -10,20 +11,17 @@ export interface ResolvedPromotedWidget {
export interface PromotedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
}
export interface PromotedWidgetView extends IBaseWidget {
readonly node: SubgraphNode
readonly entityId: WidgetEntityId
readonly sourceNodeId: string
readonly sourceWidgetName: string
/**
* The original leaf-level source node ID, used to distinguish promoted
* widgets with the same name on the same intermediate node. Unlike
* `sourceNodeId` (the direct interior node), this traces to the deepest
* origin.
*/
readonly disambiguatingSourceNodeId?: string
hydrateHostValue(value: IBaseWidget['value']): void
ensureHostWidgetState(): void
}
export function isPromotedWidgetView(

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,12 @@ import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { t } from '@/i18n'
import { nextValueForLinkedTarget } from '@/scripts/valueControl'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
stripGraphPrefix,
@@ -20,6 +22,9 @@ import {
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import { ensureWidgetState, getWidgetState } from '@/world/widgetValueIO'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
@@ -33,14 +38,6 @@ interface SubgraphSlotRef {
displayName?: string
}
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
if (value === undefined) return true
if (typeof value === 'string') return true
if (typeof value === 'number') return true
if (typeof value === 'boolean') return true
return value !== null && typeof value === 'object'
}
type LegacyMouseWidget = IBaseWidget & {
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
}
@@ -56,7 +53,6 @@ export function createPromotedWidgetView(
nodeId: string,
widgetName: string,
displayName?: string,
disambiguatingSourceNodeId?: string,
identityName?: string
): IPromotedWidgetView {
return new PromotedWidgetView(
@@ -64,7 +60,6 @@ export function createPromotedWidgetView(
nodeId,
widgetName,
displayName,
disambiguatingSourceNodeId,
identityName
)
}
@@ -91,16 +86,16 @@ class PromotedWidgetView implements IPromotedWidgetView {
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
private cachedDeepestFrame = -1
/** Cached reference to the bound subgraph slot, set at construction. */
private _boundSlot?: SubgraphSlotRef
private _boundSlotVersion = -1
private _lastAutoSeededValue?: IBaseWidget['value']
constructor(
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string,
readonly disambiguatingSourceNodeId?: string,
private readonly identityName?: string
) {
this.sourceNodeId = nodeId
@@ -116,6 +111,10 @@ class PromotedWidgetView implements IPromotedWidgetView {
return this.identityName ?? this.sourceWidgetName
}
get entityId(): WidgetEntityId {
return widgetEntityId(this.graphId, this.subgraphNode.id, this.name)
}
get y(): number {
return this.yValue
}
@@ -150,61 +149,78 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get value(): IBaseWidget['value'] {
const hostState = this.getHostWidgetState()
if (hostState && isWidgetValue(hostState.value)) return hostState.value
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
set value(value: IBaseWidget['value']) {
const linkedWidgets = this.getLinkedInputWidgets()
if (linkedWidgets.length > 0) {
const widgetStore = useWidgetValueStore()
let didUpdateState = false
for (const linkedWidget of linkedWidgets) {
const state = widgetStore.getWidget(
this.graphId,
linkedWidget.nodeId,
linkedWidget.widgetName
)
if (state) {
state.value = value
didUpdateState = true
}
}
this.setHostWidgetState(value)
}
const resolved = this.resolveDeepest()
if (resolved) {
const resolvedState = widgetStore.getWidget(
this.graphId,
stripGraphPrefix(String(resolved.node.id)),
resolved.widget.name
)
if (resolvedState) {
resolvedState.value = value
didUpdateState = true
}
}
private getHostWidgetState(): WidgetState | undefined {
return getWidgetState(this.entityId)
}
if (didUpdateState) return
}
private setHostWidgetState(value: IBaseWidget['value']): void {
if (!isWidgetValue(value)) return
const state = this.getWidgetState()
const state = this.getHostWidgetState()
if (state) {
state.value = value
this._lastAutoSeededValue = undefined
return
}
const resolved = this.resolveAtHost()
if (resolved && isWidgetValue(value)) {
resolved.widget.value = value
this.registerHostWidgetState(value)
this._lastAutoSeededValue = undefined
}
ensureHostWidgetState(): void {
const fallback = this.fallbackEffectiveValue()
const existing = this.getHostWidgetState()
if (existing) {
if (
this._lastAutoSeededValue !== undefined &&
existing.value === this._lastAutoSeededValue &&
isWidgetValue(fallback) &&
fallback !== existing.value
) {
existing.value = fallback
this._lastAutoSeededValue = fallback
}
return
}
this.registerHostWidgetState(fallback)
this._lastAutoSeededValue = fallback
}
private fallbackEffectiveValue(): IBaseWidget['value'] {
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
private registerHostWidgetState(value: IBaseWidget['value']): void {
const resolved = this.resolveDeepest()
ensureWidgetState(this.entityId, {
type: resolved?.widget.type ?? 'button',
value,
options: { ...(resolved?.widget.options ?? {}) },
label: this.displayName,
serialize: this.serialize,
disabled: this.computedDisabled
})
}
get label(): string | undefined {
const slot = this.getBoundSubgraphSlot()
if (slot) return slot.label ?? slot.displayName ?? slot.name
// Fall back to persisted widget state (survives save/reload before
// the slot binding is established) then to construction displayName.
const state = this.getWidgetState()
return state?.label ?? this.displayName
}
@@ -212,20 +228,14 @@ class PromotedWidgetView implements IPromotedWidgetView {
set label(value: string | undefined) {
const slot = this.getBoundSubgraphSlot()
if (slot) slot.label = value || undefined
// Also persist to widget state store for save/reload resilience
const state = this.getWidgetState()
if (state) state.label = value
}
/**
* Returns the cached bound subgraph slot reference, refreshing only when
* the subgraph node's input list has changed (length mismatch).
*
* Note: Using length as the cache key works because the returned reference
* is the same mutable slot object. When slot properties (label, name) change,
* the caller reads fresh values from that reference. The cache only needs
* to invalidate when slots are added or removed, which changes length.
*/
hydrateHostValue(value: IBaseWidget['value']): void {
this.setHostWidgetState(value)
}
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
const version = this.subgraphNode.inputs?.length ?? 0
if (this._boundSlotVersion === version) return this._boundSlot
@@ -306,7 +316,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
} finally {
@@ -351,14 +360,34 @@ class PromotedWidgetView implements IPromotedWidgetView {
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
}
afterQueued({
isPartialExecution
}: { isPartialExecution?: boolean } = {}): void {
this.applyValueControlToHost(isPartialExecution)
}
private applyValueControlToHost(isPartialExecution?: boolean): void {
if (this.subgraphNode.getSlotFromWidget(this)?.link != null) return
const resolved = this.resolveAtHost()
const next = nextValueForLinkedTarget({
target: this,
linkedWidgets: resolved?.widget.linkedWidgets,
nodeId: this.subgraphNode.id,
isPartialExecution
})
if (next === undefined) return
this.hydrateHostValue(next)
}
private resolveAtHost():
| { node: LGraphNode; widget: IBaseWidget }
| undefined {
return resolvePromotedWidgetAtHost(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName,
this.disambiguatingSourceNodeId
this.sourceWidgetName
)
}
@@ -372,8 +401,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
const result = resolveConcretePromotedWidget(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName,
this.disambiguatingSourceNodeId
this.sourceWidgetName
)
const resolved = result.status === 'resolved' ? result.resolved : undefined
@@ -413,9 +441,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
if (boundWidget && isPromotedWidgetView(boundWidget)) {
return (
boundWidget.sourceNodeId === this.sourceNodeId &&
boundWidget.sourceWidgetName === this.sourceWidgetName &&
boundWidget.disambiguatingSourceNodeId ===
this.disambiguatingSourceNodeId
boundWidget.sourceWidgetName === this.sourceWidgetName
)
}
@@ -542,7 +568,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
}
/** Checks if a widget is a BaseDOMWidget (DOMWidget or ComponentWidget). */
function isBaseDOMWidget(
widget: IBaseWidget
): widget is IBaseWidget & { id: string } {

View File

@@ -9,7 +9,17 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
function widgetSourceNodeId(w: IBaseWidget): string | undefined {
return isPromotedWidgetView(w) ? w.sourceNodeId : undefined
}
type TestPromotedWidget = IBaseWidget & {
sourceNodeId: string
sourceWidgetName: string
}
const updatePreviewsMock = vi.hoisted(() => vi.fn())
vi.mock('@/services/litegraphService', () => ({
@@ -18,12 +28,19 @@ vi.mock('@/services/litegraphService', () => ({
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
autoExposeKnownPreviewNodes,
demoteWidget,
getPromotableWidgets,
getWidgetName,
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
isWidgetPromotedOnSubgraphNode,
promoteValueWidgetViaSubgraphInput,
promoteRecommendedWidgets,
pruneDisconnected
pruneDisconnected,
reorderSubgraphInputsByName,
reorderSubgraphInputsByWidgetOrder
} from './promotionUtils'
function widget(
@@ -34,6 +51,34 @@ function widget(
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
}
/**
* Builds a host SubgraphNode whose subgraph contains two source nodes that
* share a widget name (`text`), then promotes both — forcing the second
* promotion to be disambiguated to `text_1`.
*/
function buildDuplicateNamePromotion() {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const nodeA = new LGraphNode('SourceA')
subgraph.add(nodeA)
const inputA = nodeA.addInput('text', 'STRING')
const widgetA = nodeA.addWidget('text', 'text', 'a', () => {})
inputA.widget = { name: widgetA.name }
const nodeB = new LGraphNode('SourceB')
subgraph.add(nodeB)
const inputB = nodeB.addInput('text', 'STRING')
const widgetB = nodeB.addWidget('text', 'text', 'b', () => {})
inputB.widget = { name: widgetB.name }
expect(promoteValueWidgetViaSubgraphInput(host, nodeA, widgetA).ok).toBe(true)
expect(promoteValueWidgetViaSubgraphInput(host, nodeB, widgetB).ok).toBe(true)
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text', 'text_1'])
return { subgraph, host, nodeA, widgetA, nodeB, widgetB }
}
describe('isPreviewPseudoWidget', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -112,58 +157,64 @@ describe('pruneDisconnected', () => {
vi.restoreAllMocks()
})
it('removes disconnected entries and emits a dev warning', () => {
it('removes disconnected linked inputs and emits a dev warning', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('TestNode')
subgraphNode.subgraph.add(interiorNode)
interiorNode.addWidget('text', 'kept', 'value', () => {})
const keptInput = interiorNode.addInput('kept', 'STRING')
const keptWidget = interiorNode.addWidget('text', 'kept', 'value', () => {})
keptInput.widget = { name: keptWidget.name }
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, keptWidget)
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' },
{
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'missing-widget'
},
{ sourceNodeId: '9999', sourceWidgetName: 'missing-node' }
])
const missingWidgetInput = subgraph.addInput('missing-widget', 'STRING')
missingWidgetInput._widget = fromPartial<TestPromotedWidget>({
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'missing-widget'
})
const missingNodeInput = subgraph.addInput('missing-node', 'STRING')
missingNodeInput._widget = fromPartial<TestPromotedWidget>({
sourceNodeId: '9999',
sourceWidgetName: 'missing-node'
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
pruneDisconnected(subgraphNode)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' }
])
expect(subgraph.inputs.map((input) => input.name)).toEqual(['kept'])
expect(warnSpy).toHaveBeenCalledOnce()
})
it('keeps virtual canvas preview promotions for PreviewImage nodes', () => {
it('does not prune preview exposures for PreviewImage nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('PreviewImage')
interiorNode.type = 'PreviewImage'
subgraphNode.subgraph.add(interiorNode)
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
const hostLocator = String(subgraphNode.id)
usePreviewExposureStore().addExposure(
subgraphNode.rootGraph.id,
hostLocator,
{
sourceNodeId: String(interiorNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
)
pruneDisconnected(subgraphNode)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
hostLocator
)
).toEqual([
{
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(interiorNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
})
@@ -232,6 +283,50 @@ describe('promoteRecommendedWidgets', () => {
updatePreviewsMock.mockReset()
})
it('promotes recommended value widgets through linked subgraph inputs', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('Sampler')
const input = interiorNode.addInput('seed', 'INT')
const seedWidget = interiorNode.addWidget('number', 'seed', 123, () => {})
input.widget = { name: seedWidget.name }
subgraph.add(interiorNode)
promoteRecommendedWidgets(subgraphNode)
const linkedInput = subgraph.inputs.find((slot) => slot.name === 'seed')
expect(linkedInput).toBeDefined()
expect(input.link).not.toBeNull()
expect(linkedInput?.linkIds).toContain(input.link)
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
})
it('promotes virtual previews through preview exposures', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
promoteRecommendedWidgets(subgraphNode)
const hostLocator = String(subgraphNode.id)
expect(
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
hostLocator
)
).toEqual([
{
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(glslNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
expect(subgraph.inputs).toHaveLength(0)
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
})
it('skips deferred updatePreviews when a preview widget already exists', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -252,7 +347,7 @@ describe('promoteRecommendedWidgets', () => {
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('eagerly promotes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
it('eagerly exposes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
@@ -261,36 +356,86 @@ describe('promoteRecommendedWidgets', () => {
promoteRecommendedWidgets(subgraphNode)
const store = usePromotionStore()
const hostLocator = String(subgraphNode.id)
expect(
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(glslNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
hostLocator
)
).toContainEqual({
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(glslNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
})
it('registers $$canvas-image-preview on configure for GLSLShader in saved workflow', () => {
// Simulate loading a saved workflow where proxyWidgets does NOT contain
// the $$canvas-image-preview entry (e.g. blueprint authored before the
// promotion system, or old workflow save).
describe('autoExposeKnownPreviewNodes', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
updatePreviewsMock.mockReset()
})
it('auto-exposes previews when host has no persisted previewExposures property', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
// Create subgraphNode — constructor calls configure → _internalConfigureAfterSlots
// which eagerly registers $$canvas-image-preview for supported node types
const subgraphNode = createTestSubgraphNode(subgraph)
autoExposeKnownPreviewNodes(subgraphNode)
const store = usePromotionStore()
expect(
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(glslNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
String(subgraphNode.id)
)
).toHaveLength(1)
})
it('does not auto-expose when host has empty persisted previewExposures (user cleared)', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.properties.previewExposures = []
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
autoExposeKnownPreviewNodes(subgraphNode)
expect(
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
String(subgraphNode.id)
)
).toEqual([])
})
it('does not auto-expose when host has non-empty persisted previewExposures', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
const otherNode = new LGraphNode('OtherShader')
otherNode.type = 'GLSLShader'
subgraph.add(otherNode)
subgraphNode.properties.previewExposures = [
{
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(otherNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
]
autoExposeKnownPreviewNodes(subgraphNode)
expect(
usePreviewExposureStore()
.getExposures(subgraphNode.rootGraph.id, String(subgraphNode.id))
.map((e) => e.sourceNodeId)
).not.toContain(String(glslNode.id))
})
})
@@ -314,12 +459,11 @@ describe('hasUnpromotedWidgets', () => {
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
const input = interiorNode.addInput('seed', 'STRING')
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
input.widget = { name: widget.name }
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'seed'
})
subgraph.addInput('seed', 'STRING').connect(input, interiorNode)
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
@@ -416,3 +560,413 @@ describe('isLinkedPromotion', () => {
expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false)
})
})
describe('reorderSubgraphInputsByName', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('reorders subgraph inputs and host inputs by subgraph input name', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first', type: 'number' },
{ name: 'second', type: 'number' },
{ name: 'third', type: 'number' }
]
})
const host = createTestSubgraphNode(subgraph)
reorderSubgraphInputsByName(host, ['third', 'first', 'second'])
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
'third',
'first',
'second'
])
expect(host.inputs.map((input) => input.name)).toEqual([
'third',
'first',
'second'
])
})
it('reorders promoted widgets on the host node from subgraph input order', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
expect(host.widgets.map((widget) => widget.name)).toEqual([
'first',
'second'
])
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'second',
'first'
])
})
it('keeps promoted widget values aligned when a plain input is reordered before them', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
subgraph.addInput('plain', 'STRING')
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
reorderSubgraphInputsByName(host, ['plain', 'second', 'first'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'second',
'first'
])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
])
})
it('updates subgraph input link slot indices after reordering', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
reorderSubgraphInputsByName(host, ['second', 'first'])
const [secondSlot, firstSlot] = subgraph.inputs
const secondLink = subgraph.getLink(secondSlot.linkIds[0])
const firstLink = subgraph.getLink(firstSlot.linkIds[0])
expect(secondLink?.origin_slot).toBe(0)
expect(firstLink?.origin_slot).toBe(1)
})
it('updates outer link target_slot when host inputs are reordered', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first', type: 'STRING' },
{ name: 'second', type: 'STRING' }
]
})
const host = createTestSubgraphNode(subgraph)
subgraph.rootGraph.add(host)
const source = new LGraphNode('Source')
source.addOutput('out', 'STRING')
subgraph.rootGraph.add(source)
const firstLink = source.connect(0, host, 0)
const secondLink = source.connect(0, host, 1)
expect(firstLink?.target_slot).toBe(0)
expect(secondLink?.target_slot).toBe(1)
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(firstLink?.target_slot).toBe(1)
expect(secondLink?.target_slot).toBe(0)
})
})
describe('reorderSubgraphInputsByWidgetOrder', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.restoreAllMocks()
})
it('reorders duplicate-named promoted inputs by widget identity', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('text', 'STRING')
const firstWidget = firstNode.addWidget('text', 'text', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('text', 'STRING')
const secondWidget = secondNode.addWidget('text', 'text', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
reorderSubgraphInputsByWidgetOrder(host, [host.widgets[1], host.widgets[0]])
expect(host.widgets.map((widget) => widgetSourceNodeId(widget))).toEqual([
String(secondNode.id),
String(firstNode.id)
])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
])
})
})
describe('demoteWidget — axiomatic projection retraction', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.restoreAllMocks()
})
function setupPromotedWidget() {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('TestNode')
host.subgraph.add(interiorNode)
const interiorInput = interiorNode.addInput('value', 'STRING')
const interiorWidget = interiorNode.addWidget(
'text',
'value',
'initial',
() => {}
)
interiorInput.widget = { name: interiorWidget.name }
const result = promoteValueWidgetViaSubgraphInput(
host,
interiorNode,
interiorWidget
)
expect(result.ok).toBe(true)
return { host, interiorNode, interiorWidget }
}
it('drops projection but keeps slot and external link when host slot is externally connected', () => {
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
const hostInput = host.inputs[0]
hostInput.link = 9999
const promotedViewsBefore = host.widgets.length
expect(host.subgraph.inputs).toHaveLength(1)
expect(promotedViewsBefore).toBeGreaterThan(0)
demoteWidget(interiorNode, interiorWidget, [host])
expect(host.subgraph.inputs).toHaveLength(1)
expect(host.inputs[0]?.link).toBe(9999)
expect(host.inputs[0]?._widget).toBeUndefined()
expect(
isLinkedPromotion(host, String(interiorNode.id), interiorWidget.name)
).toBe(false)
expect(
host.widgets.some(
(widget) =>
widgetSourceNodeId(widget) === String(interiorNode.id) &&
widget.name === interiorWidget.name
)
).toBe(false)
})
it('removes the slot entirely when host slot has no external link', () => {
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
expect(host.subgraph.inputs).toHaveLength(1)
demoteWidget(interiorNode, interiorWidget, [host])
expect(host.subgraph.inputs).toHaveLength(0)
expect(host.inputs).toHaveLength(0)
})
it('demotes the second of two promoted widgets sharing a source widget name', () => {
const { host, nodeA, widgetA, nodeB, widgetB } =
buildDuplicateNamePromotion()
const promotedViewForB = host.widgets.find(
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
)
expect(promotedViewForB!.name).toBe('text_1')
demoteWidget(nodeB, promotedViewForB!, [host])
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
expect(isLinkedPromotion(host, String(nodeB.id), widgetB.name)).toBe(false)
expect(isLinkedPromotion(host, String(nodeA.id), widgetA.name)).toBe(true)
})
it('demotes the correct slot when widget lives on a nested SubgraphNode with same-named deep sources', () => {
const { host: innerHost, nodeB } = buildDuplicateNamePromotion()
const outerSubgraph = createTestSubgraph()
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
for (const w of [...innerHost.widgets]) {
expect(
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
).toBe(true)
}
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual([
'text',
'text_1'
])
const innerViewForB = innerHost.widgets.find(
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
)
expect(innerViewForB!.name).toBe('text_1')
demoteWidget(innerHost, innerViewForB!, [outerHost])
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text_1')).toBe(
false
)
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text')).toBe(
true
)
})
})
describe('disambiguated nested promotion identity', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function linkedView(
sourceNodeId: string,
sourceWidgetName: string,
overrides: Record<string, unknown> = {}
): IBaseWidget {
return {
sourceNodeId,
sourceWidgetName,
name: sourceWidgetName,
type: 'text',
value: '',
options: {},
y: 0,
...overrides
} as unknown as IBaseWidget
}
function createSubgraphHost() {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text_1', type: 'STRING' }]
})
return createTestSubgraphNode(subgraph)
}
it('identifies a promoted nested view by its immediate slot name, not its deep source widget name', () => {
const host = createSubgraphHost()
host.inputs[0]._widget = linkedView('inner', 'text_1')
const interiorWidget = linkedView('inner', 'text', { name: 'text_1' })
const interiorNode = {
id: 'inner',
title: 'inner',
type: 'inner'
} as unknown as LGraphNode
const source = {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(interiorWidget)
}
expect(isWidgetPromotedOnSubgraphNode(host, source, interiorWidget)).toBe(
true
)
})
it('does not prune a promotion whose source is a nested SubgraphNode exposing a disambiguated widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text_1', type: 'STRING' }]
})
const host = createTestSubgraphNode(subgraph)
const nestedSubgraphNode = {
id: 'inner',
title: 'inner',
type: 'inner',
widgets: [linkedView('deep', 'text', { name: 'text_1' })]
} as unknown as LGraphNode
subgraph.add(nestedSubgraphNode)
host.inputs[0]._widget = linkedView('inner', 'text_1')
pruneDisconnected(host)
expect(host.subgraph.inputs).toHaveLength(1)
expect(host.subgraph.inputs[0]?.name).toBe('text_1')
})
it('marks a promoted interior widget as computedDisabled so the connection dot replaces its UI', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('Source')
subgraph.add(interiorNode)
const interiorInput = interiorNode.addInput('text', 'STRING')
const interiorWidget = interiorNode.addWidget('text', 'text', '', () => {})
interiorInput.widget = { name: interiorWidget.name }
expect(
promoteValueWidgetViaSubgraphInput(host, interiorNode, interiorWidget).ok
).toBe(true)
interiorNode.updateComputedDisabled()
expect(interiorWidget.computedDisabled).toBe(true)
})
it('preserves a real two-level promotion through the SubgraphEditor mount-time prune', () => {
const { host: innerHost } = buildDuplicateNamePromotion()
const outerSubgraph = createTestSubgraph()
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
for (const w of [...innerHost.widgets]) {
expect(
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
).toBe(true)
}
const beforeCount = outerHost.subgraph.inputs.length
expect(beforeCount).toBe(2)
pruneDisconnected(outerHost)
expect(outerHost.subgraph.inputs).toHaveLength(beforeCount)
})
})

View File

@@ -2,12 +2,13 @@ import * as Sentry from '@sentry/vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { t } from '@/i18n'
import type {
IContextMenuValue,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { reorderSubgraphInputs } from '@/lib/litegraph/src/subgraph/subgraphUtils'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import { useToastStore } from '@/platform/updates/common/toastStore'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
@@ -15,29 +16,36 @@ import {
} from '@/composables/node/canvasImagePreviewTypes'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { readWidgetValue } from '@/world/widgetValueIO'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
export type WidgetItem = [PartialNode, IBaseWidget]
export type WidgetItem = [LGraphNode, IBaseWidget]
export { CANVAS_IMAGE_PREVIEW_WIDGET }
export function getWidgetName(w: IBaseWidget): string {
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
return w.name
}
/**
* Returns true if the given promotion entry corresponds to a linked promotion
* on the subgraph node. Linked promotions are driven by subgraph input
* connections and cannot be independently hidden or shown.
*/
export function isLinkedPromotion(
subgraphNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string
): boolean {
return subgraphNode.inputs.some((input) => {
return (
findHostInputForPromotion(subgraphNode, sourceNodeId, sourceWidgetName) !==
undefined
)
}
export function findHostInputForPromotion(
subgraphNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string
) {
return subgraphNode.inputs.find((input) => {
const w = input._widget
return (
w &&
@@ -48,21 +56,125 @@ export function isLinkedPromotion(
})
}
export function getSourceNodeId(w: IBaseWidget): string | undefined {
if (!isPromotedWidgetView(w)) return undefined
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
export function reorderSubgraphInputsByName(
subgraphNode: SubgraphNode,
orderedInputNames: readonly string[]
): void {
const order = new Map(
orderedInputNames.map((name, index) => [name, index] as const)
)
const byOrder = <T extends { name: string }>(left: T, right: T) => {
const leftOrder = order.get(left.name) ?? Number.MAX_SAFE_INTEGER
const rightOrder = order.get(right.name) ?? Number.MAX_SAFE_INTEGER
return leftOrder - rightOrder
}
const orderedIndices = subgraphNode.subgraph.inputs
.map((input, index) => ({ input, index }))
.sort((left, right) => byOrder(left.input, right.input))
.map(({ index }) => index)
applySubgraphInputOrder(subgraphNode, orderedIndices)
}
export function reorderSubgraphInputsByWidgetOrder(
subgraphNode: SubgraphNode,
orderedWidgets: readonly IBaseWidget[]
): void {
const remainingIndices = new Set(subgraphNode.inputs.keys())
const orderedIndices = orderedWidgets.flatMap((orderedWidget) => {
for (const index of remainingIndices) {
const widget = subgraphNode.inputs[index]?._widget
if (widget && isSamePromotedWidget(widget, orderedWidget)) {
remainingIndices.delete(index)
return [index]
}
}
return []
})
for (const index of remainingIndices) orderedIndices.push(index)
applySubgraphInputOrder(subgraphNode, orderedIndices)
}
function applySubgraphInputOrder(
subgraphNode: SubgraphNode,
orderedIndices: readonly number[]
): void {
const widgetValues = subgraphNode.inputs.map((input) =>
getExplicitHostWidgetValue(input?._widget)
)
reorderSubgraphInputs(subgraphNode, orderedIndices)
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
const value = widgetValues[oldIndex]
if (value === undefined) continue
const widget = subgraphNode.inputs[newIndex]?._widget
if (widget) widget.value = value
}
}
function getExplicitHostWidgetValue(
widget: IBaseWidget | undefined
): IBaseWidget['value'] | undefined {
if (!widget) return undefined
if (!isPromotedWidgetView(widget)) return widget.value
const value = readWidgetValue(widget.entityId)
return isWidgetValue(value) ? value : undefined
}
function isSamePromotedWidget(left: IBaseWidget, right: IBaseWidget): boolean {
return (
isPromotedWidgetView(left) &&
isPromotedWidgetView(right) &&
left.sourceNodeId === right.sourceNodeId &&
left.sourceWidgetName === right.sourceWidgetName
)
}
function isPreviewExposed(
subgraphNode: SubgraphNode,
source: PromotedWidgetSource
): boolean {
const hostLocator = String(subgraphNode.id)
return usePreviewExposureStore()
.getExposures(subgraphNode.rootGraph.id, hostLocator)
.some(
(exposure) =>
exposure.sourceNodeId === source.sourceNodeId &&
exposure.sourcePreviewName === source.sourceWidgetName
)
}
export function isWidgetPromotedOnSubgraphNode(
subgraphNode: SubgraphNode,
source: PromotedWidgetSource,
widget?: IBaseWidget
): boolean {
if (widget && isPreviewPseudoWidget(widget))
return isPreviewExposed(subgraphNode, source)
return (
isLinkedPromotion(
subgraphNode,
source.sourceNodeId,
source.sourceWidgetName
) || isPreviewExposed(subgraphNode, source)
)
}
function toPromotionSource(
node: PartialNode,
widget: IBaseWidget
): PromotedWidgetSource {
const widgetIsParentLevelView =
isPromotedWidgetView(widget) && widget.sourceNodeId === String(node.id)
return {
sourceNodeId: String(node.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
? widget.disambiguatingSourceNodeId
: undefined
sourceWidgetName: widgetIsParentLevelView
? widget.sourceWidgetName
: getWidgetName(widget)
}
}
@@ -74,18 +186,67 @@ function refreshPromotedWidgetRendering(parents: SubgraphNode[]): void {
useCanvasStore().canvas?.setDirty(true, true)
}
/** Known non-$$ preview widget types added by core or popular extensions. */
type CanonicalPromotionResult =
| { ok: true }
| { ok: false; reason: 'missingSourceSlot' | 'connectFailed' }
export function promoteValueWidgetViaSubgraphInput(
subgraphNode: SubgraphNode,
sourceNode: LGraphNode,
sourceWidget: IBaseWidget
): CanonicalPromotionResult {
const sourceWidgetName = getWidgetName(sourceWidget)
if (
isLinkedPromotion(subgraphNode, String(sourceNode.id), sourceWidgetName)
) {
return { ok: true }
}
const sourceSlot = sourceNode.getSlotFromWidget(sourceWidget)
if (!sourceSlot) return { ok: false, reason: 'missingSourceSlot' }
const existingNames = subgraphNode.subgraph.inputs.map((input) => input.name)
const inputName = nextUniqueName(sourceWidgetName, existingNames)
const subgraphInput = subgraphNode.subgraph.addInput(
inputName,
String(sourceSlot.type ?? sourceWidget.type ?? '*')
)
const link = subgraphInput.connect(sourceSlot, sourceNode)
if (!link) {
subgraphNode.subgraph.removeInput(subgraphInput)
return { ok: false, reason: 'connectFailed' }
}
return { ok: true }
}
function promotePreviewViaExposure(
subgraphNode: SubgraphNode,
sourceNode: LGraphNode,
sourcePreviewName: string
): void {
const store = usePreviewExposureStore()
const rootGraphId = subgraphNode.rootGraph.id
const hostLocator = String(subgraphNode.id)
const existing = store
.getExposures(rootGraphId, hostLocator)
.some(
(exposure) =>
exposure.sourceNodeId === String(sourceNode.id) &&
exposure.sourcePreviewName === sourcePreviewName
)
if (existing) return
store.addExposure(rootGraphId, hostLocator, {
sourceNodeId: String(sourceNode.id),
sourcePreviewName
})
}
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
/**
* Returns true for pseudo-widgets that display media previews and should
* be auto-promoted when their node is inside a subgraph.
* Matches the core `$$` convention as well as custom-node patterns
* (e.g. VHS `videopreview` with type `"preview"`).
*/
export function isPreviewPseudoWidget(widget: IBaseWidget): boolean {
if (widget.name.startsWith('$$')) return true
// Custom nodes may set serialize on the widget or in options
if (widget.serialize !== false && widget.options?.serialize !== false)
return false
if (typeof widget.type === 'string' && PREVIEW_WIDGET_TYPES.has(widget.type))
@@ -98,10 +259,21 @@ export function promoteWidget(
widget: IBaseWidget,
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const source = toPromotionSource(node, widget)
if (!(node instanceof LGraphNode)) return
for (const parent of parents) {
store.promote(parent.rootGraph.id, parent.id, source)
if (isPreviewPseudoWidget(widget)) {
promotePreviewViaExposure(parent, node, source.sourceWidgetName)
continue
}
const result = promoteValueWidgetViaSubgraphInput(parent, node, widget)
if (!result.ok) {
Sentry.addBreadcrumb({
category: 'subgraph',
level: 'warning',
message: `Failed to promote widget "${source.sourceWidgetName}" on node ${node.id}: ${result.reason}`
})
}
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
@@ -116,10 +288,45 @@ export function demoteWidget(
widget: IBaseWidget,
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const source = toPromotionSource(node, widget)
for (const parent of parents) {
store.demote(parent.rootGraph.id, parent.id, source)
if (!parent.subgraph) continue
const hostInput = findHostInputForPromotion(
parent,
source.sourceNodeId,
source.sourceWidgetName
)
const linkedInput = hostInput?._subgraphSlot
if (linkedInput) {
const hasExternalLink = hostInput.link != null
if (hasExternalLink) {
linkedInput.disconnect()
} else {
parent.subgraph.removeInput(linkedInput)
}
continue
}
if (isPreviewPseudoWidget(widget)) {
const previewStore = usePreviewExposureStore()
const hostLocator = String(parent.id)
const exposure = previewStore
.getExposures(parent.rootGraph.id, hostLocator)
.find(
(entry) =>
entry.sourceNodeId === source.sourceNodeId &&
entry.sourcePreviewName === source.sourceWidgetName
)
if (exposure) {
previewStore.removeExposure(
parent.rootGraph.id,
hostLocator,
exposure.name
)
continue
}
}
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
@@ -152,11 +359,10 @@ export function addWidgetPromotionOptions(
widget: IBaseWidget,
node: LGraphNode
) {
const store = usePromotionStore()
const parents = getParentNodes()
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
(parent) => !isWidgetPromotedOnSubgraphNode(parent, source, widget)
)
if (promotableParents.length > 0)
options.unshift({
@@ -189,10 +395,9 @@ export function tryToggleWidgetPromotion() {
const widget = node.getWidgetOnPos(x, y, true)
const parents = getParentNodes()
if (!parents.length || !widget) return
const store = usePromotionStore()
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
(parent) => !isWidgetPromotedOnSubgraphNode(parent, source, widget)
)
if (promotableParents.length > 0)
promoteWidget(node, widget, promotableParents)
@@ -247,8 +452,8 @@ function nodeWidgets(n: LGraphNode): WidgetItem[] {
return getPromotableWidgets(n).map((w: IBaseWidget) => [n, w])
}
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
const store = usePromotionStore()
export function autoExposeKnownPreviewNodes(subgraphNode: SubgraphNode): void {
if (subgraphNode.properties.previewExposures !== undefined) return
const { updatePreviews } = useLitegraphService()
const interiorNodes = subgraphNode.subgraph.nodes
for (const node of interiorNodes) {
@@ -260,88 +465,86 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
function promotePreviewWidget() {
const widget = node.widgets?.find(isPreviewPseudoWidget)
if (!widget) return
if (
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(node.id),
sourceWidgetName: widget.name
})
)
return
promoteWidget(node, widget, [subgraphNode])
promotePreviewViaExposure(subgraphNode, node, widget.name)
}
// Promote preview widgets that already exist (e.g. custom node DOM widgets
// like VHS videopreview that are created in onNodeCreated).
promotePreviewWidget()
// If a preview widget already exists in this frame, there's nothing to
// defer. Core $$ preview widgets are the lazy path that needs updatePreviews.
if (hasPreviewWidget()) continue
// Nodes in CANVAS_IMAGE_PREVIEW_NODE_TYPES support a virtual $$
// preview widget. Eagerly promote it so getPseudoWidgetPreviewTargets
// includes this node and onDrawBackground can call updatePreviews on it
// once execution outputs arrive.
if (supportsVirtualCanvasImagePreview(node)) {
const canvasSource: PromotedWidgetSource = {
sourceNodeId: String(node.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
if (
!store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
canvasSource
)
) {
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, canvasSource)
}
promotePreviewViaExposure(subgraphNode, node, CANVAS_IMAGE_PREVIEW_WIDGET)
continue
}
// Also schedule a deferred check: core $$ widgets are created lazily by
// updatePreviews when node outputs are first loaded.
requestAnimationFrame(() => updatePreviews(node, promotePreviewWidget))
}
}
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
autoExposeKnownPreviewNodes(subgraphNode)
const interiorNodes = subgraphNode.subgraph.nodes
const filteredWidgets: WidgetItem[] = interiorNodes
.flatMap(nodeWidgets)
.filter(isRecommendedWidget)
.filter(([, widget]) => !isPreviewPseudoWidget(widget))
for (const [n, w] of filteredWidgets) {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
toPromotionSource(n, w)
)
const result = promoteValueWidgetViaSubgraphInput(subgraphNode, n, w)
if (!result.ok) {
Sentry.addBreadcrumb({
category: 'subgraph',
level: 'warning',
message: `Failed to promote widget "${getWidgetName(w)}" on node ${n.id}: ${result.reason}`
})
}
}
subgraphNode.computeSize(subgraphNode.size)
}
export function pruneDisconnected(subgraphNode: SubgraphNode) {
const store = usePromotionStore()
const subgraph = subgraphNode.subgraph
const entries = store.getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
const removedEntries: PromotedWidgetSource[] = []
const validEntries = entries.filter((entry) => {
const node = subgraph.getNodeById(entry.sourceNodeId)
const staleInputs = subgraph.inputs.filter((input) => {
const widget = input._widget
if (!widget || !isPromotedWidgetView(widget)) return false
// If the SubgraphInput has any live link to an interior target slot that
// still has a widget, the promotion is alive — even when the widget's
// sourceNodeId points at a deeply-nested interior node that does not exist
// directly in `subgraph` (nested SubgraphNode promotions).
for (const linkId of input.linkIds) {
const link = subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(subgraph)
if (!inputNode) continue
const targetInputSlot = inputNode.inputs?.find(
(slot) => slot.link === linkId
)
if (!targetInputSlot) continue
if (inputNode.getWidgetFromSlot(targetInputSlot)) return false
}
const node = subgraph.getNodeById(widget.sourceNodeId)
if (!node) {
removedEntries.push(entry)
return false
removedEntries.push(widget)
return true
}
const hasWidget = getPromotableWidgets(node).some(
(iw) => iw.name === entry.sourceWidgetName
(iw) => iw.name === widget.sourceWidgetName
)
if (!hasWidget) {
removedEntries.push(entry)
removedEntries.push(widget)
}
return hasWidget
return !hasWidget
})
for (const input of staleInputs) {
subgraph.removeInput(input)
}
if (removedEntries.length > 0 && import.meta.env.DEV) {
console.warn(
'[proxyWidgetUtils] Pruned disconnected promotions',
'[subgraphInputs] Pruned disconnected promoted widget inputs',
removedEntries,
{
graphId: subgraphNode.rootGraph.id,
@@ -350,27 +553,29 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
)
}
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
refreshPromotedWidgetRendering([subgraphNode])
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Pruned ${removedEntries.length} disconnected promotion(s) from subgraph node ${subgraphNode.id}`,
message: `Pruned ${removedEntries.length} disconnected promoted widget input(s) from subgraph node ${subgraphNode.id}`,
level: 'info'
})
}
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
const promotionStore = usePromotionStore()
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
const { subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>
(interiorNode.widgets ?? []).some(
getPromotableWidgets(interiorNode).some(
(widget) =>
!widget.computedDisabled &&
!promotionStore.isPromoted(rootGraph.id, subgraphNodeId, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: widget.name
})
!isWidgetPromotedOnSubgraphNode(
subgraphNode,
{
sourceNodeId: String(interiorNode.id),
sourceWidgetName: widget.name
},
widget
)
)
)
}

View File

@@ -30,7 +30,6 @@ type PromotedWidgetStub = Pick<
> & {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
node?: SubgraphNode
}
@@ -52,8 +51,7 @@ function createPromotedWidget(
name: string,
sourceNodeId: string,
sourceWidgetName: string,
node?: SubgraphNode,
disambiguatingSourceNodeId?: string
node?: SubgraphNode
): IBaseWidget {
const promotedWidget: PromotedWidgetStub = {
name,
@@ -63,7 +61,6 @@ function createPromotedWidget(
value: undefined,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId,
node
}
return promotedWidget as IBaseWidget
@@ -97,27 +94,6 @@ describe('resolvePromotedWidgetAtHost', () => {
expect(resolved).toBeUndefined()
})
test('resolves duplicate-name promoted host widgets by disambiguating source node id', () => {
const host = createHostNode(100)
const sourceNode = addNodeToHost(host, 'source')
sourceNode.widgets = [
createPromotedWidget('text', String(sourceNode.id), 'text', host, '1'),
createPromotedWidget('text', String(sourceNode.id), 'text', host, '2')
]
const resolved = resolvePromotedWidgetAtHost(
host,
String(sourceNode.id),
'text',
'2'
)
expect(resolved).toBeDefined()
expect(
(resolved!.widget as PromotedWidgetStub).disambiguatingSourceNodeId
).toBe('2')
})
})
describe('resolveConcretePromotedWidget', () => {

View File

@@ -20,40 +20,29 @@ const MAX_PROMOTED_WIDGET_CHAIN_DEPTH = 100
function traversePromotedWidgetChain(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string,
sourceNodeId?: string
widgetName: string
): PromotedWidgetResolutionResult {
const visited = new Set<string>()
const hostUidByObject = new WeakMap<SubgraphNode, number>()
let nextHostUid = 0
const visitedByHost = new WeakMap<SubgraphNode, Set<string>>()
let currentHost = hostNode
let currentNodeId = nodeId
let currentWidgetName = widgetName
let currentSourceNodeId = sourceNodeId
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) {
let hostUid = hostUidByObject.get(currentHost)
if (hostUid === undefined) {
hostUid = nextHostUid
nextHostUid += 1
hostUidByObject.set(currentHost, hostUid)
}
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}:${currentSourceNodeId ?? ''}`
const key = `${currentNodeId}:${currentWidgetName}`
const visited = visitedByHost.get(currentHost) ?? new Set<string>()
if (visited.has(key)) {
return { status: 'failure', failure: 'cycle' }
}
visited.add(key)
visitedByHost.set(currentHost, visited)
const sourceNode = currentHost.subgraph.getNodeById(currentNodeId)
if (!sourceNode) {
return { status: 'failure', failure: 'missing-node' }
}
const sourceWidget = findWidgetByIdentity(
sourceNode.widgets,
currentWidgetName,
currentSourceNodeId
const sourceWidget = sourceNode.widgets?.find(
(entry) => entry.name === currentWidgetName
)
if (!sourceWidget) {
return { status: 'failure', failure: 'missing-widget' }
@@ -73,42 +62,20 @@ function traversePromotedWidgetChain(
currentHost = sourceWidget.node
currentNodeId = sourceWidget.sourceNodeId
currentWidgetName = sourceWidget.sourceWidgetName
currentSourceNodeId = undefined
}
return { status: 'failure', failure: 'max-depth-exceeded' }
}
function findWidgetByIdentity(
widgets: IBaseWidget[] | undefined,
widgetName: string,
sourceNodeId?: string
): IBaseWidget | undefined {
if (!widgets) return undefined
if (sourceNodeId) {
return widgets.find(
(entry) =>
isPromotedWidgetView(entry) &&
(entry.disambiguatingSourceNodeId ?? entry.sourceNodeId) ===
sourceNodeId &&
(entry.sourceWidgetName === widgetName || entry.name === widgetName)
)
}
return widgets.find((entry) => entry.name === widgetName)
}
export function resolvePromotedWidgetAtHost(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string,
sourceNodeId?: string
widgetName: string
): ResolvedPromotedWidget | undefined {
const node = hostNode.subgraph.getNodeById(nodeId)
if (!node) return undefined
const widget = findWidgetByIdentity(node.widgets, widgetName, sourceNodeId)
const widget = node.widgets?.find((entry) => entry.name === widgetName)
if (!widget) return undefined
return { node, widget }
@@ -117,11 +84,27 @@ export function resolvePromotedWidgetAtHost(
export function resolveConcretePromotedWidget(
hostNode: LGraphNode,
nodeId: string,
widgetName: string,
sourceNodeId?: string
widgetName: string
): PromotedWidgetResolutionResult {
if (!hostNode.isSubgraphNode()) {
return { status: 'failure', failure: 'invalid-host' }
}
return traversePromotedWidgetChain(hostNode, nodeId, widgetName, sourceNodeId)
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
}
export function resolvePromotedWidgetSource(
hostNode: LGraphNode,
widget: IBaseWidget
): ResolvedPromotedWidget | undefined {
if (!isPromotedWidgetView(widget)) return undefined
if (!hostNode.isSubgraphNode()) return undefined
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
)
if (result.status === 'resolved') return result.resolved
return undefined
}

View File

@@ -1,23 +0,0 @@
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export function resolvePromotedWidgetSource(
hostNode: LGraphNode,
widget: IBaseWidget
): ResolvedPromotedWidget | undefined {
if (!isPromotedWidgetView(widget)) return undefined
if (!hostNode.isSubgraphNode()) return undefined
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName,
widget.disambiguatingSourceNodeId
)
if (result.status === 'resolved') return result.resolved
return undefined
}

View File

@@ -85,7 +85,7 @@ describe('resolveSubgraphInputTarget', () => {
})
})
test('returns undefined for non-widget input on nested SubgraphNode', () => {
test('resolves non-widget input on nested SubgraphNode to immediate child target', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'audio'
])
@@ -93,28 +93,41 @@ describe('resolveSubgraphInputTarget', () => {
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
expect(result).toBeUndefined()
expect(result).toMatchObject({
nodeId: '819',
widgetName: 'audio'
})
})
test('resolves widget inputs but not non-widget inputs on the same nested SubgraphNode', () => {
test('resolves both widget and non-widget inputs on nested SubgraphNodes', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'width',
'audio'
])
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
widget: 'width'
})
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
const { innerSubgraphNode: widthChild } = addLinkedNestedSubgraphNode(
outerSubgraph,
'width',
'width',
{ widget: 'width' }
)
const { innerSubgraphNode: audioChild } = addLinkedNestedSubgraphNode(
outerSubgraph,
'audio',
'audio'
)
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'width')
).toMatchObject({
nodeId: '819',
nodeId: String(widthChild.id),
widgetName: 'width'
})
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
).toBeUndefined()
).toMatchObject({
nodeId: String(audioChild.id),
widgetName: 'audio'
})
})
test('returns target for widget-backed input on plain interior node', () => {
@@ -199,7 +212,10 @@ describe('resolveSubgraphInputTarget', () => {
})
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
).toBeUndefined()
).toMatchObject({
nodeId: '820',
widgetName: 'audio'
})
})
test('three-level nesting returns immediate child target, not deepest', () => {

View File

@@ -1,12 +1,10 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import { resolveSubgraphInputLink } from './resolveSubgraphInputLink'
type ResolvedSubgraphInputTarget = {
nodeId: string
widgetName: string
sourceNodeId?: string
}
export function resolveSubgraphInputTarget(
@@ -18,19 +16,6 @@ export function resolveSubgraphInputTarget(
inputName,
({ inputNode, targetInput, getTargetWidget }) => {
if (inputNode.isSubgraphNode()) {
const targetWidget = getTargetWidget()
if (!targetWidget) return undefined
if (isPromotedWidgetView(targetWidget)) {
return {
nodeId: String(inputNode.id),
widgetName: targetWidget.sourceWidgetName,
sourceNodeId:
targetWidget.disambiguatingSourceNodeId ??
targetWidget.sourceNodeId
}
}
return {
nodeId: String(inputNode.id),
widgetName: targetInput.name

View File

@@ -1,408 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({ widgetStates: new Map() })
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
function setupSubgraph(
innerNodeCount: number = 0
): [SubgraphNode, LGraphNode[], string[]] {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const innerNodes: LGraphNode[] = []
for (let i = 0; i < innerNodeCount; i++) {
const innerNode = new LGraphNode(`InnerNode${i}`)
subgraph.add(innerNode)
innerNodes.push(innerNode)
}
const innerIds = innerNodes.map((n) => String(n.id))
return [subgraphNode, innerNodes, innerIds]
}
describe('Subgraph proxyWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
test('Can add simple widget', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(
usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
).toStrictEqual([
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
})
test('Can add multiple widgets with same name', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(2)
for (const innerNode of innerNodes)
innerNode.addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' },
{ sourceNodeId: innerIds[1], sourceWidgetName: 'stringWidget' }
]
)
expect(subgraphNode.widgets.length).toBe(2)
// Both views share the widget name; they're distinguished by sourceNodeId
expect(subgraphNode.widgets[0].name).toBe('stringWidget')
expect(subgraphNode.widgets[1].name).toBe('stringWidget')
})
test('Will reflect proxyWidgets order changes', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'widgetA', 'value', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'value', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
expect(subgraphNode.widgets.length).toBe(2)
expect(subgraphNode.widgets[0].name).toBe('widgetA')
expect(subgraphNode.widgets[1].name).toBe('widgetB')
// Reorder
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' }
])
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(subgraphNode.widgets[1].name).toBe('widgetA')
})
test('Will mirror changes to value', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.widgets[0].value).toBe('value')
innerNodes[0].widgets![0].value = 'test'
expect(subgraphNode.widgets[0].value).toBe('test')
subgraphNode.widgets[0].value = 'test2'
expect(innerNodes[0].widgets![0].value).toBe('test2')
})
test('Will not modify position or sizing of existing widgets', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
innerNodes[0].widgets[0].y = 10
innerNodes[0].widgets[0].last_y = 11
innerNodes[0].widgets[0].computedHeight = 12
subgraphNode.widgets[0].y = 20
subgraphNode.widgets[0].last_y = 21
subgraphNode.widgets[0].computedHeight = 22
expect(innerNodes[0].widgets[0].y).toBe(10)
expect(innerNodes[0].widgets[0].last_y).toBe(11)
expect(innerNodes[0].widgets[0].computedHeight).toBe(12)
})
test('Renders placeholder when interior widget is detached', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
// View resolves the interior widget's type
expect(subgraphNode.widgets[0].type).toBe('text')
// Remove interior widget — view falls back to disconnected state
innerNodes[0].widgets.pop()
expect(subgraphNode.widgets[0].type).toBe('button')
// Re-add — view resolves again
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
expect(subgraphNode.widgets[0].type).toBe('text')
})
test('Prevents duplicate promotion', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
// Promote once
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: innerIds[0],
sourceWidgetName: 'stringWidget'
})
expect(subgraphNode.widgets.length).toBe(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toHaveLength(1)
// Try to promote again - should not create duplicate
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: innerIds[0],
sourceWidgetName: 'stringWidget'
})
expect(subgraphNode.widgets.length).toBe(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toHaveLength(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
})
test('removeWidget removes from promotion list and view cache', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
expect(subgraphNode.widgets).toHaveLength(2)
const widgetToRemove = subgraphNode.widgets[0]
subgraphNode.removeWidget(widgetToRemove)
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
})
test('removeWidget removes from promotion list', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
]
)
const widgetA = subgraphNode.widgets.find((w) => w.name === 'widgetA')!
subgraphNode.removeWidget(widgetA)
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('widgetB')
})
test('removeWidget cleans up input references', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
const view = subgraphNode.widgets[0]
// Simulate an input referencing the widget
subgraphNode.addInput('stringWidget', '*')
const input = subgraphNode.inputs[subgraphNode.inputs.length - 1]
input._widget = view
subgraphNode.removeWidget(view)
expect(input._widget).toBeUndefined()
expect(subgraphNode.widgets).toHaveLength(0)
})
test('serialize does not produce widgets_values for promoted views', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets).toHaveLength(1)
const serialized = subgraphNode.serialize()
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
// Even if it were set, views have serialize: false and would be skipped.
expect(serialized.widgets_values).toBeUndefined()
})
test('serialize preserves proxyWidgets in properties', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
]
)
const serialized = subgraphNode.serialize()
expect(serialized.properties?.proxyWidgets).toStrictEqual([
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
])
})
test('multi-link representative is deterministic across repeated reads', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'shared_input', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
subgraphNode.graph!.add(subgraphNode)
const nodeA = new LGraphNode('NodeA')
const inputA = nodeA.addInput('shared_input', '*')
nodeA.addWidget('text', 'shared_input', 'first', () => {})
inputA.widget = { name: 'shared_input' }
subgraph.add(nodeA)
const nodeB = new LGraphNode('NodeB')
const inputB = nodeB.addInput('shared_input', '*')
nodeB.addWidget('text', 'shared_input', 'second', () => {})
inputB.widget = { name: 'shared_input' }
subgraph.add(nodeB)
const nodeC = new LGraphNode('NodeC')
const inputC = nodeC.addInput('shared_input', '*')
nodeC.addWidget('text', 'shared_input', 'third', () => {})
inputC.widget = { name: 'shared_input' }
subgraph.add(nodeC)
subgraph.inputNode.slots[0].connect(inputA, nodeA)
subgraph.inputNode.slots[0].connect(inputB, nodeB)
subgraph.inputNode.slots[0].connect(inputC, nodeC)
const firstRead = subgraphNode.widgets.map((w) => w.value)
const secondRead = subgraphNode.widgets.map((w) => w.value)
const thirdRead = subgraphNode.widgets.map((w) => w.value)
expect(firstRead).toStrictEqual(secondRead)
expect(secondRead).toStrictEqual(thirdRead)
expect(subgraphNode.widgets[0].value).toBe('first')
})
test('3-level nested promotion resolves concrete widget type and value', () => {
usePromotionStore()
// Level C: innermost subgraph with a concrete widget
const subgraphC = createTestSubgraph({
inputs: [{ name: 'deep_input', type: '*' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
const concreteInput = concreteNode.addInput('deep_input', '*')
concreteNode.addWidget('number', 'deep_input', 42, () => {})
concreteInput.widget = { name: 'deep_input' }
subgraphC.add(concreteNode)
subgraphC.inputNode.slots[0].connect(concreteInput, concreteNode)
const subgraphNodeC = createTestSubgraphNode(subgraphC, { id: 301 })
// Level B: middle subgraph containing C
const subgraphB = createTestSubgraph({
inputs: [{ name: 'mid_input', type: '*' }]
})
subgraphB.add(subgraphNodeC)
subgraphNodeC._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeC.inputs[0], subgraphNodeC)
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 302 })
// Level A: outermost subgraph containing B
const subgraphA = createTestSubgraph({
inputs: [{ name: 'outer_input', type: '*' }]
})
subgraphA.add(subgraphNodeB)
subgraphNodeB._internalConfigureAfterSlots()
subgraphA.inputNode.slots[0].connect(subgraphNodeB.inputs[0], subgraphNodeB)
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 303 })
// Outermost promoted widget should resolve through all 3 levels
expect(subgraphNodeA.widgets).toHaveLength(1)
expect(subgraphNodeA.widgets[0].type).toBe('number')
expect(subgraphNodeA.widgets[0].value).toBe(42)
// Setting value at outermost level propagates to concrete widget
subgraphNodeA.widgets[0].value = 99
expect(concreteNode.widgets![0].value).toBe(99)
})
test('removeWidget cleans up promotion and input, then re-promote works', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
const view = subgraphNode.widgets[0]
subgraphNode.addInput('stringWidget', '*')
const input = subgraphNode.inputs[subgraphNode.inputs.length - 1]
input._widget = view
// Remove: should clean up store AND input reference
subgraphNode.removeWidget(view)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toHaveLength(0)
expect(input._widget).toBeUndefined()
expect(subgraphNode.widgets).toHaveLength(0)
// Re-promote: should work correctly after cleanup
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].type).toBe('text')
expect(subgraphNode.widgets[0].value).toBe('value')
})
})

View File

@@ -0,0 +1,32 @@
import type { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
/**
* @param property - Raw node property value.
* @param schema - Zod schema describing the expected array shape.
* @param contextName - Prefix for `console.warn` messages (e.g. `properties.proxyWidgets`).
*/
export function parseNodePropertyArray<T>(
property: NodeProperty | undefined,
schema: z.ZodType<T[]>,
contextName: string
): T[] {
if (property === undefined) return []
let parsed: unknown
try {
parsed = typeof property === 'string' ? JSON.parse(property) : property
} catch (e) {
console.warn(`Failed to parse ${contextName}:`, e)
return []
}
const result = schema.safeParse(parsed)
if (result.success) return result.data
const error = fromZodError(result.error)
console.warn(`Invalid assignment for ${contextName}:\n${error}`)
return []
}

View File

@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest'
import { parsePreviewExposures } from './previewExposureSchema'
describe(parsePreviewExposures, () => {
it('parses a valid array of preview exposure objects', () => {
const input = [
{
name: 'preview',
sourceNodeId: '5',
sourcePreviewName: '$$canvas-image-preview'
},
{
name: 'preview2',
sourceNodeId: '7',
sourcePreviewName: '$$canvas-image-preview'
}
]
expect(parsePreviewExposures(input)).toEqual(input)
})
it('parses JSON-string input', () => {
const input = [
{
name: 'preview',
sourceNodeId: '5',
sourcePreviewName: '$$canvas-image-preview'
}
]
expect(parsePreviewExposures(JSON.stringify(input))).toEqual(input)
})
it('returns empty array for undefined', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(parsePreviewExposures(undefined)).toEqual([])
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
it('returns empty array for malformed JSON string', () => {
expect(parsePreviewExposures('not-json{')).toEqual([])
})
it('returns empty array for non-array input', () => {
expect(
parsePreviewExposures({
name: 'preview',
sourceNodeId: '5',
sourcePreviewName: '$$canvas-image-preview'
})
).toEqual([])
expect(parsePreviewExposures(42)).toEqual([])
})
it('returns empty array when entries are missing required fields', () => {
expect(
parsePreviewExposures([{ name: 'preview', sourceNodeId: '5' }])
).toEqual([])
expect(
parsePreviewExposures([
{ sourceNodeId: '5', sourcePreviewName: '$$canvas-image-preview' }
])
).toEqual([])
})
it('returns empty array when entries have wrong types', () => {
expect(
parsePreviewExposures([
{
name: 123,
sourceNodeId: '5',
sourcePreviewName: '$$canvas-image-preview'
}
])
).toEqual([])
expect(
parsePreviewExposures([
{
name: 'preview',
sourceNodeId: 5,
sourcePreviewName: '$$canvas-image-preview'
}
])
).toEqual([])
})
})

View File

@@ -0,0 +1,24 @@
import { z } from 'zod'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import { parseNodePropertyArray } from './parseNodePropertyArray'
export const previewExposureSchema = z.object({
name: z.string(),
sourceNodeId: z.string(),
sourcePreviewName: z.string()
})
export type PreviewExposure = z.infer<typeof previewExposureSchema>
const previewExposuresPropertySchema = z.array(previewExposureSchema)
export function parsePreviewExposures(
property: NodeProperty | undefined
): PreviewExposure[] {
return parseNodePropertyArray(
property,
previewExposuresPropertySchema,
'properties.previewExposures'
)
}

View File

@@ -1,29 +1,25 @@
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
const proxyWidgetTupleSchema = z.union([
import { parseNodePropertyArray } from './parseNodePropertyArray'
export const serializedProxyWidgetTupleSchema = z.union([
z.tuple([z.string(), z.string(), z.string()]),
z.tuple([z.string(), z.string()])
])
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
export type SerializedProxyWidgetTuple = z.infer<
typeof serializedProxyWidgetTupleSchema
>
const proxyWidgetsPropertySchema = z.array(serializedProxyWidgetTupleSchema)
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
export function parseProxyWidgets(
property: NodeProperty | undefined
): ProxyWidgetsProperty {
try {
if (typeof property === 'string') property = JSON.parse(property)
const result = proxyWidgetsPropertySchema.safeParse(
typeof property === 'string' ? JSON.parse(property) : property
)
if (result.success) return result.data
const error = fromZodError(result.error)
console.warn(`Invalid assignment for properties.proxyWidgets:\n${error}`)
} catch (e) {
console.warn('Failed to parse properties.proxyWidgets:', e)
}
return []
return parseNodePropertyArray(
property,
proxyWidgetsPropertySchema,
'properties.proxyWidgets'
)
}

View File

@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest'
import { parseProxyWidgetErrorQuarantine } from './proxyWidgetQuarantineSchema'
import type { ProxyWidgetQuarantineReason } from './proxyWidgetQuarantineSchema'
const baseEntry = {
originalEntry: ['10', 'seed'] as [string, string],
reason: 'missingSourceNode' as ProxyWidgetQuarantineReason,
attemptedAtVersion: 1 as const
}
describe(parseProxyWidgetErrorQuarantine, () => {
it.for([
{ name: 'without hostValue', entry: baseEntry },
{ name: 'with hostValue', entry: { ...baseEntry, hostValue: 42 } },
{
name: 'with 3-tuple originalEntry',
entry: { ...baseEntry, originalEntry: ['3', 'text', '1'] }
}
])('parses a valid entry $name', ({ entry }) => {
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
})
it.for([
'missingSourceNode',
'missingSourceWidget',
'missingSubgraphInput',
'ambiguousSubgraphInput',
'unlinkedSourceWidget',
'primitiveBypassFailed'
] as const)('parses reason %s', (reason) => {
const entry = { ...baseEntry, reason }
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
})
it('parses JSON-string input', () => {
const input = JSON.stringify([baseEntry])
expect(parseProxyWidgetErrorQuarantine(input)).toEqual([baseEntry])
})
it('returns empty array for undefined without warning', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(parseProxyWidgetErrorQuarantine(undefined)).toEqual([])
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
it.for([
{ name: 'malformed JSON string', input: 'not-json{' },
{ name: 'non-array input', input: baseEntry },
{
name: 'attemptedAtVersion is not 1',
input: [{ ...baseEntry, attemptedAtVersion: 2 }]
},
{
name: 'reason is not in the enum',
input: [{ ...baseEntry, reason: 'somethingElse' }]
},
{
name: 'originalEntry is malformed',
input: [{ ...baseEntry, originalEntry: ['only-one'] }]
}
])('returns empty array when $name', ({ input }) => {
expect(parseProxyWidgetErrorQuarantine(input)).toEqual([])
})
})

View File

@@ -0,0 +1,45 @@
import { z } from 'zod'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { parseNodePropertyArray } from './parseNodePropertyArray'
import { serializedProxyWidgetTupleSchema } from './promotionSchema'
export const proxyWidgetQuarantineReasonSchema = z.enum([
'missingSourceNode',
'missingSourceWidget',
'missingSubgraphInput',
'ambiguousSubgraphInput',
'unlinkedSourceWidget',
'primitiveBypassFailed'
])
export type ProxyWidgetQuarantineReason = z.infer<
typeof proxyWidgetQuarantineReasonSchema
>
export const proxyWidgetErrorQuarantineEntrySchema = z.object({
originalEntry: serializedProxyWidgetTupleSchema,
reason: proxyWidgetQuarantineReasonSchema,
hostValue: z.unknown().optional(),
attemptedAtVersion: z.literal(1)
})
const proxyWidgetErrorQuarantinePropertySchema = z.array(
proxyWidgetErrorQuarantineEntrySchema
)
export type ProxyWidgetErrorQuarantineEntry = Omit<
z.infer<typeof proxyWidgetErrorQuarantineEntrySchema>,
'hostValue'
> & { hostValue?: TWidgetValue }
export function parseProxyWidgetErrorQuarantine(
property: NodeProperty | undefined
): ProxyWidgetErrorQuarantineEntry[] {
return parseNodePropertyArray(
property,
proxyWidgetErrorQuarantinePropertySchema,
'properties.proxyWidgetErrorQuarantine'
) as ProxyWidgetErrorQuarantineEntry[]
}

View File

@@ -1,287 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
LGraphNode,
LiteGraph,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type {
ExportedSubgraphInstance,
Positionable,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { usePromotionStore } from '@/stores/promotionStore'
/**
* Registers a minimal SubgraphNode class for a subgraph definition
* so that `LiteGraph.createNode(subgraphId)` works in tests.
*/
function registerSubgraphNodeType(subgraph: Subgraph): void {
const instanceData: ExportedSubgraphInstance = {
id: -1,
type: subgraph.id,
pos: [0, 0],
size: [100, 100],
inputs: [],
outputs: [],
flags: {},
order: 0,
mode: 0
}
const node = class extends SubgraphNode {
constructor() {
super(subgraph.rootGraph, subgraph, instanceData)
}
}
Object.defineProperty(node, 'title', { value: subgraph.name })
LiteGraph.registerNodeType(subgraph.id, node)
}
const registeredTypes: string[] = []
afterEach(() => {
for (const type of registeredTypes) {
LiteGraph.unregisterNodeType(type)
}
registeredTypes.length = 0
})
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('_repointAncestorPromotions', () => {
function setupParentSubgraphWithWidgets() {
const parentSubgraph = createTestSubgraph({
name: 'Parent Subgraph',
inputs: [{ name: 'input', type: '*' }],
outputs: [{ name: 'output', type: '*' }]
})
const rootGraph = parentSubgraph.rootGraph
// We need to listen for new subgraph registrations so
// LiteGraph.createNode works during convertToSubgraph
rootGraph.events.addEventListener('subgraph-created', (e) => {
const { subgraph } = e.detail
registerSubgraphNodeType(subgraph)
registeredTypes.push(subgraph.id)
})
const interiorNode = new LGraphNode('Interior Node')
interiorNode.addInput('in', '*')
interiorNode.addOutput('out', '*')
interiorNode.addWidget('text', 'prompt', 'hello world', () => {})
parentSubgraph.add(interiorNode)
// Create host SubgraphNode in root graph
registerSubgraphNodeType(parentSubgraph)
registeredTypes.push(parentSubgraph.id)
const hostNode = createTestSubgraphNode(parentSubgraph)
rootGraph.add(hostNode)
return { rootGraph, parentSubgraph, interiorNode, hostNode }
}
it('repoints parent promotions when interior nodes are packed into a nested subgraph', () => {
const { rootGraph, parentSubgraph, interiorNode, hostNode } =
setupParentSubgraphWithWidgets()
// Promote the interior node's widget on the host
const store = usePromotionStore()
store.promote(rootGraph.id, hostNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
const beforeEntries = store.getPromotions(rootGraph.id, hostNode.id)
expect(beforeEntries).toHaveLength(1)
expect(beforeEntries[0].sourceNodeId).toBe(String(interiorNode.id))
// Pack the interior node into a nested subgraph
const { node: nestedSubgraphNode } = parentSubgraph.convertToSubgraph(
new Set<Positionable>([interiorNode])
)
// After conversion, the host's promotion should be repointed
const afterEntries = store.getPromotions(rootGraph.id, hostNode.id)
expect(afterEntries).toHaveLength(1)
expect(afterEntries[0].sourceNodeId).toBe(String(nestedSubgraphNode.id))
expect(afterEntries[0].sourceWidgetName).toBe('prompt')
expect(afterEntries[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
// The nested subgraph node should also have the promotion
const nestedEntries = store.getPromotions(
rootGraph.id,
nestedSubgraphNode.id
)
expect(nestedEntries).toHaveLength(1)
expect(nestedEntries[0].sourceNodeId).toBe(String(interiorNode.id))
expect(nestedEntries[0].sourceWidgetName).toBe('prompt')
})
it('preserves promotions that reference non-moved nodes', () => {
const { rootGraph, parentSubgraph, interiorNode, hostNode } =
setupParentSubgraphWithWidgets()
const remainingNode = new LGraphNode('Remaining Node')
remainingNode.addWidget('text', 'widget_b', 'b', () => {})
parentSubgraph.add(remainingNode)
const store = usePromotionStore()
store.promote(rootGraph.id, hostNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
store.promote(rootGraph.id, hostNode.id, {
sourceNodeId: String(remainingNode.id),
sourceWidgetName: 'widget_b'
})
// Pack only the interiorNode
parentSubgraph.convertToSubgraph(new Set<Positionable>([interiorNode]))
const afterEntries = store.getPromotions(rootGraph.id, hostNode.id)
expect(afterEntries).toHaveLength(2)
// The remaining node's promotion should be unchanged
const remainingEntry = afterEntries.find(
(e) => e.sourceWidgetName === 'widget_b'
)
expect(remainingEntry?.sourceNodeId).toBe(String(remainingNode.id))
expect(remainingEntry?.disambiguatingSourceNodeId).toBeUndefined()
// The moved node's promotion should be repointed
const movedEntry = afterEntries.find((e) => e.sourceWidgetName === 'prompt')
expect(movedEntry?.sourceNodeId).not.toBe(String(interiorNode.id))
expect(movedEntry?.disambiguatingSourceNodeId).toBe(String(interiorNode.id))
})
it('does not modify promotions when converting in root graph', () => {
const parentSubgraph = createTestSubgraph({ name: 'Dummy' })
const rootGraph = parentSubgraph.rootGraph
rootGraph.events.addEventListener('subgraph-created', (e) => {
const { subgraph } = e.detail
registerSubgraphNodeType(subgraph)
registeredTypes.push(subgraph.id)
})
const node = new LGraphNode('Root Node')
node.addInput('in', '*')
node.addOutput('out', '*')
node.addWidget('text', 'value', 'test', () => {})
rootGraph.add(node)
// Converting in root graph should not throw
rootGraph.convertToSubgraph(new Set<Positionable>([node]))
})
it('uses existing disambiguatingSourceNodeId as fallback on repeat packing', () => {
const { rootGraph, parentSubgraph, interiorNode, hostNode } =
setupParentSubgraphWithWidgets()
const store = usePromotionStore()
store.promote(rootGraph.id, hostNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
// First pack: interior node → nested subgraph
const { node: firstNestedNode } = parentSubgraph.convertToSubgraph(
new Set<Positionable>([interiorNode])
)
const afterFirstPack = store.getPromotions(rootGraph.id, hostNode.id)
expect(afterFirstPack).toHaveLength(1)
expect(afterFirstPack[0].sourceNodeId).toBe(String(firstNestedNode.id))
expect(afterFirstPack[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
// Second pack: nested subgraph → another level of nesting
const { node: secondNestedNode } = parentSubgraph.convertToSubgraph(
new Set<Positionable>([firstNestedNode])
)
// After second pack, promotion should use the disambiguatingSourceNodeId
// as fallback and point to the new nested node
const afterSecondPack = store.getPromotions(rootGraph.id, hostNode.id)
expect(afterSecondPack).toHaveLength(1)
expect(afterSecondPack[0].sourceNodeId).toBe(String(secondNestedNode.id))
expect(afterSecondPack[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
})
it('repoints promotions for multiple host instances of the same subgraph', () => {
const parentSubgraph = createTestSubgraph({
name: 'Shared Parent Subgraph',
inputs: [{ name: 'input', type: '*' }],
outputs: [{ name: 'output', type: '*' }]
})
const rootGraph = parentSubgraph.rootGraph
rootGraph.events.addEventListener('subgraph-created', (e) => {
const { subgraph } = e.detail
registerSubgraphNodeType(subgraph)
registeredTypes.push(subgraph.id)
})
const interiorNode = new LGraphNode('Interior Node')
interiorNode.addInput('in', '*')
interiorNode.addOutput('out', '*')
interiorNode.addWidget('text', 'prompt', 'shared', () => {})
parentSubgraph.add(interiorNode)
// Create TWO host SubgraphNodes pointing to the same subgraph
registerSubgraphNodeType(parentSubgraph)
registeredTypes.push(parentSubgraph.id)
const hostNode1 = createTestSubgraphNode(parentSubgraph)
const hostNode2 = createTestSubgraphNode(parentSubgraph)
rootGraph.add(hostNode1)
rootGraph.add(hostNode2)
// Promote on both hosts
const store = usePromotionStore()
store.promote(rootGraph.id, hostNode1.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
store.promote(rootGraph.id, hostNode2.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
// Pack the interior node
const { node: nestedNode } = parentSubgraph.convertToSubgraph(
new Set<Positionable>([interiorNode])
)
// Both hosts' promotions should be repointed to the nested node
const host1Promotions = store.getPromotions(rootGraph.id, hostNode1.id)
expect(host1Promotions).toHaveLength(1)
expect(host1Promotions[0].sourceNodeId).toBe(String(nestedNode.id))
expect(host1Promotions[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
const host2Promotions = store.getPromotions(rootGraph.id, hostNode2.id)
expect(host2Promotions).toHaveLength(1)
expect(host2Promotions[0].sourceNodeId).toBe(String(nestedNode.id))
expect(host2Promotions[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
})
})

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
@@ -8,14 +8,16 @@ import {
LGraphNode,
LiteGraph,
LLink,
Reroute
Reroute,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
createTestSubgraph,
createTestSubgraphData,
createTestSubgraphNode
} from './subgraph/__fixtures__/subgraphHelpers'
@@ -280,17 +282,17 @@ describe('Graph Clearing and Callbacks', () => {
expect(graph.nodes.length).toBe(0)
})
test('clear() removes graph-scoped promotion and widget-value state', () => {
test('clear() removes graph-scoped preview and widget-value state', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
const graphId = 'graph-clear-cleanup' as UUID
graph.id = graphId
const promotionStore = usePromotionStore()
promotionStore.promote(graphId, 1 as NodeId, {
const previewExposureStore = usePreviewExposureStore()
previewExposureStore.addExposure(graphId, `${graphId}:1`, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
sourcePreviewName: '$$canvas-image-preview'
})
const widgetValueStore = useWidgetValueStore()
@@ -305,27 +307,21 @@ describe('Graph Clearing and Callbacks', () => {
disabled: undefined
})
expect(
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
expect.objectContaining({ value: 1 })
)
expect(
previewExposureStore.getExposures(graphId, `${graphId}:1`)
).toHaveLength(1)
graph.clear()
expect(
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
).toBeUndefined()
expect(previewExposureStore.getExposures(graphId, `${graphId}:1`)).toEqual(
[]
)
})
})
@@ -991,6 +987,48 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
}
})
it('warns when configuring a host with legacy proxyWidgets and no migration hook is wired', () => {
const subgraph = createTestSubgraph()
const sourceHost = createTestSubgraphNode(subgraph)
sourceHost.graph!.add(sourceHost)
sourceHost.properties.proxyWidgets = [['9999', 'seed']]
const serialized = sourceHost.rootGraph.serialize()
const instanceData = sourceHost.serialize()
const previous = LGraph.proxyWidgetMigrationFlush
LGraph.proxyWidgetMigrationFlush = undefined
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
LiteGraph.registerNodeType(
subgraph.id,
class TestSubgraphNode extends SubgraphNode {
constructor() {
super(new LGraph(), subgraph, instanceData)
}
}
)
try {
const graph = new LGraph()
graph.configure(serialized)
const migrationCall = warn.mock.calls.find(
(call) =>
typeof call[0] === 'string' &&
call[0].includes('Legacy proxyWidgets were not migrated')
)
expect(migrationCall).toBeDefined()
expect(migrationCall![1]).toEqual(
expect.objectContaining({
hostNodeId: expect.any(Number),
proxyWidgets: expect.anything()
})
)
} finally {
LGraph.proxyWidgetMigrationFlush = previous
LiteGraph.unregisterNodeType(subgraph.id)
warn.mockRestore()
}
})
it('throws when node ID space is exhausted', () => {
expect(() => {
const graph = new LGraph()

View File

@@ -1,6 +1,5 @@
import { toString } from 'es-toolkit/compat'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
@@ -10,10 +9,7 @@ import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import {
makePromotionEntryKey,
usePromotionStore
} from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -80,6 +76,7 @@ import type {
LGraphTriggerHandler,
LGraphTriggerParam
} from './types/graphTriggers'
import { LGraphTriggerActions } from './types/graphTriggers'
import type {
ExportedSubgraph,
ExposedWidget,
@@ -100,6 +97,12 @@ export type {
LGraphTriggerParam
} from './types/graphTriggers'
const validTriggerActions = new Set<LGraphTriggerAction>(LGraphTriggerActions)
function isLGraphTriggerAction(action: string): action is LGraphTriggerAction {
return validTriggerActions.has(action as LGraphTriggerAction)
}
export type RendererType = 'LG' | 'Vue' | 'Vue-corrected'
/**
@@ -186,6 +189,15 @@ export class LGraph
static STATUS_STOPPED = 1
static STATUS_RUNNING = 2
/** @internal */
static proxyWidgetMigrationFlush?: (
hostNode: SubgraphNode,
nodeData: ISerialisedNode | undefined
) => void
/** @internal */
static autoExposePreviewNodes?: (hostNode: SubgraphNode) => void
/** List of LGraph properties that are manually handled by {@link LGraph.configure}. */
static readonly ConfigureProperties = new Set([
'nodes',
@@ -373,7 +385,7 @@ export class LGraph
const graphId = this.id
if (this.isRootGraph && graphId !== zeroUuid) {
usePromotionStore().clearGraph(graphId)
usePreviewExposureStore().clearGraph(graphId)
useWidgetValueStore().clearGraph(graphId)
}
@@ -1337,18 +1349,11 @@ export class LGraph
): void
trigger(action: string, param: unknown): void
trigger(action: string, param: unknown) {
// Convert to discriminated union format for typed handlers
const validEventTypes = new Set([
'node:slot-links:changed',
'node:slot-errors:changed',
'node:property:changed',
'node:slot-label:changed'
])
if (!isLGraphTriggerAction(action)) return
if (!param || typeof param !== 'object') return
if (validEventTypes.has(action) && param && typeof param === 'object') {
this.onTrigger?.({ type: action, ...param } as LGraphTriggerEvent)
}
// Don't handle unknown events - just ignore them
this.onTrigger?.({ type: action, ...param } as LGraphTriggerEvent)
this.events.dispatch(action, param as never)
}
/** @todo Clean up - never implemented. */
@@ -1914,13 +1919,6 @@ export class LGraph
subgraphNode._setConcreteSlots()
subgraphNode.arrange()
// Repair ancestor promotions: when nodes are packed into a nested
// subgraph, any host SubgraphNode whose proxyWidgets referenced the
// moved nodes must be repointed to chain through the new nested node.
if (!this.isRootGraph) {
this._repointAncestorPromotions(nodes, subgraphNode as SubgraphNode)
}
this.canvasAction((c) =>
c.canvas.dispatchEvent(
new CustomEvent('subgraph-converted', {
@@ -1933,75 +1931,6 @@ export class LGraph
return { subgraph, node: subgraphNode as SubgraphNode }
}
/**
* After packing nodes into a nested subgraph, repoint any ancestor
* SubgraphNode promotions that referenced the moved nodes so they
* chain through the newly created nested SubgraphNode.
*/
private _repointAncestorPromotions(
movedNodes: Set<LGraphNode>,
nestedSubgraphNode: SubgraphNode
): void {
const movedNodeIds = new Set([...movedNodes].map((n) => String(n.id)))
const store = usePromotionStore()
const nestedNodeId = String(nestedSubgraphNode.id)
const graphId = this.rootGraph.id
const nestedEntries = store.getPromotions(graphId, nestedSubgraphNode.id)
const nextNestedEntries = [...nestedEntries]
const nestedEntryKeys = new Set(
nestedEntries.map((entry) => makePromotionEntryKey(entry))
)
const hostUpdates: Array<{
node: SubgraphNode
entries: PromotedWidgetSource[]
}> = []
// Find all SubgraphNode instances that host `this` subgraph.
// They live in any graph and have `type === this.id`.
const allGraphs: LGraph[] = [
this.rootGraph,
...this.rootGraph._subgraphs.values()
]
for (const graph of allGraphs) {
for (const node of graph._nodes) {
if (!node.isSubgraphNode() || node.type !== this.id) continue
const entries = store.getPromotions(graphId, node.id)
const movedEntries = entries.filter((entry) =>
movedNodeIds.has(entry.sourceNodeId)
)
if (movedEntries.length === 0) continue
for (const entry of movedEntries) {
const key = makePromotionEntryKey(entry)
if (nestedEntryKeys.has(key)) continue
nestedEntryKeys.add(key)
nextNestedEntries.push(entry)
}
const nextEntries = entries.map((entry) => {
if (!movedNodeIds.has(entry.sourceNodeId)) return entry
return {
sourceNodeId: nestedNodeId,
sourceWidgetName: entry.sourceWidgetName,
disambiguatingSourceNodeId:
entry.disambiguatingSourceNodeId ?? entry.sourceNodeId
}
})
hostUpdates.push({ node, entries: nextEntries })
}
}
if (nextNestedEntries.length !== nestedEntries.length)
store.setPromotions(graphId, nestedSubgraphNode.id, nextNestedEntries)
for (const { node, entries } of hostUpdates) {
store.setPromotions(graphId, node.id, entries)
node.rebuildInputWidgetBindings()
}
}
unpackSubgraph(
subgraphNode: SubgraphNode,
options?: { skipMissingNodes?: boolean }
@@ -2722,6 +2651,25 @@ export class LGraph
this.updateExecutionOrder()
for (const node of this._nodes) {
if (!(node instanceof SubgraphNode)) continue
if (node.properties?.proxyWidgets !== undefined) {
const nodeData = nodeDataMap.get(node.id)
if (LGraph.proxyWidgetMigrationFlush) {
LGraph.proxyWidgetMigrationFlush(node, nodeData)
} else if (import.meta.env.DEV || import.meta.env.MODE === 'test') {
console.warn(
'[SubgraphNode] Legacy proxyWidgets were not migrated because no migration flush hook is wired',
{
hostNodeId: node.id,
proxyWidgets: node.properties.proxyWidgets
}
)
}
}
LGraph.autoExposePreviewNodes?.(node)
}
this.onConfigure?.(data)
this.incrementVersion()

View File

@@ -1,12 +1,33 @@
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphNode, createUuidv4 } from '@/lib/litegraph/src/litegraph'
import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import { autoExposeKnownPreviewNodes } from '@/core/graph/subgraph/promotionUtils'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph,
SubgraphNode,
createUuidv4
} from '@/lib/litegraph/src/litegraph'
import { remapClipboardSubgraphNodeIds } from '@/lib/litegraph/src/LGraphCanvas'
import type {
ClipboardItems,
ExportedSubgraph,
ISerialisedNode
} from '@/lib/litegraph/src/types/serialisation'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
function createSerialisedNode(
id: number,
@@ -99,4 +120,287 @@ describe('remapClipboardSubgraphNodeIds', () => {
[String(remappedInteriorId), 'seed']
])
})
it('remaps pasted SubgraphNode previewExposures sourceNodeId references', () => {
const rootGraph = new LGraph()
const existingNode = new LGraphNode('existing')
existingNode.id = 1
rootGraph.add(existingNode)
const subgraphId = createUuidv4()
const pastedSubgraph: ExportedSubgraph = {
id: subgraphId,
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
config: {},
name: 'Pasted Subgraph',
inputNode: { id: -10, bounding: [0, 0, 10, 10] },
outputNode: { id: -20, bounding: [0, 0, 10, 10] },
inputs: [],
outputs: [],
widgets: [],
nodes: [createSerialisedNode(1, 'test/node')],
links: [],
groups: []
}
const hostInfo = createSerialisedNode(99, subgraphId)
hostInfo.properties = {
previewExposures: [
{
name: '$$canvas-image-preview',
sourceNodeId: '1',
sourcePreviewName: '$$canvas-image-preview'
}
]
}
const parsed: ClipboardItems = {
nodes: [hostInfo],
groups: [],
reroutes: [],
links: [],
subgraphs: [pastedSubgraph]
}
remapClipboardSubgraphNodeIds(parsed, rootGraph)
const remappedInteriorId = parsed.subgraphs?.[0]?.nodes?.[0]?.id
expect(remappedInteriorId).not.toBe(1)
expect(parsed.nodes?.[0]?.properties?.previewExposures).toStrictEqual([
{
name: '$$canvas-image-preview',
sourceNodeId: String(remappedInteriorId),
sourcePreviewName: '$$canvas-image-preview'
}
])
})
})
describe('_deserializeItems paste-time migration & auto-expose', () => {
let originalFlush: typeof LGraph.proxyWidgetMigrationFlush
let originalAutoExpose: typeof LGraph.autoExposePreviewNodes
const registeredTypesToCleanup: string[] = []
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
originalFlush = LGraph.proxyWidgetMigrationFlush
originalAutoExpose = LGraph.autoExposePreviewNodes
})
afterEach(() => {
LGraph.proxyWidgetMigrationFlush = originalFlush
LGraph.autoExposePreviewNodes = originalAutoExpose
for (const type of registeredTypesToCleanup) {
LiteGraph.unregisterNodeType(type)
}
registeredTypesToCleanup.length = 0
})
function registerSubgraphNodeTypeOnCreate(rootGraph: LGraph): void {
rootGraph.events.addEventListener('subgraph-created', (e) => {
const { subgraph } = e.detail
class TestSubgraphNode extends SubgraphNode {
constructor() {
super(rootGraph, subgraph as Subgraph, {
id: -1,
type: subgraph.id,
pos: [0, 0],
size: [100, 100],
inputs: [],
outputs: [],
flags: {},
order: 0,
mode: 0
})
}
}
LiteGraph.registerNodeType(subgraph.id, TestSubgraphNode)
registeredTypesToCleanup.push(subgraph.id)
})
}
function createCanvas(graph: LGraph): LGraphCanvas {
const el = document.createElement('canvas')
el.width = 800
el.height = 600
el.getContext = vi
.fn()
.mockReturnValue(createMockCanvasRenderingContext2D())
el.getBoundingClientRect = vi
.fn()
.mockReturnValue({ left: 0, top: 0, width: 800, height: 600 })
return new LGraphCanvas(el, graph, { skip_render: true })
}
it('clears legacy proxyWidgets on a pasted SubgraphNode and applies host widget values', () => {
LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
flushProxyWidgetMigration({
hostNode,
hostWidgetValues: nodeData?.widgets_values
})
const rootGraph = new LGraph()
registerSubgraphNodeTypeOnCreate(rootGraph)
const canvas = createCanvas(rootGraph)
const subgraphId = createUuidv4()
const interiorId = 7
const pastedSubgraph: ExportedSubgraph = {
id: subgraphId,
version: 1,
revision: 0,
state: {
lastNodeId: interiorId,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
config: {},
name: 'Pasted Subgraph',
inputNode: { id: -10, bounding: [0, 0, 10, 10] },
outputNode: { id: -20, bounding: [0, 0, 10, 10] },
inputs: [],
outputs: [],
widgets: [],
nodes: [
{
id: interiorId,
type: 'test/inner',
pos: [0, 0],
size: [140, 80],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: {}
}
],
links: [],
groups: []
}
const hostInfo: ISerialisedNode = {
id: 99,
type: subgraphId,
pos: [0, 0],
size: [140, 80],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: { proxyWidgets: [[String(interiorId), 'seed']] },
widgets_values: [42]
}
const parsed: ClipboardItems = {
nodes: [hostInfo],
groups: [],
reroutes: [],
links: [],
subgraphs: [pastedSubgraph]
}
canvas._deserializeItems(parsed, {})
const pastedHosts = rootGraph.nodes.filter(
(n): n is SubgraphNode => n instanceof SubgraphNode
)
expect(pastedHosts).toHaveLength(1)
expect(pastedHosts[0].properties.proxyWidgets).toBeUndefined()
})
it('auto-exposes preview nodes for pasted subgraphs that lack previewExposures', () => {
LGraph.autoExposePreviewNodes = (hostNode) =>
autoExposeKnownPreviewNodes(hostNode)
const rootGraph = new LGraph()
registerSubgraphNodeTypeOnCreate(rootGraph)
const canvas = createCanvas(rootGraph)
const subgraphId = createUuidv4()
const interiorPreviewId = 5
const pastedSubgraph: ExportedSubgraph = {
id: subgraphId,
version: 1,
revision: 0,
state: {
lastNodeId: interiorPreviewId,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
config: {},
name: 'Pasted Subgraph',
inputNode: { id: -10, bounding: [0, 0, 10, 10] },
outputNode: { id: -20, bounding: [0, 0, 10, 10] },
inputs: [],
outputs: [],
widgets: [],
nodes: [
{
id: interiorPreviewId,
type: 'PreviewImage',
pos: [0, 0],
size: [140, 80],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: {}
}
],
links: [],
groups: []
}
const hostInfo: ISerialisedNode = {
id: 99,
type: subgraphId,
pos: [0, 0],
size: [140, 80],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: {}
}
const parsed: ClipboardItems = {
nodes: [hostInfo],
groups: [],
reroutes: [],
links: [],
subgraphs: [pastedSubgraph]
}
canvas._deserializeItems(parsed, {})
const pastedHost = rootGraph.nodes.find(
(n): n is SubgraphNode => n instanceof SubgraphNode
)
expect(pastedHost).toBeDefined()
const exposures = usePreviewExposureStore().getExposures(
rootGraph.id,
String(pastedHost!.id)
)
const interiorIdAfterRemap = pastedHost!.subgraph.nodes[0].id
expect(exposures).toEqual([
expect.objectContaining({
sourceNodeId: String(interiorIdAfterRemap),
sourcePreviewName: '$$canvas-image-preview'
})
])
})
})

View File

@@ -84,6 +84,7 @@ import {
} from './measure'
import { NodeInputSlot } from './node/NodeInputSlot'
import type { Subgraph } from './subgraph/Subgraph'
import { topologicalSortSubgraphs } from './subgraph/subgraphDeduplication'
import { SubgraphIONodeBase } from './subgraph/SubgraphIONodeBase'
import type { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import { SubgraphNode } from './subgraph/SubgraphNode'
@@ -4191,7 +4192,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const subgraph = graph.createSubgraph(info)
results.subgraphs.set(info.id, subgraph)
}
for (const info of parsed.subgraphs)
const configureOrder = topologicalSortSubgraphs(parsed.subgraphs)
for (const info of configureOrder)
results.subgraphs.get(info.id)?.configure(info)
// Groups
@@ -4218,6 +4220,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
graph.add(node)
node.configure(info)
if (node instanceof SubgraphNode) {
if (
node.properties?.proxyWidgets !== undefined &&
LiteGraph.LGraph.proxyWidgetMigrationFlush
) {
LiteGraph.LGraph.proxyWidgetMigrationFlush(node, info)
}
LiteGraph.LGraph.autoExposePreviewNodes?.(node)
}
created.push(node)
}
@@ -9045,12 +9057,35 @@ function remapProxyWidgets(
}
}
/**
* Remaps pasted subgraph interior node IDs that would collide with existing
* node IDs in the root graph. Also patches subgraph link node IDs and
* SubgraphNode `properties.proxyWidgets` references so promoted widget
* associations stay aligned with remapped interior IDs.
*/
function hasStringSourceNodeId(
value: unknown
): value is { sourceNodeId: string } {
return (
typeof value === 'object' &&
value !== null &&
'sourceNodeId' in value &&
typeof value.sourceNodeId === 'string'
)
}
function remapPreviewExposures(
info: ISerialisedNode,
remappedIds: Map<NodeId, NodeId> | undefined
) {
if (!remappedIds || remappedIds.size === 0) return
const previewExposures = info.properties?.previewExposures
if (!Array.isArray(previewExposures)) return
for (const entry of previewExposures) {
if (!hasStringSourceNodeId(entry) || entry.sourceNodeId === '-1') continue
const remappedNodeId = remapNodeId(entry.sourceNodeId, remappedIds)
if (remappedNodeId !== undefined)
entry.sourceNodeId = String(remappedNodeId)
}
}
export function remapClipboardSubgraphNodeIds(
parsed: ClipboardItems,
rootGraph: LGraph
@@ -9104,6 +9139,8 @@ export function remapClipboardSubgraphNodeIds(
for (const nodeInfo of allNodeInfo) {
if (typeof nodeInfo.type !== 'string') continue
remapProxyWidgets(nodeInfo, subgraphNodeIdMap.get(nodeInfo.type))
const remappedIds = subgraphNodeIdMap.get(nodeInfo.type)
remapProxyWidgets(nodeInfo, remappedIds)
remapPreviewExposures(nodeInfo, remappedIds)
}
}

View File

@@ -997,7 +997,6 @@ export class LGraphNode
return o
}
/* Creates a clone of this node */
clone(): LGraphNode | null {
if (this.type == null) return null
const node = LiteGraph.createNode(this.type)
@@ -1023,9 +1022,9 @@ export class LGraphNode
// @ts-expect-error Exceptional case: id is removed so that the graph can assign a new one on add.
data.id = undefined
if (LiteGraph.use_uuids) data.id = LiteGraph.uuidv4()
node.id = this.id
node.configure(data)
if (LiteGraph.use_uuids) node.id = LiteGraph.uuidv4()
return node
}

View File

@@ -77,7 +77,6 @@ export class LiteGraphGlobal {
WIDGET_BGCOLOR = '#222'
WIDGET_OUTLINE_COLOR = '#666'
WIDGET_PROMOTED_OUTLINE_COLOR = '#BF00FF'
WIDGET_ADVANCED_OUTLINE_COLOR = 'rgba(56, 139, 253, 0.8)'
WIDGET_TEXT_COLOR = '#DDD'
WIDGET_SECONDARY_TEXT_COLOR = '#999'

View File

@@ -135,7 +135,6 @@ LiteGraphGlobal {
"WIDGET_BGCOLOR": "#222",
"WIDGET_DISABLED_TEXT_COLOR": "#666",
"WIDGET_OUTLINE_COLOR": "#666",
"WIDGET_PROMOTED_OUTLINE_COLOR": "#BF00FF",
"WIDGET_SECONDARY_TEXT_COLOR": "#999",
"WIDGET_TEXT_COLOR": "#DDD",
"allow_multi_output_for_events": true,

View File

@@ -1,7 +1,9 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LLink, ResolvedConnection } from '@/lib/litegraph/src/LLink'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type {
ExportedSubgraph,
ISerialisedGraph,
@@ -48,4 +50,23 @@ export interface LGraphEventMap {
subgraph: Subgraph
closingGraph: LGraph | Subgraph
}
'node:property:changed': {
nodeId: NodeId
property: string
oldValue: unknown
newValue: unknown
}
'node:slot-errors:changed': { nodeId: NodeId }
'node:slot-links:changed': {
nodeId: NodeId
slotType: NodeSlotType
slotIndex: number
connected: boolean
linkId: number
}
'node:slot-label:changed': {
nodeId: NodeId
slotType?: NodeSlotType
}
}

View File

@@ -1,3 +1,4 @@
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
@@ -31,6 +32,12 @@ export interface SubgraphEventMap extends LGraphEventMap {
index: number
}
'inputs-reordered': {
subgraph: Subgraph
oldOrder: readonly string[]
newOrder: readonly string[]
}
'renaming-input': {
input: SubgraphInput
index: number

View File

@@ -9,8 +9,6 @@ import {
} from '@/lib/litegraph/src/litegraph'
import type { LGraph, ISlotType } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
import {
createTestSubgraph,
createTestSubgraphNode,
@@ -206,39 +204,4 @@ describe('SubgraphConversion', () => {
expect(linkRefCount).toBe(4)
})
})
describe('Promotion cleanup on unpack', () => {
it('Should clear promotions for the unpacked subgraph node', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const innerNode = createNode(subgraph, [], ['number'])
innerNode.addWidget('text', 'myWidget', 'default', () => {})
const promotionStore = usePromotionStore()
const graphId = graph.id
const subgraphNodeId = subgraphNode.id
promotionStore.promote(graphId, subgraphNodeId, {
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'myWidget'
})
expect(
promotionStore.isPromoted(graphId, subgraphNodeId, {
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'myWidget'
})
).toBe(true)
graph.unpackSubgraph(subgraphNode)
expect(graph.getNodeById(subgraphNodeId)).toBeUndefined()
expect(
promotionStore.getPromotions(graphId, subgraphNodeId)
).toHaveLength(0)
})
})
})

View File

@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { ToInputFromIoNodeLink } from '@/lib/litegraph/src/canvas/ToInputFromIoNodeLink'
import { LinkDirection } from '@/lib/litegraph/src//types/globalEnums'
import { usePromotionStore } from '@/stores/promotionStore'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
@@ -491,15 +490,6 @@ describe('SubgraphIO - Empty Slot Connection', () => {
'seed',
'seed_1'
])
expect(
usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
).toEqual([
{ sourceNodeId: String(firstNode.id), sourceWidgetName: 'seed' },
{ sourceNodeId: String(secondNode.id), sourceWidgetName: 'seed' }
])
}
)
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
ISlotType,
@@ -8,7 +8,22 @@ import type {
TWidgetType
} from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import {
appendQuarantine,
flushProxyWidgetMigration,
makeQuarantineEntry
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { reorderSubgraphInputsByName } from '@/core/graph/subgraph/promotionUtils'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import { computeProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { graphToPrompt } from '@/utils/executionUtil'
import {
createEventCapture,
@@ -18,7 +33,13 @@ import {
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
// Helper to create a node with a widget
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
function createNodeWithWidget(
title: string,
widgetType: TWidgetType = 'number',
@@ -46,7 +67,6 @@ function createNodeWithWidget(
return { node, widget, input }
}
// Helper to connect subgraph input to node and create SubgraphNode
function setupPromotedWidget(
subgraph: Subgraph,
node: LGraphNode,
@@ -57,6 +77,15 @@ function setupPromotedWidget(
return createTestSubgraphNode(subgraph)
}
function expectPromotedWidgetView(
widget: unknown
): asserts widget is PromotedWidgetView {
expect(widget).toMatchObject({
sourceNodeId: expect.any(String),
sourceWidgetName: expect.any(String)
})
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
@@ -291,9 +320,6 @@ describe('SubgraphWidgetPromotion', () => {
hostNode.configure(serializedHostNode)
expect(hostNode.properties.proxyWidgets).toStrictEqual([
[String(interiorNode.id), 'batch_size']
])
expect(hostNode.widgets).toHaveLength(1)
expect(hostNode.widgets[0].name).toBe('batch_size')
expect(hostNode.widgets[0].value).toBe(1)
@@ -301,11 +327,6 @@ describe('SubgraphWidgetPromotion', () => {
})
it('should prune proxyWidgets referencing nodes not in subgraph on configure', () => {
// Reproduces the bug where packing nodes into a nested subgraph leaves
// stale proxyWidgets on the outer subgraph node referencing grandchild
// node IDs that no longer exist directly in the outer subgraph.
// Uses 3 inputs with only 1 having a linked widget entry, matching the
// real workflow structure where model/vae inputs don't resolve widgets.
const subgraph = createTestSubgraph({
inputs: [
{ name: 'clip', type: 'CLIP' },
@@ -335,8 +356,6 @@ describe('SubgraphWidgetPromotion', () => {
const outerNode = createTestSubgraphNode(subgraph)
const keptSamplerNodeId = String(samplerNode.id)
// Inject stale proxyWidgets referencing nodes that don't exist in
// this subgraph (they were packed into a nested subgraph)
outerNode.properties.proxyWidgets = [
['999', 'text'],
['998', 'text'],
@@ -356,7 +375,7 @@ describe('SubgraphWidgetPromotion', () => {
expect(widgetSourceIds).toContain(keptSamplerNodeId)
})
it('should normalize legacy prefixed proxyWidgets on configure', () => {
it('resolves legacy prefixed proxyWidgets via the immediate child PromotedWidgetView identity', () => {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({
@@ -396,21 +415,16 @@ describe('SubgraphWidgetPromotion', () => {
}
hostNode.configure(serializedHostNode)
flushProxyWidgetMigration({ hostNode })
const promotedWidgets = hostNode.widgets
.filter(isPromotedWidgetView)
.filter((widget) => !widget.name.startsWith('$$'))
expect(promotedWidgets).toHaveLength(1)
expect(promotedWidgets[0].type).toBe('number')
expect(promotedWidgets[0].value).toBe(123)
expect(promotedWidgets[0].sourceWidgetName).toBe('noise_seed')
expect(promotedWidgets[0].disambiguatingSourceNodeId).toBe(
String(samplerNode.id)
)
expect(hostNode.properties.proxyWidgets).toStrictEqual([
[String(nestedNode.id), 'noise_seed', String(samplerNode.id)]
])
expect(promotedWidgets[0]?.sourceNodeId).toBe(String(nestedNode.id))
expect(hostNode.properties.proxyWidgets).toBeUndefined()
expect(hostNode.properties.proxyWidgetErrorQuarantine).toBeUndefined()
})
it('should preserve promoted widget entries after cloning', () => {
@@ -427,31 +441,17 @@ describe('SubgraphWidgetPromotion', () => {
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const hostNode = createTestSubgraphNode(subgraph)
// serialize() syncs the promotion store into properties.proxyWidgets
const serialized = hostNode.serialize()
const originalProxyWidgets = serialized.properties!
.proxyWidgets as string[][]
expect(originalProxyWidgets.length).toBeGreaterThan(0)
expect(
originalProxyWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
// Simulate clone: create a second SubgraphNode configured from serialized data
const cloneNode = createTestSubgraphNode(subgraph)
cloneNode.configure(serialized)
const cloneProxyWidgets = cloneNode.properties.proxyWidgets as string[][]
expect(cloneProxyWidgets.length).toBeGreaterThan(0)
expect(
cloneProxyWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
const promotedNames = cloneNode.widgets
.filter(isPromotedWidgetView)
.filter((widget) => !widget.name.startsWith('$$'))
.map((widget) => widget.sourceWidgetName)
// Clone's proxyWidgets should reference the same interior node
const originalNodeIds = originalProxyWidgets.map(([nodeId]) => nodeId)
const cloneNodeIds = cloneProxyWidgets.map(([nodeId]) => nodeId)
expect(cloneNodeIds).toStrictEqual(originalNodeIds)
expect(promotedNames).toContain('text')
})
})
@@ -574,4 +574,807 @@ describe('SubgraphWidgetPromotion', () => {
expect(promotedWidget.value).toBe(42)
})
})
describe('SubgraphNode.serialize', () => {
it('does not mutate interior widget values when serializing the host', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interiorNode, widget: interiorWidget } =
createNodeWithWidget('Interior')
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const hostNode = createTestSubgraphNode(subgraph)
const hostWidget = hostNode.widgets[0]
expectPromotedWidgetView(hostWidget)
useWidgetValueStore().registerWidget(hostNode.rootGraph.id, {
nodeId: hostNode.id,
name: hostWidget.name,
type: hostWidget.type,
value: 99,
options: {}
})
hostNode.serialize()
expect(interiorWidget.value).toBe(42)
})
describe('host widget values', () => {
type SourceSpec = {
inputName: string
title: string
widgetType: TWidgetType
slotType: ISlotType
initialValue: unknown
withComfyClass?: boolean
hugeMaxSeed?: boolean
}
type EditValue = string | number | boolean
type EditSpec = {
via: 'viewKey' | 'vue'
index: number
value: EditValue
}
type ReorderSpec = { kind: 'none' } | { kind: 'byName'; order: string[] }
const text = (inputName: string, title: string): SourceSpec => ({
inputName,
title,
widgetType: 'text',
slotType: 'STRING',
initialValue: '',
withComfyClass: true
})
const num = (
inputName: string,
title: string,
initialValue = 0
): SourceSpec => ({
inputName,
title,
widgetType: 'number',
slotType: 'number',
initialValue
})
const seed = (): SourceSpec => ({
inputName: 'seed',
title: 'Sampler',
widgetType: 'number',
slotType: 'INT',
initialValue: 0,
withComfyClass: true
})
const TEXT_PAIR: SourceSpec[] = [
text('first', 'First'),
text('second', 'Second')
]
const NUMBER_PAIR: SourceSpec[] = [
num('first', 'First', 1),
num('second', 'Second', 2)
]
const TEXT_TEXT_SEED: SourceSpec[] = [
text('text_1', 'Positive'),
text('text', 'Negative'),
seed()
]
function buildSources(
subgraph: ReturnType<typeof createTestSubgraph>,
specs: SourceSpec[]
) {
const built = specs.map((s) => {
const created = createNodeWithWidget(
s.title,
s.widgetType,
s.initialValue,
s.slotType
)
if (s.withComfyClass) created.node.comfyClass = s.title
if (s.hugeMaxSeed) created.widget.options.max = 1125899906842624
subgraph.add(created.node)
return created
})
for (const [i, s] of specs.entries()) {
subgraph
.addInput(s.inputName, String(s.slotType))
.connect(built[i].input, built[i].node)
}
return built
}
function vueEdit(
host: ReturnType<typeof createTestSubgraphNode>,
index: number,
value: EditValue
) {
const widgets = computeProcessedWidgets({
nodeData: extractVueNodeData(host),
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: { getTooltipConfig: () => ({}), handleNodeRightClick: () => {} }
})
widgets[index].updateHandler(value)
}
function applyEdit(
host: ReturnType<typeof createTestSubgraphNode>,
edit: EditSpec
) {
if (edit.via === 'viewKey') host.widgets[edit.index].value = edit.value
else vueEdit(host, edit.index, edit.value)
}
function applyReorder(
host: ReturnType<typeof createTestSubgraphNode>,
r: ReorderSpec
) {
if (r.kind === 'byName') reorderSubgraphInputsByName(host, r.order)
}
function makeControlWidget(
value: 'increment' | 'fixed',
marker: boolean
) {
const base = {
name: 'control_after_generate',
value,
serialize: false,
beforeQueued: () => {},
afterQueued: () => {}
}
return marker ? { ...base, [IS_CONTROL_WIDGET]: true } : base
}
type ReorderCase = {
name: string
sources: SourceSpec[]
edits: EditSpec[]
reorder: ReorderSpec
expectedNames?: string[]
expectedWidgetsValues?: unknown[]
promptByIndex?: Record<number, unknown>
}
const reorderCases: ReorderCase[] = [
{
name: 'plain numbers via ViewKey, swap by name',
sources: NUMBER_PAIR,
edits: [
{ via: 'viewKey', index: 0, value: 111 },
{ via: 'viewKey', index: 1, value: 222 }
],
reorder: { kind: 'byName', order: ['second', 'first'] },
expectedNames: ['second', 'first'],
expectedWidgetsValues: [222, 111]
},
{
name: 'plain text via Vue, swap by name (widgets_values + prompt)',
sources: TEXT_PAIR,
edits: [
{ via: 'vue', index: 0, value: 'first value' },
{ via: 'vue', index: 1, value: 'second value' }
],
reorder: { kind: 'byName', order: ['second', 'first'] },
expectedWidgetsValues: ['second value', 'first value'],
promptByIndex: { 0: 'first value', 1: 'second value' }
},
{
name: 'mixed text/text/seed via ViewKey, swap seed up by name',
sources: TEXT_TEXT_SEED,
edits: [
{ via: 'viewKey', index: 0, value: 'positive prompt' },
{ via: 'viewKey', index: 1, value: 'negative prompt' },
{ via: 'viewKey', index: 2, value: 123456 }
],
reorder: { kind: 'byName', order: ['text_1', 'seed', 'text'] },
expectedWidgetsValues: ['positive prompt', 123456, 'negative prompt'],
promptByIndex: {
0: 'positive prompt',
1: 'negative prompt',
2: 123456
}
},
{
name: 'mixed text/text/seed via Vue, swap seed up by name',
sources: TEXT_TEXT_SEED,
edits: [
{ via: 'vue', index: 0, value: 'positive prompt' },
{ via: 'vue', index: 1, value: 'negative prompt' },
{ via: 'vue', index: 2, value: 123456 }
],
reorder: { kind: 'byName', order: ['text_1', 'seed', 'text'] },
expectedWidgetsValues: ['positive prompt', 123456, 'negative prompt'],
promptByIndex: {
0: 'positive prompt',
1: 'negative prompt',
2: 123456
}
}
]
it.for(reorderCases)('$name', async (c) => {
const subgraph = createTestSubgraph()
const sources = buildSources(subgraph, c.sources)
const host = createTestSubgraphNode(subgraph)
if (c.promptByIndex) {
host.comfyClass = 'Subgraph'
host.graph?.add(host)
}
for (const edit of c.edits) applyEdit(host, edit)
applyReorder(host, c.reorder)
if (c.expectedNames) {
expect(host.widgets.map((w) => w.name)).toEqual(c.expectedNames)
}
if (c.expectedWidgetsValues !== undefined) {
expect(host.serialize().widgets_values).toEqual(
c.expectedWidgetsValues
)
}
if (c.promptByIndex) {
const { output } = await graphToPrompt(host.rootGraph)
for (const [iStr, value] of Object.entries(c.promptByIndex)) {
const i = Number(iStr)
expect(
output[`${host.id}:${sources[i].node.id}`].inputs.value
).toBe(value)
}
}
})
type ControlCase = {
name: string
editVia: 'viewKey' | 'vue'
controlMode: 'increment' | 'fixed'
controlMarker: boolean
seedHostValue: number
mutateSourceSeedAfterReorder?: number
callAfterQueued?: boolean
expect: {
promptSeed?: number
sourceSeed?: number
processedSeedValue?: number
hostSeedValue?: number
storeSeedValue?: number
}
}
const controlCases: ControlCase[] = [
{
name: 'ViewKey + increment: source seed mutation after reorder is ignored in prompt',
editVia: 'viewKey',
controlMode: 'increment',
controlMarker: false,
seedHostValue: 123456,
mutateSourceSeedAfterReorder: 789,
expect: { promptSeed: 123456 }
},
{
name: 'Vue + fixed: host-wins — does not push Vue value into source seed',
editVia: 'vue',
controlMode: 'fixed',
controlMarker: false,
seedHostValue: 123456,
expect: { sourceSeed: 0 }
},
{
name: 'Vue + increment + afterQueued: processed widgets reflect increment',
editVia: 'vue',
controlMode: 'increment',
controlMarker: true,
seedHostValue: 123456,
callAfterQueued: true,
expect: { processedSeedValue: 123457 }
},
{
name: 'ViewKey + increment + afterQueued: host seed increments without source value',
editVia: 'viewKey',
controlMode: 'increment',
controlMarker: true,
seedHostValue: 2,
mutateSourceSeedAfterReorder: 8,
callAfterQueued: true,
expect: { hostSeedValue: 3, storeSeedValue: 3 }
}
]
it.for(controlCases)('$name', async (c) => {
const subgraph = createTestSubgraph()
const sources = buildSources(
subgraph,
TEXT_TEXT_SEED.map((s) =>
s.title === 'Sampler' ? { ...s, hugeMaxSeed: true } : s
)
)
const seed = sources[2]
const host = createTestSubgraphNode(subgraph)
if (c.expect.promptSeed !== undefined) {
host.comfyClass = 'Subgraph'
host.graph?.add(host)
}
if (c.editVia === 'viewKey') {
host.widgets[0].value = 'positive prompt'
host.widgets[1].value = 'negative prompt'
host.widgets[2].value = c.seedHostValue
seed.widget.linkedWidgets = [
makeControlWidget(c.controlMode, c.controlMarker) as never
]
} else {
seed.widget.linkedWidgets = [
makeControlWidget(c.controlMode, c.controlMarker) as never
]
vueEdit(host, 2, c.seedHostValue)
}
reorderSubgraphInputsByName(host, ['text_1', 'seed', 'text'])
if (c.mutateSourceSeedAfterReorder !== undefined) {
seed.widget.value = c.mutateSourceSeedAfterReorder
}
if (c.callAfterQueued) host.widgets[1].afterQueued?.()
if (c.expect.promptSeed !== undefined) {
const { output } = await graphToPrompt(host.rootGraph)
expect(output[`${host.id}:${seed.node.id}`].inputs.value).toBe(
c.expect.promptSeed
)
}
if (c.expect.sourceSeed !== undefined) {
expect(seed.widget.value).toBe(c.expect.sourceSeed)
}
if (c.expect.processedSeedValue !== undefined) {
const updated = computeProcessedWidgets({
nodeData: extractVueNodeData(host),
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: { getTooltipConfig: () => ({}), handleNodeRightClick: () => {} }
})
expect(updated[1].value).toBe(c.expect.processedSeedValue)
}
if (c.expect.hostSeedValue !== undefined) {
expect(host.widgets[1].value).toBe(c.expect.hostSeedValue)
}
if (c.expect.storeSeedValue !== undefined) {
expect(
useWidgetValueStore()
.getNodeWidgets(host.rootGraph.id, host.id)
.find((entry) => entry.name === 'seed')?.value
).toBe(c.expect.storeSeedValue)
}
})
it('afterQueued does not run value-control when the host input is externally linked', () => {
const subgraph = createTestSubgraph()
const sources = buildSources(
subgraph,
TEXT_TEXT_SEED.map((s) =>
s.title === 'Sampler' ? { ...s, hugeMaxSeed: true } : s
)
)
const seed = sources[2]
const host = createTestSubgraphNode(subgraph)
seed.widget.linkedWidgets = [
makeControlWidget('increment', true) as never
]
host.widgets[2].value = 2
reorderSubgraphInputsByName(host, ['text_1', 'seed', 'text'])
const seedSlot = host.getSlotFromWidget(host.widgets[1])
expect(seedSlot).toBeDefined()
seedSlot!.link = -1
host.widgets[1].afterQueued?.()
expect(host.widgets[1].value).toBe(2)
})
it('serializes promoted values from each host independently', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interiorNode } = createNodeWithWidget('Interior')
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(
interiorNode.inputs[0],
interiorNode
)
const firstHost = createTestSubgraphNode(subgraph, { id: 101 })
const secondHost = createTestSubgraphNode(subgraph, { id: 102 })
subgraph.rootGraph.add(firstHost)
subgraph.rootGraph.add(secondHost)
firstHost.widgets[0].value = 111
secondHost.widgets[0].value = 222
expect(firstHost.serialize().widgets_values).toEqual([111])
expect(secondHost.serialize().widgets_values).toEqual([222])
})
it('does not persist source widget store fallback values after reordering', () => {
const subgraph = createTestSubgraph()
const sources = buildSources(subgraph, TEXT_PAIR)
const host = createTestSubgraphNode(subgraph)
const widgetStore = useWidgetValueStore()
for (const { node, widget } of sources) {
widgetStore.registerWidget(host.rootGraph.id, {
nodeId: node.id,
name: widget.name,
type: widget.type,
value: `${node.title} value`,
options: {}
})
}
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(host.serialize().widgets_values).toBeUndefined()
})
it('does not acquire a host overlay when a source fallback is saved and reloaded', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
const { node: interiorNode, widget: interiorWidget } =
createNodeWithWidget('Interior', 'text', '', 'STRING')
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(
interiorNode.inputs[0],
interiorNode
)
const host = createTestSubgraphNode(subgraph, { id: 101 })
const widgetStore = useWidgetValueStore()
widgetStore.registerWidget(host.rootGraph.id, {
nodeId: interiorNode.id,
name: interiorWidget.name,
type: interiorWidget.type,
value: 'source fallback',
options: {}
})
const serialized = host.serialize()
expect(serialized.widgets_values).toBeUndefined()
widgetStore.clearGraph(host.rootGraph.id)
const reloaded = createTestSubgraphNode(subgraph, { id: 101 })
reloaded.configure(serialized)
expect(
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
).toEqual([])
expect(reloaded.serialize().widgets_values).toBeUndefined()
})
it('does not hydrate missing widgets_values entries as explicit host overlays', () => {
const subgraph = createTestSubgraph()
buildSources(subgraph, TEXT_PAIR)
const host = createTestSubgraphNode(subgraph, { id: 101 })
host.widgets[1].value = 'second host value'
const serialized = host.serialize()
expect(serialized.widgets_values).toEqual([
undefined,
'second host value'
])
const widgetStore = useWidgetValueStore()
widgetStore.clearGraph(host.rootGraph.id)
const reloaded = createTestSubgraphNode(subgraph, { id: 101 })
reloaded.configure(serialized)
const [first, second] = reloaded.widgets
expectPromotedWidgetView(first)
expectPromotedWidgetView(second)
expect(
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, first.name)
).toBeUndefined()
expect(
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, second.name)
?.value
).toBe('second host value')
expect(
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
).toHaveLength(1)
expect(reloaded.serialize().widgets_values).toEqual([
undefined,
'second host value'
])
})
})
describe('proxyWidgets is no longer re-emitted', () => {
it('does not write properties.proxyWidgets after serialize', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interiorNode } = createNodeWithWidget('Interior')
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(
interiorNode.inputs[0],
interiorNode
)
const hostNode = createTestSubgraphNode(subgraph)
delete hostNode.properties.proxyWidgets
const serialized = hostNode.serialize()
expect(serialized.properties?.proxyWidgets).toBeUndefined()
expect(hostNode.properties.proxyWidgets).toBeUndefined()
})
it('preserves a pre-existing legacy proxyWidgets property without re-deriving it', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
const legacy: SerializedProxyWidgetTuple[] = [['7', 'seed']]
hostNode.properties.proxyWidgets = legacy
const serialized = hostNode.serialize()
expect(serialized.properties?.proxyWidgets).toStrictEqual(legacy)
})
})
describe('previewExposures round-trip', () => {
const CANVAS = '$$canvas-image-preview'
const exposure12 = { sourceNodeId: '12', sourcePreviewName: CANVAS }
const exposure14 = {
sourceNodeId: '14',
sourcePreviewName: 'videopreview'
}
const named12 = { name: CANVAS, ...exposure12 }
const named14 = { name: 'videopreview', ...exposure14 }
it('hydrates previewExposures into the store during configure', () => {
const hostNode = createTestSubgraphNode(createTestSubgraph())
hostNode.properties.previewExposures = [
{ name: 'preview', ...exposure12 }
]
hostNode._internalConfigureAfterSlots()
expect(
usePreviewExposureStore().getExposures(
hostNode.rootGraph.id,
String(hostNode.id)
)
).toEqual([{ name: 'preview', ...exposure12 }])
})
type SerializeCase = {
name: string
addExposures: (typeof exposure12)[]
staleProperty?: {
name: string
sourceNodeId: string
sourcePreviewName: string
}[]
expected: (typeof named12)[] | undefined
expectLiveUnchanged?: boolean
}
const serializeCases: SerializeCase[] = [
{
name: 'writes previewExposures from the store on serialize',
addExposures: [exposure12, exposure14],
expected: [named12, named14]
},
{
name: 'writes empty previewExposures when the store has no entries for the host',
addExposures: [],
staleProperty: [
{ name: 'stale', sourceNodeId: '0', sourcePreviewName: CANVAS }
],
expected: [],
expectLiveUnchanged: true
}
]
it.for(serializeCases)('$name', (c) => {
const hostNode = createTestSubgraphNode(createTestSubgraph())
if (c.staleProperty)
hostNode.properties.previewExposures = c.staleProperty
const store = usePreviewExposureStore()
for (const e of c.addExposures) {
store.addExposure(hostNode.rootGraph.id, String(hostNode.id), e)
}
const serialized = hostNode.serialize()
expect(serialized.properties?.previewExposures).toEqual(c.expected)
if (c.expectLiveUnchanged) {
expect(hostNode.properties.previewExposures).toEqual(c.staleProperty)
}
})
it('preserves an explicit empty previewExposures across reload, ignoring legacy locator entries', () => {
const hostNode = createTestSubgraphNode(createTestSubgraph())
const rootGraphId = hostNode.rootGraph.id
const hostLocator = String(hostNode.id)
const store = usePreviewExposureStore()
const serialized = hostNode.serialize()
expect(serialized.properties?.previewExposures).toEqual([])
const legacyKey = createNodeLocatorId(rootGraphId, hostNode.id)
store.setExposures(rootGraphId, legacyKey, [
{ name: 'legacy', ...exposure12 }
])
store.setExposures(rootGraphId, hostLocator, [])
hostNode.properties.previewExposures =
serialized.properties?.previewExposures
hostNode._internalConfigureAfterSlots()
expect(store.getExposures(rootGraphId, hostLocator)).toEqual([])
})
it('serializes preview exposures per host instance', () => {
const subgraph = createTestSubgraph()
const firstHost = createTestSubgraphNode(subgraph, { id: 101 })
const secondHost = createTestSubgraphNode(subgraph, { id: 102 })
subgraph.rootGraph.add(firstHost)
subgraph.rootGraph.add(secondHost)
const store = usePreviewExposureStore()
store.addExposure(
firstHost.rootGraph.id,
String(firstHost.id),
exposure12
)
store.addExposure(
firstHost.rootGraph.id,
String(secondHost.id),
exposure14
)
const firstExposures =
firstHost.serialize().properties?.previewExposures
const secondExposures =
secondHost.serialize().properties?.previewExposures
if (!Array.isArray(firstExposures) || !Array.isArray(secondExposures)) {
throw new Error('Expected serialized previewExposures arrays')
}
expect(firstExposures).toEqual([named12])
expect(secondExposures).toEqual([named14])
for (const exposed of [firstExposures[0], secondExposures[0]]) {
expect(exposed).not.toHaveProperty('hostInstanceId')
expect(exposed).not.toHaveProperty('hostNodeLocator')
expect(exposed).not.toHaveProperty('rootGraphId')
}
})
})
describe('proxyWidgetErrorQuarantine', () => {
it('preserves quarantine entries through serialize and is inert at runtime', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
appendQuarantine(hostNode, [
makeQuarantineEntry({
originalEntry: ['7', 'seed'],
reason: 'missingSourceNode',
hostValue: 42
})
])
const serialized = hostNode.serialize()
const quarantine = serialized.properties?.proxyWidgetErrorQuarantine
expect(Array.isArray(quarantine)).toBe(true)
expect(quarantine).toHaveLength(1)
expect(
hostNode.widgets.some(
(w) => 'sourceNodeId' in w && w.sourceNodeId === '7'
)
).toBe(false)
})
it('removes the property entirely when quarantine is empty', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
hostNode.properties.proxyWidgetErrorQuarantine = []
const serialized = hostNode.serialize()
expect(
serialized.properties?.proxyWidgetErrorQuarantine
).toBeUndefined()
expect(hostNode.properties.proxyWidgetErrorQuarantine).toEqual([])
})
it('restores host values by input name when widgets_values is out of order', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'unet_name', type: 'COMBO' },
{ name: 'clip_name', type: 'COMBO' },
{ name: 'steps', type: 'INT' }
]
})
const unetNode = new LGraphNode('UNETLoader')
const unetInput = unetNode.addInput('unet_name', 'COMBO')
unetNode.addWidget(
'combo',
'unet_name',
'z_image_turbo_bf16.safetensors',
() => {},
{ values: ['z_image_turbo_bf16.safetensors'] }
)
unetInput.widget = { name: 'unet_name' }
subgraph.add(unetNode)
subgraph.inputNode.slots[0].connect(unetNode.inputs[0], unetNode)
const clipNode = new LGraphNode('CLIPLoader')
const clipInput = clipNode.addInput('clip_name', 'COMBO')
clipNode.addWidget(
'combo',
'clip_name',
'qwen_3_4b.safetensors',
() => {},
{ values: ['qwen_3_4b.safetensors'] }
)
clipInput.widget = { name: 'clip_name' }
subgraph.add(clipNode)
subgraph.inputNode.slots[1].connect(clipNode.inputs[0], clipNode)
const samplerNode = new LGraphNode('KSampler')
const stepsInput = samplerNode.addInput('steps', 'INT')
samplerNode.addWidget('number', 'steps', 8, () => {})
stepsInput.widget = { name: 'steps' }
subgraph.add(samplerNode)
subgraph.inputNode.slots[2].connect(samplerNode.inputs[0], samplerNode)
const hostNode = createTestSubgraphNode(subgraph)
const serialized = hostNode.serialize()
serialized.widgets_values = [
8,
'qwen_3_4b.safetensors',
'z_image_turbo_bf16.safetensors'
]
serialized.properties = {
...serialized.properties,
proxyWidgetErrorQuarantine: [
{
originalEntry: ['-1', 'steps'] as SerializedProxyWidgetTuple,
reason: 'missingSourceNode',
hostValue: 8,
attemptedAtVersion: 1
},
{
originalEntry: ['-1', 'clip_name'] as SerializedProxyWidgetTuple,
reason: 'missingSourceNode',
hostValue: 'qwen_3_4b.safetensors',
attemptedAtVersion: 1
},
{
originalEntry: ['-1', 'unet_name'] as SerializedProxyWidgetTuple,
reason: 'missingSourceNode',
hostValue: 'z_image_turbo_bf16.safetensors',
attemptedAtVersion: 1
}
]
}
const reloaded = createTestSubgraphNode(subgraph)
reloaded.configure(serialized)
const byName = new Map(
reloaded.inputs.map((input) => [input.name, input._widget?.value])
)
expect(byName.get('unet_name')).toBe('z_image_turbo_bf16.safetensors')
expect(byName.get('clip_name')).toBe('qwen_3_4b.safetensors')
expect(byName.get('steps')).toBe(8)
})
})
})
})

View File

@@ -15,19 +15,10 @@ interface DeduplicationResult {
}
/**
* Pre-deduplicates node IDs across serialized subgraph definitions before
* they are configured. This prevents widget store key collisions when
* multiple subgraph copies contain nodes with the same IDs.
*
* Also patches proxyWidgets in root-level nodes that reference the
* remapped inner node IDs.
*
* Returns deep clones of the inputs — the originals are never mutated.
*
* @param subgraphs - Serialized subgraph definitions to deduplicate
* @param reservedNodeIds - Node IDs already in use by root-level nodes
* @param state - Graph state containing the `lastNodeId` counter (mutated)
* @param rootNodes - Optional root-level nodes with proxyWidgets to patch
* Dedupes node IDs across serialized subgraph definitions to prevent widget
* store key collisions, and patches any root-level legacy proxyWidgets that
* reference the remapped inner IDs. Returns deep clones; inputs are not
* mutated. `state.lastNodeId` is advanced.
*/
export function deduplicateSubgraphNodeIds(
subgraphs: ExportedSubgraph[],
@@ -197,7 +188,7 @@ export function topologicalSortSubgraphs(
return sorted
}
/** Patches proxyWidgets in root-level SubgraphNode instances. */
/** Patches legacy proxyWidgets in root-level SubgraphNode instances. */
function patchProxyWidgets(
rootNodes: ISerialisedNode[],
subgraphIdSet: Set<string>,

View File

@@ -1,3 +1,4 @@
import { isEqual } from 'es-toolkit'
import type { LGraph, SubgraphId } from '@/lib/litegraph/src/LGraph'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -25,6 +26,7 @@ import type {
import type { GraphOrSubgraph } from './Subgraph'
import type { SubgraphInput } from './SubgraphInput'
import { SubgraphInputNode } from './SubgraphInputNode'
import type { SubgraphNode } from './SubgraphNode'
import type { SubgraphOutput } from './SubgraphOutput'
import { SubgraphOutputNode } from './SubgraphOutputNode'
@@ -480,6 +482,71 @@ export function findUsedSubgraphIds(
return usedSubgraphIds
}
function reorderInPlace<T>(arr: T[], indices: readonly number[]): void {
arr.splice(0, arr.length, ...indices.flatMap((i) => arr[i] ?? []))
}
function* indexedLinks<S>(
slots: readonly S[],
resolve: (slot: S) => Iterable<LLink | undefined>
): Generator<readonly [number, LLink]> {
for (const [index, slot] of slots.entries()) {
for (const link of resolve(slot)) {
if (link) yield [index, link] as const
}
}
}
export function reorderSubgraphInputs(
subgraphNode: SubgraphNode,
orderedIndices: readonly number[]
): void {
const subgraph = subgraphNode.subgraph
if (!subgraph) return
const n = subgraph.inputs.length
if (
orderedIndices.length !== n ||
new Set(orderedIndices).size !== orderedIndices.length ||
orderedIndices.some((i) => i < 0 || i >= n)
) {
console.error(
`reorderSubgraphInputs: orderedIndices must be a permutation of 0..${n - 1}`,
orderedIndices
)
return
}
const oldOrder = subgraph.inputs.map((i) => i.id)
reorderInPlace(subgraph.inputs, orderedIndices)
reorderInPlace(subgraphNode.inputs, orderedIndices)
subgraphNode.invalidatePromotedViews()
function* innerLinks(input: SubgraphInput): Generator<LLink | undefined> {
for (const id of input.linkIds) yield subgraph.getLink(id)
}
for (const [slot, link] of indexedLinks(subgraph.inputs, innerLinks)) {
link.origin_slot = slot
}
function* outerLink(input: INodeInputSlot): Generator<LLink | undefined> {
if (input.link != null) yield subgraphNode.graph?.getLink(input.link)
}
for (const [slot, link] of indexedLinks(subgraphNode.inputs, outerLink)) {
link.target_slot = slot
}
const newOrder = subgraph.inputs.map((i) => i.id)
if (!isEqual(oldOrder, newOrder)) {
subgraph.events.dispatch('inputs-reordered', {
subgraph,
oldOrder,
newOrder
})
}
}
/**
* Type guard to check if a slot is a SubgraphInput.
* @param slot The slot to check

View File

@@ -35,7 +35,14 @@ export type LGraphTriggerEvent =
| NodeSlotLinksChangedEvent
| NodeSlotLabelChangedEvent
export type LGraphTriggerAction = LGraphTriggerEvent['type']
export const LGraphTriggerActions = [
'node:property:changed',
'node:slot-errors:changed',
'node:slot-links:changed',
'node:slot-label:changed'
] as const satisfies readonly LGraphTriggerEvent['type'][]
export type LGraphTriggerAction = (typeof LGraphTriggerActions)[number]
export type LGraphTriggerParam<A extends LGraphTriggerAction> = Extract<
LGraphTriggerEvent,

View File

@@ -1,5 +1,6 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CurveData } from '@/components/curve/types'
import type { WidgetEntityId } from '@/world/entityIds'
import type {
CanvasColour,
@@ -374,6 +375,14 @@ export interface IRangeWidget extends IBaseWidget<
export type TWidgetType = IWidget['type']
export type TWidgetValue = IWidget['value']
export function isWidgetValue(value: unknown): value is TWidgetValue {
if (value === undefined) return true
if (typeof value === 'string') return true
if (typeof value === 'number') return true
if (typeof value === 'boolean') return true
return value !== null && typeof value === 'object'
}
/**
* The base type for all widgets. Should not be implemented directly.
* @template TValue The type of value this widget holds.
@@ -390,6 +399,8 @@ export interface IBaseWidget<
linkedWidgets?: IBaseWidget[]
readonly entityId?: WidgetEntityId
name: string
options: TOptions

View File

@@ -17,17 +17,16 @@ import type {
NodeBindable,
TWidgetType
} from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
export interface DrawWidgetOptions {
/** The width of the node where this widget will be displayed. */
width: number
/** Synonym for "low quality". */
showText?: boolean
/** When true, suppresses the promoted outline color (e.g. for projected copies on SubgraphNode). */
suppressPromotedOutline?: boolean
/** Transient image source for preview widgets rendered on behalf of another node (e.g. subgraph promotion). */
previewImages?: HTMLImageElement[]
}
@@ -133,6 +132,13 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
this._state.value = value
}
get entityId(): WidgetEntityId | undefined {
const graphId = this.node.graph?.rootGraph.id
const nodeId = this._state.nodeId
if (!graphId || nodeId === undefined) return undefined
return widgetEntityId(graphId, nodeId, this.name)
}
/**
* Associates this widget with a node ID and registers it in the WidgetValueStore.
* Once set, value reads/writes will be delegated to the store.
@@ -206,17 +212,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
}
}
getOutlineColor(suppressPromotedOutline = false) {
const graphId = this.node.graph?.rootGraph.id
if (
graphId &&
!suppressPromotedOutline &&
usePromotionStore().isPromotedByAny(graphId, {
sourceNodeId: String(this.node.id),
sourceWidgetName: this.name
})
)
return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
getOutlineColor() {
return this.advanced
? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR
: LiteGraph.WIDGET_OUTLINE_COLOR
@@ -280,13 +276,13 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
*/
protected drawWidgetShape(
ctx: CanvasRenderingContext2D,
{ width, showText, suppressPromotedOutline }: DrawWidgetOptions
{ width, showText }: DrawWidgetOptions
): void {
const { height, y } = this
const { margin } = BaseWidget
ctx.textAlign = 'left'
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
ctx.strokeStyle = this.getOutlineColor()
ctx.fillStyle = this.background_color
ctx.beginPath()
@@ -307,7 +303,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
*/
protected drawVueOnlyWarning(
ctx: CanvasRenderingContext2D,
{ width, suppressPromotedOutline }: DrawWidgetOptions,
{ width }: DrawWidgetOptions,
label: string
): void {
const { y, height } = this
@@ -317,7 +313,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -23,7 +23,7 @@ export class ButtonWidget
*/
override drawWidget(
ctx: CanvasRenderingContext2D,
{ width, showText = true, suppressPromotedOutline }: DrawWidgetOptions
{ width, showText = true }: DrawWidgetOptions
) {
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
@@ -41,7 +41,7 @@ export class ButtonWidget
// Draw button outline if not disabled
if (showText && !this.computedDisabled) {
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeRect(margin, y, width - margin * 2, height)
}

View File

@@ -23,7 +23,7 @@ export class ChartWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -23,7 +23,7 @@ export class FileUploadWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -23,7 +23,7 @@ export class GalleriaWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -23,7 +23,7 @@ export class ImageCompareWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -33,7 +33,7 @@ export class KnobWidget extends BaseWidget<IKnobWidget> implements IKnobWidget {
drawWidget(
ctx: CanvasRenderingContext2D,
{ width, showText = true, suppressPromotedOutline }: DrawWidgetOptions
{ width, showText = true }: DrawWidgetOptions
): void {
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
@@ -145,10 +145,10 @@ export class KnobWidget extends BaseWidget<IKnobWidget> implements IKnobWidget {
// Draw outline if not disabled
if (showText && !this.computedDisabled) {
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
ctx.strokeStyle = this.getOutlineColor()
// Draw value
ctx.beginPath()
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
ctx.strokeStyle = this.getOutlineColor()
ctx.arc(
arc_center.x,
arc_center.y,

View File

@@ -23,7 +23,7 @@ export class MarkdownWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -23,7 +23,7 @@ export class MultiSelectWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -23,7 +23,7 @@ export class SelectButtonWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -20,7 +20,7 @@ export class SliderWidget
*/
override drawWidget(
ctx: CanvasRenderingContext2D,
{ width, showText = true, suppressPromotedOutline }: DrawWidgetOptions
{ width, showText = true }: DrawWidgetOptions
) {
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
@@ -43,7 +43,7 @@ export class SliderWidget
// Draw outline if not disabled
if (showText && !this.computedDisabled) {
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeRect(margin, y, width - margin * 2, height)
}

View File

@@ -13,6 +13,9 @@ import { VueFire, VueFireAuth } from 'vuefire'
import { setAssertReporter } from '@/base/assert'
import { getFirebaseConfig } from '@/config/firebase'
import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import { autoExposeKnownPreviewNodes } from '@/core/graph/subgraph/promotionUtils'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import {
configValueOrDefault,
remoteConfig
@@ -127,6 +130,15 @@ app
modules: [VueFireAuth()]
})
LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
flushProxyWidgetMigration({
hostNode,
hostWidgetValues: nodeData?.widgets_values
})
LGraph.autoExposePreviewNodes = (hostNode) =>
autoExposeKnownPreviewNodes(hostNode)
const bootstrapStore = useBootstrapStore(pinia)
void bootstrapStore.startStoreBootstrap()

Some files were not shown because too many files have changed in this diff Show More